Skip to content

0041. Nodo lookup: query SQL in sola lettura in mezzo al grafo

  • Stato: Accettata
  • Data: 2026-09-09
  • Decisori: Team BI/ETL
  • Ticket: ETL-2 — emerso da IGGN-10

Aggiornamento (2026-09-14). La decisione resta valida ed è ora disponibile su tutti i DBMS supportati: accanto a mssqlLookup c'è postgresLookup. La parte indipendente dal database — controllo di sola lettura, segnaposto @chiavi, normalizzazione delle chiavi, riassociazione delle righe — sta in src/connectors/shared/sql-lookup.ts; ogni connettore aggiunge solo la sintassi dei parametri bindati (@k0, … su SQL Server, $1, … su Postgres) e il driver. La stessa query si scrive quindi allo stesso modo sui due database. Unica differenza da sapere: le chiavi arrivano normalizzate in maiuscolo, e Postgres — a differenza della collation tipica di SQL Server — distingue maiuscole e minuscole, quindi la WHERE va normalizzata allo stesso modo. Dettaglio in postgres.md § Lookup.

Contesto

CLAUDE.md §1 dichiara che le Transform non fanno IO verso sistemi esterni, e che un database entra nel grafo solo come Source. La regola è buona e resta: è ciò che rende le trasformazioni testabili in isolamento, senza mock e senza rete.

Ma esiste una domanda che quella regola non sa esprimere bene: «questo valore esiste nel sistema di destinazione?» — un lookup selettivo, su poche chiavi, contro una tabella grande.

Con i soli strumenti odierni l'unica risposta è il nodo join, che impone due costi non negoziabili:

  1. Il lato build si materializza per intero in memoria (buildJoinTable, src/core/join.ts). Per rispondere a 168 domande al giorno si carica l'intera anagrafica ordini del cliente — centinaia di migliaia di righe, che è il punto aperto §9 di IGGN-10.
  2. Una sola chiave per record probe. probeKey: (record) => string. Quando servono N varianti per record — un lookup fuzzy, un tentativo a ranghi — si è costretti a invertire il flusso: esplodere N record { id, candidato }, passarli tutti nel probe duplicando il payload su ciascuno, e ricomporli a valle con un groupBy. La topologia che ne esce descrive il meccanismo, non il problema.

Il join non è sbagliato: è il ferro giusto quando il lato build è piccolo e riusato su ogni record. È sbagliato quando il lato build è grande e interrogato selettivamente. Oggi il secondo caso non ha un ferro.

Decisione

Si introduce un tipo di nodo nuovo, lookup, che esegue una query SQL in sola lettura su un'istanza del registry connettori (ADR-0030) per arricchire i record che lo attraversano.

ts
.lookup(fromId, id, {
  connection: "dwh",
  query: "SELECT chiave, ordine FROM v WHERE chiave IN (@chiavi)",
  key: (record) => string | readonly string[],
  merge: (rows, record) => O,
  onNotFound: "null" | "error",   // default "null"
  batchSize: 200,
  cache: true,
})

1. Un nodo, non una Transform che fa IO di nascosto

La scelta importante è dove si rompe la regola. Non nella Transform: quel contratto è pubblico, lo scrivono gli autori delle pipeline a mano, e una volta ammesso l'IO lì non c'è più modo di sapere quali trasformazioni siano pure.

Il lookup è invece un nodo dichiarativo: l'autore descrive cosa cercare (query, chiave, merge), e il framework costruisce l'esecuzione. L'IO è del framework, non del codice utente. Conseguenza pratica: Transform resta pura per definizione, e il fatto che quel punto del grafo tocchi un DB è visibile nella topologiastepType: "lookup", con connection e query nel descriptor mostrato in UI, esattamente come per una Source.

Questo è il vero guadagno rispetto a «lasciamo che una transform faccia una query»: quest'ultima nasconde l'IO dentro una closure, dove né il DAG né una review possono vederlo.

2. Internamente è un nodo transform-like

LookupGraphNode porta un campo transform costruito dal framework a partire dalla configurazione. Il graph-runner lo tratta nel dispatch esattamente come un transform: un solo edge in ingresso, stesse varianti di esecuzione (tagged/lineage, dead-letter di nodo, isolamento sotto guard).

È una scelta deliberata di non duplicare: quelle varianti sono quattro per kind e sono la parte più delicata del runner. Un kind nuovo con un percorso di esecuzione proprio sarebbe una seconda implementazione da tenere allineata a ADR-0031, ADR-0033 e ADR-0035 per sempre.

Il kind distinto esiste per ciò che è distinto: validazione, descriptor, stepType in toPlanSpec, e la leggibilità del DAG.

3. Sola lettura imposta a build(), non promessa

La query è rifiutata alla costruzione del grafo se non è una singola SELECT (o WITH … SELECT): niente INSERT/UPDATE/DELETE/MERGE/TRUNCATE/DROP/ EXEC, niente statement multipli separati da ;.

È un controllo sintattico, non un modello di sicurezza: la garanzia vera resta l'utenza di sola lettura sul database. Serve a trasformare un errore di configurazione in un fallimento immediato e leggibile, invece che in una scrittura inattesa in produzione — e a rendere esplicita l'intenzione a chi legge la pipeline.

I valori vanno sempre bindati come parametri; la query non viene mai composta per interpolazione di stringhe.

4. Batching e cache, non una query per record

Le chiavi si accumulano a finestre di batchSize e si risolvono con un solo round-trip. Senza questo il nodo sarebbe N query per N record — su IGGN-10 sarebbero 168 movimenti × fino a 500 candidati — e non varrebbe la pena di esistere.

Una Map per-run memoizza le chiavi già risolte. La cache vive quanto la run: non c'è invalidazione da progettare, e due run vedono stati diversi del DB esattamente come oggi.

Il batching cambia l'ordine di emissione? No, e non deve: i record vengono riemessi nell'ordine di arrivo, dopo la risoluzione della loro finestra. È un requisito, non un dettaglio — su IGGN-10 il file di uscita deve avere le stesse righe dell'ingresso nello stesso ordine.

5. Il non-trovato è una scelta del chiamante, mai un default silenzioso

  • "null" (default): merge riceve zero righe. Semantica da left join — il record passa comunque. È ciò che serve quando l'output deve conservare tutti i record d'ingresso.
  • "error": solleva LookupError.

Non esiste il terzo caso, «scarta in silenzio»: è il modo in cui i record spariscono senza che nessuno se ne accorga.

Perché due valori e non tre. La prima stesura prevedeva "dead-letter" accanto a "fail". Sono la stessa cosa: in questo framework un nodo segnala un record problematico sollevando, e se finisca nella dead-letter o aborta la run lo decide il nodo (deadLetter presente o assente), non la trasformazione — è esattamente il contratto di validate(). Tenere tre valori avrebbe messo la stessa decisione in due posti, con la possibilità di configurarli in contraddizione.

Quando usare lookup e quando restare sul join

La distinzione è il volume del lato di destra, non il gusto:

SituazioneFerro
Lato build piccolo, riusato su (quasi) ogni recordjoin
Lato build grande, interrogato su poche chiavi selettivelookup
Servono N chiavi per record (varianti, ranghi, tentativi)lookup
Le due sorgenti sono simmetriche e vanno fusejoin
La sorgente di destra non è un DB (file, API, altra pipeline)join

Il join non è deprecato da questo ADR e non va sostituito dove già c'è.

Alternative considerate

  • Lasciare fare la query a una Transform. Scartata: nasconde l'IO in una closure, invisibile al DAG e a una review, e una volta ammesso non è più possibile affermare che una Transform qualunque sia pura. Il costo non è la riga di codice, è la perdita di una proprietà verificabile.
  • Estendere il join con probeKey a più chiavi ((record) => string[]). Scartata: risolve metà del problema (le N chiavi) e non l'altra metà (il lato build resta interamente in memoria), e complica un nodo che oggi ha una semantica netta.
  • Un Source parametrico rieseguito per finestra, con un nodo che pilota l'iterazione. Scartata: Source.extract(ctx) non prende argomenti per contratto, e piegarlo a questo uso significherebbe passare le chiavi da ctx.params — cioè uno stato mutabile condiviso fra nodi, che è l'opposto del modello a stream.
  • Materializzare comunque, ma su disco/SQLite invece che in memoria. Scartata per ora: sposta il limite senza toglierlo, e aggiunge una dipendenza e un ciclo di vita di file temporanei per risolvere un problema che il lookup non ha.

Conseguenze

Positive

  • Un lookup selettivo non richiede più di caricare l'intera tabella di destinazione in memoria: sparisce la classe di problemi «regge finché il cliente è piccolo».
  • Le topologie che ne risultano descrivono il problema invece del meccanismo: niente esplosione dei candidati nel probe né groupBy di ricomposizione.
  • L'IO verso il DB in mezzo al grafo diventa visibile in UI, con connessione e query nel descriptor.

Negative / costi

  • La regola di CLAUDE.md §1 va riscritta: non più «il DB entra solo come Source», ma «l'IO verso sistemi esterni sta nei nodi che lo dichiarano — source, sink, lookup — mai in una Transform». Va aggiornata anche la skill etl-graph.
  • Un nodo lookup introduce latenza dentro il grafo, dove prima c'era solo CPU. Una pipeline con un lookup mal configurato (batch piccolo, cache spenta) è lenta in un modo che il DAG non rende evidente da solo: da qui i default (batchSize: 200, cache: true) e i contatori di query/cache-hit nel report.
  • noeva-etl-ui deve conoscere il nuovo stepType, altrimenti il nodo non si disegna. Il submodule va aggiornato nello stesso ciclo.

Cosa diventa più difficile da cambiare

Una volta che le pipeline usano lookup, la promessa «una Transform è pura e il grafo è riproducibile senza rete» vale ancora per le transform ma non più per il grafo nel suo insieme: un test end-to-end di una pipeline con lookup ha bisogno di un doppio della connessione. È il prezzo esplicito di questa decisione, e il motivo per cui il lookup prende la connessione dal registry per id invece di un pool: un doppio si inietta lì.

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