Un grup de stații de încărcare EV pe același circuit împarte un singur buget de amperaj. Un nivel de parcare conectat la o siguranță de 200 A nu poate lăsa fiecare loc să consume putere maximă simultan, așa că operatorii scriu reguli — limitează această stație, rezervă-o pe aceea, pune pe pauză profilul unui partener, lasă siguranța să câștige mereu — iar control plane-ul trebuie să transforme aceste reguli în limite de curent per stație și să le trimită către hardware, rapid.
„Rapid” a fost esența întregului proiect. Ținta: o scriere de regulă ajunge la stații în mai puțin de 100 ms. Iată unde acest obiectiv ne-a împins din urmă.
Nu poți deține o latență pe care nu o controlezi
Prima decizie reală a fost și cea mai importantă, și nu era cod. O schimbare de regulă devine în cele din urmă un mesaj OCPP către o stație fizică, peste o rețea de teren instabilă. Acel ultim salt este extrem de variabil și complet în afara controlului nostru. Dacă SLA-ul l-ar fi inclus, am fi promis un număr pe care nu l-am fi putut respecta niciodată.
Așa că am trasat linia la dispatch, nu la acknowledgement:
SLA-ul de 100 ms este măsurat de la scrierea regulii până în momentul în care request-ul downstream este predat clientului HTTP — nu când stația trimite ack. Tot ce vine după dispatch este explicit în afara scopului.
Acea singură decizie de delimitare a modelat întreaga arhitectură. Odată ce „gata” înseamnă „request-ul e pe fir”, ești liber să faci tot ce e în amonte de el deliberat decuplat de hardware.
Separă calculul de hardware
Munca se împarte clar în două sarcini cu personalități opuse:
- Resolving este calcul pur. Date fiind regulile curente și ce stații există, ce amperaj ar trebui să primească fiecare stație? Predominant read, sigur de recalculat oricât de des dorești, nu atinge niciodată rețeaua.
- Reconciling este numai I/O. Ia acele numere țintă și apelează efectiv gateway-ul. Predominant write, lent, predispus la erori.
Le-am ținut separate. Resolver-ul este o funcție pură — intrările intră, iese un
Map, ceasul fiind pasat ca argument pentru ca testele să poată fixa timpul. Fără
bază de date, fără HTTP, nimic de mock-uit. Asta a făcut din cea mai complicată
logică de business (benzi de prioritate, redistribuire prin împărțire egală,
reguli pe ferestre de timp) partea ușor de testat.
Reconciler-ul împrumută pattern-ul de controller din Kubernetes: un singur worker async minuscul per stație, fiecare urmărindu-și propriul diff desired-vs-applied. O stație defectă își blochează propriul worker și pe al nimănui altcuiva.
Colapsarea semnalelor este o caracteristică, nu un bug
Worker-ele per stație ne-au oferit ceva ce nu am apreciat pe deplin până nu le-am
văzut rulând. Fiecare reconciler ține un singur flag signaled și un promise pe
care îl așteaptă. Declanșează trei schimbări de regulă la o stație în 50 ms și
worker-ul tot face un singur apel downstream — către cea mai recentă valoare.
Asta se potrivește cu realitatea hardware-ului. Nu are rost să-i spui unei stații să meargă la 32 A, apoi 10 A, apoi 16 A într-o rafală scurtă; ar trebui pur și simplu să meargă la 16 A. Colapsarea rezultă din design pe gratis, în loc să necesite un strat de debounce lipit pe deasupra.
Când modelul tău de worker oglindește constrângerea fizică, optimizarea încetează să mai fie ceva ce implementezi și devine ceva ce nu poți evita.
Timerele pe care voiam să le scriem, și nu le-am scris
O mulțime de reguli sunt legate de timp: o limitare programată care rulează
18:00–22:00, o regulă care expiră la miezul nopții. Mișcarea evidentă este
setTimeout.
Este și o capcană. Repornirile procesului volatilizează timerele din memorie, iar
un control plane care uită în tăcere să ridice o limitare după un deploy este mai
rău decât inutil. Așa că substratul de wall-clock este Postgres, nu event loop-ul:
fiecare graniță de schedule este un rând durabil, un sweeper face poll după rândurile
scadente într-o tranzacție cu FOR UPDATE SKIP LOCKED, emite un eveniment și le
șterge.
Compromisul onest este un prag de polling de ~1 s pentru reconcilierile bazate pe timp — prețul durabilității fără a aduce un workflow engine gestionat. Schimbările bazate pe reguli (cele acoperite de SLA) nu îl plătesc; sunt event-driven și aterizează în zeci de milisecunde.
Un timer pe care nu îl poți recupera după un crash nu este o caracteristică, este un incident latent. Am dat un secund de latență pentru a nu pierde niciodată unul.
Două subtilități pe care ni le-a oferit Postgres
Câteva lucruri au devenit evidente abia când ne-am bazat pe baza de date ca pe coloana vertebrală.
NOTIFY este tranzacțional. Emitem pg_notify în interiorul tranzacției de
scriere a regulii. Postgres amână livrarea până la commit, așa că subscriberii nu
pot observa fizic o schimbare care ulterior face rollback. Aceasta este o garanție
de corectitudine pe gratis — dar numai dacă reziști tentației de a muta notify-ul
„undeva mai curat”, în afara tranzacției.
Un socket căzut este un gol tăcut. Conexiunea LISTEN poate muri și se poate
reconecta, iar orice NOTIFY declanșat în acea fereastră pur și simplu dispare.
Polling-ul l-ar masca; nu voiam să facem poll. Soluția este un backstop: la
reconectare, listener-ul emite un semnal santinelă „reîncarcă tot”, iar resolver-ul
face o recalculare completă. Rar, ieftin și închide singura gaură pe care
sistemele event-driven adoră să se prefacă că nu există.
Bug-ul plictisitor care mușcă pe toată lumea
Pentru consemnare, în caz că salvează cuiva o după-amiază: TypeORM returnează
coloanele numeric ca string-uri JavaScript, nu ca numere.
// amperes comes back as "32.00", a string
const total = chargers.reduce((sum, c) => sum + c.maxAmperes, 0);
// => "032.0016.00..." — string concatenation, not additionNicio eroare de tip, niciun crash — doar un calcul de buget care e, în tăcere, catastrofal de greșit. Convertim la graniță, de fiecare dată, intenționat.
Ce face de fapt
Cap la cap, pe o bază de date proaspăt populată, latența de dispatch măsurată de la scrierea regulii până la apelul către gateway s-a situat la 19–33 ms pentru fiecare operație testată — adăugarea unei limitări, pauză, reluare, excluderea unei stații, modificarea unei valori. Un parc de 20 de stații converge de la pornire la rece în 21 ms wall-clock, de la primul request până la ultimul. Restul de ~70 ms de marjă este deliberat: absoarbe sughițurile Postgres, varianța notify-urilor și trecerile grele ale resolver-ului fără a amenința vreodată SLA-ul.
Lecția pe care o tot reînvățăm: decide unde se termină responsabilitatea ta înainte să scrii o linie de cod. Trasarea SLA-ului la dispatch și tratarea stării durabile din Postgres ca sursă a adevărului în locul memoriei procesului au făcut tot ce a urmat mai simplu decât ar fi avut vreun drept să fie.