Primitive care nu diverg

ForkTex Engineering · 5 septembrie 2026 · 4 min

Fiecare serviciu pe care-l construim atinge trei lucruri înainte să facă ceva interesant: emite un identificator, înregistrează când s-a întâmplat ceva, și în cele din urmă eșuează și trebuie să spună de ce. Astea sunt cele mai neglamuroase decizii dintr-un sistem și cele mai scumpe de revizuit, pentru că până când dor, sunt în fiecare tabel, fiecare linie de log și fiecare client.

Așa că le-am specificat o dată, le-am pus într-o bibliotecă comună, și am încetat să mai avem discuția.

Identificatorii sunt ordonați temporal, și niciodată trunchiați

Fiecare identificator pe care-l emitem e un UUIDv7 complet.

Versiunea 7 pune timestamp-ul la început, ceea ce aduce două lucruri. Inserțiile de cheie primară aterizează la marginea dreaptă a indexului, în loc să se împrăștie în frunze aleatorii, iar un tabel aglomerat continuă să adauge, nu să se fragmenteze. Iar rândurile se sortează după creare, fără o coloană separată după care să sortezi.

A doua regulă contează mai mult decât prima: un UUIDv7 nu se taie niciodată.

Asta pare inofensiv:

trace_id = str(uuid7())[:8]

Nu e o scurtare. Pentru că v7 pune timestamp-ul la început, caracterele de la început sunt aproape numai ceas. Trunchierea la un prefix colapsează spațiul de chei la aproximativ o valoare distinctă pe milisecundă, așa că două request-uri în aceeași milisecundă primesc același identificator „unic". Asta e o coliziune, deghizată în comoditate.

Atracția către asta e reală: un UUID complet e urât într-o linie de log și mai rău într-un tichet de suport. Dar un trace id există ca un screenshot de la un utilizator să devină o interogare de log, iar unul trunchiat încetează tacit să-și mai facă treaba exact la încărcarea la care ai avea nevoie de el.

Identificatorii sunt de asemenea emiși ca valoare implicită de coloană, nu la locul apelului. A pasa o valoare proaspăt generată într-un constructor înseamnă că valoarea există înainte ca ORM-ul să fi decis dacă are nevoie de una, ceea ce diverge tacit de ce ar fi făcut baza de date singură.

Fiecare timestamp e UTC, într-o singură formă

Un modul decide cum devine un moment text și înapoi. Nu o convenție, nu un obicei de code review. Un modul, pe care-l apelează tot restul.

Face foarte puțin:

  • now() returnează un datetime UTC conștient de fus orar.
  • to_iso(value) convertește la UTC și formatează.
  • from_iso(text) returnează întotdeauna un datetime conștient de UTC.

Valoarea nu e în funcții, care sunt triviale. E în faptul că există exact una din fiecare. Modul de eșec pe care-l previne asta nu e o cădere. Sunt două servicii care fiecare își face manual „aceeași" normalizare, de acord timp de doi ani, apoi în dezacord despre o graniță de oră de vară într-un raport pe care nimeni nu-l recitește.

O nuanță onestă care merită numită: intrarea naivă e presupusă a fi UTC, nu respinsă. Strict vorbind, e greșit. Un datetime naiv nu poartă niciun fus, iar ghicitul e cum încep bug-urile. Există pentru că apelanții dinaintea modulului se bazau pe asta, și a-i rupe pe toți deodată ar fi fost mai rău decât concesia. Există un mod strict care semnalează eroare în schimb, iar codul nou îl folosește. Preferăm să scriem asta decât să pretindem că implicitul e principial.

Un detaliu conex care mușcă lumea: precizia urmează formatatorul standard, iar microsecundele apar doar când sunt diferite de zero. Șirul nu are lățime fixă. Orice îl parsează după offset de caracter va funcționa până la primul timestamp care aterizează exact pe o secundă.

O eroare poartă un cod, nu un status

A treia primitivă e cea pe care echipele o descoperă de obicei ultima.

O eroare nu e un status HTTP. Devine unul la margine, dacă marginea se întâmplă să fie HTTP. Același eșec ridicat într-un consumator de coadă, un job programat sau un CLI n-are niciun status de purtat și niciun transport pe care să-l poarte.

Deci tipul nostru de eroare nu știe nimic despre HTTP. Poartă un code citibil de mașină, un message citibil de om, details structurate opționale și trace id-ul. Fiecare serviciu serializează eșecurile într-un singur plic:

{
  "code": "validation",
  "message": "Start date must not be in the past.",
  "details": { "field": "startDate", "received": "2020-01-01" },
  "traceId": "019203f1-8c4a-7f3e-b1d2-5a6c8e0f1a2b"
}

O ierarhie, multe mapări de transport. O excepție care știe că e un 404 nu poate fi ridicată dintr-un worker fără să mintă.

Două detalii își câștigă locul. Întâi, vocabularul e închis în cod, dar deschis pe rețea: codurile comune sunt un enum, dar câmpul se serializează ca string, așa că un serviciu își poate adăuga propriile coduri de domeniu fără ca fiecare alt serviciu să afle despre ele. Al doilea, textul driverului nu ajunge niciodată la client. Șirurile de eroare ale bazei de date citează valorile vinovate, care sunt frecvent date de utilizator, numesc constrângeri și coloane interne, și se schimbă între versiuni majore. Un client care face pattern-matching pe textul driverului se rupe la upgrade, și merită.

Există și o mică regulă defensivă: serializarea details cade înapoi pe o reprezentare de string, în loc să semnaleze eroare. O cale de raportare a erorilor care ea însăși aruncă transformă un eșec gestionat într-unul negestionat și pierde cauza originală, ceea ce e cel mai enervant mod de a pierde o după-amiază.

De ce merită

Nimic din toate astea nu e ingenios. Ăsta e chiar ideea.

Costul specificării acestor trei lucruri e câteva sute de linii într-o bibliotecă comună și un argument, o dată. Costul de a nu le specifica e o acumulare lentă de aproape-eșecuri: un format de id care diferă între două servicii, un timestamp care e ora locală într-un tabel, o suprafață de erori unde jumătate din rute returnează un cod și jumătate returnează proză.

Niciunul dintre astea nu va doborî un sistem în ziua în care e introdus. Toate fac sistemul mai greu de raționat pentru totdeauna, și fiecare e aproape imposibil de reparat mai târziu, pentru că până atunci e deja portant.