ADR-0038: il report di una run fallita conserva topologia e conteggi parziali
Contesto
Fino a qui, una run che abortiva produceva un report costruito dall'entrypoint con buildFailedGraphReport: nodes: [], edges: [], rejectedReasons: {}, e i soli conteggi globali leggibili da ctx.metrics.
L'effetto è paradossale: la run che più serve ispezionare — quella fallita — è l'unica che non dice dove si è rotta né fin dove era arrivata. Nella UI il DAG di una run fallita risultava vuoto; da CLI, jq '.nodes' restituiva [].
La causa è strutturale, non una svista. I conteggi per nodo (stats), i tempi e i nodeReports vivono nella closure di runGraph e muoiono con l'abort. Un entrypoint può al più risalire alla topologia dichiarata via pipeline.getSpec(); non può sapere quanti record ogni nodo avesse già attraversato — informazione che esiste solo dentro il runner.
Decisione
È runGraph ad assemblare il report anche quando la run fallisce, e il report viaggia allegato all'errore.
- Le fasi di esecuzione (cablaggio dei nodi, consumo dei sink, scarico dei dead-letter) sono isolate in
executePhases()e avvolte in untry/catch. - Su errore,
assembleReport(error)produce il report con la stessa funzione che serve il percorso di successo — è ciò che garantisce che una run abortita porti la stessa topologia e gli stessi conteggi per nodo di una riuscita. L'unica differenza èerror:nullsu successo, valorizzato sul fallimento. - Il report viene allegato all'errore con
attachGraphRunReport(err, report)e l'errore è rilanciato così com'è. Gli entrypoint lo recuperano congraphRunReportOf(err). buildFailedGraphReportresta il fallback per i fallimenti sollevati fuori darunGraph(tipicamente il wiring dei connettori inrun.ts), dove una topologia eseguita non esiste per davvero.
Perché una WeakMap e non un errore dedicato
La prima stesura lanciava un GraphRunFailure che conteneva il report. Ha rotto subito quattro test esistenti (file-connector.test.ts, graph-runner.test.ts) che asseriscono rejects.toBeInstanceOf(ExtractError) / LoadError / ValidationError sul risultato di run().
Quei test documentano un contratto pubblico reale: il tipo concreto dell'errore lanciato è osservabile e ci si fa affidamento. Sostituirlo lo avrebbe rotto per ogni consumer, non solo per i test.
Il report è quindi tenuto in una WeakMap chiave-errore, con due proprietà utili:
- l'errore originale non viene né mutato né sostituito — ogni
instanceofcontinua a funzionare; - il report non finisce nella serializzazione accidentale dell'errore (log,
JSON.stringify), dove nessuno lo vuole e dove potrebbe gonfiare le righe di log.
GraphRunFailure sopravvive solo per il caso limite di un valore lanciato che non è un oggetto (una stringa) e non può quindi fare da chiave in una WeakMap: lì non c'è alcun instanceof significativo da preservare.
error.nodeId accusa il colpevole, non l'ultimo testimone
Un report che conserva la topologia serve a rispondere «dove si è rotto». Perché la risposta sia affidabile, withNodeId conserva l'attribuzione esistente invece di sovrascriverla: vince il primo taggatore.
Serve perché un EtlError fail-fast risale lo stream attraversando i nodi a valle, e ciascuno di essi lo rilancia — interceptErrors per i nodi intermedi, il catch della fase sink per i sink. Con un'attribuzione distruttiva l'ultimo vinceva, e per una transform che lancia verso un sink il report diceva che era stato il sink: l'indagine partiva dalla parte opposta del grafo. Il primo a taggare è sempre il nodo in cui l'errore è nato, perché interceptErrors avvolge lo stream di ogni nodo.
Il rovescio resta vero e testato: quando è il sink a rompersi, il primo (e unico) taggatore è lui. Un EtlError costruito da codice utente con un nodeId già valorizzato mantiene il proprio — chi solleva l'errore sa più del runner su chi sia il colpevole.
Alternative considerate
runGraphritorna il reportfailedinvece di lanciare. Più semplice da leggere, ma cambia il contratto pubblico documentato indocs/conventions/run-report.md(«come libreria,run()lancia sull'errore fatale») e obbliga a rivedere ogni chiamante che oggi fatry/catch. Scartata: l'obiettivo era arricchire il report, non riscrivere la semantica dirun().- L'entrypoint ricostruisce la topologia da
getSpec(). Impatto minimo sul core, ma restituiscenodes[]con tutti i conteggi a zero. Soddisfa l'acceptance criteria solo a metà: si sa quali nodi c'erano, non fin dove la run era arrivata — cioè si perde esattamente l'informazione diagnostica che serve. - Proprietà non-enumerabile sull'errore (
Object.defineProperty(err, "report", …)). Preserva l'instanceofcome laWeakMap, ma muta un oggetto che appartiene al chiamante e sopravvive astructuredClone/serializzatori che ignorano l'enumerabilità. Scartata per il principio di immutabilità del progetto.
Conseguenze
- Positive: il DAG di una run fallita è ispezionabile in UI e da CLI; si vede quale nodo era in corso e quanti record aveva già processato. Nessuna modifica ai consumer esistenti:
output_datacontinua ad avere la stessa forma, solo più popolata. - Nessuna rottura di contratto: il tipo dell'errore lanciato, l'exit code e il formato stdout restano quelli di ADR-0006.
- Costo:
assembleReportnon è idempotente — incrementa i contatori aggregati suctx.metrics— quindi va chiamata una sola volta per run. È un invariante interno arunGraph, non esposto.
Riferimenti
src/core/graph-runner.ts—assembleReport,executePhases,toGraphRunErrorsrc/core/errors.ts—attachGraphRunReport,graphRunReportOf,GraphRunFailuredocs/conventions/run-report.md— schema v4- ADR-0006 (report stateless), ADR-0018 (node progress reporting), ADR-0037 (parallelismo osservabile)

