Sticky Notes Warmup – פתרון ביצועים משולב

summary:

מה מחזיק היום את המסלול של הפתקים הדביקים אחרי תקלת נעילת האינדקסים — דגלי מוכנוּת שחוסמים את בניית האינדקסים מהמסלול החם, חימום עלייה שכבוי כברירת מחדל, ומה ה-timeout של Gunicorn עושה (ולא עושה) מאז המעבר ל-gevent.

רקע קצר

  • הנתיב /api/sticky-notes/reminders/summary נתקע כי בניית האינדקסים הופעלה בכל בקשה וננעלה על מנעול משותף.

  • כל worker ניסה לבנות אינדקסים מחדש בעת בקשת משתמש, הבקשות הצטברו מאחוריו, ו-Gunicorn הרג worker ששתק — מה שנראה מבחוץ כמו אתר תקוע.

  • התרופה שנרשמה אז הייתה העלאת ה-timeout של Gunicorn ל-180 שניות. הסעיף הזה כבר אינו נכון, ומה שהחליף אותו מתואר למטה.

חשוב

העמוד הזה תוקן אחרי שנמצא שההנחיה המקורית — export GUNICORN_CMD_ARGS="--timeout 180 --graceful-timeout 180" — אינה משפיעה על השירות כפי שהוא רץ היום. scripts/start_webapp.sh מעביר ל-Gunicorn דגלי --timeout ו---graceful-timeout מפורשים, ו-Gunicorn מחיל את GUNICORN_CMD_ARGS לפני דגלי שורת הפקודה ומיד אחר כך דורס אותם בהם (gunicorn/app/base.py, ההערה שם: ”Lastly, update the configuration with any command line settings“). כלומר הערך שנקבע דרך המשתנה הזה נבלע בשקט.

מה מחזיק את המסלול היום

1. המסלול החם לא נוגע בבניית האינדקסים. _ensure_indexes ב-webapp/sticky_notes_api.py בודקת דגל בזיכרון התהליך ודגל ב-Redis לפני שהיא ניגשת לנעילה בכלל, ולכן בקשה רגילה יוצאת ממנה מיד. מפתח הדגל מוגדר ב-_INDEX_READY_CACHE_KEY ותוקפו 24 שעות. הערך עצמו אינו כתוב כאן בכוונה: הוא נושא מספר גרסה שעולה בכל שינוי ברשימת האינדקסים, ועמוד שמצטט אותו מתיישן בשקט — כפי שקרה כאן, כשהעמוד הצהיר _v3 אחרי שהקוד כבר עלה. מי שצריך את המחרוזת קורא אותה מהקבוע. התוקף מצמצם את התדירות: בלי הדגל כל בקשה נכנסה לבנייה, ואיתו כל תהליך משלם עליה לכל היותר פעם ביממה. הוא אינו נעילה בין תהליכים ואינו מתיימר להיות כזו — _INDEX_READY_LOCK הוא threading.Lock מקומי לתהליך, ו-_mark_cache_flag כותב set רגיל ולא SETNX, ולכן שני workers שמגיעים יחד אחרי פקיעה יבנו שניהם. הבנייה היא idempotent, ולכן זה בזבוז ולא שחיתות; מי שרוצה בנייה אחת בלבד צריך נעילה אטומית משותפת, ואין כזו היום.

2. כשל אינו הופך ללולאה חמה. אחרי ניסיון בנייה שנכשל נקבע חסם של 60 שניות (_INDEX_RETRY_SECONDS) שנבדק לפני הנעילה. בלעדיו כשל מתמשך היה מחזיר כל בקשה לתוך הנעילה ומסריאל את השירות סביב מנעול אחד — בדיוק התקלה המקורית, רק במסווה אחר.

3. הדגל נכתב רק אחרי אימות בקריאה חוזרת. _mark_indexes_ready רץ רק כששני אינדקסי השם אומתו מול המסד, ולכן קיום הדגל הוא עדות שהאילוץ באמת חי — ולא ש“הרצנו create_index וקיבלנו ערך חזרה“. גרסת המפתח מקודמת בדיוק בשביל זה, בכל פעם שמשמעות הדגל משתנה: דגל ישן שהעיד על חלק מהאינדקסים היה מתפרש תחת אותו מפתח כאימות של אינדקס שמעולם לא אומת. המספר הנוכחי חי ב-_INDEX_READY_CACHE_KEY.

אזהרה

חימום העלייה קיים בקוד אבל כבוי כברירת מחדל. kickoff_index_warmup נקרא מ-webapp/app.py רק כאשר DISABLE_STARTUP_WARMUP אינו דלוק — וברירת המחדל שלו בקוד היא true. אותו מתג מכבה גם את חימום ה-Observability בעלייה. כלומר בפריסה שלא הגדירה אותו במפורש, האינדקסים נבנים בבקשה הראשונה שמגיעה אליהם, ולא לפני שהתהליך מקבל תעבורה. כדי לראות מה המצב בפועל: /admin/config-inspector, המשתנה DISABLE_STARTUP_WARMUP.

מה השתנה מאז המעבר ל-gevent

זו הסיבה המרכזית לכך שה-timeout כבר אינו קו ההגנה שהיה.

scripts/start_webapp.sh מריץ את הוובאפ עם --worker-class gevent (ברירת המחדל בסקריפט), ומחלקת ה-worker הזו מריצה monkey.patch_all() בעצמה בעת אתחול ה-worker (gunicorn/workers/ggevent.py). מהרגע הזה גם מנעול רגיל של threading הופך לשיתופי: גרינלט שממתין עליו משחרר את התור לגרינלטים אחרים במקום להקפיא את התהליך.

מה זה משנה למשמעות של --timeout, מתוך התיאור של ההגדרה ב-Gunicorn עצמו:

Workers silent for more than this many seconds are killed and restarted… For the non sync workers it just means that the worker process is still communicating and is not tied to the length of time required to handle a single request.

מצב

worker סינכרוני (כפי שהיה בזמן התקלה)

worker של gevent (המצב היום)

בקשה איטית אחת

ה-worker עסוק בה בלבד, שותק, ונהרג בתום ה-timeout

שאר הבקשות ממשיכות להיענות; ה-worker ממשיך לדווח שהוא חי

מה --timeout מודד

את אורך הבקשה הבודדת, בפועל

את השתיקה של ה-worker — לא את אורך הבקשה

מה כן יהרוג worker

בקשה ארוכה

חסימה של לולאת האירועים עצמה: לולאת CPU, או קריאה חוסמת ש-gevent אינו מתקן

המסקנה המעשית: העלאת --timeout אינה מאריכה בקשה ואינה מגנה עליה. היא רק מאריכה את הזמן שלוקח למערכת לזהות worker שבאמת מת ולהחליף אותו.

העלאת ה-timeout — איך עושים את זה נכון

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

WEBAPP_GUNICORN_TIMEOUT=180            # ברירת המחדל בסקריפט; שם ותיק נתמך: GUNICORN_TIMEOUT
WEBAPP_GUNICORN_GRACEFUL_TIMEOUT=180   # ריק ⇒ מקבל את ערך ה-timeout

השרשרת בסקריפט היא ”אם לא הוגדר, קח את הבא“: WEBAPP_GUNICORN_TIMEOUT קודם, אחריו GUNICORN_TIMEOUT, ורק בסופה ברירת המחדל שבקוד. משתנה ותיק שהוגדר פעם בסביבה גובר על ברירת המחדל שבקוד, גם אם היא הועלתה מאז — ולכן שווה לבדוק ב-Config Inspector מה הערך הפעיל לפני ששואלים למה השינוי לא נתפס.

הערה

--graceful-timeout הוא הפרמטר שכן נוגע בכל דיפלוי: הוא קובע כמה זמן בקשות שכבר בעבודה מקבלות לסיים אחרי סיגנל restart, ואחריו הן נהרגות בכוח. אם בקשות ארוכות נקטעות בדיוק בזמן עליית גרסה — זה החשוד, והעלאה שלו היא הפעולה הממוקדת.

הערה

DEPLOY_GRACE_PERIOD_SECONDS נשמע דומה ואינו קשור: הוא חלון שבו התראת ה-latency עוברת לסף מקל אחרי דיפלוי (metrics.py), ואינו משפיע על אף בקשה.

איפוס הדגל בעת שינוי אינדקסים

  1. עדכנתם אינדקס (שינוי שם, הוספה, הסרה) בקולקציית Sticky Notes? המפתח חייב להשתנות, אחרת דגל חי ימשיך לומר ”מוכן“ ליממה שלמה ובכל התהליכים — והאינדקס החדש פשוט לא ייבנה.

  2. העלו את גרסת המפתח בקוד (_INDEX_READY_CACHE_KEY). זו הדרך המומלצת: היא פועלת בכל התהליכים בלי גישה ל-Redis, ונכנסת לתוקף עם הפריסה עצמה.

    tests/test_note_boards_api.py נועל את הגרסה יחד עם רשימת האינדקסים שהיא מעידה עליהם, כך ששינוי ברשימה בלי קידום המפתח מפיל בדיקה במקום להיבלע.

  3. לחלופין, מחיקה ידנית של המפתח הנוכחי:

    redis-cli DEL <_INDEX_READY_CACHE_KEY>
    
  4. ושלושה מקומות, לא אחד. הוספת אינדקס לפתקים נוגעת ב-_QUERY_INDEX_SPECS שבוובאפ, ברשימה המקבילה ב-mcp_server.backend._notes_coll, ובקידום המפתח. המפרטים בשני הצדדים חייבים להיות זהים: מונגו דוחה ב-code 85/86 אינדקס בשם קיים עם מפתחות אחרים, כלומר סטייה של תו אחד הופכת את הבוטסטראפ השני לכשל שקט לצמיתות. tests/test_mcp_notes_handlers.py בודק את הזהות הזו ונגזר מהמפרט של הוובאפ, ולא מרשימה מוקלדת.

מה לבדוק אם ה-latency קופץ שוב

  • לחפש בלוגים את האירוע sticky_indexes_warmup:

    • stage=done: הבנייה הצליחה והדגל נכתב. אם הוא חוזר בתדירות גבוהה — הדגל אינו נשמר (Redis כבוי או פוקע), ושווה לבדוק את הקאש.

    • stage=failed: הבנייה נכשלה, לרוב זמינות או הרשאות מול המסד. הבנייה היא best-effort ואינה מפילה את השרת, אבל עד שהיא לא מצליחה כל בקשה ראשונה אחרי חלון ה-60 שניות תנסה שוב.

    • היעדר אירוע: הדגל כבר קיים ולא נדרשה בנייה.

  • לוודא מה הערך הפעיל של DISABLE_STARTUP_WARMUP. כשהוא דלוק, הבנייה מתרחשת בבקשה הראשונה ולא בעלייה — וזה מסביר קפיצת latency נקודתית אחרי כל דיפלוי או אחרי פקיעת הדגל.

  • לוודא מה הערכים הפעילים של ה-timeout וה-graceful-timeout, ולזכור שדגלי הסקריפט הם שקובעים ולא GUNICORN_CMD_ARGS.

קישורים