Ricostruire un motore di smistamento continentale senza mai metterlo offline.
Ottomila autisti, 220 dispatcher, quattordici anni di logica stratificata. Abbiamo sostituito il cervello dell’operazione mentre la merce continuava a muoversi.
01Il problema
Il sistema di smistamento di Northwind era stato scritto nel 2011 su uno schema Microsoft SQL Server che era stato migrato sul posto tre volte. Nel 2024 era il single point of failure di un’azienda da 1,3 miliardi di dollari di ricavi. L’azione di un dispatcher impiegava in media 1,4 secondi a essere risolta; durante il picco mattutino tra le 5:30 e le 7:00 ET superava i nove secondi e il sistema smetteva silenziosamente di propagare le modifiche ai tablet degli autisti.
Il precedente tentativo di riscrittura era stato chiuso diciotto mesi prima, dopo che un fornitore aveva consegnato un artefatto a forma di Kubernetes che nessuno in Northwind si sentiva sicuro di operare. L’istinto della leadership, secondo noi correttamente, era di essere estremamente diffidente verso qualsiasi cosa somigliasse a una riscrittura greenfield. Abbiamo concordato un vincolo: in nessun momento del progetto il sistema di smistamento poteva essere non disponibile per più di trenta secondi.
02Architettura e decisioni
Abbiamo diviso il dominio dello smistamento in tre servizi lungo le linee già presenti nel vocabolario del team operations: pianificazione carichi (assegnare merce a una motrice), routing (calcolare la sequenza effettiva delle fermate) e esecuzione (il flusso di eventi da tablet ed ELD). Ognuno ha ricevuto la propria istanza Postgres e un singolo processo Rust. Tutto parla via Kafka con un piccolo schema registry scritto a mano — abbiamo provato Protobuf e Avro e alla fine abbiamo preferito un contratto JSON versionato per la facilità di grep durante gli incidenti.
La migrazione è andata avanti in shadow mode per nove mesi. Le scritture andavano sia al vecchio sistema sia al nuovo; le letture venivano dal vecchio, mentre l’output del nuovo veniva continuamente confrontato. Abbiamo intercettato circa quaranta stranezze comportamentali sviluppate dal codice legacy in quattordici anni — regole che nessuno sapeva spiegare — e abbiamo riprodotto quelle da cui i dispatcher dipendevano, disattivando quelle che nessuno riusciva a giustificare. Il cutover è stato un cambio di configurazione di tre righe deployato una domenica alle 04:00 ET.
Il trade-off che difendiamo: abbiamo tenuto Postgres per servizio ed evitato del tutto le transazioni distribuite. Ogni cambio di stato nel routing emette un evento che la pianificazione carichi consuma da Kafka e applica in modo idempotente — compresa la compensazione correttiva se una rotta viene poi revocata. Ci costa circa quaranta millisecondi di eventual consistency sullo schermo del dispatcher. Abbiamo testato con il team operations che fosse accettabile; lo è.
La cosa che avremmo voluto fare prima: scrivere il diff-against-legacy harness alla prima settimana, non alla sesta. È stato l’unico strumento che ha tenuto tutti calmi durante il cutover.
// load_planning::apply_route_event pub async fn apply(evt: RouteEvent, db: &Pg) -> Result<()> { // natural key: (load_id, sequence_no) is unique in route_assignments let existing = db.fetch_one( /* sql */ r#"SELECT applied_at FROM route_assignments WHERE load_id = $1 AND sequence_no = $2"#, &[&evt.load_id, &evt.sequence_no], ).await.optional()?; if existing.is_some() { return Ok(()); } // already applied -- safe replay db.execute(/* sql */ r#" INSERT INTO route_assignments (load_id, sequence_no, route, applied_at) VALUES ($1, $2, $3, now()) "#, &[&evt.load_id, &evt.sequence_no, &evt.route]).await?; audit::record(&evt).await?; Ok(()) }
03Il prodotto
04Risultato
Dodici mesi dopo, il sistema gira con quattro ingegneri lato Northwind — il monolite precedente ne richiedeva undici. Il carico on-call, misurato in eventi di paging per trimestre, è sceso di due terzi. Restiamo su un piccolo retainer mensile per la review architetturale; tutto il resto è loro da operare.