La lezione 16, nel modulo 2, ha coperto l’idempotenza per il messaging: come progettare un consumer in modo che processare lo stesso messaggio due volte sia innocuo. Questa lezione è la controparte batch. La stessa idea, applicata ai job schedulati che leggono grandi fette di input e scrivono grandi fette di output: rieseguire lo stesso job due volte dovrebbe produrre lo stesso end state.
Se prendi un solo consiglio da tutto questo modulo, prendi questo. Il batch idempotente è la proprietà che trasforma il resto della data platform da una pila di script one-shot in qualcosa che puoi operare con calma. I fallimenti smettono di essere incidenti. I backfill smettono di essere spedizioni. La frase “questo job è girato due volte” non dovrebbe far sussultare nessuno. Quasi ogni dolore operativo in un team di dati che ho visto, rintracciato fino alla radice, si riduce a un job da qualche parte che non è rieseguibile in sicurezza.
Perché è importante
I batch job falliscono. Falliscono per blip di rete, problemi transitori del cloud provider, un worker node che va in OOM, un dataset upstream in ritardo, un bug, o un deploy. L’orchestrator (Airflow, Dagster, qualunque sia) fa retry. Se il primo tentativo è arrivato a metà strada e ha scritto un po’ di output prima di crashare, e il retry esegue di nuovo l’intera cosa, com’è fatto l’output?
In un job non idempotente, ora hai due metà dei dati del primo tentativo più l’intero secondo tentativo, tutto mischiato insieme. Potresti avere righe duplicate. Potresti avere righe da una run a metà che sembrano vere ma sono basate su input incompleto. Probabilmente non sai, senza ispezionare, quali righe sono quali.
Lo stesso pattern si presenta in tre altri posti che non sono guidati dal fallimento:
- Backfill. Ti accorgi che la run della scorsa settimana aveva un bug. Vuoi rieseguirla per le date interessate. Se il job è idempotente, rieseguirlo per qualsiasi range di date produce output corretto. Se non lo è, devi prima cancellare ciò che c’è, poi rieseguire, sperando di cancellare la cosa giusta.
- Schedule sovrapposti. Un job giornaliero gira alle 02:00. La run delle 02:00 del 18 è in ritardo e finisce alle 02:50. La run delle 02:00 del 19 sta partendo proprio ora. Se leggono input sovrapposti, è meglio che non producano output sovrapposti scorretti.
- Replay. Qualcosa downstream si è corrotto e devi ricostruirlo da upstream. Rieseguire ogni job nel dependency graph per la finestra interessata farebbe meglio a produrre la stessa risposta che avrebbero prodotto le run originali.
Tutti e quattro (retry su fallimento, backfill, sovrapposizione, replay) sono di routine in qualunque team di dati. Tutti e quattro funzionano in modo pulito se i tuoi job sono idempotenti e diventano esercizi forensi se non lo sono.
La buona notizia: l’idempotenza per il batch non è difficile. È un piccolo insieme di pattern, applicati con coerenza. Il resto di questa lezione è quei pattern.
Le tre modalità di scrittura
Ogni scrittura di output di un batch job ricade in una di tre modalità, e la modalità determina la storia dell’idempotenza.
Append. Il job scrive nuove righe nella tabella esistente. Rieseguire aggiunge le righe di nuovo. Per default, questo non è idempotente: una riesecuzione produce duplicati. L’append va bene quando è abbinato a una deduplicazione downstream (il consumer successivo collassa i duplicati per chiave) o quando l’applicazione si accontenta di una tabella a forma di log a cui è permesso contenere duplicati. Come modalità di scrittura primaria per tabelle analitiche, è la peggior scelta.
Overwrite. Il job sostituisce una partition o l’intera tabella in modo atomico con l’output della run. Rieseguire con lo stesso input produce lo stesso output, e la sostituzione atomica significa che non vedi mai uno stato a metà. Questo è il percorso più semplice verso l’idempotenza. L’unità di overwrite di solito è una partition, non l’intera tabella: scrivi event_date=2026-01-21 e sostituisci qualunque cosa ci fosse prima.
Upsert / MERGE. Il job inserisce nuove righe e aggiorna quelle esistenti basandosi su una chiave. Rieseguire con lo stesso input è idempotente perché la seconda run vede le righe che la prima ha scritto e fa il merge dentro di esse, lasciando lo stesso end state. È la modalità più flessibile e la più costosa.
La regola pratica: preferisci overwrite per dati a forma di partition, preferisci MERGE per dati a forma di entità, evita il plain append a meno che non tu abbia un motivo specifico.
flowchart TB
subgraph A[Append + dedup]
A1[Job writes new rows] --> A2[Downstream dedup]
A2 --> A3[Idempotent end state]
end
subgraph O[Partition overwrite]
O1[Job computes partition] --> O2[Atomic replace partition]
O2 --> O3[Idempotent end state]
end
subgraph M[MERGE / upsert]
M1[Job stages records] --> M2[MERGE INTO target ON key]
M2 --> M3[Idempotent end state]
end
Il pattern partition-overwrite
Questo è il pattern da cavallo da tiro del batch moderno. La maggior parte delle tabelle analitiche è partitionata per data, e la maggior parte dei batch job gira una volta al giorno per produrre l’output di una giornata. La forma è:
INSERT OVERWRITE TABLE silver.orders PARTITION (event_date = '2026-01-21')
SELECT ...
FROM bronze.orders
WHERE event_date = '2026-01-21'
Quella istruzione è idempotente. Eseguila una, due, dieci volte per la stessa data: la partition finisce con lo stesso contenuto ogni volta. Il table format (Delta, Iceberg, o Hudi) rende la sostituzione della partition atomica, così i reader non vedono mai una partition sostituita a metà.
Il backfill è banale. Per rieseguire la settimana dal 14 al 21 gennaio, fai un loop sulle date ed esegui il job per ognuna. L’output di ogni run è limitato alla propria partition, quindi il backfill non può accidentalmente toccare i dati di altre date.
La disciplina che questo pattern richiede è che il job sia una funzione della partition: la stessa input partition produce la stessa output partition, senza dipendenze da quando il job gira o da quali altri job sono girati. Se il job silver per il 21 dipende da cosa c’era nella tabella silver per il 20, la proprietà di funzione-della-partition si rompe, e rieseguire diventa rischioso.
Questo è il motivo per cui i batch job partition-aligned negli stack moderni sono quasi sempre scritti come leggi partition X, trasforma, overwrite partition X. La semplicità del contratto è tutto il punto.
Il pattern MERGE
Per le tabelle che non sono a forma di partition, il trucco del partition-overwrite non funziona. Una tabella silver.customers ha una riga per cliente, non partition di eventi datati. Aggiornare il record di un cliente significa cambiare una riga in place, non sostituire una partition.
Il pattern MERGE, supportato da Delta, Iceberg, e Hudi (lezione 37), è la risposta idempotente:
MERGE INTO silver.customers AS t
USING staging.customers_today AS s
ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
Il MERGE è atomico a livello di table format: o l’intero merge si applica, o niente di esso. È idempotente perché la seconda run vede le righe che la prima ha scritto, le matcha, e le aggiorna agli stessi valori. L’end state dopo la run numero due è identico all’end state dopo la run numero uno.
Due note pratiche. Primo, la clausola ON deve usare una chiave reale. Se la chiave non è unica nel source, il MERGE fallirà o produrrà spazzatura a seconda dell’engine; deduplica prima il source. Secondo, se stai facendo merge solo dei record cambiati (non dello snapshot completo), l’output è corretto solo se il feed dei record cambiati è di per sé completo. Saltare un update lato source significa che il target rimane stantio fino alla prossima volta che quel record appare nel feed.
Il pattern watermark / high-water-mark
Sia il partition-overwrite che il MERGE hanno bisogno che il job sappia “quale input sto processando questa volta”. Per input partitionati per tempo, la risposta è “la partition per cui sto girando”. Per un source in continua crescita dove vuoi processing incrementale, la risposta è un watermark: il timestamp (o il sequence number) del record più recente processato nell’ultima run.
Il pattern:
- Il job legge il watermark dal suo state store (spesso una metadata table nel lakehouse stesso).
- Seleziona i record di input con
event_time > watermark. - Li processa e scrive l’output (idempotentemente, via overwrite o MERGE).
- Avanza il watermark al massimo
event_timeche ha appena processato.
Combinato con MERGE, questa diventa la ricetta canonica incrementale-e-idempotente. Ogni run riprende da dove l’ultima si è fermata. Fallimenti e riesecuzioni non fanno double-processing: il watermark è avanzato solo dopo che la scrittura dell’output ha avuto successo, quindi una run fallita lascia il watermark invariato e il prossimo tentativo processa di nuovo lo stesso input, idempotentemente.
Attento ai dati in arrivo tardivo. Se i record possono arrivare con event_time precedente al watermark corrente, un filtro stretto “maggiore del watermark” li mancherà. Il fix è o ritardare il watermark dietro al wall clock di una finestra di tolleranza (processa record più vecchi di now() - 1h), o permettere al job di guardare indietro un numero fisso di partition a ogni run.
Il pattern append-with-dedup
Talvolta l’append ti è imposto. La scrittura più economica verso una tabella backed da object store, specialmente in un handoff streaming-to-batch, è un append. Se non puoi evitarlo, il pattern dedup preserva l’idempotenza al costo di un po’ più di lavoro downstream:
- Fai append dei record, includendo la chiave naturale del source e un ingestion timestamp.
- Il consumer downstream (o un successivo step batch) raggruppa per chiave e tiene la riga con l’ingestion timestamp più recente.
L’end state è idempotente: rieseguire lo step di append aggiunge duplicati, ma lo step di dedup li collassa. Il costo è che la dedup è lavoro extra a ogni lettura, o infrastruttura batch extra per materializzare una vista deduplicata.
Questo è il pattern che raccomando di meno. Funziona, e ci sono situazioni dove è l’unica opzione realistica, ma è operativamente più pesante di overwrite o MERGE. Se hai una scelta, scegline una delle altre.
Trappole
Qualche tranello che rende silenziosamente non idempotenti job che sembrano idempotenti.
Side effect che non sono idempotenti. Un batch job che manda una mail a ogni run, addebita una carta di credito, o fa POST a un webhook non è idempotente: rieseguire rimanda, riaddebita, ripubblica. Sposta il side effect fuori dal percorso batch, o avvolgilo in un check di idempotency-key (lezione 16) così il sistema ricevente assorbe i duplicati.
Dipendenze da random o wall-clock. Un job che usa now() per etichettare record, o un random() per spareggiare, produrrà output diverso a ogni run. Il fix è fissare il timestamp al logical run-time dello schedule (l’orchestrator di solito lo espone come parametro, per esempio execution_date o logical_date), e seedare qualunque randomness da una source deterministica. La disciplina è: ogni valore che il job scrive dovrebbe essere una funzione dell’input, non di quando il job è girato.
Risorse esterne che cambiano. Un job che colpisce una API esterna con una query come “dammi gli ultimi tassi di cambio” non è idempotente: rieseguirlo domani restituisce tassi diversi. Se non puoi evitarlo, fai prima snapshot dei dati esterni in bronze, con una label di logical-date, e fai leggere il resto del job dallo snapshot. Bronze diventa il confine tra il mondo esterno non idempotente e la pipeline batch idempotente.
Trasformazioni order-dependent. Una trasformazione che usa un row-number o un rank senza un tie-breaker deterministico può produrre output diverso per lo stesso input in run diverse (l’esecuzione parallela dell’engine può ordinare le righe legate diversamente ogni volta). Aggiungi una colonna esplicita di tie-breaker. Se due ordini hanno lo stesso timestamp, ordina anche per order_id, così il rank è completamente determinato.
Saltare l’avanzamento del watermark in caso di fallimento. Se il tuo watermark è avanzato prima che la scrittura dell’output committi, un crash tra i due lascia il watermark spostato oltre record che non sono mai stati scritti. Questo è il bug canonico “dati persi al retry”. Avanza sempre il watermark nella stessa transazione dell’output, o dopo che l’output è durabile.
La disciplina
L’end state di tutto questo: ogni batch job nella tua piattaforma dovrebbe essere rieseguibile in sicurezza per qualunque range di input, da chiunque, in qualunque momento, senza coordinamento. Il proprietario del job dovrebbe poter dire “sì, eseguilo pure per la scorsa settimana” senza doverci pensare. L’orchestrator dovrebbe poter fare retry senza scatenare un alert. Un bug, trovato in produzione tre mesi dopo, dovrebbe essere fixabile con una riesecuzione.
Quella proprietà si costruisce un job alla volta, attraverso i pattern qui sopra e attraverso norme di team che dicono “se non è idempotente, non è finito”. È la fondazione su cui si appoggiano la prossima lezione (backfill e replay) e la maggior parte del resto del modulo. Senza di essa, ogni operazione successiva è un esercizio attento di non rompere la produzione. Con essa, la piattaforma resta calma.