Partiamo dalla parte più facile da perdere leggendo il resto: non è stato prelevato denaro. Nessun portafoglio è stato addebitato due volte, nessuna carta è stata addebitata, nessuna quota di abbonamento è stata spesa per lavoro che non è mai avvenuto.
Ciò che si era rotto era un numero su uno schermo. Su alcuni account la ripartizione dei consumi nella tua pagina account segnava troppo — sul peggiore che abbiamo misurato, 2,4 volte il registro che dovrebbe riassumere. Il registro è stato corretto per tutto il tempo. Così anche i tetti mensili, che si leggono da tutt'altra parte. È stato un difetto di visualizzazione, è corretto, ed ecco la causa.
Una chiave che cercava qualcosa che nessuno aveva scritto
Il lavoro che parte da qualcosa che hai fatto — aggiornare un riepilogo giornaliero dei consumi, accreditare una segnalazione, diffondere una notifica — non avviene dentro la tua richiesta. La richiesta scrive un messaggio e un consumatore in background lo raccoglie un istante dopo: è ciò che impedisce a un effetto collaterale lento di rendere lenta una pagina veloce.
Il pericolo di quella disposizione è fare due volte lo stesso lavoro, perché un broker riconsegna volentieri lo stesso messaggio dopo un intoppo. La difesa è una casella di ingresso: prima di elaborare, un consumatore rivendica il messaggio scrivendo una riga che porta la chiave di quel messaggio, e un indice univoco rende impossibile una seconda rivendicazione.
La nostra rivendicava su una coppia — il tenant a cui apparteneva il messaggio, più la chiave del messaggio — e cercava quella coppia contro l'identificativo di tenant portato dalla busta del messaggio, che invece è valorizzato.
Le righe di rivendicazione non portavano alcun identificativo di tenant. Erano tutte vuote, e lo erano state fin dall'inizio. La documentazione dell'entità affermava, con queste parole, che il framework lo timbrava all'inserimento. Non lo ha mai fatto. Misurato in produzione il giorno in cui l'abbiamo trovato: tutte le 3.344 righe della casella di ingresso vuote, mentre 1.174 delle 1.451 righe sul lato produttore portavano un tenant reale — ed è esattamente per questo che nessuno ha sospettato del lato in lettura.
Così la ricerca chiedeva (tenant, chiave), la riga salvata era (niente, chiave), e non poteva mai corrispondere.
Non ha fallito nulla, ed è per questo che è andato avanti per settimane
Una ricerca che non può mai corrispondere sembra qualcosa che dovrebbe sollevare un errore al primo tentativo. Ha fatto l'opposto.
La rivendicazione ha deciso che il messaggio era nuovo. L'inserimento ha poi urtato l'indice univoco ed è stato respinto come duplicato — correttamente, perché la riga era già lì. Un ramo di ripiego ha intercettato quel rifiuto e lo ha letto con benevolenza: un'altra consegna deve aver rivendicato questo, qui non c'è nulla da fare. La busta è rimasta nello stato «ancora da elaborare», e poiché era ancora da elaborare, la consegna successiva l'ha raccolta e ha rifatto il lavoro.
Ogni consumatore rielaborava il proprio arretrato, circa ogni quarantacinque secondi, all'infinito. Ventisei lo facevano. Una riga di produzione si è conclusa 333 ore dopo il suo arrivo, e si era conclusa più e più volte per la maggior parte di quel tempo.
Che cosa ha rotto davvero
Di quei ventisei consumatori, quattro accumulano invece di sovrascrivere. Quei quattro sono le proiezioni di fatturazione che la pagina dei consumi legge, ed è lì che la cosa è emersa.
Avevano una protezione esplicita contro esattamente questo, scritta dopo un incidente di agosto in cui un singolo evento era stato applicato nove volte a un indicatore. La protezione marca una riga come riuscita prima di elaborarla, così che una riconsegna trovi il marchio e si fermi. Il marchio veniva scritto correttamente ogni volta. La rivendicazione successiva lo cercava poi con la stessa chiave rotta e non lo trovava. La garanzia era vuota dal giorno in cui era stata rilasciata.
Ciò che questo non ha toccato merita un elenco preciso, perché «proiezione di fatturazione» suona allarmante e i fatti non lo sono:
- Il registro era corretto. Ogni chiamata all'IA scrive esattamente un record di fatturazione con la propria istantanea di prezzo, e nulla su questo percorso li scrive.
- Nessun portafoglio si è mosso. I saldi derivano dal registro, mai da un riepilogo.
- Nessun tetto si è mosso. Il guardiano del budget legge un contatore separato, che è l'unica fonte di verità su quanto hai speso questo mese. Nessuno è stato interrotto in anticipo per via di un numero gonfiato, e nessuno è passato senza doverlo fare.
L'unica cosa che legge il riepilogo interessato è la cronologia dei consumi nella tua pagina account: il tuo totale e la ripartizione di dove è andato. Questo è l'intero raggio d'impatto, e quello che faceva era segnare troppo.
La metà che nessuno stava guardando
La stessa ricerca rotta stava in un secondo punto: il percorso che gira quando un messaggio fallisce. Lì non trovava mai la riga che doveva aggiornare, e subito sotto c'era un ramo con il commento «irraggiungibile in pratica» — che restituiva «questo errore è terminale» a ogni fallimento.
Così il contatore dei tentativi non avanzava mai e nessun messaggio avvelenato ha mai raggiunto la coda dei messaggi non recapitabili. Un intero strato di gestione degli errori era spento, dietro un commento che diceva a chiunque lo leggesse che qui non c'era nulla da controllare.
La correzione
Entrambe le ricerche ora usano come chiave il message key all'interno del gruppo di consumatori, che è ciò che l'indice univoco impone comunque. Le chiavi dei messaggi derivano dall'identificativo di ciò che le ha prodotte, quindi non possono collidere tra tenant per costruzione.
La documentazione che affermava la timbratura è stata corretta anziché cancellata. Quella frase era la trappola, e cancellarla non avrebbe lasciato nulla a dire che qualcuno finalmente aveva controllato.
Nove test fissano il comportamento. Sei di essi falliscono contro il vecchio codice, cosa che abbiamo confermato rimettendo il vecchio codice.
Ripulire dopo
Le righe gonfiate si riparano da sole. Un worker ricostruisce i riepiloghi giornalieri dal registro ogni ora, su una finestra di due giorni. Girava da sempre e perdeva: una volta all'ora contro un consumatore che riapplica ogni quarantacinque secondi non è una gara equa.
Ora che il consumatore non rigira più, vince. I due giorni ancora dentro quella finestra sono stati ricostruiti dal registro appena la correzione è andata in produzione. Due giorni precedenti ne erano già usciti, e ciò che vi resta è un account nostro più una differenza trascurabile altrove — così li abbiamo lasciati anziché allargare una finestra in produzione per inseguirli, il che è sembrato meglio che lasciar intendere che la pulizia fosse totale.
Cosa ne abbiamo tratto
Una chiave di deduplicazione deve rispecchiare ciò che l'indice impone davvero, non ciò che il codice crede sia stato scritto. Le due metà di questa erano alimentate da campi diversi e nessuna era mai stata misurata contro l'altra. Ora entrambe lo sono, e il test che lo dimostra diventa rosso contro la versione che era sbagliata.