Job Terjadwal yang Bertumpuk - Pilih Lock Sesuai Cakupan Pekerjaan
Sebuah job dijadwalkan setiap lima menit, tetapi sesekali satu eksekusi memerlukan enam menit. Ketika trigger berikutnya tiba, apakah sistem sebaiknya memulai salinan kedua, menunggu eksekusi pertama, atau membiarkan jadwal kali ini berlalu?
Jadwal saja tidak dapat menjawab pertanyaan itu. Eksekusi bersamaan mungkin tidak bermasalah untuk laporan read-only, tetapi berbahaya untuk job yang merotasi file, menerbitkan konten, atau memperbarui record yang sama. Mencegah overlap dimulai dari keputusan kebijakan, lalu memilih lock yang cakupannya sesuai dengan semua proses yang dapat menjalankan job tersebut.
Artikel ini membahas command PHP terjadwal pada server Debian. Perilaku systemd timer, local file lock, dan named lock MariaDB digunakan sebagai tiga batas koordinasi yang berbeda. Tidak ada satu pun yang menghasilkan jaminan universal “exactly once”, dan keterbatasan itu sama pentingnya dengan konfigurasinya.
Tentukan apa yang harus dilakukan eksekusi yang bertabrakan
“Jangan overlap” masih menyisakan tiga kebijakan:
- Skip: jika eksekusi lain masih aktif, catat kondisi tersebut lalu keluar dengan sukses. Pilihan ini bisa sesuai untuk job refresh ketika jadwal berikutnya sudah cukup.
- Wait: tetap terblokir sampai eksekusi saat ini melepas lock. Cara ini mempertahankan setiap trigger, tetapi proses yang menunggu dapat menumpuk jika pekerjaan terus lebih lambat daripada jadwal.
- Queue: simpan satu atau beberapa job pending dan biarkan worker mengambilnya secara terencana. Mekanismenya lebih banyak, tetapi biasanya inilah pilihan yang jujur ketika setiap eksekusi yang diminta merupakan pekerjaan berbeda yang tidak boleh dibuang.
Lock menerapkan eksklusi; lock tidak memilih kebijakan yang tepat. Percobaan non-blocking secara alami mendukung skip. Percobaan blocking menerapkan wait, tetapi tetap memerlukan timeout atau batas lain jika menunggu tanpa akhir tidak dapat diterima. Queue memodelkan pekerjaan pending alih-alih menyembunyikannya di balik proses yang tertidur.
Pilihan tersebut juga memengaruhi monitoring. Refresh yang dilewati mungkin normal, sedangkan ekspor invoice yang dilewati dapat berarti pekerjaan hilang. Berikan lock conflict event log atau metric tersendiri agar tidak terlihat sama dengan keberhasilan maupun kegagalan.
Biarkan scheduler melakukan serialisasi jika kontraknya sudah memadai
systemd timer mengaktifkan unit lain, biasanya service dengan base name yang sama. Manual Debian systemd.timer(5) menyatakan bahwa jika unit target masih aktif ketika timer mencapai waktunya, systemd membiarkannya tetap berjalan. systemd tidak me-restart unit tersebut atau membuat instance service lain.
Hal ini memberi service Type=oneshot sederhana sebuah batas single-unit yang berguna:
# /etc/systemd/system/catalog-refresh.service
[Unit]
Description=Refresh the local catalog
[Service]
Type=oneshot
User=www-data
ExecStart=/usr/bin/php /srv/example/bin/refresh-catalog.php
Jika catalog-refresh.service masih aktif, timer pasangannya tidak akan memulai instance kedua dari unit tersebut. Cakupan ini lebih sempit daripada “job tidak mungkin overlap.” Administrator dapat menjalankan command PHP secara langsung, service unit lain dapat memanggilnya, atau host kedua dapat menjalankan aplikasi yang sama. Jalur-jalur itu berada di luar batas timer-ke-unit ini.
Detail versi penting ketika mengubah cadence timer. Sebagai contoh, DeferReactivation= ditambahkan pada systemd 257 dan mengubah cara calendar timer menjadwalkan elapse berikutnya setelah service menjadi inactive. Directive ini tidak boleh disalin ke rilis lama, dan tidak menggantikan koordinasi pada level aplikasi ketika terdapat beberapa jalur eksekusi.
Gunakan dedicated file lock untuk satu host
Ketika semua runner yang mungkin melihat filesystem lokal yang sama, file lock dapat menempatkan batas di dalam command PHP. Dokumentasi resmi PHP untuk flock() menjelaskan advisory reader/writer lock. “Advisory” berarti perlindungan hanya bekerja ketika program yang bersaing bekerja sama dengan mengunci objek yang sama.
Berikut kebijakan skip yang ringkas:
<?php
declare(strict_types=1);
$lockPath = '/run/lock/example/catalog-refresh.lock';
$lock = fopen($lockPath, 'c');
if ($lock === false) {
fwrite(STDERR, "Cannot open lock file.\n");
exit(1);
}
if (!flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDOUT, "Another run is still active; skipping.\n");
fclose($lock);
exit(0);
}
try {
refreshCatalog();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
LOCK_EX meminta exclusive lock, sedangkan LOCK_NB membuat permintaan tersebut non-blocking. Direktori dan file lock khusus harus writable oleh user service, tetapi sebaiknya tidak writable oleh user yang tidak berkaitan. Buat direktori itu saat deployment atau startup service dengan ownership yang disengaja; fallback diam-diam ke eksekusi tanpa lock akan merusak batas tersebut.
Stream handle bukan detail sampingan. Manual PHP menyatakan bahwa menutup stream, atau membiarkannya dihapus oleh garbage collector, akan melepas lock. Karena itu, handle harus tetap dapat dijangkau selama seluruh critical section. Contoh juga melepasnya dalam finally, walaupun penghentian proses dan penutupan stream biasanya ikut melepasnya.
Contoh ini telah diperiksa syntax-nya dan dijalankan secara lokal dengan dua proses CLI bersamaan: proses pertama menahan lock, proses kedua mengambil jalur skip, dan proses berikutnya memperoleh lock setelah release. Hal itu memverifikasi contoh kecil ini pada host yang diperiksa. Hasil tersebut tidak membuktikan perilaku pada filesystem, runtime PHP, atau topologi deployment yang berbeda.
Jangan menganggap keberadaan file lock sebagai lock itu sendiri
File dapat tetap berada di disk di antara eksekusi. Keberadaannya bukan bukti bahwa sebuah proses masih bekerja; state yang relevan adalah kernel lock yang sedang dipegang. Menghapus lalu membuat ulang file lock justru dapat menyesatkan karena proses berbeda mungkin kemudian memegang file descriptor yang merujuk ke underlying file yang berbeda.
Karena alasan yang sama, PID yang ditulis ke file merupakan informasi diagnostik, bukan protokol locking yang lengkap. PID dapat dipakai ulang, dan nomor lama tidak membuktikan bahwa critical section sebelumnya masih aktif. Biarkan locking primitive menentukan ownership dan gunakan metadata hanya untuk mempermudah diagnosis.
Tempatkan lock di luar PHP jika command adalah batasnya
Manual util-linux flock(1) mendokumentasikan bentuk command yang menahan lock selama child command berjalan. Karena itu, sebuah cron entry dapat menyatakan kebijakan non-blocking yang sama tanpa mengubah program PHP:
*/5 * * * * flock --nonblock /run/lock/example/catalog-refresh.lock /usr/bin/php /srv/example/bin/refresh-catalog.php
Cara ini menarik ketika setiap invocation dikendalikan oleh scheduler. Lock melingkupi seluruh child process dan dilepas ketika file descriptor terkait ditutup. Konsekuensinya ada pada visibility: secara default, lock conflict dan kegagalan biasa pada child sama-sama dapat menghasilkan command result non-zero. Manual menyediakan --conflict-exit-code ketika otomasi perlu membedakan keduanya.
Shell wrapper dan in-program lock tidak sebaiknya ditumpuk sembarangan dengan file atau nama yang berbeda. Dua batas yang tidak selaras dapat memberikan rasa aman palsu. Pilih satu identitas local lock yang otoritatif, dokumentasikan, dan pastikan setiap jalur eksekusi yang bersaing menggunakannya.
Pindahkan koordinasi ke MariaDB ketika satu filesystem tidak dipakai bersama
Local file lock tidak dapat mengoordinasikan dua application host yang tidak berbagi filesystem lock. Jika semua runner menggunakan server MariaDB yang sama, named advisory lock dapat menyediakan cakupan berbeda. Dokumentasi resmi MariaDB untuk GET_LOCK() menjelaskan bahwa nama berlaku server-wide dan menyarankan nama khusus aplikasi atau database untuk mengurangi collision.
SELECT GET_LOCK('example.catalog-refresh', 0);
-- Run the protected work on this connection.
SELECT RELEASE_LOCK('example.catalog-refresh');
Dengan timeout nol detik, GET_LOCK() segera mengembalikan hasil: 1 berarti lock diperoleh, 0 berarti percobaan mengalami timeout karena lock tidak tersedia, dan NULL menunjukkan error. Kode aplikasi harus membedakan ketiga hasil tersebut.
Koneksi merupakan bagian dari lifetime lock ini. MariaDB melepas named lock ketika koneksi tersebut berakhir, termasuk saat termination abnormal, dan COMMIT tidak melepasnya karena named lock tidak berinteraksi dengan transaction. Connection pool atau abstraction yang mengganti koneksi di balik job dapat merusak implementasi yang sekilas terlihat rapi. Pertahankan satu koneksi yang diketahui sampai release.
Mekanisme ini tetap berupa cooperative advisory locking. Client lain dapat mengabaikan konvensi tersebut, atau bahkan memperoleh nama yang sama jika pemilihan namanya buruk. Cakupannya juga hanya satu server MariaDB, bukan secara ajaib semua server independen. MariaDB memperingatkan bahwa statement yang menggunakan GET_LOCK() tidak aman untuk statement-based replication, sehingga topologi database yang nyata perlu ditinjau dan mekanisme ini tidak boleh dianggap sebagai distributed lock universal.
Lock tidak membuat pekerjaan aman untuk diulang
Mutual exclusion menjawab, “Apakah dua eksekusi yang bekerja sama dapat berada dalam critical section ini sekarang?” Mekanisme itu tidak menjawab, “Apakah eksekusi sebelumnya menyelesaikan setiap side effect?”
Bayangkan sebuah job memanggil API publishing remote, API menerima request, lalu proses PHP crash sebelum mencatat penyelesaian. Lock dilepas ketika proses berakhir. Eksekusi berikutnya dapat memperoleh lock secara bersih dan mengulangi API call. Tidak pernah ada overlap bersamaan, tetapi external effect tetap mungkin terjadi dua kali.
Kegagalan tersebut membutuhkan desain terpisah: idempotency key yang diterima downstream API, durable local state transition, uniqueness constraint, atau reconciliation terhadap sistem yang otoritatif. Mekanisme yang tepat bergantung pada effect-nya. Lock dapat melindungi state transition, tetapi tidak dapat memperluas local transaction ke remote service yang tidak berkaitan.
Durasi lock yang panjang juga perlu diperiksa. Menahan lock selama network work yang lambat mungkin diperlukan untuk mencegah overlap, tetapi juga meningkatkan jumlah eksekusi yang dilewati atau waktu tunggu. Terkadang batas yang lebih baik justru singkat: ambil durable job dalam transaction, lepaskan claim lock, lalu lakukan pekerjaan dengan retry yang dirancang secara eksplisit.
Tinjau batas secara keseluruhan
- Daftar setiap jalur yang dapat menjalankan job: timer, cron, command line, web request, queue worker, dan host lain.
- Tentukan apakah conflict harus di-skip, menunggu dengan batas, atau menjadi queued work yang durable.
- Gunakan satu identitas lock yang stabil dan namespaced bagi semua runner yang bekerja sama.
- Pertahankan file handle atau koneksi database selama seluruh critical section.
- Perlakukan kegagalan membuat atau memperoleh lock sebagai outcome yang disengaja, bukan izin untuk berjalan tanpa lock.
- Catat lock conflict secara terpisah dari kegagalan job dan pekerjaan yang sukses.
- Periksa perilaku filesystem dan mount sebelum mengandalkan file lock pada shared storage.
- Rancang external side effect agar aman diulang bahkan setelah crash dan timeout dengan hasil ambigu.
- Uji contention, release normal, abrupt termination, dan eksekusi terjadwal berikutnya.
Kesimpulan
Overlap control terkecil yang benar adalah mekanisme yang visibility-nya sesuai dengan setiap runner. systemd timer dapat menserialkan satu unit aktif. File lock melalui PHP atau util-linux dapat mengoordinasikan proses yang bekerja sama dan melihat satu filesystem yang sesuai. Named lock MariaDB dapat mengoordinasikan client dari satu server database ketika local file bukan batas yang dipakai bersama.
Tidak satu pun seharusnya disebut exactly-once execution. Pertama, tentukan apakah eksekusi yang bertabrakan perlu di-skip, menunggu, atau masuk queue. Setelah itu, tahan lock yang dipilih selama critical section yang dimaksud, buat conflict terlihat, lalu secara terpisah rancang pekerjaan agar dapat dipulihkan dengan aman. Pertanyaan yang berguna bukan sekadar “Apakah kita punya lock?”, melainkan “Eksekusi mana yang dapat melihatnya, dan apa yang masih tidak pasti setelah lock dilepas?”
