ADR-0039: storico locale delle run — il core resta stateless, persiste il chiamante
Contesto
ADR-0006 stabilisce che il servizio è stateless: «nessuna persistenza lato servizio», il report va su stdout e cosa farne è deciso dal chiamante.
La conseguenza pratica, però, è che nessuno lo decide. Su CLI il report finisce su stdout e evapora; nel worker finisce in processes.output_data su noeva-server-api. In un ambiente senza cloud non esiste alcun modo di rileggere la run di ieri, e l'osservabilità è zero.
ADR-0006 non è sbagliato: è incompleto sul chi è il chiamante. Nel nostro caso il chiamante siamo noi — etl run ed etl serve — e non aver mai scritto quel «cosa farne» ha lasciato un buco.
Decisione
La persistenza esiste, ma non nel core.
GraphPipeline.run()continua a restituire un report e a non scrivere nulla. ADR-0006 resta valido alla lettera: il grafo è una funzione da(pipeline, config)a report.- La scrittura è di un
RunStoreiniettato dal chiamante:src/cli.tslo crea e lo passa astartWorkers, che lo propaga fino adispatchEtlProcess. Senza store iniettato tutto si comporta esattamente come prima. - Il
RunStorevive in un modulo separatosrc/runs/, non insrc/core/.src/core/non importa mai dasrc/runs/: il confine dell'ADR è visibile nel layout delle directory, non affidato alla convenzione. Una violazione salta all'occhio in review. - Su disco va un envelope
StoredRuncon il propriostoreSchemaVersion, distinto dareportSchemaVersion. IlGraphRunReportresta il contratto pubblico condiviso con il worker e con il cloud, e non viene inquinato dai metadati di persistenza.
Layout su disco
.runs/ # ETL_RUNS_DIR, default `.runs/`, git-ignored
<runId>.json # StoredRun: envelope + reportUn file per run, nessun index.json.
Un indice materializzato sarebbe stato letto, modificato e riscritto sia da etl run (un processo) sia da etl serve (un altro), che condividono la directory. Due scritture concorrenti perderebbero silenziosamente delle run (lost update) — e un rename atomico non basta: rende atomica la scrittura, non la sequenza read-modify-write. Servirebbe un lockfile, con i suoi casi limite (lock orfani dopo un kill -9) per un problema che readdir elimina alla radice. Con la retention di default (200 run) ricostruire l'elenco costa qualche centinaio di KB per comando: irrilevante.
Solo run concluse
Sul disco finiscono solo run concluse (state: "completed"). Gli snapshot parziali di una run in corso restano in memoria e viaggeranno sul bus in-process che alimenta l'SSE (epic #93, sub-issue #98).
Scrivere lo stato parziale a ogni nodo avrebbe reso una run visibile anche da un altro terminale, ma al prezzo di due write per nodo e di file running orfani da riconciliare all'avvio dopo un crash. La semantica «sul disco c'è solo ciò che è finito» è più semplice da spiegare e da testare, e il campo state resta nell'envelope proprio per poter aggiungere gli snapshot in futuro senza cambiare formato.
Attiva di default (opt-out)
ETL_RUNS_DIR non valorizzata ⇒ persistenza attiva su .runs/. Si disattiva con ETL_RUNS_DIR="".
È deliberatamente l'opposto di un flag opt-in: l'acceptance criteria dell'epic è «dopo etl run il report è su disco», e un flag che nessuno ricorda lascerebbe lo storico sistematicamente vuoto — cioè non risolverebbe il problema per cui la feature esiste.
La persistenza non può far fallire una run
Un errore di scrittura (disco pieno, filesystem read-only) viene loggato e ignorato: non cambia l'exit code di etl run né lo stato riportato a processes. Il contratto di ADR-0006 — stdout è il report, l'exit code è l'esito — resta la fonte di verità; la persistenza è additiva.
Sicurezza
- Il report contiene solo conteggi e categorie, mai payload (
docs/conventions/run-report.md).lastConsumedeguardSnapshotsnon ci finiscono mai;rawsolo con l'opt-in esplicito di ADR-0034. Lo storico è quindi sicuro da conservare, ed è testato che lo sia. .runs/è git-ignored: è stato locale di una macchina, non un artefatto del repo.- Il
runIdè validato (^[A-Za-z0-9_-]{1,128}$) prima di diventare un nome di file. Non è difensività per abitudine: con le rotte/runs/:iddi #98 quel valore arriva dalla rete.
Conseguenze
- Positive:
etl runs list/etl runs showdanno storico e dettaglio senza UI e senza rete; il server locale (#98) e la UI (#99) hanno una sorgente da cui leggere; ADR-0006 resta valido e ora è anche completo. - Costi/limiti:
list()e il pruning rileggono la directory: O(run conservate) per comando. ConETL_RUNS_KEEPmolto alto conviene reintrodurre un indice come cache.- Lo storico è per-macchina e per-directory di lavoro: due istanze
etlsu host diversi hanno storici distinti. È voluto — è uno strumento locale, non un archivio centrale (quello restaprocessessunoeva-server-api).
Riferimenti
src/runs/—types.ts,config.ts,run-store.ts,format.tssrc/cli.ts—persistRun,runsCommandsrc/worker/dispatch.ts—DispatchOptions.runStore- ADR-0006 (report stateless), ADR-0028 (
serveco-locato col worker), ADR-0034 (rawopt-in), ADR-0038 (reportfailedcon topologia)

