Skip to content

0018. Report sintetico e progress per nodo via writeReport

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

Contesto

noeva-etl è stateless: lo stato di una run vive interamente nella riga processes che l'ha originata (ADR-0017). Finora dispatch.ts scriveva processes.progress/output_data una sola volta, alla fine dell'intera esecuzione: durante la run (che per pipeline con milioni di record può durare minuti o ore) chi osserva il processo da UI/API non ha alcun segnale di avanzamento, e l'unico output disponibile a fine corsa è il RunReport/ GraphRunReport aggregato — utile, ma senza un punto di estensione per un nodo che voglia esporre un dettaglio sintetico e specifico (es. "letti 1.000.000 di record da Postgres, watermark 2026-07-01") senza doverlo dedurre da conteggi generici (extracted/written/rejected).

Serve un meccanismo che:

  1. aggiorni processes.progress (0-100) man mano che i nodi del grafo completano, non solo alla fine;
  2. permetta a ogni nodo (source/transform/join/sink, sia nel DSL lineare sia in quello a grafo) di scrivere un riassunto sintetico e significativo in output_data, mai il payload grezzo;
  3. non richieda supposizioni: se un nodo non implementa il riassunto, il default deve essere un fatto verificabile ({ name, success }), non un tentativo di indovinare cosa fosse importante;
  4. resti innocuo per le pipeline esistenti che non lo adottano (nessuna breaking change silenziosa).

Decisione

  • Nuovo modulo src/core/progress.ts con due contratti generici, senza dipendenze da Supabase/processes (quel dettaglio resta nel layer worker/):
    • WriteReportFn = (ctx, stats) => Record<string, unknown> | Promise<...> — hook opzionale per nodo, riceve i conteggi del nodo (extracted/written/rejected/rejectedReasons) così un nodo può scegliere di includere volumi senza scrivere i record stessi.
    • ProgressReporter.onNodeComplete(update) — osservatore opzionale su RunContext, notificato una volta per nodo completato, con l'esito di writeReport (o il default) e il progress calcolato.
  • writeReport si configura per nodo, nel builder (GraphBuilder.source/ transform/join/sink(..., { writeReport })) e, per il DSL lineare, tramite RunOptions.writeReport: { source?, chain?, sink? } sui 3 nodi sintetici che Pipeline.run() costruisce internamente.
  • Se writeReport è assente, graph-runner.ts scrive il default { name: node.name, success }, dove success è true se il nodo non ha abortito la run (i record isolati a dead-letter non contano come fallimento del nodo — sono un esito atteso e già tracciato altrove).
  • progress è calcolato come round(nodi completati / nodi totali * 100): semplice, monotono, e non richiede stime sulla dimensione dei dati (che noeva-etl, essendo a streaming, spesso non conosce in anticipo).
  • Sia writeReport che ProgressReporter.onNodeComplete sono wrappati in try/catch dentro graph-runner.ts: un errore nell'uno o nell'altro logga un warning e ricade sul default/viene ignorato, ma non abortisce mai la run — un riassunto o un aggiornamento di progresso non possono degradare l'affidabilità del dato.
  • Il layer worker/ resta l'unico a conoscere processes: nuovo src/worker/progress-reporter.ts (createProcessProgressReporter) adatta ProgressReporter a ProcessManager.updateProcess, scrivendo {status: "running", progress, output_data: {nodes: {...}}} ad ogni nodo completato. dispatch.ts scrive anche un progress: 0 iniziale subito dopo il claim (prima che qualunque nodo completi), e in caso di eccezione arricchisce il RunReport fallito con lo snapshot dei nodi completati fino a quel punto (nodeReports), invece di perdere quel dettaglio.
  • GraphRunReport/RunReport guadagnano il campo nodeReports: Record<string, unknown> (schema v3): mappa nodeId → esito di writeReport. Additivo, quindi non-breaking per consumatori esistenti del report.

Alternative considerate

  • writeReport come parte del contratto Source/Sink/Transform invece che opzione del builder — scartata: avrebbe richiesto modificare l'interfaccia di ogni connettore/trasformazione esistente (anche quelli senza bisogno di un riassunto custom), mentre l'opzione per-nodo nel builder è additiva e ricalca esattamente il precedente già in uso per deadLetter (NodeDeadLetterOptions).
  • Progress stimato sui volumi (es. extracted/stima totale) invece che su nodi completati — scartata: richiederebbe conoscere in anticipo la cardinalità totale, che in un framework a streaming spesso non è nota prima di terminare l'estrazione; "nodi completati su totale" è l'unica metrica sempre disponibile a costo zero dalla topologia del grafo.
  • success = nessun record rifiutato invece di "il nodo non ha abortito la run" — scartata: renderebbe success: false l'esito normale di ogni pipeline con dead-letter configurato (uno scarto è un esito atteso e già gestito, non un fallimento del nodo).
  • Un solo aggiornamento di output_data a fine run (status quo) — scartata: è esattamente il problema che questo ADR risolve (nessuna visibilità intermedia su run lunghe).

Conseguenze

  • Positive: chi osserva un processo ETL da UI/API vede progress avanzare in tempo reale e un output_data incrementale, senza attendere la fine della run; ogni nodo ha un punto di estensione esplicito per un riassunto significativo, con un default sicuro ({name, success}) che non richiede di scrivere nulla per ottenere comunque un segnale utile.
  • Costi/limiti:
    • Una scrittura updateProcess in più per nodo completato (oltre a quella iniziale e finale): per grafi con molti nodi, più round-trip verso Supabase durante la run. Nessun throttling/debounce introdotto in questa iterazione — se diventa un problema di rate, è un possibile seguito.
    • writeReport è best-effort: un suo fallimento non abortisce la run ma degrada silenziosamente al default (loggato come warning, non visibile nel report finale se non nei log).
    • Bump di GRAPH_REPORT_SCHEMA_VERSION/REPORT_SCHEMA_VERSION a 3: consumatori che deserializzano il report con uno schema stretto vanno aggiornati (vedi docs/conventions/run-report.md).

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