Prefect e dbt su dati veri: la pipeline di MoviesGraph
/ 7 min read
Updated:Table of Contents
Ogni tanto ho bisogno di un progetto abbastanza grande e abbastanza disordinato da mettere davvero alla prova una tecnologia.
Il pretesto, questa volta, è stato MoviesGraph: un’applicazione che prende i dataset pubblici di IMDb e li trasforma in un grafo navigabile di film, serie e persone. C’è un backend FastAPI e un frontend React che disegna il grafo, ma quelli sono la vetrina. Il vero motivo per cui ho costruito tutto questo è un altro: volevo capire Prefect e dbt in un contesto reale.
Il contesto: dati veri, non dati da demo
I dataset non commerciali di IMDb sono perfetti proprio perché sono scomodi:
- sono grandi —
title.principalsda solo è nell’ordine dei 100 milioni di righe; - arrivano come snapshot completi giornalieri, senza nessun segnale
di cosa è cambiato: niente
updated_at; - sono
.tsv.gzcon valori nulli codificati come\Ne campi che a volte non rispettano quello che promettono.
Prima decisione: ELT, non ETL
La tentazione classica è ETL: scarico, trasformo in Python, carico il risultato pulito. L’ho adottata diverse volte, ma questa volta non poteva reggere il carico, così l’ho scartata subito.
Con questi volumi, il percorso più veloce per far entrare i dati in
Postgres è COPY, e lo è di ordini di grandezza rispetto a qualunque
ciclo Python riga-per-riga. E una volta che i dati grezzi sono già dentro
al database, trasformarli con SQL è di nuovo molto più veloce che tirarli fuori,
manipolarli in memoria e riscriverli.
Quindi: prima carico i byte grezzi, poi trasformo in SQL.
ELT.
E questo divide naturalmente il lavoro in due strati, con due strumenti diversi: Prefect orchestra l’estrazione e il caricamento, dbt si occupa della trasformazione. Ognuno fa la cosa per cui è nato.
Prefect: l’orchestrazione dove serve davvero
Prefect governa il primo strato: inizializza lo schema, scarica i quattro dataset in parallelo e li carica in tabelle di staging. Fin qui niente di speciale, un cron e uno script farebbero lo stesso. Il punto è cosa succede quando qualcosa va storto, ed è lì che ho voluto vedere se Prefect ripagava la sua presenza.
Alcune decisioni che ho preso sfruttando l’orchestrazione:
Il caricamento è transazionale. TRUNCATE + COPY + controllo del numero di righe + upsert dei metadati, tutto in un’unica transazione. Se
qualcosa fallisce, come per esempio la rete che cade, un file malformato, un conteggio righe
troppo basso, la transazione fa rollback e il caricamento
precedente sopravvive intatto. Non mi ritrovo mai con dati a metà.
I “floor” sul numero di righe. COPY non sa distinguere un file
valido da una pagina di errore servita con HTTP 200, o da un gzip troncato
che però si decomprime senza errori. Ho messo delle soglie minime (circa
un terzo delle dimensioni attuali): se il file “è valido ma con poche righe rispetto al normale”, il
caricamento viene rifiutato.
Download condizionali. Salvo ETag e Last-Modified di ogni dataset;
al giro successivo mando If-None-Match e mi fermo su un HTTP 304. IMDb
aggiorna una volta al giorno, quindi una ri-esecuzione infragiornaliera
costa un round-trip HTTP invece di qualche gigabyte scaricato di nuovo.
Il modello di fallimento è esplicito. Le task hanno retry, il flow
fallisce in modo pulito se un dataset non arriva (e in quel caso dbt non
parte proprio), e il deployment ha limit=1 così
due run non si pestano i piedi. Tutto questo, in Prefect, è dichiarativo:
basta descriverlo, non implementarlo a mano con try/except annidati.
Il valore vero di Prefect, ho scoperto essere la UI e la semantica degli stati: quando una run fallisce alle quattro di notte, la mattina dopo vedo esattamente quale task, con quali log, e in che stato ha lasciato il sistema.
dbt: la trasformazione come contratto
Se Prefect porta i dati grezzi dentro, dbt li rende affidabili. Qui ho strutturato tutto su due livelli:
- staging (viste): convertono il
TEXTgrezzo nei tipi reali, tolgono i prefissitt/nmdagli ID trasformandoli in chiavibigint, e filtrano in base allo scopo del prodotto (film e serie, attori e attrici). Sono viste, quindi “costruirle” non costa nulla; - marts (tabelle): il contratto di servizio vero e proprio, quello che l’API legge.
Le decisioni interessanti, di nuovo, sono quelle che emergono solo con dati veri.
Materializzazione table, non incrementale. L’istinto dice
“incrementale, così ricostruisci solo ciò che cambia”. Ma IMDb pubblica
snapshot completi senza alcun segnale di cambiamento. Le strategie
incrementali hanno bisogno di sapere quali righe sono cambiate; senza
quell’informazione, accumulano duplicati. La ricostruzione
completa da snapshot è garantita corretta, e in più dbt fa un
create-swap-drop atomico: i lettori vedono la vecchia tabella fino al
commit, poi la nuova. Deploy a downtime quasi zero, gratis. A volte la
scelta “meno furba” è quella giusta.
Gli indici vivono nel modello. Ogni indice è dichiarato
nel config(indexes=[...]) del suo modello, così l’adapter lo crea sulla
nuova tabella prima dello swap atomico. Ci ero arrivato dopo aver
sbagliato: con dei post-hook CREATE INDEX IF NOT EXISTS, l’indice
“esisteva già” sulla tabella vecchia rinominata come backup, veniva saltato,
e spariva insieme al backup. Un bug invisibile finché non degradano le
query. Ogni indice, poi, esiste per un preciso pattern di query del
backend — GIN trigram per la ricerca UPPER(x) LIKE '%…%', btree per gli
ORDER BY.
I test come cancello, non come contorno. Uso dbt build, non
dbt run: i test girano tra un modello e l’altro. Un test di unicità
o di integrità referenziale che fallisce ferma i modelli a valle, così
i dati sbagliati non arrivano mai ai marts. L’integrità la garantisco con
inner join nei marts più questi test, non con vincoli FK. È stato costruendo
questi test che ho scoperto verità sui dati che non conoscevo: per esempio
che la coppia film/attore non è unica, perché una persona può avere
più ruoli nello stesso titolo.
Un principio che tiene tutto insieme: chi possiede lo schema
Una cosa che questo progetto mi ha chiarito è il principio di un solo
proprietario per schema. dbt possiede lo schema imdb, Prefect possiede
staging, e il backend non li tocca. Non esistono migrazioni scritte a
mano per i dati: il codice SQL dei modelli dbt in git è la definizione
dello schema. Il backend si connette con un
utente in un ruolo api_readonly e fisicamente non può né
scrivere su imdb né leggere staging.
È un livello di rigore che su un progetto personale non era obbligatorio. L’ho messo apposta, perché volevo vedere se dbt reggeva il ruolo di “unica fonte di verità dello schema”.
Regge.
La vetrina: API e frontend
Il backend FastAPI e il frontend React ci sono, funzionano, e servono il grafo, ma sono solo la conseguenza. Esistono per dare un motivo concreto a tutte quelle scelte fatte a monte: gli indici esistono per quelle query, i marts hanno quella forma perché è quella che l’API consuma. Senza qualcosa che legge davvero i dati, ogni decisione sulla pipeline sarebbe stata teorica. Inoltre, con un frontend i dati sono navigabili molto più facilmente.
Cosa mi porto a casa
Prefect e dbt, alla prova dei dati veri, hanno ripagato la loro presenza.
Di Prefect ho apprezzato meno “l’orchestrazione” in sé e più l’osservabilità e il modello di fallimento esplicito: sapere sempre in che stato è il sistema dopo una notte andata storta.
Di dbt mi ha convinto l’idea del database come artefatto versionato: lo schema è codice, i test sono un cancello, lo swap è atomico. Rende la trasformazione dei dati, da script usa-e-getta, qualcosa che assomiglia a ingegneria del software vera.
La lezione più generale è quella di sempre: una tecnologia la capisci solo quando la rompi. E per romperla ti serve un pretesto.