משתמש לוחץ על כפתור. הבקשה נתקעת. עשר שניות של שקט. הוא לוחץ על כפתור ה-fallback. עכשיו שני jobs רצים מול כוונה אחת. אתה נגמר עם השפעות לוואי כפולות (duplicate side effects), חיובים כפולים, ומצב נתונים מבולגן שגוזל לך את כל אחר הצהריים.
זה לא באג ב-frontend. כפתור מושבת או טיימר debounce ב-React לא יצילו אותך. הבקשה הראשונה כבר הייתה בתנועה (in flight). הרשת פשוט בלעה את התגובה. אם ה-backend שלך מתייחס לכל בקשה נכנסת כהוראה חדשה לגמרי, ניסיונות חוזרים (retries) הופכים לנטל. אתה צריך לתקן את זה בעיצוב ה-API ובסכימת מסד הנתונים שלך.
הפתרון מתחיל בפיצול מבני פשוט.
הפרדת Jobs מ-Attempts
חשוב על job כעל הרשומה העמידה של מה שהמשתמש רוצה. הוא לוכד את הבעלים, הפרמטרים, הספק המיועד ואת הכוונה המדויקת. attempt הוא ניסיון ספציפי לממש את הכוונה הזו.
דמיינו בית דפוס. אתם מוסרים קובץ והם נותנים לכם כרטיס #45. הכרטיס הזה הוא ה-job. בית הדפוס מנסה במדפסת הזרקת דיו. היא נתקעת. זה ה-attempt הראשון. הם מעבירים את הקובץ למדפסת לייזר. זה ה-attempt השני. לאורך כל התהליך, כרטיס #45 לעולם לא משתנה. אם בית הדפוס היה מנפיק כרטיס חדש עבור כל מדפסת שהוא מנסה, הייתם משלמים שלוש פעמים ומקבלים שלושה עותקים לא רצויים.
מסד הנתונים שלכם צריך לשקף זאת. טבלה אחת מחזיקה jobs. טבלה אחרת מחזיקה attempts. שורת ה-job נשארת קבועה בזמן שה-attempts מצטברים מתחתיה.
ההפרדה הזו מעניקה לכם שליטה. היא גם נותנת לכם מקום לצרף מפתח אידמפוטנטיות (idempotency key) ששורד תקלות רשת.
דרישת מפתח אידמפוטנטיות בכל Job
כל בקשת POST שיוצרת job חייבת לשאת מפתח אידמפוטנטיות ייחודי. המפתח הזה שייך למשתמש, לא לסשן (session). שרכו את ה-owner ID ואת המפתח, ואז אכפו אילוץ ייחודי (unique constraint) במסד הנתונים על שתי העמודות הללו.
למה אילוץ במסד הנתונים? כי בדיקת קיום בקוד האפליקציה לפני ההכנסה היא race condition שמחכה לקרות. שתי בקשות זהות יכולות לחמוק דרך אותו מרווח של מיקרו-שנייה. תנו למסד הנתונים להיות האוכף. אם משתמש שולח את אותו owner ID ואותו מפתח פעמיים, הבקשה השנייה תיתקל בהפרת הייחודיות ואתם תחזירו את ה-job הקיים. לשתי הבקשות יהיה את אותו job ID. שום עבודה כפולה לא תתחיל.
היו קפדניים לגבי ההיקף (scope). אם מישהו משתמש מחדש במפתח אך משנה את ה-payload של הקלט, החזירו שגיאת קונפליקט (conflict). מפתח האידמפוטנטיות חייב להיות קשור לכוונה מדויקת, לא רק למשתמש. אותו מפתח עם קלט שונה אומר שהלקוח (client) מבולבל, והמערכת שלכם צריכה לדחות זאת במקום לנחש.
הגנה על מעברי מצב (State Transitions)
attempt הוא מעבר מצב, לא job חדש. ה-API שלכם חייב לסרב ליצור attempt חדש אם attempt קודם עדיין תקוע במצב התחלה או במצב לא ידוע.
ה-timeouts הם הסיבה. כשבקשה לספק (provider) פגה בזמן (timeout), הלקוח רואה כישלון, אך התהליך בצד השרת עשוי עדיין להיות פעיל. אשכול ה-GPU עשוי עדיין לעבד את בקשת ה-inference שלכם. הקונטיינר עשוי עדיין לכתוב ל-blob storage. אם תסמנו את ה-attempt שפג בזמן כנכשל ותפעילו מיד attempt שני, אתם מהמרים על השפעות לוואי כפולות.
התייחסו ל-timeout כמצב לא ידוע, לא ככישלון. חסמו attempts חדשים עד שהקודם מגיע למצב סופי (terminal state) או מבוטל במפורש על ידי תהליך חיצוני. ההשהיה הזו אינה נעימה. היא מאלצת את המשתמש לחכות. היא גם מונעת את הכאוס שבו שני עובדים (workers) משנים את אותם משאבים בשרשרת (downstream resources).
פתרון מרוצים (Races) באמצעות Compare-and-Swap
הבעיות הקשות ביותר מופיעות כאשר מספר attempts מסתיימים. אולי המערכת שלכם הפעילה את attempt אחד מול הספק הראשי. לאחר עשר שניות של שקט, היא הפעילה את attempt שני מול ה-fallback. עכשיו שני ה-attempts הסתיימו. אתם לא יכולים לאפשר לשניהם לכתוב את התוצאות שלהם לאותה שורת job.
השתמשו בלוגיקת compare-and-swap. הוסיפו מספר גרסה (version number) לשורת ה-job. כאשר attempt מסתיים, הוא מריץ עדכון עם תנאים:
- הגרסה הנוכחית חייבת להתאים למה שה-attempt קרא בהתחלה.
- אף attempt אחר לא תפס כבר את מקום התוצאה.
- אם שניהם עוברים, כתבו את התוצאה והעלו את הגרסה.
במונחי SQL, זה נראה כמו פקודת update עם WHERE id = $1 AND version = $2 AND completed_by IS NULL. אם ה-update מחזיר אפס שורות, attempt אחר כבר ניצח. יש להתעלם מההגעה המאוחרת. זרקו את התוצאה שלה. אל תמזגו. אל תוסיפו. השליכו את העבודה. תוצאה מאוחרת שדורסת מנצח מוקדם יותר היא שחיתות נתונים (data corruption), והצעד הבטוח היחיד הוא להשליכה.
This handles the reverse-order finish cleanly. Attempt A leaves first but returns after thirty seconds. Attempt B leaves second but returns after five seconds. Attempt B wins the compare-and-swap. Attempt A’s update touches zero rows. Your system logs the race, ignores the stale payload, and moves on.
Test the Breakpoints
You will not catch these bugs in happy-path testing. Your suite needs to target the fractures.
- Simulate a double-click. Two simultaneous POST requests with the same idempotency key must return identical job IDs.
- Send the same key with mismatched input. Expect a conflict response. The system must not silently return the existing job if the parameters differ.
- Provoke a timeout. Verify the job lands in an unknown state, not a failed state, and that the system blocks further attempts until the ambiguity clears.
- Force two attempts to finish in reverse order. Confirm that the second one to return loses, even if the first one to leave was the official primary provider.
These tests are not edge-case luxuries. They are the contract your API makes with the rest of the system.
Validate Provider Intent Before You Fail Over
If you run a multi-provider setup, you might be tempted to treat different AI models as interchangeable slots. They share the same code path, the same HTTP client, and the same JSON schema. That does not mean they behave the same.
One model might hallucinate a top-level key. Another might ignore your system prompt formatting. Schema validation catches syntax errors, but it will pass a response that your business logic cannot interpret. A provider might return valid JSON that simply does the wrong thing with your prompt template.
Run provider-specific tests before you allow automatic model switching. Confirm that the fallback model actually respects your output structure at low temperature. Verify that your prompt renders correctly through that provider’s tokenizer. Test the full round trip with real inputs. Automatic failover is only safe when you have proven that the fallback shares the same operational contract.
Keep One Job Per Intent
Fallback paths are good. Uncontrolled fallback multiplication is a bug. Every layer of your stack needs to evaluate whether it has already seen the exact task. The load balancer, the API handler, the database, and the worker must all respect the same identity.
Build your system so that retries and fallbacks surface as new attempts under one stable job. Lock the job down with a database-backed idempotency key. Guard the transitions. Race the attempts. Let exactly one win. That is how you keep a single user click from turning into a weekend of data cleanup.
