# Analisis: Message Stuck di Queued (Pending/Delayed/Sending)

Dokumen ini menganalisis **kenapa banyak message gagal saat proses kirim** sehingga **status message tetap stuck** di `queued` (atau `pending` / `delayed` / `sending`) dan tidak pernah sampai `sent` atau `failed`.

**Konteks:** Queue layer pertama (Redis → insert ke table `messages`) sudah berhasil. Yang bermasalah adalah **saat job memproses kirim pesan** — job gagal/hilang sehingga message tidak pernah di-update ke terminal (`sent` / `failed`).

---

## 1. Alur Status Message di SimpleWhatsAppJob

| Tahap | queue_status | Kondisi |
|-------|--------------|----------|
| Setelah enqueue | `pending` atau `delayed` | Job masuk Redis, message ada di DB |
| Sebelum kirim (rate limit) | `delayed` | Job di-`release()`, menunggu jadwal |
| Mulai proses kirim | `sending` | Baris 229–231: `$message->update(['queue_status' => 'sending'])` |
| Sukses kirim | `sent` | Baris 270–276 |
| Gagal (retry) | `pending` | Baris 774–778 di `handleError`, lalu `release()` |
| Gagal terminal | `failed` | `markFailed()` atau `failed()` |

Message **stuck** = `queue_status` tetap **pending**, **delayed**, atau **sending** dan tidak pernah berubah ke **sent** / **failed**.

---

## 2. Penyebab Stuck: Job Gagal/Hilang Sebelum Update ke Terminal

Satu-satunya cara message bisa **tetap** di pending/delayed/sending adalah: **job tidak pernah menyelesaikan** salah satu dari:

- update ke `sent` (sukses), atau  
- update ke `failed` / `pending` lewat `markFailed()` / `handleError()` / `failed()`.

Artinya: **proses job terputus** (worker mati, timeout, crash) **sebelum** kode yang mengubah status sempat jalan.

### 2.1. Worker timeout saat proses kirim (penyebab utama)

**Urutan di job:**

1. Update `queue_status` → `sending`.
2. `getActiveToken()`.
3. `sendMessage()` → panggil Meta API (HTTP).

**Fakta:**

- Worker supervisor: `--timeout=60` (atau 120 jika sudah dinaikkan).
- Meta API: `sendRawPayload` timeout **15 detik**; endpoint lain ada yang 30–60 detik (media upload 60s).
- Total waktu bisa melebihi 60s: throttle middleware, validasi, token, HTTP ke Meta, update CampaignLog, dll.

**Skenario:**

- Job sudah set `queue_status = 'sending'`.
- Job masih di dalam `sendMessage()` atau `getActiveToken()` atau setelah HTTP (mis. saat update CampaignLog).
- Worker menganggap job **timeout** dan **memutus proses** (SIGTERM/SIGKILL).
- Proses mati **sebelum**:
  - `catch` di `handle()` jalan, atau
  - `failed()` Laravel jalan.
- Dengan **Redis**: job sudah di-pop, tidak dikembalikan ke queue.
- **Message tetap `sending`** (atau tetap `pending` jika job mati setelah `handleError` update ke `pending` tapi sebelum `release()` selesai — jarang).

**Kesimpulan:** Banyak message stuck di **sending** karena **worker kill job di tengah proses kirim**, sehingga tidak ada satu pun kode yang mengubah status ke `sent` / `failed` / `pending` yang sempat dijalankan.

---

### 2.2. Job hilang dari Redis (worker mati) setelah retry

**Alur retry di kode:**

1. Gagal kirim → masuk `handleError()`.
2. Masih ada sisa retry → `$message->update(['queue_status' => 'pending'])` lalu `$this->release($backoffSeconds)`.
3. Job kembali ke Redis (antrian).
4. Worker lain (atau yang sama) nanti akan ambil job lagi.

**Skenario stuck:**

- Job sudah update message ke `pending` dan panggil `release()`.
- Job benar-benar masuk kembali ke Redis.
- **Saat job itu di-pop dan dijalankan lagi**, worker **mati** (timeout, OOM, restart) **sebelum** ada update status (mis. sebelum sampai baris yang set `sending` lagi atau sebelum `handleError`/`markFailed`).
- Dengan Redis, job yang sudah di-pop **hilang**.
- Message sudah di set `pending` tadi dan **tidak ada job lagi** yang akan memproses → **stuck di pending**.

Jadi: **bukan** retry-nya yang salah, tapi **job hasil retry itu hilang** karena worker mati di tengah jalan, sehingga message yang sudah di-set ke `pending` tidak pernah diproses sampai terminal.

---

### 2.3. Worker mati sebelum handle() sempat jalan

**Alur:**

- Job ada di Redis.
- Worker pop job, jalankan **middleware** (Redis throttle).
- Di middleware: jika lock tidak dapat, `$job->release($delay)` → job kembali ke queue, **message tidak diubah** (handle belum jalan).
- Jika lock dapat, `$next($job)` → masuk `handle()`.

**Skenario stuck:**

- Worker pop job, dapat lock, **sebelum** atau **tepat** saat masuk `handle()` (atau saat load message), worker **crash** (OOM, kill -9).
- Job hilang dari Redis.
- Message **masih** `pending` atau `delayed` (karena handle belum sampai ke baris update mana pun) → **stuck**.

Ini menjelaskan stuck di **pending** / **delayed** untuk message yang seharusnya baru akan diproses.

---

### 2.4. Exception di luar try/catch handle()

**Di dalam `handle()`:**

- Sebelum `try {`: validasi, rate limit, campaign guard, lalu `$message->update(['queue_status' => 'sending'])`.
- Di dalam `try`: token, `sendMessage()`, update sukses, CampaignLog, event.
- `catch (\Exception $e) { $this->handleError($message, $e); }` — semua exception dari try akan masuk sini dan status akan di-update (ke failed atau pending + release).

**Satu-satunya jalan exception “tidak tertangkap”:**

- Exception terjadi **sebelum** `try` (setelah update ke `sending`): misalnya tidak ada — update ke `sending` langsung diikuti `try`.
- Atau exception terjadi **di dalam** `handleError` / `markFailed` (mis. DB error) dan ter-throw lagi — maka Laravel akan anggap job gagal dan panggil `failed()`. Di `failed()` kita update message ke `failed`, jadi message tidak stuck **kecuali** `failed()` sendiri throw atau proses mati sebelum `failed()` selesai (mis. timeout/kill).

Jadi: **stuck karena exception murni** (tanpa kill) jarang, selama `handleError` dan `failed()` tidak throw dan proses tidak mati.

---

### 2.5. Fatal error / OOM

- PHP fatal error atau out of memory → proses mati.
- Tidak ada `catch`, tidak ada `failed()`.
- Dengan Redis, job hilang.
- Message tetap di status terakhir: **sending** (paling sering) atau **pending** → **stuck**.

---

## 3. Ringkasan: Kenapa “Gagal Saat Proses Kirim” Bikin Stuck

- **Proses kirim** = kode antara **set `sending`** sampai **set `sent` / `failed` / `pending`**.
- Semua jalan normal (sukses atau gagal tertangkap) **selalu** mengubah status lewat:
  - update `sent`,
  - atau `markFailed` / `handleError` (termasuk update ke `pending` + `release()`),
  - atau `failed()`.
- Message stuck **hanya** kalau **proses job berhenti** (timeout, kill, crash, OOM) **sebelum** salah satu dari itu jalan.
- Dengan **Redis**, begitu job di-pop dan worker mati, job tidak ada lagi → tidak ada retry, tidak ada panggilan ke `failed()` → **message tidak pernah di-update** → tetap **queued** (pending/delayed/sending).

Jadi: **bukan** logic retry atau logic “gagal kirim” di kode yang salah, tapi **kombinasi**:

1. **Worker timeout/kill di tengah proses kirim** → message stuck di **sending**.
2. **Job (termasuk hasil retry) hilang dari Redis karena worker mati** → message stuck di **pending** (atau sending).
3. **Worker mati sebelum handle jalan** → message stuck di **pending** / **delayed**.

---

## 4. Rekomendasi Perbaikan

### A. Kurangi timeout kill: timeout worker ≥ waktu proses kirim

- Pastikan **worker timeout** (mis. `--timeout=120`) **lebih besar** dari total waktu terburuk: middleware + token + HTTP Meta + update DB.
- Job `SimpleWhatsAppJob` sudah punya `$timeout` dari config (disarankan ≥ 120).
- Config `whatsapp_queue.queue.timeout` default 120; worker sebaiknya `--timeout=120` atau lebih.

Ini mengurangi kemungkinan worker **kill job persis di tengah** proses kirim, sehingga `catch` atau `failed()` sempat jalan dan status tidak stuck.

### B. Pakai database queue untuk antrian WhatsApp

- Dengan **database** driver, job di-**reserve** (tidak hilang dari DB).
- Saat worker mati, setelah `retry_after` job bisa diambil lagi → **retry otomatis** dan message punya kesempatan sampai ke `sent`/`failed`.
- Dengan **Redis**, job yang sudah di-pop dan worker mati = **hilang permanen** = message bisa stuck selamanya.

Ini mengatasi akar masalah: job tidak “hilang” sehingga status pasti akan di-update suatu saat (sukses atau gagal).

### C. Deteksi dan perbaiki message stuck (safety net)

- **Cron/scheduler:** query message dengan `queue_status IN ('pending','delayed','sending')` dan `updated_at` / `processing_started_at` sudah lama (mis. > 15–30 menit).
- Untuk yang **sending** lama: anggap job hilang; update ke `pending` dan **dispatch ulang** `SimpleWhatsAppJob` agar tidak stuck selamanya.
- Command **`php artisan messages:retry-stuck`** hanya memproses message dengan **status = 'queued'** dan **created_at hari ini** (maksimal hari ini):
  - **pending/delayed:** `queue_status IN ('pending','delayed')`, stuck > X jam (default 1 jam).
  - **sending:** `queue_status = 'sending'`, stuck > X menit (default 15 menit).
- Contoh: `php artisan messages:retry-stuck --sending-minutes=15 --hours=1 --dry-run` (preview), lalu tanpa `--dry-run` untuk re-dispatch. Bisa dijadwalkan di cron (mis. tiap 15–30 menit) sebagai safety net.

### D. Jangan kurangi retry; selaraskan worker tries dengan job

- Job punya `$tries = 5`; worker sebaiknya `--tries=5` agar retry ke-4 dan ke-5 benar-benar jalan.
- Kalau worker `--tries=3`, banyak job yang seharusnya retry lagi akan langsung masuk `failed_jobs` dan status message akan di-update lewat `failed()` — jadi tidak stuck, tapi jumlah “gagal” bertambah. Tetap lebih baik worker tries ≥ job tries agar retry konsisten.

---

## 5. Checklist Verifikasi di Lapangan

1. **Stuck di `sending`:**
   - Cek `messages` dengan `queue_status = 'sending'` dan `processing_started_at` lama (mis. > 10 menit). Itu indikasi job di-kill saat proses kirim.
2. **Stuck di `pending` / `delayed`:**
   - Cek message yang `queue_status IN ('pending','delayed')` dan `updated_at` sangat lama. Bisa job belum pernah jalan atau job hasil retry hilang.
3. **Log & supervisor:**
   - Cek log worker (supervisor) untuk **timeout** atau **killed**.
   - Cek `storage/logs` untuk error/exception tepat sebelum tidak ada log lagi untuk message tersebut (indikasi proses mati).
4. **Failed jobs:**
   - Cek `failed_jobs`: apakah error “timeout” atau “killed”. Itu konsisten dengan worker kill di tengah proses kirim.

Dengan langkah di atas, Anda bisa memastikan bahwa **stuck di queued** memang disebabkan oleh job yang gagal/hilang **saat proses kirim** (timeout/kill/hilang dari Redis), dan mitigasinya: timeout lebih besar, queue database, dan mekanisme re-dispatch untuk message stuck.
