Pipeline kita kini tahu memproses satu tanggal dengan aman, tetapi belum ada yang membangunkannya setiap malam. Penjadwalan menjawab dua pertanyaan yang berbeda: kapan pipeline harus bangun, dan tanggal mana yang harus dikerjakan saat bangun. Pertanyaan pertama kita jawab dengan ekspresi cron; pertanyaan kedua dengan fungsi due_runs yang membaca watermark. Kedua logika kita tulis sebagai fungsi murni terhadap waktu sehingga dapat diuji dengan jam palsu tanpa menunggu detik sungguhan berlalu.
Ekspresi Cron Lima Bidang #
Ekspresi 30 2 * * * pada config/pipeline.yaml berarti pukul 02:30 setiap hari. Lima bidangnya secara berurutan:
| Bidang | Nilai contoh | Arti |
|---|---|---|
| minute | 30 | menit ke-30 |
| hour | 2 | jam 02 pagi |
| dom | * | setiap tanggal dalam bulan |
| month | * | setiap bulan |
| dow | * | setiap hari dalam minggu |
def _parse_bidang(token: str, minimum: int, maksimum: int) -> frozenset[int]:
"""Parse satu bidang cron: '*', '*/n', 'a', 'a-b', 'a-b/n', daftar."""
nilai: set[int] = set()
for bagian in token.split(","):
langkah = 1
if "/" in bagian:
bagian, _, langkah_teks = bagian.partition("/")
langkah = int(langkah_teks)
if bagian == "*":
awal, akhir = minimum, maksimum
elif "-" in bagian:
awal, akhir = int(bagian.partition("-")[0]), int(bagian.partition("-")[2])
else:
awal = akhir = int(bagian)
nilai.update(range(awal, akhir + 1, langkah))
return frozenset(nilai)
def next_run(ekspresi: str, setelah: datetime) -> datetime:
"""Waktu jalankan berikutnya yang > `setelah` (eksklusif)."""
elemen = parse_cron(ekspresi)
kandidat = setelah.replace(second=0, microsecond=0) + timedelta(minutes=1)
for _ in range(366 * 24 * 60): # batas pencarian satu tahun
if (_hari_cocok(elemen, kandidat)
and kandidat.hour in elemen["hour"]
and kandidat.minute in elemen["minute"]):
return kandidat
kandidat += timedelta(minutes=1)
raise ValueError(f"tidak ada jadwal dalam satu tahun: {ekspresi!r}")Parser menerima *, */n, a, a-b, dan daftar; nama hari tidak didukung, sedangkan 0 dan 7 sama-sama berarti Minggu. Fungsi _hari_cocok menerapkan aturan OR cron klasik: bila dom dan dow sama-sama dibatasi, cukup salah satu yang cocok, sehingga 0 0 1 * 0 berarti “tanggal 1 atau Minggu, tengah malam”. Hasil nyatanya pada November 2026:
Output yang Dihasilkan:
== parse_cron('30 2 * * *') ==
{'minute': frozenset({30}), 'hour': frozenset({2}), 'dom': frozenset({1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}), 'month': frozenset({1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}), 'dow': frozenset({0, 1, 2, 3, 4, 5, 6})}
== next_run: tiga contoh ==
setelah 2026-11-05 00:10:00 -> 2026-11-05 02:30:00
setelah 2026-11-05 02:30:00 -> 2026-11-06 02:30:00
setelah 2026-11-05 03:00:00 -> 2026-11-06 02:30:00
== next_run dengan dom/dow (aturan OR) ==
setelah 2026-11-05 12:00 -> 2026-11-08 00:00:00due_runs: Mengejar yang Tercecer #
Mengetahui jam bangun saja tidak cukup: mesin yang mati tiga hari bangun dengan tunggakan tiga partisi, dan jadwal harian hanya memberi satu kesempatan per malam. Fungsi berikut memutuskan tanggal mana yang wajib dikerjakan:
def due_runs(state, saat, raw_dir, lookback_days=7, max_attempts=3) -> list[date]:
"""Tanggal partisi yang wajib diproses pada waktu `saat`."""
hasil: list[date] = []
hari_ini = saat.date()
for offset in range(1, lookback_days + 1):
tanggal = hari_ini - timedelta(days=offset)
kunci = tanggal.isoformat()
if not partition_path(raw_dir, tanggal).exists():
continue # tidak ada partisinya: bukan pekerjaan kita
if state.has_success(kunci):
continue # sudah selesai
if state.attempts(kunci) >= max_attempts:
continue # terus gagal: butuh intervensi manual
hasil.append(tanggal)
hasil.sort()
return hasilEmpat keputusan di dalamnya:
- Hari ini tidak pernah masuk daftar: partisi hari ini belum tentu selesai ditulis sumber, jadi hanya tanggal lampau yang dikejar.
- Partisi harus ada dan belum sukses: tanggal tanpa berkas bukan tanggung jawab pipeline, dan tanggal yang sudah sukses dilewati lewat watermark.
- Percobaan terbatas
max_attempts: tanggal yang tiga kali gagal berhenti dicoba otomatis dan menunggu diperiksa manusia.
Skenario berikut dijalankan pada data proyek dengan jam palsu:
Output yang Dihasilkan:
== due_runs, jam palsu 2026-11-05 02:30 (state masih kosong) ==
['2026-11-02', '2026-11-03', '2026-11-04']
== setelah 02 dan 03 sukses, jam 2026-11-06 02:30 ==
['2026-11-04']
== semua sukses: tidak ada yang jatuh tempo ==
[]Loop Penjadwal #
run_loop merangkai kedua fungsi: tidur sampai next_run, bangun, minta due_runs, lalu panggil runner untuk tiap tanggal. Jam dan fungsi tidur disuntik sebagai parameter. Daemon produksi dijalankan dengan python3 -m src.scheduler dan tidak kita eksekusi di sini karena memang tidak berhenti; tests/test_scheduler.py menguji siklusnya dengan jam palsu:
while max_cycles is None or siklus < max_cycles:
sekarang = now_fn()
berikutnya = next_run(cron, sekarang)
jeda = (berikutnya - sekarang).total_seconds()
if jeda > 0:
log(f"[scheduler] menunggu {jeda:.0f} detik "
f"sampai {berikutnya:%Y-%m-%d %H:%M}")
sleep_fn(jeda)
sekarang = now_fn()
jadwal = due_runs(state, sekarang, raw_dir, lookback_days, max_attempts)
if jadwal:
log("[scheduler] partisi jatuh tempo: "
+ ", ".join(d.isoformat() for d in jadwal))
for tanggal in jadwal:
runner(tanggal)
siklus += 1Dengan jam palsu yang maju dari 00:10 ke 02:30:05, satu siklus menghasilkan:
Output yang Dihasilkan:
[scheduler] menunggu 8400 detik sampai 2026-11-05 02:30
(tidur 8400 detik)
[scheduler] partisi jatuh tempo: 2026-11-02, 2026-11-03, 2026-11-04
runner dipanggil untuk: ['2026-11-02', '2026-11-03', '2026-11-04']Cron dan systemd di Produksi #
Dua berkas di config/ menyiapkan penjadwalan level sistem operasi. Keduanya bisa diunduh dari bagian Berkas Sumber; isinya sebagai berikut:
# /etc/cron.d/etl-tokokita
# Jadwal pipeline ETL harian TokoKita dengan cron sistem.
# Instalasi: salin ke /etc/cron.d/etl-tokokita, lalu chmod 644.
# Karakter % harus ditulis %% karena cron menafsirkan % sebagai pemisah baris.
SHELL=/bin/sh
CRON_TZ=Asia/Jakarta
30 2 * * * tokokita cd /srv/tokokita/etl && /usr/bin/python3 -m src.pipeline --date yesterday >> data/state/cron.log 2>&1[Unit]
Description=Penjadwal pipeline ETL harian TokoKita
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target[Unit]
Description=Menjalankan pipeline ETL pesanan TokoKita
After=network-online.target
[Service]
Type=oneshot
User=tokokita
WorkingDirectory=/srv/tokokita/etl
ExecStart=/usr/bin/python3 -m src.pipeline --date yesterdayKetiga berkas mengasumsikan proyek dipasang di /srv/tokokita/etl dengan pengguna tokokita. Baris --date yesterday memakai fitur parse_run_date: kata tersebut diubah menjadi tanggal sebelumnya. Pada systemd, Persistent=true menjalankan timer yang terlewat begitu mesin hidup kembali; pasangan ini sejalan dengan due_runs yang mengejar partisi terlewat, tema chapter Backfill dan Scheduling. Untuk belajar, cron lebih sederhana; untuk produksi, systemd memberi journalctl dan kebijakan restart tanpa mengubah kode pipeline.
Dengan penjadwal di tempatnya, satu masalah terakhir pada rangkaian ini: kegagalan itu sendiri, yang kita tangani pada Bagian 7.
Bacaan lanjut: crontab(5) , systemd.timer , dan systemd.service .