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

מדוע הנעילה נכשלה

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

שני תסמינים עלו מהבעיה:

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

מכיוון שרוב המשתמשים עוצרים בין הודעה להודעה, הבאג נשאר מתחת לרדאר. רק כותבים מהירים גרמו למרוץ תהליכים (race condition), והמקרים היו נדירים.

התיקון החלקי שלא עשה את העבודה

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

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

בניית הגנה אמינה: מוני גרסאות, טיימרים מבודדים ו-lease

הצוות תכנן מחדש את זרימת העבודה סביב שלושה עמודים:

  • מונה גרסאות (Version counter) – כל הודעה נכנסת מגדילה מונה המאוחסן יחד עם השיחה. המונה אומר למערכת כמה הודעות הגיעו מאז התשובה האחרונה, מה שמקל על זיהוי קלט חדש בזמן שתגובה נמצאת בתהליך יצירה.
  • חלון debounce ייעודי – הטיימרים חיים כעת באזור אחסון נפרד, מבודדים מנתוני השיחה (payloads). הגבלה קשיחה על משך ה-debounce מונעת ממשתמש לעכב את הבוט ללא הגבלה.
  • lease של הסשן (Session lease) – הנעילה המקורית הוחלפה ב-lease (חוזה) הנושא חותמת פקיעת תוקף מפורשת. ה-lease נתפס באמצעות פעולת compare-and-swap (CAS): התהליך קורא את ערך ה-lease הנוכחי, כותב ערך חדש רק אם הערך הישן תואם, ובכך זוכה לזכויות בלעדיות על השיחה. אם התהליך קורס, ה-lease פוקע אוטומטית, ומשחרר את השיחה למטפל הבא.

כיצד ה-pipeline החדש עובד

  1. הגעת הודעה – המערכת מגדילה את מונה הגרסאות ומאפסת (או מעדכנת) את טיימר ה-debounce. היא מחזירה תשובה ללקוח מיד, מבלי להפעיל את ה-AI.
  2. פג תוקף הטיימר – מטפל הטיימר מנסה לרכוש את ה-lease. אם פעולת ה-CAS מצליחה, המטפל ממשיך; אחרת, הוא נסוג, בידיעה שתהליך אחר כבר מחזיק בשיחה.
  3. בדיקת קלט חדש – המטפל משווה את מונה הגרסאות הנוכחי עם הערך שהוא רשם כאשר הטיימר התחיל. אם המונה עלה, הוא מאחד את ההודעות הממתינות ל-prompt יחיד.
  4. יצירת תשובה – מודל ה-AI רץ פעם אחת, ומפיק תשובה אחת המכסה את כל קלטי המשתמש האחרונים.
  5. בדיקת תקינות (sanity check) סופית – רגע לפני שליחת התשובה, המטפל קורא שוב את מונה הגרסאות. אם הודעה חדשה יותר הגיעה במהלך היצירה, התשובה נזרקת והתהליך מאתחל את הטיימר, מה שמבטיח ששום תשובה לא רלוונטית לא תגיע למשתמש.

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

לקח מרכזי

נעילה שנעלמת לפני תחילת הקטע הקריטי (critical section) אינה מספקת הגנה כלל. על ידי החלפת נעילת מסד נתונים חולפת ב-lease מפורש ובעל תוקף, ובידוד הטיימרים מנתוני השיחה, הבוט מבטיח כעת תשובה אחת ומעודכנת גם כאשר משתמשים מקלידים במהירות הבזק. המקרה הזה מדגיש לקח נצחי: מנגנוני הגנה מפני מקביליות (concurrency safeguards) חייבים להחזיק מעמד זמן רב יותר מהעבודה שהם מגינים עליה, אחרת הם הופכים למחסומים בלתי נראים המאפשרים לבאגים לחמוק.