Pipeline terjadwal pasti dijalankan lebih dari sekali untuk tanggal yang sama: alarm yang menyala dua kali, operator yang belum yakin, atau proses yang berhenti di tengah jalan lalu diulang. Pipeline yang baik menyambut eksekusi ulang dengan dua sikap: melewatinya bila hasilnya sudah ada, dan menghasilkan hasil yang sama persis bila dipaksa mengulang. Kita mencapai keduanya dengan satu berkas state JSON sebagai watermark per tanggal, ditambah semantik upsert yang sudah dibangun pada Bagian 4.
Watermark di state.json #
data/state/state.json mencatat satu entri per tanggal partisi. Berikut isi sebenarnya setelah tiga partisi dan satu eksekusi ulang:
{
"pipeline": "etl-pesanan-tokokita",
"runs": {
"2026-11-02": {
"attempt": 2,
"status": "success",
"updated_at": "2026-10-17T07:50:17+00:00",
"rows": {
"extracted": 8, "karantina": 0, "duplikat": 0,
"fact": 8, "summary": 4
},
"checksums": {
"fact_2026-11-02": "597d2f68aa8be0d00a0da7b14aab616c1f30cacdeae849422772dde270318ec1",
"summary_2026-11-02": "59da51bb74631b00653ff899f1df1012d934b4152875c981335774bb28122333"
},
"gold": "data/gold/summary_harian_2026-11-02.csv"
}
}
}Entri menyimpan status, jumlah percobaan (attempt), jumlah baris, dan checksum hasil. Checksum inilah yang nanti dibandingkan untuk membuktikan bahwa eksekusi ulang tidak mengubah apa pun. Kelas StateStore mengelola berkas ini:
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
sementara = self.path.with_suffix(".json.tmp")
sementara.write_text(
json.dumps(self.data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
os.replace(sementara, self.path)
def has_success(self, tanggal) -> bool:
return self._run(str(tanggal)).get("status") == "success"
def _catat(self, tanggal, status: str, muatan: dict) -> None:
kunci = str(tanggal)
run = self.data["runs"].setdefault(kunci, {})
run["attempt"] = int(run.get("attempt", 0)) + 1
run["status"] = status
for k, v in muatan.items():
if k != "attempt":
run[k] = vDua keputusan di sini layak diperhatikan:
save()menulis ke berkas.tmplaluos.replace:os.replacemengganti berkas secara atomik pada level sistem berkas. Proses yang berhenti di tengah penulisan tidak meninggalkanstate.jsonsetengah jadi; hasil terburuk adalah berkas.tmpyang tak terpakai.attemptbertambah pada setiap pencatatan: keberhasilan maupun kegagalan sama-sama dihitung. Jumlah percobaan inilah yang dipakai scheduler pada Bagian 6 untuk memblokir tanggal yang terus-menerus gagal.
Jalur Lewat dan --force #
Bagian penentu keputusan berada di awal run_for_date:
if state.has_success(order_date) and not force:
lama = state.get(order_date)
m = akhir("skipped", lama.get("rows", {}), lama.get("checksums", {}),
lama.get("gold", ""), None, state.attempts(order_date))
write_manifest(m, runs_dir)
print(f"[lewati] {kunci} sudah sukses pada percobaan sebelumnya "
"(pakai --force untuk memproses ulang)")
...
return mEksekusi kedua tanpa --force berhenti di sini:
Output yang Dihasilkan:
[lewati] 2026-11-02 sudah sukses pada percobaan sebelumnya (pakai --force untuk memproses ulang)
[selesai] run-2026-11-02-075015 status=skipped durasi=0ms exit=0Status tetap exit=0: melewati pekerjaan yang sudah selesai bukanlah kegagalan. Manifest tetap ditulis dengan status skipped sehingga riwayat eksekusi lengkap.
Bukti Eksekusi Ulang Identik #
Sekarang kita paksa mengulang tanggal yang sama dengan --force, lalu bandingkan ketiga manifestnya:
python3 -m src.pipeline --date 2026-11-02 --forceOutput yang Dihasilkan:
[extract] pesanan_2026-11-02.csv: 8 baris, 771 bytes
[transform] 8 baris -> 8 fact, 0 karantina, 0 duplikat
[load] MERGE fact: 8 baris; summary 2026-11-02: 4 baris
[validate] OK reconcile kota: 8 baris di 4 kota (total sumber 8 = total target 8)
[validate] OK agregat harian: fact: 8 baris, Rp3,542,000.00 bruto, Rp3,092,000.00 bersih; summary: 8 baris, Rp3,542,000.00 bruto, Rp3,092,000.00 bersih
[validate] OK gold csv: 4 baris gold vs 4 baris summary
[gold] data/gold/summary_harian_2026-11-02.csv
[selesai] run-2026-11-02-075017 status=success durasi=46ms exit=0Output yang Dihasilkan:
== manifest eksekusi 2026-11-02 (urut waktu) ==
run-2026-11-02-075011: status=success attempt=1 rows=8 durasi=43ms
checksum fact : 597d2f68aa8be0d00a0da7b14aab616c1f30cacdeae849422772dde270318ec1
checksum summary: 59da51bb74631b00653ff899f1df1012d934b4152875c981335774bb28122333
run-2026-11-02-075015: status=skipped attempt=1 rows=8 durasi=0ms
checksum fact : 597d2f68aa8be0d00a0da7b14aab616c1f30cacdeae849422772dde270318ec1
checksum summary: 59da51bb74631b00653ff899f1df1012d934b4152875c981335774bb28122333
run-2026-11-02-075017: status=success attempt=2 rows=8 durasi=46ms
checksum fact : 597d2f68aa8be0d00a0da7b14aab616c1f30cacdeae849422772dde270318ec1
checksum summary: 59da51bb74631b00653ff899f1df1012d934b4152875c981335774bb28122333Ketiga checksum fact dan checksum summary bernilai sama. Eksekusi ulang menghasilkan warehouse yang identik di tingkat baris, bukan sekadar jumlah baris yang kebetulan sama. Pengujian otomatisnya juga ada: test_rerun_force_identik menjalankan skenario yang sama pada direktori uji dan membandingkan checksum dari state.
Kombinasi ini adalah wujud dari chapter Idempotent Transformation dan Idempotent Ingestion: upsert kunci untuk fact, ganti partisi untuk summary, dan watermark untuk mengetahui pekerjaan mana yang belum selesai. Watermark juga berfungsi sebagai checkpoint murah seperti dibahas chapter Checkpointing, dan memungkinkan pemrosesan ulang yang dibahas Pemrosesan Ulang Data. Pada bagian berikutnya kita menyerahkan keputusan “tanggal apa yang harus diproses” kepada penjadwal.
Bacaan lanjut: os.replace
pada dokumentasi Python.