דילוג לתוכן הראשי
ניסיון מקצועי

שיפור אמינות ועקביות במערכת תשלומי B2B

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

מקור
Intuit, Senior Backend Engineer, QuickBooks Bill Pay / B2B Payments
Tech Stack
Java · Spring Boot · REST APIs · DynamoDB · AWS · ShedLock · Splunk
פיתוח Backend, אמינות, מערכות מבוזרות,

אמל״ק

הבעיה
בשירות תשלומי B2B קיים היו פערים בוולידציה, בניקוי תשלומים תקועים, בהתאוששות מתשלומים שנכשלו ובעקביות מצב התשלום בין שירותים.
התרומה שלי
מימשתי ולידציה ברמת ה-batch, כללי זכאות ל-Retry וניקוי מתוזמן של הוראות תשלום תקועות, יחד עם ה-GSI שמאחוריו ב-DynamoDB. הייתי אחראי על החקירה והתיקון של באג בעקביות מצב התשלום בין שירותים, לצד מהנדסים נוספים.
תוצאה וראיות
לשינויים בוולידציה, בכללי הזכאות ובניקוי יש בדיקות אוטומטיות ובדיקות רגרסיה ממוקדות. לתיקון בין השירותים יש גם אימות מקצה לקצה בסביבה הדומה לפרודקשן, והפריסה שלו בפרודקשן אומתה. אין טענה לפריסה של שאר השינויים.
להעמקה בפרטים הטכניים

הבעיה

שירות תשלומי ה-B2B של QuickBooks Bill Pay מקבל batches של תשלומים שממומנים בכמה אמצעי תשלום, וחלק מהשילובים אינם תקינים. דוגמה אחת היא מימון בכרטיס אשראי יחד עם הוראות Real-Time Payments (RTP). השירות עוקב אחרי כל Payment Instruction לאורך מחזור חיים in-flight, עד שהיא מסולקת או מבוטלת.

היו ארבעה פערים.

ולידציה. שילובי תשלום שאינם נתמכים בתוך batch יכלו להגיע לשכבת השמירה ולעיבוד ב-downstream לפני שמשהו עצר אותם.

תשלומים תקועים. Payment Instructions יכלו להיתקע במצב in-flight, ושום מנגנון לא ניקה אותן אוטומטית. המשתמשים נותרו בלי אינדיקציה ברורה למצב האמיתי של התשלום.

התאוששות. כשתשלום בכרטיס אשראי נכשל, ה-API של ה-polling לתשלומי batch לא ציין אילו כשלים אפשר לנסות שוב, וה-client נאלץ להסיק זאת מקודי סטטוס גולמיים. פעולת ה-Reschedule יכלה להופיע גם עבור כשלים שלעולם לא יצליחו, וסוג הכשל והאינדיקציה לזכאות ל-Retry יכלו אף לסתור זה את זה.

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

הקשר

זהו שירות תשלומי B2B קיים (Java 21, Spring Boot), ולא בנייה מאפס. הצטרפתי לתפקיד אחרי העברה פנימית מצוות Data Exchange Platform / IDX ב-Intuit. בשירות כבר היו סדר ולידציות מוגדר, מוסכמות לטיפול בביטולים ומאגר Payment Instructions שנשען על DynamoDB. השינויים שלי היו צריכים להשתלב במוסכמות האלה, ולא להציג גישה מקבילה.

אילוצים

  • אי אפשר היה לשנות את סדר העדיפויות הקיים בוולידציה של מימון מעורב. בדיקה חדשה הייתה צריכה להשתלב בסדר הקיים.
  • job מתוזמן שנוגע במצב תשלום היה צריך להיות בטוח להרצה על פני כמה מופעים של האפליקציה, בלי לעבד את אותה Payment Instruction פעמיים.
  • ניקוי שסורק ואז פועל עלול לפעול על נתונים לא עדכניים, אם Payment Instruction משנה מצב בין האיתור לביטול. את ה-race condition הזה היה צריך לסגור ולא לקבל.
  • סריקה מלאה של טבלת DynamoDB לא מתאימה לטבלה שגדלה, וגם אי אפשר להחזיר באמצעותה candidates לפי גיל בעלות נמוכה. האיתור היה צריך דפוס גישה שעלותו נקבעת לפי מספר ה-candidates התקועים ולא לפי גודל הטבלה.
  • ההתנהגות הקיימת של התראות, אגרגציה ותאימות לאחור הייתה חייבת להישאר שלמה. התיקון יכול היה לתקן את פירוש מצב התשלום, אבל לא לשנות דברים שצרכני ה-downstream כבר נשענו עליהם.

התפקיד שלי

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

מה בניתי או תיקנתי:

  • ולידציה ברמת ה-batch וטיפול ב-abort שמודע למצב התשלום, כולל לוגיקת הוולידציה המשותפת והפעלה מאחורי feature flag.
  • לוגיקת הזכאות ל-Retry וסיווג סוגי הכשלים בתשלומי batch בכרטיס אשראי שנכשלו: המיפוי בין כשל ליכולת לנסות שוב, כללי העדיפות וה-fallback לסטטוס ה-legacy, ומטא-דאטה מוגדרת (typed) על הכשל ב-API של ה-polling לתשלומי batch.
  • TTL לתפוגה שניתן לרענון בזמן ריצה, וה-job המתוזמן שמנקה הוראות תשלום תקועות.
  • ה-GSI ב-DynamoDB שעומד מאחורי ה-job. הייתי אחראי על תכנון דפוס הגישה שלו מקצה לקצה: המפתח הדליל ומפתח ה-shard, ה-projection ואימות ברמת השאילתה בהיקף מייצג.
  • סינון בנתיב הקריאה של תשלומי כרטיס אשראי שנכשלו.
  • השינוי באופן שבו שירותי ה-downstream רושמים תשלומי כרטיס אשראי ב-QuickBooks.

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

על מה לא הייתי אחראי: ארכיטקטורת ה-batches המקורית של התשלומים, מערכות Credit Card ו-RTP, זרימת הביצוע של כרטיס האשראי, ארכיטקטורת השמירה וההפצה ב-downstream, טבלת ה-Payment Instruction הבסיסית וחוויית ה-Reschedule.

ארכיטקטורה וגישה

ולידציה של תשלומים. ה-validator ברמת ה-batch דוחה עכשיו שילובי Credit Card + RTP בגבול הוולידציה, לפני שה-batch מגיע לשכבת השמירה או להפצה ב-downstream. הוא מחזיר HTTP 400 מובנה עם sub-code ספציפי, כך שמצב לא תקין לא נכנס למערכת. הבדיקה חלה גם על יצירת תבנית חוזרת וגם על נתיב התזמון אחרי אישור, והיא ממומשת בכלי ולידציה אחד ולא משוכפלת בין הנתיבים. ההפעלה נעשית לפי חברה, מאחורי feature flag, והתראות Splunk מכסות את נתיב הדחייה.

בנפרד תיקנתי באג בטיפול ב-abort של batches: Payment Instructions של כרטיס אשראי במצב ממתין לאישור בוטלו על ידי לוגיקה שנועדה למצבי תשלום אחרים. התיקון הפך את הטיפול ב-abort למודע למצב התשלום, וההתנהגות באמצעי תשלום ובמצבים אחרים לא השתנתה.

סיווג כשלים בכרטיס אשראי וזכאות ל-Retry. כשתשלום בכרטיס אשראי נכשל, ה-client צריך תשובה אחת ברורה: האם אפשר לתזמן אותו מחדש. העברתי את ההכרעה הזו לשכבת התחום (domain) המרכזית ב-Backend, במקום להשאיר לכל client לפרש קודי סטטוס גולמיים. סיווג כשלים אחיד מקטלג כל כשל (נדחה על ידי המנפיק, דולג בעקבות דחייה קודמת, דחיית סיכון, לא עבר עיבוד, ומקרי סטטוס legacy), ומיפוי יחיד הופך את הסיווג לתוצאה: ניתן ל-Retry או לא. סוג הכשל שנשמר הוא הקובע כשהוא קיים. כשהוא חסר, המיפוי חוזר ל-fallback לסטטוס התשלום ה-legacy, כך שגם רשומות ישנות מקבלות תשובה מוגדרת. ה-API של ה-polling לתשלומי batch מחזיר עכשיו, לכל תשלום שנכשל, סיווג כשל ומטא-דאטה של זכאות ל-Retry, וה-UI מציע פעולת Reschedule רק עבור כשלים שבאמת זכאים לכך. סגרתי גם פער שבו סוג הכשל ודגל ה-Retry יכלו לסתור זה את זה, כך ששניהם מתארים תמיד את אותה תוצאה. את המיפוי, את כללי העדיפות וה-fallback ואת מקרי הקצה של דחייה והתאוששות כיסיתי בסט בדיקות רגרסיה.

ארכיטקטורת הניקוי של מחזור החיים. Payment Instructions שנשארות במצב in-flight מעבר לחלון הצפוי צריכות להתבטל אוטומטית, ולא להישאר כך ללא הגבלת זמן. תחילה הפכתי את חלון התפוגה ל-TTL שמוגדר באמצעות Spring וניתן לרענון בזמן ריצה. הוא תומך בדקות חלקיות, דוחה קלט שאינו חיובי, ואפשר לכוונן אותו בלי deploy מחדש.

מעל זה תכננתי job מתוזמן שמתואם באמצעות ShedLock, כך שבכל סבב (sweep) רץ בדיוק מופע אחד של האפליקציה.

איתור ה-candidates התחיל כסריקה מדופדפת של כל טבלת DynamoDB, ותכננתי אותו מחדש סביב GSI דליל שמחולק ל-shards.

  • ה-index דליל: רק רשומות במצב ממתין נושאות בו ערך, ורשומה שיוצאת ממצב ממתין יוצאת מה-index מעצמה.
  • ה-candidates מפוזרים על פני מפתחות partition של shards, וחותמת הזמן היא מפתח המיון. כל shard שואל על רשומות ישנות מסף ה-TTL ומדפדף בהן מהישנה לחדשה.
  • העבודה בכל הרצה מוגבלת. ה-shard שבו מתחילים מתחלף בין הרצות, כך שאותם shards לא מקבלים תמיד את הפרוסה הראשונה.
  • שיוך ה-shards נשאר יציב כשהקונפיגורציה משתנה.

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

שינוי בטוח והפעלה הדרגתית. ה-GSI הוא מנגנון איתור ולא מקור האמת למצב התשלום. Payment Instruction יכולה להסתיים מעצמה בין האיתור לביטול.

נתיב הקריאה בנוי סביב זה. תחילה ה-job מסנן זכאות לפי הנתונים שמוקרנים ל-index (projection): סטטוס ממתין, סף הזמן, מצב ממתין לאישור ונתונים חסרים. אחר כך, ממש לפני שהוא משנה משהו, הוא קורא מחדש את המצב השמור הנוכחי ובודק זכאות שוב. candidate שכבר הסתיים הוא no-op ולא שגיאה.

נשארת בדיוק קריאה טרייה אחת מהטבלה הבסיסית, בתוך נתיב ה-abort האסינכרוני הקיים, ממש לפני ההפצה. הסינון לפי נתוני ה-projection הוריד את מספר הקריאות מהטבלה הבסיסית לכל candidate משתיים לאחת עבור candidate שבוטל, ולאפס עבור candidate שדולג. הקריאה שמגנה מפני רשומת index לא מעודכנת נשארה.

ל-sweep יש מנגנוני בטיחות תפעוליים: kill switch גלובלי שיכול לעצור אותו, הפעלה לפי חברה באמצעות feature flag ששולטת בחשיפה, ובידוד כשלים ברמת ה-candidate, כך ש-Payment Instruction בעייתית אחת לא יכולה לעצור את ההרצה. לוגים מובנים ומטריקות מכסים את נפח הביטולים, גיל ה-candidates וגודל השאילתה.

תיקון קשור בנתיב הקריאה מוסיף סינון בטוח ל-null: Payment Instructions של כרטיס אשראי שנכשלו כבר לא מופיעות בתוצאות הרשימה. ACH, אמצעי תשלום legacy והסדר הקיים לא שונו. התיקון הזה מעצב את מה שמוחזר בקריאה, ואינו מבצע ניקוי של מחזור החיים.

שירותי downstream. רישום תשלום בכרטיס אשראי ב-QuickBooks הופעל על ידי אירוע ביניים במחזור החיים. זרימת הביצוע בצד ה-upstream הפסיקה לפלוט את האירוע הזה, ולכן העברתי את ה-trigger ב-downstream לאירוע הראשון בשלב הבא של מחזור החיים. ל-ACH יש נתיב אירועים משלו, ולא נגעתי בו.

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

כשל ברישום ב-downstream אינו נחשב לכשל תשלום: הכסף של כרטיס האשראי כבר עבר בצד הביצוע. אין עסקה אחת שמשתרעת על שני השירותים, ולכן אין מה לבטל באמצעות rollback.

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

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

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

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

זה נפרד משינוי הרישום ב-downstream שתואר קודם, אף שהשניים באותו תחום: שינוי במחזור החיים יכול להיות תקין מקומית ובכל זאת לשנות את האופן שבו צרכני ה-downstream מפרשים את מצב התשלום.

החלטות מרכזיות

  • לדחות לפני השמירה ולא אחריה. בדיקת CC+RTP רצה בגבול הוולידציה, כך ש-batches לא תקינים אינם נכנסים לשכבת השמירה או להפצה, ואין צורך לנקות אותם אחר כך ב-downstream. הבדיקה המשותפת הופרדה לכלי ולידציה אחד, כדי ששני נתיבי יצירת ה-batches לא יתרחקו זה מזה.
  • לקבוע ב-Backend אילו כשלים ניתנים ל-Retry, ולחשוף זאת כחוזה. זכאות ל-Retry היא כלל עסקי לגבי אילו תשלומים שנכשלו מותר לתזמן מחדש. לכן מקומה בשכבת התחום המרכזית, כמיפוי אחד בין כשל לזכאות, ולא נגזרת מחדש בכל client מקודי סטטוס. ה-API של ה-polling לתשלומי batch מפרסם את התוצאה כמטא-דאטה מוגדרת על הכשל, וסוג הכשל השמור גובר על fallback לסטטוס התשלום ה-legacy, כך שהתשובה מוגדרת גם לרשומות ישנות וגם לחדשות.
  • איתור באמצעות GSI מחולק ל-shards במקום סריקת טבלה מלאה. ה-GSI הדליל כולל רק candidates במצב ממתין. חלוקה על פני מפתחות partition מונעת partition חם של סטטוס, ומפתח מיון לפי חותמת זמן בכל shard מאפשר דפדוף לפי גיל. מכאן נגזרים גם הגבלת העבודה בכל הרצה וסיבוב ה-shard ההתחלתי, שמשאירים את עלות ה-sweep צפויה ומפזרים את העומס ככל שה-backlog גדל.
  • להשתמש ב-GSI לאיתור בלבד, ולקרוא מחדש את המצב מהטבלה הבסיסית לפני כל שינוי. זו החלטה שנוגעת לתקינות ולא לביצועים. ה-index נושא מספיק נתונים כדי לסנן בזול את רוב ה-candidates, אבל ה-job מבצע קריאה טרייה אחת של המצב השמור ממש לפני ה-abort, כך שהוא אינו משנה מצב על סמך רשומת index לא עדכנית. candidates שכבר הסתיימו הם no-op.
  • kill switch גלובלי והפעלה הדרגתית לפי חברה, ולא הכול או כלום. job מתוזמן שמשנה מצב תשלום בכל החברות צריך גם דרך לעצור מיד וגם חשיפה הדרגתית, עם בידוד כשלים ברמת ה-candidate, כדי שרשומה בעייתית אחת לא תעצור את ה-sweep עבור כולם.
  • להתייחס לביצוע התשלום ולרישום ב-downstream כמערכות שיכולות להיכשל בנפרד, ולא כיחידה אטומית אחת. אין עסקה שמשתרעת על שירות ביצוע כרטיס האשראי ועל נתיב הרישום ב-QuickBooks. שלוש תכונות צריכות להתקיים באופן עצמאי: רישום idempotent, נתיב Retry וקריסה שאפשר ליישב, ואי-ביצוע rollback כשיש כשל ברישום. העקביות נובעת משלוש התכונות האלה ולא מעסקה משותפת.
  • לאמת את הזרימה כולה ולא רק את היחידה. הבאג נבע מהאופן שבו מצב פורש בין נתיבים, ולכן התיקון אומת באמצעות כיסוי רגרסיה ל-batches עם תוצאות מעורבות, יחד עם אימות מקצה לקצה בסביבה הדומה לפרודקשן ו-observability בזמן ריצה, ולא באמצעות בדיקות יחידה של נתיב בודד.

שיקולים ופשרות

השארת קריאה טרייה אחת מהטבלה הבסיסית בתוך נתיב ה-abort עולה קריאת DynamoDB לכל candidate שבוטל, בנוסף לשאילתת ה-GSI. זה יקר יותר מאשר לסמוך על ה-index באופן מלא, אבל abort של Payment Instruction במצב in-flight על סמך index לא עדכני הוא באג חמור יותר מעלות הקריאה. הסרת השליפה המיותרת המוקדמת החזירה את רוב העלות הזו.

GSI שמחולק ל-shards מוסיף מורכבות לשאילתות, כי הן מתפזרות על פני shards ולא פוגעות ב-partition אחד. בתמורה נמנע צוואר בקבוק של partition חם ככל שנפח התשלומים במצב in-flight גדל.

הפעלה לפי חברה באמצעות feature flag אומרת שבזמן ההפעלה צריך להתחשב בשתי התנהגויות. בתמורה החשיפה הדרגתית ואפשר לעצור מיד.

תוצאות ואימות

התנהגות שמומשה. ה-validator ברמת ה-batch דוחה שילובי Credit Card + RTP שאינם נתמכים לפני שמירה או הפצה ב-downstream. נתיב ה-abort של תשלומים הממתינים לאישור מודע למצב התשלום. רישום ה-QuickBooks ב-downstream לתשלומי כרטיס אשראי מופעל עכשיו מאירוע מוקדם יותר במחזור החיים, והרישום עצמו idempotent. ה-API של ה-polling לתשלומי batch חושף סיווג כשלים אחיד ודגל זכאות ל-Retry מוגדר (typed) לתשלומי כרטיס אשראי שנכשלו, כשסוג הכשל השמור גובר על fallback לסטטוס התשלום ה-legacy ושני השדות עקביים זה עם זה. ה-TTL של תפוגת Payment Instruction ניתן לרענון בזמן ריצה. ה-sweep המתוזמן מגלה ומבטל הוראות תשלום תקועות במצב in-flight דרך ה-GSI המחולק ל-shards, וקורא מחדש את המצב השמור לפני כל abort. Payment Instructions של כרטיס אשראי שנכשלו מסוננות החוצה מקריאות של Payments UI/API.

אימות. השינויים בוולידציה ובטיפול ב-abort מכוסים בסט בדיקות ממוקד ובסט הרגרסיה המלא של המודול. מיפוי סיווג הכשלים והזכאות ל-Retry מכוסה בסט רגרסיה על מטריצת המדיניות המלאה, כולל עדיפות סוגים, fallback ל-legacy ונתוני כשל לא ידועים או לא מלאים. לקונפיגורציית ה-TTL יש שינוי קונפיגורציה מתואם וכיסוי יחידה. ה-job המתוזמן וה-GSI מכוסים בסט בדיקות אוטומטי ממוקד שכולל guards של ה-scheduler, התנהגות של job מבוזר, סמנטיקת שאילתה ואגרגציה בין shards, ותרחישי abort מקבילים ובידוד כשלים. ה-GSI הוקם ואומת ברמת השאילתה מקצה לקצה בהיקף מייצג. אימות בהיקף מייצג אינו ראיה בהיקף פרודקשן. הסינון בנתיב הקריאה מאומת מול חוזה ה-controller. התיקון של מצב התשלום בין שירותים מכוסה בבדיקות רגרסיה ממוקדות, כולל batches עם תוצאות מעורבות, ובאימות מקצה לקצה בסביבה הדומה לפרודקשן. הוא נפרס ופועל בפרודקשן.

מנגנוני בטיחות. ה-sweep מוגן ב-kill switch גלובלי ובהפעלה לפי חברה באמצעות feature flag, עם בידוד כשלים ברמת ה-candidate, כך ש-Payment Instruction בעייתית אחת לא תעצור הרצה.

גבולות האחריות. זוהי עבודה להקשחת הוולידציה והאמינות של תשלומים: אחריות ב-Backend על מדיניות Retry וסיווג כשלים להתאוששות תשלומים, אוטומציה של מחזור החיים ותכנון דפוסי גישה ב-DynamoDB בתוך פלטפורמה קיימת. אין כאן אחריות על ארכיטקטורת ה-batches של התשלומים, על מערכות Credit Card או RTP, על זרימת הביצוע של כרטיס האשראי, על השמירה וההפצה ב-downstream, על טבלת ה-Payment Instruction הבסיסית או על חוויית ה-Reschedule. עבודת הזכאות ל-Retry היא כלל עסקי בתחום התשלומים לגבי אילו תשלומים שנכשלו מותר לתזמן מחדש, ולא Retry של שליחת הודעות או עיבוד at-least-once. לא נמדדו נתונים על נפח עסקאות, latency, הכנסות, הפחתת תשלומים שנכשלו, שיעור התאוששות, חומרת תקלות או הפחתת תקלות, ולא נטענים נתונים כאלה.

מה למדתי

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

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

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

הגרסה הראשונה של ה-job השתמשה בסריקה של כל הטבלה. כדי להגיע לתכנון של GSI מחולק ל-shards עם בדיקה מחדש לפני ה-abort, הייתי צריך לערער על נקודת ההתחלה הזו, ולחשוב איך ישאלו את ה-index ואיפה נתונים לא עדכניים עלולים להיכנס למערכת מצב התשלום הרחבה יותר.

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

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