Skip to content

0033. Payload dei nodi non isolabili sul ramo catch (lastConsumed)

  • Stato: Accettata
  • Data: 2026-07-27
  • Decisori: Team BI/ETL

Contesto

Con ADR-0031 le transform stateless dentro uno scope guard girano isolate per-record, quindi il DeadLetter che raggiunge il ramo catch porta il raw esatto. Con ADR-0032 lo stesso vale per i sink che implementano loadOne.

Restava scoperto il caso peggiore: un nodo che non può essere isolato. Le transform stateful (groupBy) non vanno mai isolate — l'isolamento falserebbe l'aggregazione — e un guard in modalità circuit-breaker non isola per scelta. In entrambi i casi l'errore emerge dallo stream di output e viene catturato da interceptErrors, che accoda { raw: undefined, error }: il payload è perso del tutto, non solo degradato.

L'issue #78 proponeva di risolverlo con un'infrastruttura completa: envelope TaggedRecord con id/derivedFrom propagati lungo tutto il flusso, più un buffer di snapshot trattenuto dal guard e una risalita della catena di lineage per correlare. Costo alto: tocca ogni punto di consumo degli stream del runner, in dual-mode acceso/spento per non alterare il comportamento a feature spenta, e introduce un buffer di payload in memoria con politica di eviction.

Decisione

lastConsumed sul DeadLetter

Il runner traccia, per ogni nodo transform che non gira isolato, l'ultimo record consumato, e lo allega al DeadLetter su un campo dedicato:

ts
readonly lastConsumed?: unknown;

Il punto di aggancio esisteva già: countPulls avvolge l'input di ogni transform. È bastato passargli l'item invece di niente.

Perché un campo distinto e non raw

raw significa «il record su cui l'errore è avvenuto». Qui la garanzia è più debole, e servirla sullo stesso campo renderebbe impossibile, per chi scrive un nodo sul ramo catch, sapere quale delle due promesse ha in mano.

Un flag di provenienza su raw ('exact' | 'last-consumed') è stato valutato e scartato: un consumatore che ignora il flag legge un dato sbagliato senza accorgersene. Un campo separato rende l'errore impossibile per costruzione — chi non conosce lastConsumed semplicemente non lo legge, e raw resta undefined come prima.

Distinzione consumo / emissione

groupBy ha due fasi: consuma l'input in un for await, poi emette gli aggregati. Un errore in key/init/reduce è causato dal record corrente — che è esattamente l'ultimo pullato. Un errore in finalize è causato da un gruppo, e l'ultimo record dello stream non c'entra nulla.

countPulls segnala quindi anche l'esaurimento dell'input, e lastConsumed è popolato solo finché l'input non è esaurito. In fase di emissione il campo resta undefined: meglio assente che vero alla lettera e inutile in pratica.

Limite dichiarato

L'esattezza vale per le transform che non interleavano consumo ed emissione. groupBy non lo fa. Una transform utente che bufferizza in avanti, o che emette prima di aver esaurito l'input, può produrre un lastConsumed che non è il colpevole: è una garanzia della transform, non del runner, ed è documentata come tale nel JSDoc del campo.

Fuori scope: i join

fastJoinOutput ha due input (build e probe): «ultimo consumato» sarebbe ambiguo. I nodi join non popolano il campo, e un test fissa il confine.

recordId anche per le transform

isolatedTransformOutput rigettava con il solo nodeId. Ora dichiara anche recordId, nella stessa forma posizionale ${runId}:${nodeId}:${seq} introdotta da ADR-0032 per i sink, eliminando l'asimmetria per cui solo gli errori dei sink erano correlabili a un record.

Sensibilità dei dati

lastConsumed è payload reale e resta sull'envelope in-process del ramo catch: non entra in CaughtErrorSummary né nel report JSON. recordId è sintetico e quindi sicuro da esporre. Un test asserisce sul report serializzato, non solo sull'oggetto in memoria.

Alternative considerate

Envelope completo con lineage e snapshot al guard (#78 come scritta) — copre anche gli errori in emissione e i nodi a valle di un groupBy, che qui restano scoperti. Rimandata: paga l'intera infrastruttura, più un buffer di payload con eviction, per casi che non sappiamo ancora se qualcuno colpisce nella pratica. Il buffer è inoltre un buco di correttezza proprio sul caso d'uso di punta — un path di fallback che perde lo snapshot non funziona, e lo scopre a valle.

Envelope e lineage senza snapshot — identità reale ancorata alla sorgente e catena derivedFrom visibile in UI, senza trattenere payload. È la strada naturale dopo questa consegna, se e quando i casi scoperti si presentano.

Conseguenze

Positive

  • Il caso di perdita totale del payload è chiuso, con una modifica di poche righe su un punto di aggancio già esistente.
  • Il costo in memoria è un record per nodo, non un buffer: nessuna politica di eviction, nessun limite da configurare, nessun modo di perdere silenziosamente il dato.
  • recordId è ora simmetrico tra sink e transform.

Negative / da tenere d'occhio

  • Due promesse diverse (raw e lastConsumed) sul ramo catch: chi scrive un nodo di fallback deve sapere quale aspettarsi. Mitigato dal JSDoc e dal fatto che sono campi distinti.
  • Restano scoperti: errori in fase di emissione di un nodo stateful, nodi a valle di un groupBy, e i join. Sono lo scope residuo di #78.
  • Il tracker trattiene un riferimento all'ultimo record finché la run non finisce. Un record per nodo transform non isolato — trascurabile, ma non zero.

Non toccato

  • GRAPH_REPORT_SCHEMA_VERSION resta 3: le aggiunte sono opzionali e additive.
  • Comportamento delle transform isolate per-record, dei sink, e delle pipeline senza guard.

Riferimenti

  • Issue #78 (consegna parziale; il residuo resta aperto).
  • Piano: docs/plans/2026-07-27-last-consumed-record-id.md.
  • ADR-0032 (sink isolabili), ADR-0031 (guard per-record), ADR-0029 (nodo try-catch).

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