Skip to content

0036. Rimozione del DSL lineare: il grafo è l'unico modo di costruire un processo

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

Contesto

ADR-0016 ha introdotto il DSL a grafo (graph().source().transform().join().sink().build()) accanto al DSL lineare (pipeline().from().pipe().to()), non al suo posto: il lineare copriva già i casi a singolo source/sink e riscriverlo non era la priorità.

Da allora il lineare ha smesso di essere un modo alternativo di eseguire ed è diventato una façade su quello a grafo: Pipeline.run() costruiva un GraphSpec a tre nodi (source → chain → sink, dove chain collassava l'intera sequenza di transform in un unico nodo) e lo eseguiva con runGraph. Un solo motore, due superfici pubbliche.

Il costo di tenerle entrambe:

  • Due tipi di report. RunReport e GraphRunReport con la stessa semantica ma campi diversi, e una union RunReport | GraphRunReport propagata su RunnablePipeline, CLI e worker. Ogni consumatore doveva restare sul sottoinsieme comune.
  • Un grafo finto in UI. Una pipeline lineare mostrava sempre tre nodi source/chain/sink, indipendentemente da cosa facesse davvero: il nodo chain non dice niente né all'operatore né a chi debugga una run.
  • Due punti di verità da documentare. writeReport si configurava per nodo nel builder a grafo e via RunOptions.writeReport: { source, chain, sink } nel lineare; il catch era un'opzione di run() da una parte e un nodo dall'altra.
  • Un solo processo lo usava ancora (langfuse-sessions-daily). Le restanti 38 occorrenze erano in 14 file di test, dove serviva solo come harness comodo.

Decisione

Il DSL lineare viene rimosso. src/core/pipeline.ts è cancellato per intero: pipeline(), PipelineBuilder, Pipeline, RunOptions, PipelineWriteReportOptions, RunReport, RunStatus, RunError, buildFailedReport, REPORT_SCHEMA_VERSION.

Conseguenze dirette della rimozione:

  1. langfuse-sessions-daily è migrato a graph() — quattro stadi come quattro nodi (sessions → raw-contract → aggregate-daily → fact-contract → sink) invece di un unico chain. Il suo getSpec() statico usa gli stessi id, e un test verifica che topologia dichiarata ed eseguita non divergano.
  2. RunnablePipeline.run ritorna Promise<GraphRunReport>, non più una union.
  3. buildFailedGraphReport rimpiazza buildFailedReport per CLI e worker: senza di esso una run abortita non avrebbe più prodotto un report JSON strutturato. GraphRunError guadagna il campo stage, che aveva solo RunError.
  4. GraphRunReport guadagna gli aggregati top-level extracted, written, rejected, rejectedReasons, con una semantica dichiarata:
    • extracted somma i soli nodi source. È ciò che rende il numero non ambiguo su un fan-out, dove lo stesso record attraversa più rami a valle.
    • written somma i nodi sink. Su un fan-out lo stesso record scritto su due sink conta due volte, perché due scritture sono davvero avvenute.
  5. runGraph scrive i contatori standard su ctx.metrics e li include nel report insieme a quelli custom degli stage. Prima lo faceva solo Pipeline.run(): senza questo, rimuovendo il lineare, un ctx.metrics.increment("api_calls") dentro una transform sarebbe sparito dal report — perdendo un punto di estensione documentato in run-report.md.
  6. I test che usavano il lineare come harness passano a graphOf() (tests/helpers/graph-of.ts), che conserva la forma fluente .from().pipe().to() ma costruisce un grafo vero, con un nodo per transform.

Alternative considerate

  • Deprecare pipeline() per un ciclo di release invece di rimuoverlo — scartata: terrebbe in vita esattamente ciò che l'operazione vuole eliminare (due superfici, due report), e non risulta alcun consumatore esterno del pacchetto oltre a questo repo. La rimozione è secca, con bump a 2.0.0.
  • Rimuovere gli aggregati top-level dal report invece di calcolarli sommando i NodeReport — scartata: docs/conventions/run-report.md documenta il contratto CLI etl run … | jq '.status, .written', e romperlo per un refactor interno sarebbe un costo scaricato sui consumatori senza contropartita. Il costo del mantenerli è dover dichiarare la semantica sul fan-out, non subirla.
  • Tenere l'harness dei test come graph() esplicito, senza graphOf() — scartata per la dimensione del diff: 38 call-site in 14 file, dove la topologia non è il soggetto del test (codec, storage, integrazione) e riscriverla a mano aggiungeva rumore senza aggiungere copertura. Dove invece la topologia è il soggetto (tests/core/, src/core/), i test usano graph() esplicito.
  • Far collassare graphOf() le transform in un unico nodo chain, per preservare al byte i report dei test migrati — scartata: replicherebbe nell'harness proprio la finzione che stiamo eliminando dal prodotto. Il prezzo è che i report dei test migrati hanno più nodi di prima; è una differenza attesa e verificata.

Conseguenze

  • Positive:
    • Un solo modo di costruire un processo, un solo tipo di report, un solo posto dove configurare writeReport e la gestione errori.
    • La topologia mostrata in UI è sempre quella reale: niente più nodo chain opaco.
    • GraphRunReport è ora un superset di ciò che offriva RunReport — nessun consumatore perde informazione.
  • Negative / costi:
    • Breaking change sulla public API del pacchetto @gruppo4d/noeva-etl (2.0.0): chi importasse pipeline() dall'esterno deve passare a graph().
    • Le pipeline a catena singola sono ora leggermente più verbose: cinque chiamate al builder invece di .from().pipe().to(). È il prezzo di avere id di nodo espliciti — che è anche ciò che rende la UI leggibile.
    • graphOf() vive in tests/, quindi i test colocati in src/ lo importano per path relativo (../../../../tests/helpers/graph-of.js). Alternativa scartata: metterlo in src/, dove finirebbe nel bundle pubblicato e nel calcolo coverage.
  • Cosa diventa più difficile da cambiare: la semantica di written sul fan-out è ora un contratto documentato. Cambiarla (es. deduplicare i record scritti su più sink) sarebbe un breaking sul report, non un dettaglio interno.

Riferimenti

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