Inginerie · Fluxuri de date
Cache-uri și backfill-uri sigure: nu ștergi istoricul înainte să sosească datele noi
Un cache sigur la eșec este unul în care o reîmprospătare picată lasă datele vechi neatinse: scrii cu upsert pur, niciodată ștergere urmată de reinserare, și reîncerci exact excepția de timeout pe care o aruncă biblioteca. Backfill-ul umple goluri cu IS NULL în loc să rescrie decizii, instantaneele se verifică singure la citire, iar o valoare de azi nu răspunde niciodată la o întrebare despre trecut.
Actualizat
Ce înseamnă un cache sau un backfill sigur la eșec
Un cache sau un backfill este sigur la eșec atunci când o rulare picată lasă datele anterioare exact cum erau. Nu ștergi istoricul ca să-l reinserezi cu condiția ca noua extragere să reușească; scrii cu upsert pur (inserezi sau, dacă cheia există, actualizezi), astfel încât o extragere care eșuează sau expiră nu schimbă nimic. Un backfill umple goluri (WHERE coloana IS NULL), nu rescrie valori pe care un om le-a decis deja.
Ținem mai multe tabele derivate, reconstruite periodic din API-uri externe: costul zilnic de publicitate, costul pe produs, profitul lunar pe linie de business. Întrebarea utilă nu e dacă o extragere va eșua, ci cum arată tabelul după ce eșuează.
Regula 1: upsert pur și reîncercarea timeout-ului real
Tentația, când reîmprospătezi un interval de date, este DELETE FROM cache WHERE zi >= :start, urmat de inserarea a ce a adus extragerea. Pare idempotent, dar nu e sigur la eșec. Dacă extragerea nu aduce nimic pentru că a expirat o cheie de acces sau API-ul n-a răspuns la timp, ștergerea a rulat deja și istoricul a dispărut.
Varianta sigură este upsert-ul: INSERT ... ON CONFLICT (cheie) DO UPDATE. Rândurile aduse de extragere se actualizează, iar cele neaduse își păstrează ultima valoare cunoscută. O extragere picată lasă cifrele de ieri, vechi dar corecte, pe care o verificare de prospețime le poate semnala. Cifrele șterse nu se pot semnala: arată pur și simplu ca zero.
Reîncerci excepția pe care o aruncă biblioteca, nu pe cea la care te aștepți. Orice apel extern stă într-o reîncercare cu pauze crescătoare, iar reîncercarea trebuie să prindă clasa corectă. O bibliotecă client pentru API-urile Google pe care o folosim aruncă un TimeoutError brut la un răspuns lent, nu RequestException din requests, pentru care se scrie de obicei învelișul de reîncercare. O reîncercare care prinde altă clasă pare rezolvată la revizie și nu pornește niciodată. Provoci un timeout într-un test, citești ce clasă iese și o prinzi pe nume.
Regula 2: backfill-ul umple goluri, nu rescrie decizii
Un backfill este un script care completează o coloană nouă pe rânduri deja existente. Filtrul sigur este WHERE coloana IS NULL: doar rândurile care încă n-au niciun răspuns. Filtrul periculos este WHERE coloana != tinta: tot ce nu are deja valoarea pe care ar alege-o scriptul.
Noi am scris varianta periculoasă. Scriptul atribuia o categorie înregistrărilor existente. La prima rulare coloana era goală peste tot, așa că ambele filtre selectau aceleași rânduri și rezultatul era corect. Între timp însă formularul din interfață scrie o categorie la fiecare salvare, cu o valoare implicită. De la a doua rulare încolo, backfill-ul ar fi transformat orice alegere făcută de un om în valoarea scriptului. Am dovedit-o pe o copie a producției: am pus de mână două categorii diferite pe două înregistrări, am rulat iar backfill-ul și amândouă au ieșit cu categoria scriptului. Antetul scriptului spunea chiar că e idempotent și se poate rula de câte ori vrei, ceea ce făcea a doua rulare nu doar posibilă, ci recomandată.
Idempotent nu înseamnă inofensiv. O operație poate ajunge la aceeași stare la fiecare rulare și totuși să distrugă informație: decizia luată de un om între două rulări. Testul care prinde un backfill distructiv nu e „îl rulez de două ori la rând”, care trece, ci „un om a ales altceva, apoi scriptul a mai rulat o dată”.
Regula 3: instantaneul se verifică singur la citire
Un instantaneu este orice valoare calculată o dată și păstrată pentru refolosire: o setare ținută în memorie, un context precalculat, un tabel materializat. Un instantaneu care așteaptă să fie invalidat de codul care îi schimbă sursa rămâne în urmă la prima cale de cod care uită să-l invalideze.
Am pățit-o într-o aplicație Android. Un obiect scris ca să țină toate ecranele pe aceeași temă de culori păstra un context construit o singură dată, la pornirea procesului. Comutatorul de temă din meniu schimba setarea sistemului, dar nu reîmprospăta niciodată obiectul. Din clipa în care omul alegea modul întunecat și până murea procesul, fundalurile erau închise și textul la fel: un contrast de 1,02:1 pe aproximativ 25 de ecrane.
Reparația face instantaneul să se verifice singur la fiecare citire: compară modul pentru care a fost construit cu modul cerut acum și se reconstruiește doar când diferă. Pentru un cache de date, aceeași regulă înseamnă să păstrezi intrarea lângă valoarea calculată și s-o compari la citire, ca o invalidare uitată să producă o reconstruire, nu un răspuns greșit.
Regula 4: starea de azi nu spune ce era atunci
O valoare curentă nu poate răspunde la o întrebare despre trecut. Am construit o regulă care oprea comenzile al căror produs nu mai avea stoc și am raportat 166 de comenzi care așteptau o marfă inexistentă. Obiecția a venit imediat: produsele erau epuizate acum, dar aveau stoc când s-au plasat comenzile. Pentru aproape fiecare produs semnalat plecaseră comenzi mai noi, într-un caz la 122 de ore după prima comandă oprită. Marfa se completează în loturi.
Stocul este un număr despre acum; comenzile sunt evenimente din trecut. Fără un istoric de stoc, întrebarea care desparte cazurile ține tot de evenimente: a mai plecat ceva cu acest produs după ce a fost plasată comanda? Dacă da, marfa a existat după ea și oprirea are altă cauză. Dacă nu, comanda e ultima din coadă. Aplicată în cod, lista a scăzut de la 166 la 26 de comenzi, fiecare chiar ultima pe produsul ei. Când o decizie depinde de o stare din trecut, păstrezi un instantaneu datat al acelei stări, nu o reconstruiești din ziua de azi.
Capcanele care întorc mai puțin în loc să dea eroare
Un plafon justificat de o măsurătoare veche. Două limite de paginare din același sistem aveau fiecare câte un comentariu care le justifica. Un API refuză pagina 50 și peste, adică un plafon de 10.000 de rânduri, iar paginile merg de la nou la vechi, deci pentru cel mai mare magazin se citeau doar zilele cele mai recente. Acoperirea cu facturi era de 0% între zilele 12 și 23, în timp ce magazinele mai mici, care încăpeau sub plafon, stăteau constant pe la 90%: grupul de control care a dovedit-o. Celălalt plafon citea 30 de pagini a câte 200, cu comentariul „6.000 de comenzi, peste orice fereastră de 10 zile”. Adevărat când a fost scris; mai târziu un magazin avea 9.966 de comenzi în 10 zile, iar jobul pierdea 3.966 din coadă.
Un plafon nu dă eroare. Întoarce mai puțin, iar „mai puțin” arată identic cu „atât există”. Când paginarea e ordonată, ce se pierde e sistematic coada. Acum orice constantă de tipul „N pagini, N rânduri, N zile ajung” se scrie cu volumul măsurat alături și cu o verificare la rulare care notează în log când plafonul e atins. După reparație, același job a citit 28.039 de comenzi, față de 10.000 înainte.
Un prag de timp sare peste eșecuri pentru totdeauna. Un importator lua linkurile de fișiere postate într-un grup de chat mai noi decât ultimul fișier importat cu succes. Un link care eșua o dată nu mai era reluat niciodată, fiindcă la rularea următoare intraseră deja linkuri mai noi, iar pragul trecuse de el. Măsurat după 16 zile de rulări programate: 8 linkuri din 59 nu ajunseseră niciodată. Patru nici nu fuseseră încercate; patru picaseră o dată, iar trei dintre ele mergeau la o nouă încercare. Un prag de timp notează până unde ai ajuns, nu ce ai făcut, așa că e sigur doar când prelucrarea e tot-sau-nimic.
Reparația este câte un rând pentru fiecare unitate, cu starea ei (de luat, reușit, eșuat definitiv), iar reîncercările citesc starea, nu o dată. O fereastră de valabilitate oprește reîncercările pe ce e cu adevărat mort, aici 10 zile față de cele 7 zile cât trăiesc linkurile serviciului de transfer, iar acele linkuri se raportează în continuare, nu se uită.
Unde am greșit
Fiecare caz de mai sus a pornit de la cod care avea o justificare ce părea corectă: un comentariu care explica plafonul, o promisiune de idempotență în antet, un înveliș de reîncercare, o componentă construită exact ca să prevină defectul pe care l-a produs. Justificarea a fost tocmai lucrul care l-a oprit pe următorul cititor să verifice. Acum tratăm un comentariu care justifică o limită ca pe un semnal să măsurăm din nou, nu ca pe o dovadă. Iar când am verificat fals-pozitivele regulii de stoc, am numărat livrările recente pe produs, deși coletele acelea plecaseră când era stoc: aceeași greșeală de două ori în aceeași zi.
Limitele metodei
Sigur la eșec înseamnă vechi dar semnalat, nu corect. Un upsert pur păstrează ultima valoare bună, deci are nevoie de o verificare de prospețime care se înroșește când valoarea nu se mai mișcă; fără ea, un cache cu upsert ascunde o pană mai mult timp decât ar ascunde-o o ștergere.
Listă de verificare
1. Upsert. Niciun DELETE înainte de extragere; ștergerile sunt un pas separat, după ce extragerea a fost verificată.
2. Reîncercare. Provoci un timeout într-un test și prinzi clasa de excepție care iese de fapt.
3. Backfill. Filtrezi pe IS NULL și testezi „un om a ales altceva, apoi scriptul a mai rulat”.
4. Instantaneu. Păstrezi intrarea lângă valoarea derivată și le compari la fiecare citire.
5. Întrebări despre trecut. Le răspunzi cu evenimente sau instantanee datate, niciodată cu valoarea de azi.
6. Plafoane. Scrii volumul măsurat lângă fiecare limită și notezi în log când e atinsă.
7. Unități. Orice element care poate eșua separat primește un rând cu starea lui.
Întrebări și răspunsuri
De ce e periculos să ștergi și apoi să inserezi într-un tabel cache?
Ștergerea urmată de inserare scoate rândurile vechi înainte să fi sosit datele noi. Dacă API-ul extern nu răspunde la timp sau o cheie de acces a expirat, extragerea nu aduce nimic, iar istoricul a dispărut deja, deci rapoartele arată zerouri în loc de o eroare. Un upsert pur (INSERT ... ON CONFLICT DO UPDATE) actualizează doar rândurile aduse și le lasă pe celelalte la ultima valoare cunoscută, pe care o verificare de prospețime o poate semnala.
Care este filtrul sigur pentru un script de backfill?
Un backfill selectează doar rândurile care încă n-au valoare, cu WHERE coloana IS NULL. Filtrul pe rândurile diferite de valoarea țintă pare echivalent la prima rulare, când coloana e goală, dar la fiecare rulare ulterioară suprascrie alegerile făcute de oameni în interfață. Testezi un backfill cu scenariul „un om a pus altă valoare, apoi scriptul a mai rulat”, nu doar rulându-l de două ori la rând.
Un script idempotent poate fi rulat oricând din nou fără grijă?
Nu. Idempotent înseamnă că scriptul ajunge la aceeași stare la fiecare rulare, nu că păstrează informația. Un backfill care readuce fiecare rând la valoarea calculată de el este idempotent și totuși șterge deciziile luate de oameni între rulări. Siguranța cere ca scriptul să atingă doar rândurile fără răspuns și să raporteze, nu să șteargă, rândurile pe care acum le consideră greșite.
Cum verific dacă o limită de paginare pierde date pe tăcute?
Scrii volumul măsurat lângă limită și notezi în log, la rulare, de fiecare dată când limita e atinsă, pentru că un plafon întoarce mai puține rânduri fără să dea eroare. Compari ce citește instrumentul cu ce ai în propria bază pentru aceeași fereastră sau păstrezi un grup de control destul de mic încât să încapă sub plafon. Cu ordonare de la nou la vechi, rândurile pierdute sunt mereu cele mai vechi din fereastră.
De ce are nevoie fiecare element dintr-un import de un rând propriu de stare?
Un import care își amintește doar ora ultimului succes sare peste orice element care a eșuat o dată, pentru că pragul trece de el imediat ce reușesc elemente mai noi. Un rând pe element, cu o stare precum de luat, reușit sau eșuat definitiv, lasă reîncercările să citească starea, nu o dată. O fereastră de valabilitate oprește apoi reîncercările pe elementele cu adevărat moarte, care se raportează în continuare.
Cum se citează
Going Stellar (2026). Cache-uri și backfill-uri sigure: nu ștergi istoricul înainte să sosească datele noi. GOING STELLAR S.R.L..
https://goingstellar.ro/ro/metode/cache-uri-si-backfill-uri-sigure-la-esec/