Skip to content

ADR-0040: eventi di run live via SSE dal server locale, non via polling

  • Stato: Accettata
  • Data: 2026-07-29
  • Issue: #98, #99 (epic #93)

Contesto

La UI segue una run interrogando GET /api/processes/:id/status su noeva-server-api ogni 2 secondi. Due limiti, entrambi strutturali:

  1. Dipende dal cloud. Senza noeva-server-api la sidebar non mostra nulla — proprio lo scenario che l'epic #93 vuole coprire.
  2. È in ritardo per costruzione. Un nodo che parte e finisce entro l'intervallo di polling non viene mai visto come "in esecuzione": la UI mostra un salto da pending a ok e il parallelismo reso osservabile da ADR-0037 resta invisibile.

Decisione

Il server locale etl serve espone gli eventi di run in Server-Sent Events, alimentati da un bus in-process.

worker (stesso processo) ──► ProgressReporter ──► RunEventBus ──► GET /runs/:id/events ──► UI
  • RunEventBus (src/runs/run-events.ts) è pura memoria: publish / subscribe, con un buffer per run.
  • createBusProgressReporter traduce onNodeStart/onNodeComplete in eventi; nel worker è composto con il reporter verso processes (compositeProgressReporter), che resta invariato — ADR-0017/0019 non cambia.
  • dispatchEtlProcess pubblica l'evento terminale run-completed dopo aver persistito il report, così un client che reagisce rileggendo GET /runs/:id lo trova già scritto.

Perché un bus e non fs.watch o il polling del filesystem

Perché ADR-0028 co-loca etl serve e i worker nello stesso processo: gli oggetti si possono passare direttamente. fs.watch avrebbe richiesto di scrivere snapshot parziali su disco a ogni nodo — proprio ciò che ADR-0039 ha escluso — e avrebbe reintrodotto latenza e casi limite (eventi coalescenti, file a metà).

Il prezzo è che il bus vive solo dentro quel processo: deployare serve e worker separatamente rende lo stream muto. È il rischio già dichiarato in ADR-0028, e la ragione per cui il fallback lato UI non è opzionale (vedi sotto).

Sottoscrizione prima che la run inizi

GET /runs/:id/events accetta un runId sconosciuto e tiene aperta la connessione.

Non è una tolleranza difensiva: è il caso normale. La UI apre lo stream subito dopo POST /api/processes/etl/:pipeline/run, ma il worker rivendica il job dal polling su processes (ogni 2 s di default). Rispondere 404 a una run non ancora iniziata avrebbe fatto perdere sistematicamente i primi nodi.

Simmetricamente, il bus ritrasmette gli eventi già emessi a chi si connette a metà run: senza, una riconnessione mostrerebbe una sidebar vuota fino al nodo successivo.

flushHeaders() non è un dettaglio

Express bufferizza gli header finché non si scrive un primo chunk. Su una sottoscrizione a una run non ancora partita, questo lasciava il client appeso sulla fetch — senza sapere se lo stream fosse aperto o se il server non avesse risposto. Lo stream invia quindi subito flushHeaders() e un commento SSE, che è anche il primo byte che conferma al client che la connessione è viva.

La UI: locale con fallback, non locale al posto del cloud

useRunEvents alimenta lo stesso ProcessOutputData che il polling già produce. È la scelta che rende l'integrazione economica: deriveNodeRunStatus, DagCanvas e DetailPanel diventano live senza modifiche, perché l'SSE è una sorgente più veloce dello stesso dato, non un secondo modello da mantenere.

Polling e stream convivono. Nella fusione:

  • nodeReports e nodes live vincono (arrivano nell'istante del completamento);
  • runningNodes arriva solo dal live;
  • la fase resta quella del polling: completed/failed li decide noeva-server-api, che è la fonte di verità sullo stato del processo.

Con live === false la fusione è un no-op e si ottiene esattamente il comportamento precedente.

Il fallback è obbligatorio, non un extra

EventSource è una API nativa del browser: non passa dal plugin HTTP di Tauri (@tauri-apps/plugin-http) che l'app usa per aggirare la CORS dall'origine tauri://localhost. Sul server locale dovrebbe funzionare — CORS *, e tauri.conf.json ha csp: null — ma è il punto in cui l'ambiente desktop può divergere da quello di sviluppo, e non è verificabile senza eseguire l'app compilata.

Per questo useRunEvents degrada in silenzio: se EventSource non esiste o lo stream non si apre, live resta false e la UI continua col polling. Lo stesso vale per un deploy con serve e worker separati.

Sicurezza

Nessuna autenticazione, coerentemente con /graphs (ADR-0024): gli eventi portano id di nodo, conteggi e categorie — mai payload né segreti (docs/conventions/run-report.md). Il runId che arriva dalla rete è validato dallo store prima di toccare il filesystem (ADR-0039).

Alternative considerate

  • WebSocket. Bidirezionale, ma qui il flusso è a senso unico e SSE si riconnette da solo. Avrebbe aggiunto una dipendenza per nulla.
  • Polling di GET /runs/:id sul server locale. Toglie il cloud dal percorso ma non il ritardo, e ADR-0039 non scrive gli stati parziali su disco: non ci sarebbe nulla da leggere fino a fine run.
  • Sostituire del tutto il polling su processes. Avrebbe reso la sidebar non funzionante in ogni deploy dove la UI non vede la porta 3005 del processo che esegue davvero la run.

Conseguenze

  • Positive: la sidebar si aggiorna nodo per nodo, senza cloud e senza attese di polling; i rami paralleli tornano visibili come previsto da ADR-0037.
  • Costi/limiti:
    • Il bus è per-processo: serve e worker separati ⇒ stream muto (fallback).
    • Il buffer per run è limitato (512 eventi) e liberato dopo un TTL dalla conclusione: una riconnessione molto tardiva rilegge da GET /runs/:id.
    • EventSource in Tauri resta da verificare sull'app compilata — vedi la QA manuale nel piano docs/plans/2026-07-29-gh93-report-locale-run.md.

Riferimenti

  • src/runs/run-events.ts, src/runs/bus-progress-reporter.ts
  • src/server/runs-routes.ts, src/server/http-server.ts, src/server/run-server.ts
  • src/cli.ts (runServeUntilStopped: bus e store creati una volta, passati a server e worker)
  • noeva-etl-ui/src/lib/use-run-events.ts, noeva-etl-ui/src/lib/use-pipeline-run.ts
  • ADR-0017/0019 (report su processes), ADR-0018 (node progress), ADR-0024 (server HTTP), ADR-0028 (serve co-locato), ADR-0037 (parallelismo osservabile), ADR-0039 (storico locale)

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