0029. Nodo try/catch — instradamento errori downstream su un branch di catch
- Stato: Accettata (semantica di cattura rivista da ADR-0031)
- Data: 2026-07-23
- Decisori: Team BI/ETL
Nota: la semantica di cattura descritta qui (circuit-breaker: 1 record per nodo, poi la sub-stream si ferma) è stata rivista da ADR-0031: il default è ora per-record (N record per branch nel
catch, conrawinoltrato). Il circuit-breaker resta disponibile viamode: "circuit-breaker".
Contesto
La gestione errori delle pipeline a grafo era solo per-nodo: con deadLetter (ADR-0016, ADR-0027) il singolo record fallito va a un sink terminale e la run continua (partial); senza (fail-fast, default) qualsiasi EtlError propaga e abortisce l'intera run. Mancava un modo per dire "se qualcosa a valle fallisce, non far cadere tutta la pipeline: manda l'errore su un ramo alternativo". Gli edge avevano solo porte di input (toPort per il join), e il fan-out clonava lo stesso stream (tee), quindi non era nemmeno esprimibile "record diversi su porte diverse".
Decisione
Nuovo node kind "try-catch" + porta di output sull'edge (GraphEdge.fromPort?: "out" | "catch", default "out").
Semantica (circuit breaker, decisa in brainstorm):
- Il try node è un pass-through sul flusso dati (porta
out) e apre uno scope = i nodi raggiungibili dalla portaout, fino ai sink (confine), esclusi i nodi condeadLetterpropria (gestiscono i loro errori localmente) e i try annidati (aprono un proprio scope). - Alla prima
EtlErrorfail-fast che sfugge da un nodo dello scope: l'errore (taggato colnodeId) è deviato come envelopeDeadLetter { raw, error }sulla portacatch, la sub-stream protetta termina in modo pulito e la run non abortisce. I rami paralleli fuori scope proseguono. - Gli errori non-
EtlError(bug di programmazione) propagano comunque e abortiscono, dentro o fuori scope.
Implementazione (src/core/):
graph.ts: unionGraphNodeKind+TryCatchGraphNode;GraphEdge.fromPort; arità invalidateGraph(try: 1 ingresso, ≥1 portaout, esattamente 1catch;fromPortsolo su edge da try node); proiezione UI (PlanEdge.kind: "catch").graph-runner.ts:computeTryScopes(mappanodeId → tryId);interceptErrors(devia gliEtlErrorin scope sulla coda del ramo catch invece di rilanciarli); fan-out con routing per porta (la portacatchriceve la coda, non un clone).async-queue.ts: coda push→pull (canale) che fa da ponte tra la cattura push durante la run e il consumo pull (async-iterable) del ramo catch; chiusa quando tutti i nodi dello scope hanno fatto settle.graph-builder.ts:.tryCatch(fromId, id);fromPortopzionale su.transform()/.sink()per collegare il ramo catch.pipeline.ts(DSL lineare): opzionecatch?: Sink<DeadLetter>che avvolge la catena in uno scope try/catch.
Stato del run: si riusa "partial" (nessun nuovo GraphRunStatus) quando caught > 0; il conteggio è in NodeReport.caught (per nodo try) e in metrics["<tryId>:caught"].
Lato UI (noeva-etl-ui, submodule, sync manuale): ETLStepType e lo schema Zod di plan-loader accettano "try-catch"; DagEdgeSpec.kind / edgeKindSchema accettano "catch". Il nodo guard è un nodo di controllo del flusso, non di dati, quindi: ha un colore proprio "try/catch" — token dedicato warning (ambra, aggiunto a index.css + tailwind.config.ts), distinto dai 4 colori degli step-dato e dal destructive dei rami d'errore, con icona ShieldAlert; non mostra colonne (render compatto, solo header: pur ereditando le colonne a monte per la propagazione a valle, in UI le nasconde); ha handle sorgente distinti TRY_OUT_SOURCE_HANDLE (destra, flusso protetto) e CATCH_SOURCE_HANDLE (basso). Il ramo di catch è sempre la diramazione più in basso: la porta catch è su lato SOUTH in ELK, ma poiché la crossing-minimization di ELK non lo garantisce, layoutDag applica uno shift deterministico post-layout (computeCatchShift) che trasla l'intero sotto-albero del catch sotto tutti i nodi del flusso principale. L'edge catch è tratteggiato/destructive come il dead-letter.
Alternative scartate
- Cattura per-record (come dead-letter, ma su un ramo di nodi anziché un sink terminale) — scartata: su un sotto-DAG con join/fan-out/stateful un record di output non è tracciabile 1:1 a un input, quindi la semantica per-record non è ben definita fuori dal singolo transform isolabile. Il circuit-breaker (per-esecuzione del sotto-DAG) è chiaro e trattabile nel modello a stream.
- Nuovo
GraphRunStatus "caught"— scartato: cambierebbe il contratto per tutti i consumatori (UI, worker, cli);partial+ conteggiocaughtcopre il bisogno restando additivo. - Rendere il ramo catch un sink terminale (come il dead-letter) invece di un vero ramo di nodi — scartato: l'obiettivo era proprio poter processare l'errore (loggare, arricchire, instradare), non solo scaricarlo.
Conseguenze
- Pro: gestione errori a livello di sotto-DAG senza cambiare i default (fail-fast resta invariato dove non c'è un try node); cambiamento additivo (nuovo kind/porta, retrocompatibile); riusa l'envelope
DeadLettere il pattern di accumulo del dead-letter; convive col dead-letter per-record (precedenza: i nodi con dead-letter propria restano autonomi ed esclusi dallo scope). - Contro: duplicazione di contratto cross-repo (
"try-catch"/"catch"va aggiunto sia innoeva-etlsia innoeva-etl-ui), mitigata dai test su entrambi i lati; la definizione di scope è conservativa sui diamanti (un nodo raggiungibile da un try via due percorsi, uno passante per un try annidato, è assegnato al primo try che lo visita) — caso raro, documentato. - Il circuit-breaker ferma tutto il sotto-DAG protetto al primo errore: i record già emessi a valle prima dell'errore restano, quelli successivi no. È la semantica voluta ("try/catch"), non un dead-letter che continua record-by-record.
Riferimenti
src/core/graph.ts(TryCatchGraphNode,GraphEdge.fromPort,validateGraph, proiezionecatch),src/core/graph-runner.ts(computeTryScopes,interceptErrors, routing per porta,caught),src/core/async-queue.ts,src/core/graph-builder.ts(.tryCatch()),src/core/pipeline.ts(opzionecatch),src/pipelines/orders-enriched-daily/(demo).- UI:
noeva-etl-ui/src/lib/types.ts,plan-loader.ts,step-visuals.ts,elk-layout.ts,components/dag/DatasetNode.tsx,tailwind.config.ts,src/index.css(tokenwarning). - Correlati: ADR-0016 (dead-letter per nodo), ADR-0027 (dead-letter nella DAG).
- Piano di implementazione: docs/plans/2026-07-23-try-catch-node.md.

