English
תנועה
נגישותפרטיות
7 דקות קריאהמערכות · DevOpsלקריאה באנגלית

מותר לשבור את ההדגמות: יומן ביטול שמחזיר כל הדגמה למצבה המקורי אחרי שלוש שעות

איך ההדגמות החיות באתר שלי מבטלות את השינויים של המבקרים אחרי שלוש שעות, עם טריגרים של SQLite וטרנזקציה אחת, בתוכנית החינמית של Cloudflare.

MobileSign, הפרויקט שהפוסט עוסק בו

לרוב פרויקטי הווב באתר שלי יש הדגמה חיה, וברובן מותר לשנות הכול: להוסיף פרויקט, למחוק מחסן, לרשום נוכחות, לשנות שם של צוות. כך אני רוצה שזה יהיה. בהדגמה שאפשר רק להסתכל עליה אין הרבה מה לראות: הלוגיקה האמיתית, ההרשאות והכללים מתגלים רק כשמפעילים עליהם לחץ.

הבעיה היא שהמבקר הבא מוצא את מה שהקודם השאיר אחריו, ולכן כל הדגמה צריכה לחזור מעצמה למצבה המקורי. באוקטובר 2026 כתבתי מודול משותף אחד שעושה את זה בשש מהן: שלוש שעות אחרי השינוי הראשון, הביקור הבא מחזיר את ההדגמה לנתוני הדמה, בשקט, עוד לפני שהוא מקבל תשובה. בפוסט הזה אסביר איך זה עובד, ואספר על שלושה מקרים שכמעט הפילו את הרעיון כולו.

הבעיה

ההדגמות רצות בתוכנית החינמית של Cloudflare. כל אחת מהן היא Python Worker עם מסד נתונים D1, שמבוסס על SQLite, והנתונים נטענים בסקריפט האכלוס של האפליקציה עצמה. המגבלות של התוכנית החינמית קבעו כל החלטה. נכון לאוקטובר 2026:

  • חמש משימות מתוזמנות לחשבון. יש לי שבע הדגמות עם מסד נתונים, כך שמשימה מתוזמנת לכל הדגמה פשוט לא נכנסת במכסה.
  • כתיבה של 100,000 שורות ביום, לכל ההדגמות יחד. טעינה מחדש של נתוני הדמה של MobileSign לבדה הייתה כותבת, לפי הערכה, כ-76,000 שורות, כולל הרשומות שכל שורה מוסיפה לאינדקסים. טעינה מלאה של כל ההדגמות כל כמה שעות לא באה בחשבון.
  • זמן מעבד קצר לכל בקשה. הדפים הכבדים כבר נשמרים במטמון כדי לעמוד בו.

התיעוד של Cloudflare לא חד-משמעי בחלק מהמגבלות, למשל בשאלה כמה קריאות למסד הנתונים מותרות בבקשה אחת. בכל מקרה כזה הלכתי לפי הפירוש המחמיר.

מה ניסיתי קודם

קודם לכן ההדגמות התאפסו בדרך הגסה: שתיים מהן טענו מחדש את כל נתוני הדמה פעם ביום, ממשימה מתוזמנת. זה בזבז משימות מתוזמנות שלא היו לי, וכתב בכל פעם את כל השורות, גם כשאף אחד לא נגע בהדגמה.

ההדגמה של הפלטפורמה הדיגיטלית של תע“ץ עדיין עובדת כך: משימה מתוזמנת מחזירה את הנתונים שלה כל שלוש שעות. זו הדגמה אחת, אז מגיעה לה משימה אחת. לשש האחרות הייתי צריך פתרון שלא עולה כלום כשאין מבקרים, ועולה מעט מאוד כשיש.

מה עבד: יומן ביטול

הרעיון: לרשום רק את מה שהמבקרים משנים, ולבטל רק את זה.

טריגרים רושמים את השינוי הראשון בכל שורה. שלב בבנייה מייצר לכל טבלה של האפליקציה טבלת יומן וארבעה טריגרים של SQLite. בפעם הראשונה שמבקר מעדכן או מוחק שורה, הטריגר שומר אותה כמו שהייתה בנתוני הדמה. כשמבקר מוסיף שורה, הטריגר רושם את המפתח שלה. שינוי נוסף באותה שורה לא מוסיף דבר, כך שהיומן נשאר קטן גם כשמשחקים בהדגמה הרבה זמן. גם כל טבלה שהשתנתה נרשמת, פעם אחת, עם השעה של השינוי הראשון בה.

הנה אחד הטריגרים, כמו שנוצר לטבלה מומצאת בשם item, עם הערות שהוספתי:

CREATE TRIGGER "demo_undo__item__bd" BEFORE DELETE ON "item"
WHEN NOT EXISTS (SELECT 1 FROM "demo_undo_guard")
BEGIN
  -- save the row as it was, unless it is already saved or a visitor added it
  INSERT INTO "demo_undo__item" ("demo_kind", "id", "warehouse_id", "label")
  SELECT 0, OLD."id", OLD."warehouse_id", OLD."label"
  WHERE NOT EXISTS (SELECT 1 FROM "demo_undo__item" WHERE "demo_kind" = 1 AND "id" = OLD."id")
    AND NOT EXISTS (SELECT 1 FROM "demo_undo__item" WHERE "demo_kind" = 0 AND "id" = OLD."id");
  -- note that this table changed, once, with the time
  INSERT INTO "demo_undo_dirty" (name, at)
  SELECT 'item', CAST(strftime('%s', 'now') AS INTEGER)
  WHERE NOT EXISTS (SELECT 1 FROM "demo_undo_dirty" WHERE name = 'item');
END;

גם מחיקות בשרשרת נרשמות: כשמחיקה של מחסן מוחקת את הפריטים שלו דרך ON DELETE CASCADE, אותם טריגרים פועלים גם על הפריטים, והם חוזרים יחד איתו.

האיפוס הוא טרנזקציה אחת. לפני שה-Worker עונה לבקשה, הוא שואל את מסד הנתונים שאלה קטנה אחת, לכל היותר פעם בדקה בכל מופע פעיל: האם השינוי הוותיק ביותר בן שלוש שעות? אם כן, האיפוס רץ מיד, כאצווה (batch) אחת של D1. אצווה היא טרנזקציה אחת, כך שאף מבקר לא רואה איפוס שנעצר באמצע. לפי הסדר, האיפוס:

  1. משתיק את הטריגרים בזמן שהוא עצמו כותב;
  2. דוחה את בדיקת המפתחות הזרים לסוף הטרנזקציה;
  3. מוחק את השורות שמבקרים הוסיפו, קודם את הילדים ואחר כך את ההורים;
  4. מחזיר את השורות השמורות, קודם את ההורים ואחר כך את הילדים: מעדכן את אלה שעדיין קיימות ומכניס מחדש את אלה שנמחקו;
  5. מרוקן את היומן ומפעיל שוב את הטריגרים.

האיפוס נוגע רק בטבלאות שהשתנו: ארבע פקודות לכל טבלה, ועוד ארבע. כך נראית האצווה כולה כשהשתנו שתי טבלאות לדוגמה:

INSERT OR IGNORE INTO "demo_undo_guard" (id) VALUES (1);
PRAGMA defer_foreign_keys = ON;
DELETE FROM "item" WHERE "id" IN (SELECT "id" FROM "demo_undo__item" WHERE "demo_kind" = 1);
DELETE FROM "warehouse" WHERE "id" IN (SELECT "id" FROM "demo_undo__warehouse" WHERE "demo_kind" = 1);
UPDATE "warehouse" SET "name" = j."name" FROM "demo_undo__warehouse" j
  WHERE j."demo_kind" = 0 AND "warehouse"."id" = j."id";
INSERT INTO "warehouse" ("id", "name") SELECT j."id", j."name" FROM "demo_undo__warehouse" j
  WHERE j."demo_kind" = 0 AND NOT EXISTS (SELECT 1 FROM "warehouse" WHERE "warehouse"."id" = j."id");
-- ...the same two statements for "item"...
DELETE FROM "demo_undo__warehouse";
DELETE FROM "demo_undo__item";
DELETE FROM "demo_undo_dirty" WHERE name IN ('warehouse', 'item');
DELETE FROM "demo_undo_guard";

אין כאן שום משימה מתוזמנת. הדגמה שאף אחד לא מבקר בה לא עולה כלום, והדגמה שמישהו שיחק בה מתאפסת בביקור הראשון אחרי שלוש השעות.

המודול קורא את הטבלאות, המפתחות והמפתחות הזרים של כל הדגמה מתוך הסכמה של SQLite בנתוני הדמה עצמם, כך שהוא מתאים לכל הדגמה בלי רשימות שנכתבות ביד. ב-MobileSign יש 20 טבלאות וכ-13,900 שורות של נתוני דמה, ב-MILESTONE יש 16 טבלאות ו-64 טריגרים, ב-T100 עשר טבלאות ו-40 טריגרים, ובאפליקציית ניטור החמ“ל שש טבלאות ו-24 טריגרים. במדידה מקומית, איפוס של T100 נמשך כ-26 אלפיות שנייה.

שלושת המקרים שהסקירות תפסו

כל שילוב של המודול בהדגמה עבר סקירה נפרדת, והסקירות מצאו באגים אמיתיים. שלושה מהם שינו את התכנון.

ספירת שורות. ‏D1 סופר בדיווח שלו גם את השורות שהטריגרים כותבים, ולכן עדכון של שורה אחת דיווח פתאום על שלוש. שכבת מסד הנתונים של האפליקציות סמכה על המספר הזה, ואחת מהן הסיקה שהעדכון פגע בשורות הלא נכונות ונכשלה בכל עריכה. הפתרון היה לשאול את SQLite עצמו: כל כתיבה נשלחת עכשיו ל-D1 באצווה אחת יחד עם SELECT changes(), שאף פעם לא סופר כתיבות של טריגרים. זה עדיין סבב אחד מול השרת.

ערכים שהוחלפו. מבקר יכול להחליף ערך ייחודי בין שתי שורות. ב-MobileSign, כשמקדמים מנהל משנה למנהל ראשי, המנהל הקודם יורד דרגה, וזו בדיוק החלפה כזו. האיפוס נכשל אז על אילוץ הייחודיות. הוא נכשל בצורה נקייה, כי זו טרנזקציה אחת, אבל הוא נכשל שוב ושוב, כך שההדגמה כבר לא הייתה מתאפסת לעולם. היום, כשהאצווה נכשלת על אילוץ ייחודיות, היא רצה שוב, ולפני כן מזיזה הצידה את השורות שהיא עומדת להחזיר: היא מוחקת אותן, ופקודת ה-INSERT שבאה אחריה מכניסה אותן מחדש בשלמותן, ובטבלה שטבלאות אחרות מפנות אליה עם כלל ON DELETE היא נותנת לעמודות הייחודיות שלהן ערך זמני שלא יכול להתנגש. במודול זה נקרא parking (“חנייה”). במדידה מקומית, איפוס עם חנייה ב-T100 כתב 250 עד 252 שורות ונמשך 34 עד 39 אלפיות שנייה.

שינוי בזמן האיפוס. שינוי של מבקר יכול להגיע בין הבדיקה לבין האיפוס. האיפוס היה מרוקן את כל רשימת הטבלאות שהשתנו, כך שהשינוי הזה היה מאבד את הסימון שלו ונשאר לתמיד. היום האיפוס מוחק רק את הסימונים של הטבלאות שהוא החזיר.

איך אני יודע שזה עובד

לא רציתי לסמוך על איפוס רק כי הוא נראה בסדר. שלב הבנייה שכותב את הטריגרים מריץ גם בדיקה על SQLite רגיל: הוא טוען את נתוני הדמה, עושה שינויים כמו שמבקרים עושים (עריכות, מחיקות עם השרשרת שלהן, שורות חדשות, שורות שנכתבות מעל שורות קיימות, מפתח שזז, שתי שורות שמחליפות ביניהן ערך ייחודי), מריץ את האיפוס בדיוק כמו שה-Worker מריץ אותו, ודורש שהנתונים יהיו שוב זהים לנתוני הדמה, עם מפתחות זרים תקינים ויומן ריק. הוא מריץ כמה סבבים עם שינויים שונים, ובכל סבב מאפס פעמיים, כי היומן צריך לעבוד גם אחרי איפוס. אם טבלה כלשהי שונה מנתוני הדמה, הבנייה נעצרת ומדפיסה את השורות העודפות והחסרות הראשונות.

אחר כך כל הדגמה עברה בדיקה חיה: עשיתי שינוי, הזזתי אחורה את חותמת הזמן שלו כך שמועד האיפוס יגיע, ובדקתי שהביקור הבא מחזיר את נתוני הדמה.

מה הייתי עושה אחרת

הייתי גורם לכל אפליקציה לקחת את ספירת השורות מ-SQLite כבר מהיום הראשון. ההסתמכות על ספירת השינויים שמחזיר הדרייבר של מסד הנתונים הייתה בסדר, עד שמשהו אחר כתב באותה פקודה, ואז היא שברה את כל העריכות בבת אחת.

הייתי גם בודק מול מסד הנתונים האמיתי בענן מוקדם יותר. הסביבה המקומית לא אוכפת כל מגבלה של התוכנית החינמית, ועל חלק מהשאלות, למשל כמה קריאות מותרות בבקשה אחת, אפשר לענות רק בסביבה החיה. והייתי מוסיף תאריך לכל עובדה על התוכנית החינמית בהערות בקוד, כי המגבלות משתנות.

איפה זה רץ

המודול הוא קובץ Python אחד, שמועתק כמו שהוא לתיקיית הפריסה של כל הדגמה, לצד רשימה שנוצרת אוטומטית של הטבלאות של אותה הדגמה. הוא רץ בשש הדגמות, ביניהן MobileSign, ‏MILESTONE, ‏T100, אפליקציית ניטור החמ“ל ומערכת מעקב הנוכחות, שפיתחתי מחדש מגרסה 0.1 שנועם שדמי בנה. בכל אחת מהן מופיעה אותה שורה מתחת לכפתור ההדגמה: נתוני דמה, והשינויים מתבטלים כשלוש שעות אחר כך. עכשיו אתם יודעים מה קורה מאחורי השורה הזו. קדימה, תשברו משהו.

הפרויקטים

  • מערכות
  • DevOps
הפוסט הבא
לא סומכים, מודדים: לוח מודעות שנבנה ביום אחד ונבדק במדידות

פרקים