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:
- aggiorni
processes.progress(0-100) man mano che i nodi del grafo completano, non solo alla fine; - 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; - 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; - resti innocuo per le pipeline esistenti che non lo adottano (nessuna breaking change silenziosa).
Decisione
- Nuovo modulo
src/core/progress.tscon due contratti generici, senza dipendenze da Supabase/processes(quel dettaglio resta nel layerworker/):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 suRunContext, notificato una volta per nodo completato, con l'esito diwriteReport(o il default) e ilprogresscalcolato.
writeReportsi configura per nodo, nel builder (GraphBuilder.source/ transform/join/sink(..., { writeReport })) e, per il DSL lineare, tramiteRunOptions.writeReport: { source?, chain?, sink? }sui 3 nodi sintetici chePipeline.run()costruisce internamente.- Se
writeReportè assente,graph-runner.tsscrive il default{ name: node.name, success }, dovesuccessè 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 comeround(nodi completati / nodi totali * 100): semplice, monotono, e non richiede stime sulla dimensione dei dati (chenoeva-etl, essendo a streaming, spesso non conosce in anticipo).- Sia
writeReportcheProgressReporter.onNodeCompletesono wrappati in try/catch dentrograph-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 conoscereprocesses: nuovosrc/worker/progress-reporter.ts(createProcessProgressReporter) adattaProgressReporteraProcessManager.updateProcess, scrivendo{status: "running", progress, output_data: {nodes: {...}}}ad ogni nodo completato.dispatch.tsscrive anche unprogress: 0iniziale subito dopo il claim (prima che qualunque nodo completi), e in caso di eccezione arricchisce ilRunReportfallito con lo snapshot dei nodi completati fino a quel punto (nodeReports), invece di perdere quel dettaglio. GraphRunReport/RunReportguadagnano il camponodeReports: Record<string, unknown>(schema v3): mappa nodeId → esito diwriteReport. Additivo, quindi non-breaking per consumatori esistenti del report.
Alternative considerate
writeReportcome parte del contrattoSource/Sink/Transforminvece 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 perdeadLetter(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: renderebbesuccess: falsel'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_dataa 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
progressavanzare in tempo reale e unoutput_dataincrementale, 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
updateProcessin 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_VERSIONa 3: consumatori che deserializzano il report con uno schema stretto vanno aggiornati (vedidocs/conventions/run-report.md).
- Una scrittura

