KyaniteLabs Studio — dal 2017
Caso studio 001 — Logistica

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.

Cliente
Northwind Freight Inc.
Progetto
Sviluppo incorporato · 11 mesi
Team
4 ingegneri, 1 PM
Anno
2024—2025
Dominio
Trasporti · smistamento LTL
Stack
Rust, Postgres, Kafka, React
Ruolo
Architettura, sviluppo, on-call
Stato
Spedito · in produzione
Fig. 1.0 — Console di smistamento, vista principale. Assegnazione carichi in tempo reale sul corridoio orientale.

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.

Fig. 2.1 — Topologia dei servizi dopo la migrazione
dispatcher UI driver tablet ELD · webhooks api gateway load planning Rust · pg-A routing Rust · pg-B execution Rust · pg-C Kafka 9 topics data lake replay audit log

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.

Fig. 2.2 — Il pattern di apply idempotente, semplificato
// 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(())
}
Un singolo vincolo di unicità, nessuna transazione distribuita. La scrittura di audit è nella stessa transazione Postgres dell’insert.

03Il prodotto

01La vista principale del dispatcher. Abbiamo resistito alla tentazione di ridisegnarla. Le scorciatoie da tastiera e l’ordine delle colonne erano il risultato di quattordici anni di uso esperto e li abbiamo preservati esattamente — il sistema “nuovo” sembrava il vecchio al primo giorno, che è ciò che volevamo.
02Dettaglio rotta con stato live degli autisti. La colonna sottile sulla destra è il log eventi — ogni cambio di stato visibile, copiabile, con il servizio sorgente nominato. Il team operations ci ha fatto aggiungerla in quarta settimana ed è diventata la singola superficie più usata per debug in produzione.
03La vista del supervisore di campo, per quando un regional manager è in un piazzale. L’abbiamo costruita deliberatamente come un secondo client a scope stretto invece che come una porta responsive del desktop. Contesto ergonomico diverso, software diverso.
04La dashboard del diff in shadow mode che abbiamo tenuto attiva per nove mesi. Ogni discrepanza tra vecchio e nuovo smistamento era una riga qui; le triavamo ogni giorno. Al sesto mese ne chiudevamo circa due a settimana. Al momento del cutover, il diff era vuoto da quattordici giorni consecutivi.

04Risultato

Azione dispatcher · p95
800ms 120ms
Misurato lato server all’api gateway. Il p99 del picco mattutino è sceso da 9,2s a 410ms.
Downtime al cutover
0s
Cambio di configurazione di tre righe, domenica alle 04:00 ET. Nessun impatto visibile ai clienti registrato.
Costo infrastrutturale annuo
−38%
Per lo più dal pensionamento di due farm di server Windows legacy e dal consolidamento su istanze t4g.

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.