Skip to content

ADR-0038: il report di una run fallita conserva topologia e conteggi parziali

  • Stato: Accettata
  • Data: 2026-07-29
  • Issue: #95 (epic #93)

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.

  1. Le fasi di esecuzione (cablaggio dei nodi, consumo dei sink, scarico dei dead-letter) sono isolate in executePhases() e avvolte in un try/catch.
  2. 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: null su successo, valorizzato sul fallimento.
  3. Il report viene allegato all'errore con attachGraphRunReport(err, report) e l'errore è rilanciato così com'è. Gli entrypoint lo recuperano con graphRunReportOf(err).
  4. buildFailedGraphReport resta il fallback per i fallimenti sollevati fuori da runGraph (tipicamente il wiring dei connettori in run.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 instanceof continua 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

  • runGraph ritorna il report failed invece di lanciare. Più semplice da leggere, ma cambia il contratto pubblico documentato in docs/conventions/run-report.md («come libreria, run() lancia sull'errore fatale») e obbliga a rivedere ogni chiamante che oggi fa try/catch. Scartata: l'obiettivo era arricchire il report, non riscrivere la semantica di run().
  • L'entrypoint ricostruisce la topologia da getSpec(). Impatto minimo sul core, ma restituisce nodes[] 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'instanceof come la WeakMap, ma muta un oggetto che appartiene al chiamante e sopravvive a structuredClone/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_data continua 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: assembleReport non è idempotente — incrementa i contatori aggregati su ctx.metrics — quindi va chiamata una sola volta per run. È un invariante interno a runGraph, non esposto.

Riferimenti

  • src/core/graph-runner.tsassembleReport, executePhases, toGraphRunError
  • src/core/errors.tsattachGraphRunReport, graphRunReportOf, GraphRunFailure
  • docs/conventions/run-report.md — schema v4
  • ADR-0006 (report stateless), ADR-0018 (node progress reporting), ADR-0037 (parallelismo osservabile)

Noeva è un marchio registrato di 4D S.R.L.