Status "Dalam Proses" yang Tidak Pernah Selesai: Tiga Cara Job Laravel Gagal Tanpa Memberi Tahu Siapa Pun

Artikel
Authors
Status "Dalam Proses" yang Tidak Pernah Selesai: Tiga Cara Job Laravel Gagal Tanpa Memberi Tahu Siapa Pun

Pengguna menekan tombol "Unduh laporan lengkap". Tidak ada file yang turun, tidak ada pesan apa pun. Setelah halaman dimuat ulang, label yang tadinya "Selesai" berubah menjadi "Belum". Di laporan lain, statusnya malah "Dalam Proses" sejak kemarin dan tidak pernah berubah.

Dua kejadian itu nyata, dari aplikasi pelaporan berbasis Laravel di salah satu proyek klien yang saya pegang. Yang pertama terjadi saat pengujian tanggal 20 Oktober. Yang kedua tercatat lebih dulu dalam audit kode proyek yang sama.

Dari sisi pengguna, keduanya sama: tombol ditekan, lalu sunyi. Dari sisi kode, penyebabnya berbeda, dan masing-masing butuh perbaikan sendiri. Artikel ini bagian kedua dari seri "Queue Laravel di Produksi". Bagian pertama membahas job yang gagal sebelum handle() jalan karena payload-nya membawa terlalu banyak. Bagian ini membahas job yang sempat jalan, gagal di tengah, lalu tidak memberi tahu siapa pun.

Sama seperti bagian pertama, semua screenshot berasal dari demo Laravel 12 yang saya jalankan untuk mereproduksi ketiga kasusnya.

Kenapa kegagalan bisa sunyi

Job unduhan biasanya punya tiga tugas: mengubah status menjadi "Dalam Proses", membuat file, lalu memberi tahu pengguna hasilnya. Masalahnya, tugas ketiga sering hanya ditulis untuk jalur sukses. Jalur gagal ditulis seadanya, dan jarang diuji.

Ada tiga cara job gagal tanpa memberi tahu siapa pun:

1. Error di dalam penangan error. Kode di blok catch juga bisa gagal. Di proyek klien, pembuat laporan yang gagal tidak mengembalikan filePath, tapi job tetap membacanya saat mengirim event "gagal" ke browser. Status sudah diubah menjadi "Belum", lalu baris berikutnya melempar error sebelum event terkirim. Browser tidak pernah tahu.

2. Kehabisan memori. Di PHP, kehabisan memori adalah fatal error, bukan exception. try/catch tidak menangkapnya, bahkan catch (\Throwable $e) sekalipun. Proses worker langsung mati. Tidak ada kode yang sempat mengubah status, jadi status tetap "Dalam Proses".

3. Worker yang masih memakai kode lama. Horizon dan queue:work adalah proses yang berjalan lama. Class yang sudah dimuat tetap dipakai sampai proses itu dihidupkan ulang. Setelah deploy, worker bisa menjalankan kode lama yang bercampur dengan data atau class baru. Di kasus 20 Oktober, inilah yang membuat pembuatan laporan gagal sejak awal, dan kemudian memicu masalah nomor 1.

Ketiga kasus itu saya reproduksi dengan satu job unduhan yang punya tiga mode: normal, melempar exception, dan kehabisan memori.

Job versi naif dengan tiga mode
Job versi naif dengan tiga mode

Hanya unduhan yang sukses yang memberi tahu pengguna. Kasus exception mengembalikan status ke "Belum" tanpa pemberitahuan. Kasus memori meninggalkan "Dalam Proses" tanpa batas waktu.

Prinsipnya: setiap job harus punya akhir yang jelas

Satu aturan menyelesaikan sebagian besar masalah ini: setiap job unduhan harus berakhir di "Selesai" atau "Gagal", dan pengguna harus diberi tahu di kedua kasus. "Dalam Proses" hanya boleh menjadi status sementara.

Untuk mencapainya, pemberitahuan gagal tidak boleh hanya ditaruh di blok catch, karena blok itu tidak jalan kalau prosesnya mati. Laravel menyediakan tempat yang lebih tepat: method failed() di class job. Method ini dipanggil saat Laravel akhirnya menyerah pada sebuah job, apa pun penyebabnya: exception, timeout, atau worker yang mati lalu tidak pernah melapor kembali.

Perbaikan di proyek klien mengikuti prinsip yang sama:

  • filePath dibaca dengan ?? null, sehingga jalur gagal tidak lagi melempar error sendiri.
  • Untuk laporan lengkap, exception apa pun sekarang mengubah status menjadi "Belum" dan mengirim event gagal, bukan hanya dicatat ke log. Status tidak lagi tersangkut di "Dalam Proses".
  • Pembuat laporan diambil lewat service container, supaya tes bisa memaksanya gagal dan jalur gagal ikut teruji.
  • Catatan rilis yang wajib diikuti: setiap selesai deploy, jalankan php artisan horizon:terminate. Supervisor akan menyalakan ulang Horizon dengan kode baru.

Cara memasang jalur gagal di job

Demo memakai Laravel 12 dengan queue driver database. Untuk Horizon dan Redis, prinsipnya sama, perintah restart-nya yang berbeda.

Langkah 1: buat jalur gagal tidak bisa gagal

Mulai dari hal paling murah. Setiap nilai yang mungkin tidak ada di jalur gagal dibaca dengan aman:

// Sebelum: melempar error kalau generate() gagal
event('download.failed', ['path' => $result['filePath']]);

// Sesudah
$this->download->update(['status' => 'Selesai', 'file_path' => $result['filePath'] ?? null]);

Langkah 2: pindahkan pemberitahuan gagal ke failed()

Daripada menangani semuanya di handle(), biarkan handle() fokus pada jalur sukses, lalu taruh penanganan gagal di failed():

class GenerateDownloadSafe implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;

    public function __construct(public Download $download, public string $mode) {}

    public function handle(): void
    {
        $this->download->update(['status' => 'Dalam Proses']);

        $result = $this->generate();
        $this->download->update(['status' => 'Selesai', 'file_path' => $result['filePath'] ?? null]);
        $this->notifyUser();
    }

    // Dipanggil saat Laravel menyerah pada job ini, apa pun penyebabnya.
    public function failed(?Throwable $e): void
    {
        $this->download->update(['status' => 'Gagal']);
        $this->notifyUser();
    }
}

$tries = 1 sengaja dipakai di demo. Untuk pembuatan laporan yang hasilnya tidak berubah kalau diulang, mencoba ulang hanya membuang waktu pengguna. Untuk job yang bergantung pada layanan luar, nilai yang lebih besar masuk akal.

Langkah 3: buktikan kasus memori juga tertangani

Ini bagian yang paling sering diragukan. Kalau proses mati karena memori, siapa yang memanggil failed()?

Jawabannya: worker berikutnya. Job yang sedang dikerjakan saat worker mati tetap tercatat di antrean dengan tanda "sedang diproses". Setelah lewat batas retry_after, worker lain mengambilnya kembali, melihat jumlah percobaannya sudah habis, lalu menandainya gagal dan memanggil failed().

Di demo, retry_after saya turunkan menjadi 5 detik supaya terlihat cepat:

DB_QUEUE_RETRY_AFTER=5 php artisan queue:work --once   # worker mati karena memori
sleep 6
DB_QUEUE_RETRY_AFTER=5 php artisan queue:work --once   # worker baru menutup job itu
Job versi aman dengan tiga mode
Job versi aman dengan tiga mode

Ketiga unduhan sekarang berakhir jelas, dan pengguna diberi tahu di ketiganya. Alasan yang tercatat di failed_jobs menunjukkan bedanya:

Alasan gagal di failed_jobs
Alasan gagal di failed_jobs

Kasus exception tercatat dengan pesan aslinya. Kasus memori tercatat sebagai MaxAttemptsExceededException, karena yang Laravel tahu hanyalah percobaan sebelumnya tidak pernah selesai.

Langkah 4: restart worker setiap kali deploy

Langkah terakhir mencegah penyebab kasus 20 Oktober. Di demo, saya menjalankan worker yang berjalan terus, lalu mengubah kode job seperti saat deploy:

Worker tetap memakai kode lama sampai di-restart
Worker tetap memakai kode lama sampai di-restart

Unduhan setelah deploy masih memakai kode lama. Baru setelah php artisan queue:restart, worker lama berhenti dengan sendirinya. Di server sungguhan, supervisor yang menyalakannya kembali dengan kode baru.

Masukkan perintah ini ke skrip deploy kamu, setelah kode baru terpasang:

php artisan queue:restart       # untuk queue:work
php artisan horizon:terminate   # untuk Horizon

Keduanya tidak mematikan job yang sedang berjalan secara paksa. Worker menyelesaikan job yang sedang dipegang dulu, baru berhenti.

Langkah 5: uji jalur gagal, bukan hanya jalur sukses

Tes yang hanya memastikan unduhan berhasil tidak akan menangkap satu pun dari tiga kasus di atas. Tambahkan tes yang memaksa pembuat laporan gagal, lalu periksa dua hal: statusnya bukan "Dalam Proses", dan pengguna diberi tahu. Di proyek klien, ini alasan pembuat laporan diambil lewat service container: tes bisa menggantinya dengan versi yang selalu gagal.

Checklist untuk setiap job yang punya status

  • Status "Dalam Proses" selalu punya jalan keluar ke "Selesai" atau "Gagal".
  • Pemberitahuan gagal ada di failed(), bukan hanya di blok catch.
  • Tidak ada baris di jalur gagal yang bisa melempar error baru, misalnya membaca index yang mungkin tidak ada.
  • $tries disesuaikan dengan sifat job: 1 untuk proses yang hasilnya sama kalau diulang, lebih dari 1 untuk yang bergantung pada layanan luar.
  • Skrip deploy menjalankan queue:restart atau horizon:terminate.
  • Ada tes yang memaksa job gagal dan memeriksa status akhirnya.

Di mana demo ini berbeda dari produksi

  • Kasus memori butuh waktu sebelum tertutup. failed() baru dipanggil setelah lewat retry_after, yang bawaannya 90 detik untuk driver database. Selama itu, pengguna masih melihat "Dalam Proses". Kalau itu terlalu lama, set $timeout pada job dan pastikan nilainya lebih kecil dari retry_after.
  • Worker yang mati harus dinyalakan lagi oleh sesuatu. Di demo, saya yang menjalankan worker berikutnya. Di produksi, itu tugas supervisor atau Horizon. Tanpa itu, tidak ada yang mengambil job yang ditinggal.
  • Memperbaiki pemberitahuan tidak memperbaiki penyebab kehabisan memori. Laporan yang terlalu berat tetap perlu diperkecil, misalnya dengan memecah rentang data atau menurunkan ukuran gambar. Bagian ini memastikan pengguna tahu saat itu terjadi.
  • Demo memakai driver database. Perintah restart, nama tabel, dan nilai bawaan retry_after bisa berbeda di Redis dan Horizon. Periksa konfigurasi config/queue.php dan config/horizon.php kamu sendiri.

Job yang mengubah status ke "Dalam Proses" tapi belum punya failed() adalah daftar kerja kamu berikutnya. Setiap job di daftar itu bisa jadi laporan berikutnya yang menggantung.

tutorial series

Queue Laravel di Produksi

Pelajaran dari job antrean Laravel yang gagal di aplikasi sungguhan, lengkap dengan demo yang bisa dijalankan.

Bab 2 dari 2
  1. 1.Job Laravel Gagal Sebelum handle() Jalan, dan Retry Tidak Akan Menolong
  2. 2.Status "Dalam Proses" yang Tidak Pernah Selesai: Tiga Cara Job Laravel Gagal Tanpa Memberi Tahu Siapa Pun

Artikel terkait

Tulisan lain yang nyambung dengan topik ini.

Comments

Komentar akan tampil setelah disetujui.

Memuat komentar...