Skip to content

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, con raw inoltrato). Il circuit-breaker resta disponibile via mode: "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 porta out, fino ai sink (confine), esclusi i nodi con deadLetter propria (gestiscono i loro errori localmente) e i try annidati (aprono un proprio scope).
  • Alla prima EtlError fail-fast che sfugge da un nodo dello scope: l'errore (taggato col nodeId) è deviato come envelope DeadLetter { raw, error } sulla porta catch, 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: union GraphNodeKind + TryCatchGraphNode; GraphEdge.fromPort; arità in validateGraph (try: 1 ingresso, ≥1 porta out, esattamente 1 catch; fromPort solo su edge da try node); proiezione UI (PlanEdge.kind: "catch").
  • graph-runner.ts: computeTryScopes (mappa nodeId → tryId); interceptErrors (devia gli EtlError in scope sulla coda del ramo catch invece di rilanciarli); fan-out con routing per porta (la porta catch riceve 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); fromPort opzionale su .transform()/.sink() per collegare il ramo catch.
  • pipeline.ts (DSL lineare): opzione catch?: 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 + conteggio caught copre 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 DeadLetter e 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 in noeva-etl sia in noeva-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, proiezione catch), 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 (opzione catch), 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 (token warning).
  • 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.

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