Skip to content

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.

  1. 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.
  2. La scrittura è di un RunStore iniettato dal chiamante: src/cli.ts lo crea e lo passa a startWorkers, che lo propaga fino a dispatchEtlProcess. Senza store iniettato tutto si comporta esattamente come prima.
  3. Il RunStore vive in un modulo separato src/runs/, non in src/core/. src/core/ non importa mai da src/runs/: il confine dell'ADR è visibile nel layout delle directory, non affidato alla convenzione. Una violazione salta all'occhio in review.
  4. Su disco va un envelope StoredRun con il proprio storeSchemaVersion, distinto da reportSchemaVersion. Il GraphRunReport resta 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 + report

Un 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). lastConsumed e guardSnapshots non ci finiscono mai; raw solo 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/:id di #98 quel valore arriva dalla rete.

Conseguenze

  • Positive: etl runs list / etl runs show danno 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. Con ETL_RUNS_KEEP molto alto conviene reintrodurre un indice come cache.
    • Lo storico è per-macchina e per-directory di lavoro: due istanze etl su host diversi hanno storici distinti. È voluto — è uno strumento locale, non un archivio centrale (quello resta processes su noeva-server-api).

Riferimenti

  • src/runs/types.ts, config.ts, run-store.ts, format.ts
  • src/cli.tspersistRun, runsCommand
  • src/worker/dispatch.tsDispatchOptions.runStore
  • ADR-0006 (report stateless), ADR-0028 (serve co-locato col worker), ADR-0034 (raw opt-in), ADR-0038 (report failed con topologia)

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