Scheduler yang sempat mati tiga hari meninggalkan tiga partisi tak terolah, dan laporan harian menjadi bolong. Backfill adalah seni memutar ulang waktu dengan aman: menjalankan kembali pipeline untuk periode lampau tanpa mendouble-kan hasil. Airflow menangani hal ini lewat mekanisme catchup dan clear run, sedangkan proyek kita membuktikan semantik intinya lewat simulasi lokal yang benar-benar berjalan: memutar ulang tiga tanggal lewat fungsi murni dan memeriksa state per tanggal. Bagian ini membedah keduanya.

catchup: Scheduler yang Mengejar yang Terlewat #

Argumen catchup=True pada dekorator @dag memberi scheduler satu tugas tambahan: sebelum membuat run baru, scheduler memeriksa semua data interval sejak start_date yang belum punya run, lalu membuat run untuk setiap interval yang terlewat itu:

Tanpa catchup (catchup=False), run yang terlewat dibiarkan kosong dan hanya interval terbaru yang dijalankan — pilihan yang tepat bila data historis sudah diisi lewat jalur lain. Dengan max_active_runs=1, run catchup kita dieksekusi satu per satu sehingga berkas state.json tidak pernah ditulis dua proses sekaligus.

Untuk memproses ulang periode yang sudah pernah sukses, mekanismenya adalah clear: menghapus status DAG run lama (dari UI atau CLI) sehingga scheduler menjadwalkannya kembali. Perlu dicatat bahwa perintah CLI airflow dags backfill yang klasik pada Airflow 2 tidak dilanjutkan ke Airflow 3; jalur resmi Airflow 3 untuk periode lampau adalah catchup untuk yang terlewat dan clear untuk yang diulang. Kedua jalur bermuara pada prinsip yang sama: setiap run menangani satu data interval, dan aman diulang karena task idempoten.

Simulasi Lokal Backfill #

Semantik itu bisa kita jalankan tanpa Airflow karena intinya sederhana: daftar tanggal yang belum sukses, diproses satu per satu. Fungsi tanggal_tertunda dan jalankan_backfill di src/ membentuk simulasi tersebut:

python src/state.py — baris 110-112
def tanggal_tertunda(state: dict, mulai: str, selesai: str) -> list[str]:
    """Tanggal pada rentang yang belum sukses — kandidat catchup/backfill."""
    return [t for t in interval_terjadwal(mulai, selesai) if not sudah_sukses(state, t)]
python src/tasks.py — baris 276-296
def jalankan_backfill(
    raw_dir: str | Path,
    gold_dir: str | Path,
    state_path: str | Path,
    mulai: str,
    selesai: str,
    force: bool = False,
) -> list[HasilRun]:
    """Proses ulang rentang tanggal — inti apa yang dilakukan catchup/backfill.

    Ini simulasi lokal berbasis state: setiap tanggal pada rentang dijalankan
    lewat jalankan_etl, sehingga tanggal yang sudah sukses otomatis dilewati
    (kecuali force=True). Berbeda dari Airflow yang mengisolasi setiap run,
    kegagalan pada satu tanggal di sini menghentikan perulangan.
    """
    state = state_etl.baca_state(state_path)
    if force:
        daftar = state_etl.interval_terjadwal(mulai, selesai)
    else:
        daftar = state_etl.tanggal_tertunda(state, mulai, selesai)
    return [jalankan_etl(raw_dir, gold_dir, state_path, t, force=force) for t in daftar]

Mari kita telaah logikanya: baris pertama membaca state terkini; cabang force memilih seluruh rentang (semua diproses ulang) atau hanya tanggal tertunda; dan pemetaan di baris terakhir menjalankan jalankan_etl per tanggal. Simetri ini meniru catchup Airflow — scheduler juga hanya membuat run untuk interval yang belum ada.

Skrip demonstrasi di luar bundel proyek menumbuhkan tiga partisi (02 sampai 04 November) pada direktori sementara, lalu memutar ulang rentangnya:

Output yang Dihasilkan:

text
== keadaan awal (DAG baru dideploy 2026-11-05) ==
watermark: None
tanggal tertunda 2026-11-02..2026-11-04: ['2026-11-02', '2026-11-03', '2026-11-04']

== jalankan_backfill 2026-11-02..2026-11-04 (simulasi catchup) ==
[2026-11-02] sukses: 7 baris mentah -> 4 valid, 2 ditolak, 1 duplikat; 3 kota, omzet Rp843,000 -> summary_harian_2026-11-02.csv
[2026-11-03] sukses: 5 baris mentah -> 4 valid, 1 ditolak, 0 duplikat; 4 kota, omzet Rp605,500 -> summary_harian_2026-11-03.csv
[2026-11-04] sukses: 3 baris mentah -> 3 valid, 0 ditolak, 0 duplikat; 3 kota, omzet Rp674,000 -> summary_harian_2026-11-04.csv

== eksekusi ulang tanpa force ==
jumlah run: 0 (semua tanggal sudah sukses)

Tiga tanggal diproses berurutan, setiap tanggal menghasilkan berkas gold sendiri, dan eksekusi ulang tanpa force tidak mengerjakan apa pun karena state sudah mencatat semuanya sukses. Catatan per tanggal di state/state.json menjadi bukti permanennya:

json data/state/state.json setelah backfill (hasil demonstrasi)
{
  "dag_id": "etl_pesanan_tokokita",
  "terolah": {
    "2026-11-02": {
      "berkas_gold": "demo-data/backfill/gold/summary_harian_2026-11-02.csv",
      "jumlah_baris": 7,
      "jumlah_ditolak": 2,
      "jumlah_duplikat": 1,
      "jumlah_kota": 3,
      "jumlah_valid": 4,
      "omzet": 843000,
      "status": "sukses"
    },
    "2026-11-03": {
      "berkas_gold": "demo-data/backfill/gold/summary_harian_2026-11-03.csv",
      "jumlah_baris": 5,
      "jumlah_ditolak": 1,
      "jumlah_duplikat": 0,
      "jumlah_kota": 4,
      "jumlah_valid": 4,
      "omzet": 605500,
      "status": "sukses"
    },
    "2026-11-04": {
      "berkas_gold": "demo-data/backfill/gold/summary_harian_2026-11-04.csv",
      "jumlah_baris": 3,
      "jumlah_ditolak": 0,
      "jumlah_duplikat": 0,
      "jumlah_kota": 3,
      "jumlah_valid": 3,
      "omzet": 674000,
      "status": "sukses"
    }
  },
  "watermark": "2026-11-04"
}

Dua detail penting dari berkas tersebut: watermark maju ke 2026-11-04 karena 04 November adalah tanggal terbaru yang sukses, dan backfill tanggal lama tidak pernah menurunkannya — aturan yang ditegakkan catat_sukses di src/state.py. Jalur berkas_gold pada demonstrasi relatif karena skrip dijalankan dari direktori kerjanya sendiri; pada deployment sungguhan jalur ini mengikuti TOKOKITA_GOLD_DIR.

Eksekusi Ulang Harus Aman #

Skrip demonstrasi di atas menunjukkan sisi “yang terlewat”; sisi “yang diulang” dijaga dua uji pada tests/test_backfill_sim.py: test_backfill_force_memproses_ulang_identik menjalankan ulang seluruh rentang dengan force=True lalu memastikan isi ketiga berkas gold byte-demi-byte identik, dan test_backfill_mengisi_celah_setelah_scheduler_mati memastikan hanya tanggal yang belum sukses yang dikerjakan. Keduanya hijau:

bash
python3 -m pytest tests/test_backfill_sim.py -v

Output yang Dihasilkan:

text
tests/test_backfill_sim.py::test_backfill_memproses_tiga_tanggal_berurutan PASSED [ 14%]
tests/test_backfill_sim.py::test_state_per_tanggal_sesuai_partisi PASSED [ 28%]
tests/test_backfill_sim.py::test_backfill_ulang_tidak_ada_yang_tertunda PASSED [ 42%]
tests/test_backfill_sim.py::test_backfill_mengisi_celah_setelah_scheduler_mati PASSED [ 57%]
tests/test_backfill_sim.py::test_backfill_force_memproses_ulang_identik PASSED [ 71%]
tests/test_backfill_sim.py::test_backfill_berhenti_di_partisi_hilang PASSED [ 85%]
tests/test_backfill_sim.py::test_watermark_maju_selangkah_per_tanggal PASSED [100%]

============================== 7 passed in 0.19s ===============================

Satu perbedaan simulasi kita dari Airflow perlu dipegang jujur: uji test_backfill_berhenti_di_partisi_hilang membuktikan bahwa simulasi ini berhenti di partisi yang hilang (tanggal setelahnya tidak diproses), sedangkan Airflow mengisolasi setiap DAG run — run untuk 04 November tetap dieksekusi meski run 03 November gagal. Konsep pemrosesan ulang data secara lebih luas dibahas pada chapter Backfill dan Pemrosesan Ulang Data, serta dokumentasi Apache Airflow untuk perilaku catchup per versi.