skip to content
OphusDev

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 grandititle.principals da solo è nell’ordine dei 100 milioni di righe;
  • arrivano come snapshot completi giornalieri, senza nessun segnale di cosa è cambiato: niente updated_at;
  • sono .tsv.gz con valori nulli codificati come \N e 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 TEXT grezzo nei tipi reali, tolgono i prefissi tt/nm dagli ID trasformandoli in chiavi bigint, 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.