---
title: "İş Kuyruğu ve Worker Sistemi"
url: "https://cilginyazilim.com/kutuphane/php-queue-worker"
description: "Bu örnek, MySQL üzerine kurulmuş tam bir iş kuyruğu ve worker sistemi gösterir; Redis veya başka bir kuyruk servisi gerektirmez. Çözdüğü asıl problem iş çalıştırmak değil, aynı işin iki kez çalışmamasını sağlamaktır: iki worker aynı satırı aynı anda görürse e-posta iki kez gider. Klasik \"önce SELECT, sonra UPDATE\" yaklaşımı bir yarış koşuludur; bu proje kontrolü ve yazmayı tek bir UPDATE ifadesine koyar ve rowCount() ile doğrular, böylece MySQL 8'e özgü SKIP LOCKED'a ihtiyaç duymadan aynı garantiyi verir. Hata alan iş üstel geri çekilmeyle yeniden denenir (10, 20, 40 saniye; bir saatte tavanlanır) ve deneme hakkı dolduğunda silinmez, ölü mektup kutusu olan failed_jobs tablosuna taşınır; panelden yeniden kuyruğa gönderilebilir. Worker çökerse kilit üzerinde kalır ve iş sonsuza kadar \"çalışıyor\" görünürdü; bu yüzden beş dakikadan eski rezervasyonlar ayrı bir \"takılmış\" durumuyla gösterilir ve tek düğmeyle kurtarılır. Hata metinlerinde yığın izi saklanmaz, tanınmayan iş sınıfları çalıştırılmaz. Komut satırı worker'ı queue, max, once ve sleep seçeneklerini destekler."
published: "2026-09-03T09:44:21+03:00"
modified: "2026-09-05T02:01:19+03:00"
category: "Uygulama İskeleti"
type: "kod örneği"
difficulty: "İleri"
tech: ["PHP 8", "PDO", "MySQL", "Bootstrap 5", "Ajax", "Oturum Girişi"]
tags: ["Arka Plan İşleri", "İş Kuyruğu", "Ölü Mektup Kutusu", "Worker", "Yeniden Deneme"]
license: "MIT"
site: "CılgınYazılım"
language: "tr"
---

# İş Kuyruğu ve Worker Sistemi

Bu örnek, MySQL üzerine kurulmuş tam bir iş kuyruğu ve worker sistemi gösterir; Redis veya başka bir kuyruk servisi gerektirmez. Çözdüğü asıl problem iş çalıştırmak değil, aynı işin iki kez çalışmamasını sağlamaktır: iki worker aynı satırı aynı anda görürse e-posta iki kez gider. Klasik "önce SELECT, sonra UPDATE" yaklaşımı bir yarış koşuludur; bu proje kontrolü ve yazmayı tek bir UPDATE ifadesine koyar ve rowCount() ile doğrular, böylece MySQL 8'e özgü SKIP LOCKED'a ihtiyaç duymadan aynı garantiyi verir. Hata alan iş üstel geri çekilmeyle yeniden denenir (10, 20, 40 saniye; bir saatte tavanlanır) ve deneme hakkı dolduğunda silinmez, ölü mektup kutusu olan failed_jobs tablosuna taşınır; panelden yeniden kuyruğa gönderilebilir. Worker çökerse kilit üzerinde kalır ve iş sonsuza kadar "çalışıyor" görünürdü; bu yüzden beş dakikadan eski rezervasyonlar ayrı bir "takılmış" durumuyla gösterilir ve tek düğmeyle kurtarılır. Hata metinlerinde yığın izi saklanmaz, tanınmayan iş sınıfları çalıştırılmaz. Komut satırı worker'ı queue, max, once ve sleep seçeneklerini destekler.

- **Gereksinim:** PHP 8.0+ (CLI erişimi) · MySQL 5.7+ / MariaDB 10.3+ · pdo_mysql · mbstring · Apache mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/queue-worker-system

## Öne Çıkan Özellikler

- Aynı işi iki worker alamaz: kontrol ve yazma tek UPDATE ifadesinde
- SKIP LOCKED gerektirmez — MySQL 5.7 ve MariaDB 10.3 uyumlu
- Üstel geri çekilmeli yeniden deneme: 10 sn → 20 sn → 40 sn, tavan 1 saat
- Deneme hakkı dolan iş silinmez, ölü mektup kutusuna taşınır
- Takılmış iş ile çalışan iş ayrı rozetlerle gösterilir ve kurtarılabilir
- Hata mesajı saklanır, yığın izi saklanmaz — dosya yolları sızmaz
- Tanınmayan iş sınıfı çalıştırılmaz (is_subclass_of denetimi)
- Adlandırılmış kuyruklar ve iş başına deneme hakkı
- Redis yok: elinizdeki MySQL ve tek bir bileşik indeks yeterli

## Kullanım Senaryoları

- Kayıt veya sipariş sonrası e-posta, rapor ve bildirim gönderenler
- "Sayfa neden 8 saniye açılıyor?" sorusuyla boğuşanlar
- Redis kurmadan, elindeki MySQL ile kuyruk isteyenler
- Yeniden deneme, geri çekilme ve ölü mektup kutusunu öğrenmek isteyenler

## Kurulum

1. Depoyu klonlayın veya ZIP olarak indirip web kökünüze açın
2. database.sql dosyasını içe aktarın (veritabanını kendisi oluşturur)
3. .env.example dosyasını .env adıyla kopyalayıp veritabanı bilgilerinizi yazın
4. Tarayıcıdan açıp admin@cilginyazilim.com / Admin1234 ile giriş yapın
5. Ayrı bir terminalde php bin/worker.php --max=5 çalıştırıp akışı izleyin

## Ayrıntılı Anlatım

<img src="assets/images/logo.png" alt="Çılgın Yazılım" width="90">

## İş Kuyruğu ve Worker Sistemi

#### PHP 8 · PDO · MySQL · Oturum Girişli Panel · Yeniden Deneme · Ölü Mektup Kutusu · Çılgın Yazılım Tasarım Kalıbı

**Kullanıcıyı bekletmeyin: işi kuyruğa bırakın, arka planda bir worker çalıştırsın.**

[![PHP](https://img.shields.io/badge/PHP-8.0%2B-777BB4?style=flat-square&logo=php&logoColor=white)](https://php.net)
[![MySQL](https://img.shields.io/badge/MySQL-5.7%2B-4479A1?style=flat-square&logo=mysql&logoColor=white)](https://mysql.com)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-5.3-7952B3?style=flat-square&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![Redis](https://img.shields.io/badge/Redis-gerekmiyor-16a34a?style=flat-square&logo=redis&logoColor=white)](#neden-redis-yok)
[![License](https://img.shields.io/badge/Lisans-MIT-16a34a?style=flat-square)](LICENSE)

**🇹🇷 Türkçe** · [🇬🇧 English](README.en.md)

[**▶ Canlı Demo**](https://cilginyazilim.com/kutuphane/uygulama/queue-worker-system/) · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane/php-queue-worker) · [cilginyazilim.com](https://cilginyazilim.com)

---

### Canlı Demo

**Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.**

<a href="https://cilginyazilim.com/kutuphane/uygulama/queue-worker-system/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane/php-queue-worker"><img src="https://img.shields.io/badge/KAYNAK_KODU_%C4%B0NCELE-0ea5e9?style=for-the-badge&logo=readthedocs&logoColor=white&labelColor=061321" alt="Kaynak Kodu İncele" height="42"></a>
<a href="https://github.com/CilginYazilim/queue-worker-system/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/ZIP_%C4%B0ND%C4%B0R-16a34a?style=for-the-badge&logo=github&logoColor=white&labelColor=061321" alt="ZIP İndir" height="42"></a>

<br><br>

<a href="https://cilginyazilim.com/kutuphane/uygulama/queue-worker-system/" title="Canlı demoyu açmak için tıklayın">
  <img src="docs/screenshots/03-kuyruk.png" alt="İş kuyruğu canlı demo önizlemesi" width="860">
</a>

<sub>▲ Görsele tıklayarak demoyu açabilirsiniz</sub>

<br>

#### Demo hesapları

| Rol | E-posta | Parola |
|---|---|---|
| Yönetici | `admin@cilginyazilim.com` | `Admin1234` |
| Kullanıcı | `demo@cilginyazilim.com` | `Demo1234` |

Giriş ekranındaki hazır düğmelerle alanları tek tıkla doldurabilirsiniz.

#### Demoda 60 saniyede neleri deneyebilirsiniz?

| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|---|---|
| **1** | **Kuyruk** sayfasını açın ve dört sayaca bakın | Bekleyen · Çalışıyor · Ertelenmiş · Başarısız. Dördü tek bir `SUM(CASE WHEN ...)` sorgusundan gelir; dört ayrı `COUNT` atılmaz |
| **2** | Tablodaki **#106** satırına bakın: rozeti `Takılmış` diyor | O işi bir worker kaptı ama bitirmedi (süreç çöktü). `reserved_at` 5 dakikadan eskiyse iş "çalışıyor" değil, **takılmış**tır. İkisini aynı rozetle göstermek sorunu gizlerdi |
| **3** | **"Takılanları kurtar"** düğmesine basın | `reserved_at` temizlenir, iş kuyruğa geri döner. Eşik, `Queue::STUCK_AFTER` ile rozetinkiyle **aynı** sabittir — ekranda takılmış görünen işi düğme mutlaka bulur |
| **4** | **#107** satırında `2 / 3` ve kırmızı `son hata` yazısına bakın | Kararsız iş iki kez denendi. Üstel geri çekilme yürürlükte: 10 sn → 20 sn → 40 sn. Hemen tekrar denemek, düşmüş bir servisi daha da yormaktır |
| **5** | **Başarısız İşler** sayfasını açın | Deneme hakkı dolan işler buraya düşer (ölü mektup kutusu). Hata metinleri okunabilir; **yığın izi saklanmaz** — dosya yolları veritabanına yazılmaz |
| **6** | Bir başarısız işi **"Yeniden dene"** ile kuyruğa geri gönderin | Satır `failed_jobs`'tan `jobs`'a taşınır, deneme sayacı sıfırlanır. Servis düzeldiğinde işi kaybetmemiş olursunuz |
| **7** | **"Kuyruğa ekle"** ile bir **e-posta** işi bırakın | `jobs` tablosuna satır düşer, sayfa hemen döner. Kullanıcı SMTP'yi beklemez — kuyruğun bütün varlık sebebi budur |
| **8** | **Gecikme** kutusuna `120` yazıp iş ekleyin | Satır `Ertelenmiş` görünür: `available_at` iki dakika ileridedir. Worker onu zamanı gelmeden **görmez** |
| **9** | **Kararsız iş**i ekleyin ve terminalde `php bin/worker.php --max=5` çalıştırın | %70 ihtimalle hata verir, geri çekilmeyle yeniden denenir, üçüncü denemeden sonra başarısız kutusuna düşer. Tüm akışı canlı izlersiniz |
| **10** | Telefonunuzdan açın | Sayaçlar alt alta dizilir, ikincil sütunlar gizlenir; sayfa gövdesinde **yatay kaydırma yoktur** |

> **Not:** Demo sunucusunda sürekli çalışan bir worker **yoktur**. Bu bilinçlidir: kuyruğun dolu görünmesi, "işleri bekleten şey nedir?" sorusunu somutlaştırır. Panel de bunu söyler ve çalıştırılacak komutu ekrana yazar. İşleri gerçekten işlemek için projeyi indirip `php bin/worker.php` çalıştırın.

#### Demo alanı hakkında bilinmesi gerekenler

| Konu | Durum |
|---|---|
| **Veriler** | `database.sql` içindeki **51 kullanıcı + 10 kuyruk işi + 4 başarısız iş**. Gerçek kişi verisi yoktur. |
| **Sıfırlama** | Demo veritabanı **düzenli aralıklarla** başlangıç hâline döner; eklediğiniz işler kalıcı değildir. |
| **Worker** | Demoda **çalışmıyor** (yukarıdaki nota bakın). Yerelde tek komutla çalıştırılır. |
| **Kimlik doğrulama** | **Vardır.** Oturum, "beni hatırla" jetonu, hız sınırı ve CSRF korumasıyla birlikte gelir. |
| **`APP_DEBUG`** | Canlıda **kendiliğinden `false`** — sunucu adından türetilir, yerelde `true` kalır. |
| **Bağımlılık** | **Sıfır.** Redis yok, Composer yok, npm yok, CDN yok. |

> Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp `database.sql`'i içe aktarmanız aynı ekranı kendi bilgisayarınızda **2 dakikada** ayağa kaldırır → [Kurulum](#kurulum)

---

### Bu Proje Nedir?

Kayıt formuna basan kullanıcı, hoş geldin e-postası gönderilirken **bekler**. SMTP sunucusu yavaşsa 4 saniye bekler. SMTP sunucusu düşmüşse 30 saniye bekler ve sonunda bir hata sayfası görür — üstelik kaydı başarıyla oluşturulmuş olduğu hâlde.

Sorun e-postada değil, **işin yanlış yerde yapılmasındadır**. Kullanıcının isteği ile e-postanın gönderilmesi aynı anda olmak zorunda değildir. İkisini ayırırsanız istek milisaniyelerde biter, e-posta arka planda gider ve gitmezse **tekrar denenir**.

Bu proje o ayrımı MySQL üzerinde kurulmuş bir iş kuyruğuyla gösteriyor: `jobs` tablosuna bir satır bırakırsınız, ayrı bir süreç (worker) o satırı kapar, çalıştırır ve siler. Hata olursa iş **üstel geri çekilmeyle** yeniden denenir; deneme hakkı dolarsa `failed_jobs` tablosuna — ölü mektup kutusuna — düşer ve orada sizi bekler.

Kuyruğun zor kısmı iş çalıştırmak değildir; **aynı işin iki kez çalışmamasını** sağlamaktır. İki worker aynı satırı aynı anda görürse e-posta iki kez gider. Bu proje o yarışı, koşullu bir `UPDATE` ile — kilitsiz ve MySQL 5.7 uyumlu — çözer.

**Kimler için uygun?**

- Kayıt/sipariş sonrası e-posta, rapor veya bildirim gönderenler
- "Sayfa neden 8 saniye açılıyor?" sorusuyla boğuşanlar
- Redis kurmadan, elindeki MySQL ile kuyruk isteyenler
- Yeniden deneme, geri çekilme ve ölü mektup kutusunun **neden** gerekli olduğunu öğrenmek isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir panel kalıbı arayanlar

> **Klonla, `database.sql`'i içe aktar, çalıştır.** Başka hiçbir kurulum adımı yok. Composer yok, npm yok, Redis yok.

Bu proje, **[Çılgın Yazılım Kütüphanesi](https://cilginyazilim.com/kutuphane)** altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.

---

### İçindekiler

- [Canlı Demo](#canlı-demo)
- [Bu Proje Nedir?](#bu-proje-nedir)
- [Ekran Görüntüleri](#ekran-görüntüleri)
- [Bir işin hayatı](#bir-işin-hayatı)
- [Kritik Kararlar](#kritik-kararlar)
- [Neler Var?](#neler-var)
- [Worker'ı çalıştırmak](#workerı-çalıştırmak)
- [Kendi işinizi yazmak](#kendi-işinizi-yazmak)
- [Güvenlik: Neyi, Nasıl Kapattık?](#güvenlik-neyi-nasıl-kapattık)
- [Kurulum](#kurulum)
- [Yapılandırma](#yapılandırma)
- [Dosya Yapısı](#dosya-yapısı)
- [Nasıl Çalışıyor?](#nasıl-çalışıyor)
- [Veritabanı Şeması](#veritabanı-şeması)
- [SSS](#sss)
- [Canlı Ortama Alırken](#canlı-ortama-alırken)
- [Sorun Giderme](#sorun-giderme)
- [Yol Haritası](#yol-haritası)
- [Katkı](#katkı)
- [Lisans](#lisans)

---

### Ekran Görüntüleri

#### Kuyruk

Dört sayaç — Bekleyen · Çalışıyor · Ertelenmiş · Başarısız — tek bir `SUM(CASE WHEN …)` sorgusundan gelir; dört ayrı `COUNT` atılmaz. Tablodaki `Takılmış` rozeti "çalışıyor"dan **ayrı** bir durumdur: işi bir worker kapmış ama süreç ölmüştür. Rozetin eşiği ile "Takılanları kurtar" düğmesinin eşiği aynı sabittir (`Queue::STUCK_AFTER`), yani ekranda takılmış görünen işi düğme mutlaka bulur. `2 / 3` yazan satır üstel geri çekilmeyi gösterir: 10 sn → 20 sn → 40 sn.

![Kuyruk sayfası: dört durum sayacı, takılmış iş rozeti ve deneme sayaçları](docs/screenshots/03-kuyruk.png)

#### Başarısız işler

Deneme hakkı dolan işler buraya — ölü mektup kutusuna — düşer ve kaybolmadan bekler. Hata metinleri okunabilir; **yığın izi saklanmaz**, çünkü dosya yolları veritabanına yazılmamalıdır. "Yeniden dene" satırı `failed_jobs`'tan `jobs`'a geri taşır ve deneme sayacını sıfırlar.

![Başarısız işler: ölü mektup kutusu, okunabilir hata metinleri ve yeniden deneme](docs/screenshots/04-basarisiz-isler.png)

#### Kontrol paneli

Kuyruğun nabzı ve worker'ın durumu. Demo sunucusunda sürekli çalışan bir worker **yoktur**; panel bunu açıkça söyler ve çalıştırılacak komutu kopyalanmaya hazır biçimde ekrana yazar.

![Kontrol paneli: kuyruk sayaçları ve worker komutu](docs/screenshots/02-kontrol-paneli.png)

#### Giriş ekranı

Demo hesapları tek tıkla doldurulur. Giriş denemeleri hız sınırına tabidir; art arda başarısız denemeden sonra hesap geçici olarak kilitlenir.

![Giriş ekranı: demo hesapları tek tıkla doldurulur](docs/screenshots/01-giris.png)

#### Koyu tema

Tema tarayıcıda değil **kullanıcı hesabında** saklanır. Tablo hücrelerinin kontrastı koyu temada 14,5:1'dir; durum rozetleri rengin yanında **metin de** taşır, böylece rengi ayırt edemeyen bir ziyaretçi de durumu okuyabilir.

![Koyu tema görünümü](docs/screenshots/05-koyu-tema.png)

#### Mobil görünüm

390px genişlikte sayaçlar alt alta dizilir, ikincil sütunlar gizlenir ve alt navigasyon devreye girer. Sayfa gövdesinde yatay kaydırma yoktur; geniş tablolar yalnızca kendi kapsayıcılarında kayar.

<img src="docs/screenshots/06-mobil.png" alt="390px genişlikte mobil görünüm" width="360">

---

### Bir işin hayatı

```
  push()                    reserve()                 handle()
    │                          │                          │
    ▼                          ▼                          ▼
┌────────┐   zamanı geldi  ┌──────────┐   başarılı   ┌─────────┐
│BEKLİYOR│ ──────────────► │ ÇALIŞIYOR│ ───────────► │  SİLİN  │
└────────┘                 └──────────┘              └─────────┘
    ▲                          │
    │                          │ hata
    │                          ▼
    │                     deneme < max ?
    │                       │        │
    │  10 sn → 20 sn → 40sn │ evet   │ hayır
    └───────────────────────┘        ▼
       (üstel geri çekilme)    ┌──────────────┐
                               │ BAŞARISIZ    │  ölü mektup kutusu
                               │ failed_jobs  │  → "Yeniden dene" ile geri döner
                               └──────────────┘

  Worker çöktüyse:  ÇALIŞIYOR durumunda 5 dakikadan uzun kalan iş
                    TAKILMIŞ sayılır → recoverStuck() onu BEKLİYOR'a döndürür
```

Dört durum, iki sütundan okunur:

| Durum | Nasıl anlaşılır |
|---|---|
| **Bekliyor** | `reserved_at IS NULL` **ve** `available_at <= NOW()` |
| **Ertelenmiş** | `reserved_at IS NULL` **ve** `available_at > NOW()` |
| **Çalışıyor** | `reserved_at` dolu ve 5 dakikadan yeni |
| **Takılmış** | `reserved_at` dolu ve 5 dakikadan eski |

---

### Kritik Kararlar

#### 1. Aynı işi iki worker alamaz — kilit kullanmadan

En sık yapılan hata şudur:

```php
// YANLIŞ: iki worker aynı satırı görebilir
$job = $db->query('SELECT * FROM jobs WHERE reserved_at IS NULL LIMIT 1')->fetch();
$db->exec("UPDATE jobs SET reserved_at = NOW() WHERE id = {$job['id']}");
```

İki worker `SELECT`'i aynı anda çalıştırırsa ikisi de aynı satırı alır ve e-posta **iki kez** gider. "Önce bak, sonra yaz" her zaman bir yarış koşuludur.

Doğrusu, kontrolü ve yazmayı **tek ifadeye** koymaktır:

```sql
UPDATE jobs
   SET reserved_at = NOW(), reserved_by = :worker, attempts = attempts + 1
 WHERE id = :id
   AND reserved_at IS NULL        -- ◄── kritik satır
```

`reserved_at IS NULL` koşulu `UPDATE`'in içindedir; veritabanı satırı yazarken zaten kilitler. İki worker aynı satırı denerse **yalnızca birinin** `rowCount()` değeri 1 olur:

```php
if ($claim->rowCount() === 0) {
    continue;   // başka bir worker önce davrandı, sıradakine geç
}
```

Uygulama önce 5 aday satır okur, sonra bunları sırayla kapmayı dener. Böylece iki worker çakışsa bile boş dönmez, listedeki bir sonrakine geçer.

#### 2. `SELECT ... FOR UPDATE SKIP LOCKED` neden kullanılmadı?

Kullanılabilirdi ve MySQL 8.0'da daha zarif olurdu. Ama `SKIP LOCKED`, **MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur** — paylaşımlı hosting'lerin büyük kısmı hâlâ orada.

Koşullu `UPDATE` yöntemi her sürümde çalışır, ek bir işlem (transaction) yönetimi gerektirmez ve aynı güvenceyi verir. Taşınabilirlik, buradaki zarafetten daha değerli görüldü.

#### 3. Yeniden deneme **üstel geri çekilmeyle** yapılır

Hata alan işi hemen tekrar denemek, çoğu zaman zarar verir: düşmüş bir SMTP sunucusuna saniyede üç istek göndermek onu daha da boğar.

```php
// app/Core/Queue.php — release()
$delay = min(3600, 10 * (2 ** ($attempts - 1)));
```

Bekleme süresi her denemede ikiye katlanır: **10 sn → 20 sn → 40 sn → …** ve bir saatte tavanlanır. Tavan olmasaydı 20. deneme 60 gün sonraya düşerdi.

#### 4. Deneme hakkı dolan iş silinmez, **taşınır**

Üç kez denenip başaramayan bir işi silmek, veriyi kaybetmektir. Bu iş `failed_jobs` tablosuna taşınır ve orada sizi bekler.

Neden ayrı tablo? Çünkü `jobs` tablosu worker'ın **en sıcak** sorgusudur; saniyede birkaç kez taranır. Başarısız işleri orada tutmak, o tabloyu asla temizlenmeyen ölü satırlarla şişirirdi.

Servisi düzelttiğinizde "Yeniden dene" düğmesi işi geri gönderir — kaybolmuş bir e-posta yerine, gönderilmeyi bekleyen bir e-postanız olur.

#### 5. Hata metni saklanır, **yığın izi saklanmaz**

```php
'error' VARCHAR(2000) NOT NULL DEFAULT ''
```

Yığın izi dosya yollarınızı, klasör yapınızı ve sınıf adlarınızı içerir. Veritabanı bir gün sızarsa saldırgana hazır bir harita vermiş olursunuz. Hata **mesajı** sorunu anlamaya yeter.

#### 6. Takılmış iş ile çalışan iş ayrı gösterilir

Worker çökerse (sunucu yeniden başladı, bellek doldu, deploy sırasında kapatıldı) `reserved_at` dolu kalır ve iş sonsuza kadar "çalışıyor" görünür — oysa onu çalıştıran kimse yoktur.

Bu yüzden 5 dakikadan eski rezervasyonlar arayüzde ayrı bir **Takılmış** rozetiyle gösterilir. Eşik `Queue::STUCK_AFTER` sabitindedir ve **hem rozet hem kurtarma sorgusu** onu kullanır. İkisi ayrışsaydı, ekranda takılmış görünen bir işi kurtarma düğmesi bulamazdı.

#### 7. Payload JSON, iş sınıfı ise tam sınıf adı

```
job_class:  App\Jobs\SendEmailJob
payload:    {"to":"...","subject":"..."}
```

İşin **ne olduğu** ile **neyle çalışacağı** ayrılır. Worker sınıfı yükler, `Job` sınıfından türediğini doğrular ve `handle($payload, $db)` çağırır:

```php
if (!class_exists($class) || !is_subclass_of($class, Job::class)) {
    // tanınmayan sınıf çalıştırılmaz
}
```

Bu kontrol güvenliğin parçasıdır: `job_class` alanına elle bir değer yazılsa bile rastgele bir sınıf örneklenip çalıştırılamaz.

#### 8. Neden Redis yok?

Redis kuyruk için mükemmeldir ama **bir bağımlılıktır**: kurulacak, izlenecek, yedeklenecek ve paylaşımlı hosting'de çoğu zaman bulunmayan bir servistir.

Günde on binlerce işe kadar MySQL fazlasıyla yeter — özellikle `idx_jobs_reserve` bileşik indeksi varken. Üstelik işleriniz veritabanı yedeğinizin içinde durur; ayrıca yedeklemeniz gereken ikinci bir depo olmaz.

Milyonlarca işe çıktığınızda Redis'e geçin. O gün gelene kadar kurmayın.

---

### Neler Var?

<tr><td valign="top" width="50%">

**Kuyruk çekirdeği**

- `push()` — iş bırak, gecikme verilebilir
- `reserve()` — yarışsız kapma (koşullu `UPDATE`)
- `release()` — üstel geri çekilmeyle iade
- `fail()` — ölü mektup kutusuna taşı
- `recoverStuck()` — çökmüş worker'ın kilidini aç
- Adlandırılmış kuyruklar (`default`, `reports`, …)
- İş başına ayarlanabilir deneme hakkı

**Worker (CLI)**

- `--queue=` belirli kuyruğu dinle
- `--max=N` N iş sonra dur
- `--once` tek iş çalıştır ve çık
- `--sleep=N` boşta bekleme süresi
- `PHP_SAPI` kontrolü — tarayıcıdan çalışmaz

</td><td valign="top" width="50%">

**Panel**

- Dört canlı sayaç (bekleyen/çalışan/ertelenmiş/başarısız)
- Kuyruk listesi, dört durumlu rozet
- Başarısız işler sayfası, yeniden dene / unut
- Takılanları kurtar düğmesi
- Panelden iş bırakma (üç örnek iş türü)
- Worker çalışmıyorsa uyarı şeridi

**Ortak altyapı**

- Oturum girişi, "beni hatırla", hız sınırı, CSRF
- CSP (`script-src 'self'`), `X-Frame-Options: DENY`
- Açık / koyu tema, hesaba kayıtlı
- Mobilde alt navigasyon, yatay kaydırma yok
- Kullanıcılar sayfasında canlı filtre (JS kapalıysa da çalışır)
- Sıfır bağımlılık

</td></tr>

---

### Worker'ı çalıştırmak

İşler ancak bir worker çalışıyorken işlenir. Proje klasöründe **ayrı bir terminal** açın:

```bash
php bin/worker.php
```

Süreç kuyruğu dinlemeye başlar ve iş geldikçe çalıştırır. Durdurmak için `Ctrl+C`.

#### Seçenekler

| Seçenek | Anlamı |
|---|---|
| `--queue=reports` | Yalnızca bu kuyruğu dinle (varsayılan: `default`) |
| `--max=5` | 5 iş çalıştırdıktan sonra çık — denemek için ideal |
| `--once` | Tek iş çalıştır ve çık (cron'dan tetiklemek için) |
| `--sleep=3` | Kuyruk boşken kaç saniye beklesin (varsayılan: 1) |
| `--help` | Seçenekleri yazdır |

#### Canlı sunucuda

Worker bir gün mutlaka ölür: bellek dolar, deploy sırasında kapatılır, sunucu yeniden başlar. Bu yüzden onu **kendiliğinden yeniden başlatan** bir yönetici altında çalıştırın.

**systemd** (`/etc/systemd/system/cy-worker.service`):

```ini
[Unit]
Description=CY Queue Worker
After=network.target mysql.service

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/uygulama
ExecStart=/usr/bin/php bin/worker.php
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl enable --now cy-worker
```

**supervisor** veya cPanel kullanıyorsanız: her dakika çalışan bir cron kurun ve `--once` verin.

```
* * * * * cd /var/www/uygulama && php bin/worker.php --once >> /dev/null 2>&1
```

> **Deploy sırasında:** worker'ı önce durdurun, kodu güncelleyin, sonra başlatın. Aksi hâlde çalışan süreç eski kodu, yeni gelen işler yeni şemayı kullanır.

---

### Kendi işinizi yazmak

Bir iş, `Job` sınıfından türeyen ve iki metot uygulayan bir sınıftır.

```php
<?php
namespace App\Jobs;

use PDO;

final class SendSmsJob extends Job
{
    /** Arayüzde ve günlükte görünecek ad. */
    public static function label(): string
    {
        return 'SMS gönderimi';
    }

    /** Kaç kez denensin? (varsayılan 3) */
    public static function maxAttempts(): int
    {
        return 5;
    }

    /**
     * İşin kendisi. İstisna fırlatırsa iş başarısız sayılır ve
     * geri çekilmeyle yeniden denenir.
     */
    public function handle(array $payload, PDO $db): void
    {
        $numara = (string) ($payload['numara'] ?? '');

        if ($numara === '') {
            // Kalıcı hata: tekrar denemenin anlamı yok.
            throw new \InvalidArgumentException('Numara boş.');
        }

        // ... SMS sağlayıcısına istek ...
    }
}
```

Kuyruğa bırakmak:

```php
(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…']);

// 10 dakika sonra çalışsın:
(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…'], Queue::DEFAULT, 600);

// Ayrı bir kuyruğa:
(new Queue($pdo))->push(SendSmsJob::class, [...], 'sms');
```

**İki kural:**

1. **`handle()` yeniden çalıştırılabilir olsun.** İş, ağ koptuğu için tam bittiği anda da başarısız sayılabilir ve tekrar denenir. "Bu işi ikinci kez çalıştırırsam ne olur?" sorusunun cevabı zararsız olmalıdır — sipariş iki kez oluşturulmamalıdır.
2. **Payload'a nesne değil, kimlik koyun.** `['user_id' => 42]` yazın, kullanıcı nesnesinin tamamını değil. İş çalıştığında veri değişmiş olabilir; taze hâlini `handle()` içinde okuyun.

---

### Güvenlik: Neyi, Nasıl Kapattık?

| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| **Rastgele sınıf çalıştırma** | `(new $payload['class'])->run()` | Sınıf `Job`'dan türemiyorsa **çalıştırılmaz** (`is_subclass_of`) |
| **Yarış koşulu (çift işleme)** | "Önce SELECT, sonra UPDATE" | Kontrol ve yazma **tek `UPDATE` ifadesinde**; `rowCount()` ile doğrulanır |
| **Yığın izi sızıntısı** | Hata nesnesinin tamamını loglamak | Yalnızca **mesaj** saklanır; dosya yolu ve sınıf yapısı veritabanına yazılmaz |
| **SQL enjeksiyonu** | `"... WHERE id = $id"` | Tüm sorgular hazır ifade; `ATTR_EMULATE_PREPARES = false` |
| **CLI betiğine web'den erişim** | `bin/` klasörü açık | `bin/.htaccess` ile kapalı **ve** betikte `PHP_SAPI === 'cli'` kontrolü |
| **CSRF** | Gizli alan yok | Her POST'ta jeton; karşılaştırma `hash_equals()` ile |
| **XSS** | `echo $row['payload']` | Sunucuda `e()`; ayrıca CSP `script-src 'self'` |
| **Parola sızıntısı** | `md5($parola)` | `password_hash()` / `password_verify()`; otomatik yeniden özetleme |
| **Kaba kuvvet** | Sınırsız deneme | `login_attempts` tablosuyla e-posta + IP başına hız sınırı |
| **Hata sızıntısı** | Canlıda `display_errors=On` | `APP_DEBUG` **ortamdan türetilir**; gerçek alan adında kendiliğinden kapanır |
| **Sonsuz büyüyen kuyruk** | Tavansız geri çekilme | Geri çekilme **1 saatte tavanlanır**; deneme hakkı dolan iş ölü mektup kutusuna taşınır |
| **Bozuk UTF-8'de sessiz JSON kaybı** | `json_encode($v)` | `JSON_INVALID_UTF8_SUBSTITUTE` |

---

### Kurulum

#### Gereksinimler

| | |
|---|---|
| PHP | 8.0 veya üzeri (CLI erişimi gerekir) |
| MySQL / MariaDB | 5.7+ / 10.3+ |
| Web sunucusu | Apache (`mod_rewrite`) veya Nginx |
| PHP eklentileri | `pdo_mysql`, `mbstring` |

#### Adımlar

**1 · Dosyaları yerleştirin**

```bash
git clone https://github.com/CilginYazilim/queue-worker-system.git
cd queue-worker-system
```

**2 · Veritabanını içe aktarın**

```bash
mysql -u root -p < database.sql
```

Dosya veritabanını **kendisi oluşturur** (`cy_queue_worker`), tabloları kurar ve örnek kuyruk verisini yükler.

**3 · `.env` dosyasını oluşturun**

```bash
cp .env.example .env        # Windows: copy .env.example .env
```

**4 · Açın ve worker'ı başlatın**

```
http://localhost/queue-worker-system/
```

Ayrı bir terminalde:

```bash
php bin/worker.php --max=5
```

Giriş: `admin@cilginyazilim.com` / `Admin1234`

---

### Yapılandırma

```env
APP_DEBUG=true          # silerseniz: yerelde açık, canlıda kapalı
APP_URL=
APP_PRETTY_URLS=true    # false → /index.php?r=queue

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=cy_queue_worker
DB_USER=root
DB_PASS=
••••••••

Kuyruk davranışını belirleyen sabitler koddadır:

| Sabit | Yeri | Varsayılan | Ne yapar |
|---|---|---|---|
| `Queue::DEFAULT` | `app/Core/Queue.php` | `'default'` | Kuyruk adı verilmezse kullanılan |
| `Queue::STUCK_AFTER` | `app/Core/Queue.php` | `300` | Kaç saniye sonra iş "takılmış" sayılır |
| `Job::maxAttempts()` | `app/Jobs/Job.php` | `3` | Deneme hakkı (iş sınıfında değiştirilebilir) |
| geri çekilme | `Queue::release()` | `10 · 2ⁿ`, tavan `3600` | Yeniden deneme aralığı |

> **`STUCK_AFTER`'ı işlerinize göre ayarlayın.** İşleriniz dakikalarca sürüyorsa 300 saniye çok kısadır: hâlâ çalışan bir iş "takılmış" sayılıp ikinci kez kuyruğa girebilir.

---

## Dosya Yapısı

```
queue-worker-system/
│
├── index.php                  Ön denetleyici — TEK giriş noktası
├── database.sql               Şema + örnek kuyruk verisi
├── .env.example
│
├── bin/
│   ├── worker.php             
 Kuyruk işçisi (CLI)
│   └── .htaccess              Web erişimine kapalı
│
├── app/
│   ├── Core/
│   │   ├── Queue.php          
 push · reserve · release · fail · recoverStuck
│   │   ├── Worker.php         
 Döngü, sinyal yakalama, sınıf doğrulama
│   │   ├── Auth.php           Giriş, "beni hatırla"
│   │   ├── Session.php        Sertleştirilmiş oturum
│   │   ├── Csrf.php           Jeton + hash_equals
│   │   ├── RateLimiter.php    Giriş denemesi sınırı
│   │   ├── Database.php       PDO (EMULATE_PREPARES = false)
│   │   ├── Env.php            .env okuyucu + isLocalHost()
│   │   └── ...
│   │
│   ├── Jobs/
│   │   ├── Job.php            
 Soyut taban: handle() · label() · maxAttempts()
│   │   ├── SendEmailJob.php   Örnek: e-posta gönderimi
│   │   ├── ReportJob.php      Örnek: rapor üretimi (dosyaya yazar)
│   │   └── FlakyJob.php       Örnek: %70 hata veren "kararsız" iş
│   │
│   ├── Http/Controllers/      Auth · Dashboard · Queue · User
│   ├── Repositories/          JobRepository · UserRepository · ActivityRepository
│   └── Support/helpers.php
│
├── storage/                   Worker çıktıları (web'e kapalı)
├── views/                     Düzenler, kuyruk ve başarısız iş sayfaları
├── assets/                    css · js · images
├── config/config.php
├── routes/web.php
└── docs/screenshots/
```

---

## Nasıl Çalışıyor?

```
 WEB İSTEĞİ                                  AYRI SÜREÇ (worker)
 ──────────                                  ───────────────────
 Kullanıcı "Kaydet"e basar                   php bin/worker.php
        │                                            │
        ▼                                            ▼
 Queue::push(SendEmailJob::class, [...])      ┌─► reserve('default', $workerId)
        │                                     │      │
        │  INSERT INTO jobs (...)             │      │ 1) 5 aday satır SELECT
        │  available_at = NOW() + gecikme     │      │    WHERE reserved_at IS NULL
        │                                     │      │      AND available_at <= NOW()
        ▼                                     │      │
 Yanıt DÖNER (milisaniyeler)                  │      │ 2) her aday için koşullu UPDATE
 Kullanıcı beklemez                           │      │    SET reserved_at = NOW()
                                              │      │    WHERE id=? AND reserved_at IS NULL
                                              │      │
                                              │      │    rowCount() === 1 ?
                                              │      │      hayır → sıradaki adaya geç
                                              │      │      evet  → iş benim
                                              │      ▼
                                              │   Job::handle($payload, $db)
                                              │      │
                                              │      ├─ başarılı → Queue::delete()
                                              │      │
                                              │      └─ istisna  → deneme < max ?
                                              │             │           │
                                              │        evet │           │ hayır
                                              │             ▼           ▼
                                              │      Queue::release()  Queue::fail()
                                              │      available_at =    failed_jobs'a
                                              │      NOW() + 10·2ⁿ     TAŞINIR
                                              │             │
                                              └─────────────┘
                                                  (kuyruğa geri döner)
```

---

## Veritabanı Şeması

### `jobs` — bekleyen ve çalışan işler

| Sütun | Tip | İşi |
|---|---|---|
| `id` | BIGINT UNSIGNED | Birincil anahtar |
| `queue` | VARCHAR(60) | Kuyruk adı; farklı önceliklere ayrı worker atanabilir |
| `job_class` | VARCHAR(190) | Çalıştırılacak sınıf (`App\Jobs\SendEmailJob`) |
| `payload` | TEXT | İşin verisi (JSON) |
| `attempts` / `max_attempts` | SMALLINT | Kaç kez denendi / en fazla kaç kez |
| `available_at` | DATETIME | "Bu zamandan önce çalıştırma" — gecikme **ve** geri çekilme |
| `reserved_at` / `reserved_by` | DATETIME / VARCHAR | Kapan worker ve kapma anı; `NULL` = boşta |
| `last_error` | VARCHAR(1000) | Son hata; iş hâlâ kuyrukta ama sorunlu |
| `created_at` | DATETIME | Bırakılma anı |

**Tek indeks, en sıcak sorgu için:**

```sql
KEY idx_jobs_reserve (queue, reserved_at, available_at)
```

Worker saniyede birkaç kez şunu sorar:

```sql
WHERE queue = ? AND reserved_at IS NULL AND available_at <= NOW() ORDER BY id
```

Bu indeks olmadan her sorgu **tüm tabloyu tarar**; kuyruk büyüdükçe sistem yavaşlar. Sütun sırası rastgele değildir: önce eşitlik (`queue`), sonra `NULL` kontrolü, en sonda aralık karşılaştırması.

### `failed_jobs` — ölü mektup kutusu

| Sütun | Tip | İşi |
|---|---|---|
| `id` | BIGINT UNSIGNED | Birincil anahtar |
| `queue` · `job_class` · `payload` | — | `jobs`'tan taşınan alanlar |
| `attempts` | SMALLINT | Kaç kez denendi |
| `error` | VARCHAR(2000) | **Yalnızca mesaj** — yığın izi saklanmaz |
| `failed_at` | DATETIME | Pes edilen an |

| Karar | Neden |
|---|---|
| Ayrı tablo | `jobs`, worker'ın en sıcak sorgusudur; ölü satırlarla şişmemeli |
| Yığın izi yok | Dosya yolu ve sınıf yapısı veritabanına yazılmamalı |
| `payload` korunur | "Yeniden dene" işi aynı veriyle geri gönderebilsin diye |

---

## SSS

<details>
<summary><b>Worker'ı sürekli açık tutmak zorunda mıyım?</b></summary>

Hayır, iki seçeneğiniz var.

**Sürekli süreç** (önerilen): `php bin/worker.php` bir kez başlar ve dinler. İşler saniyeler içinde işlenir. systemd veya supervisor ile yeniden başlatılmasını garanti edin.

**Cron ile**: her dakika `php bin/worker.php --once` çalıştırın. Kurulumu daha kolaydır ama işler en fazla bir dakika bekler. Paylaşımlı hosting'de genellikle tek seçenek budur.
</details>

<details>
<summary><b>Aynı e-posta iki kez gitti, neden?</b></summary>

Neredeyse her zaman şu sebeptendir: iş, gönderimi **bitirdikten sonra** ama satır silinmeden önce başarısız oldu (ağ koptu, süreç öldürüldü). Kuyruk onu bitmemiş sayar ve tekrar dener.

Bu, kuyrukların yapısal bir gerçeğidir: "en az bir kez" teslim garantisi verilir, "tam olarak bir kez" değil. Çözüm işin kendisindedir — `handle()` yeniden çalıştırıldığında zararsız olmalıdır. Gönderdiğiniz her e-postaya bir kimlik verip "bu kimlik daha önce gönderildi mi?" diye bakmak en yaygın yöntemdir.
</details>

<details>
<summary><b>Birden fazla worker çalıştırabilir miyim?</b></summary>

Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu `UPDATE` bunu garanti eder.

Farklı kuyruklara farklı worker atamak da yaygındır:

```bash
php bin/worker.php --queue=default     # hızlı işler
php bin/worker.php --queue=reports     # uzun süren raporlar
```

Böylece 4 dakika süren bir rapor, hoş geldin e-postasını bekletmez.
</details>

<details>
<summary><b>İşler birikiyor ama işlenmiyor</b></summary>

Sırayla kontrol edin:

1. Worker gerçekten çalışıyor mu? (`ps aux | grep worker.php`)
2. Doğru **kuyruğu** mu dinliyor? İşi `reports` kuyruğuna bırakıp `default` dinliyorsanız hiçbir şey olmaz.
3. İşler **Ertelenmiş** mi görünüyor? `available_at` gelecekteyse worker onları görmez.
4. Hepsi **Takılmış** mı? Bir worker çökmüş olabilir; "Takılanları kurtar" düğmesine basın.
</details>

<details>
<summary><b>Neden `SKIP LOCKED` kullanmadınız?</b></summary>

MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada.

Koşullu `UPDATE` yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz `reserve()` metodunu `SKIP LOCKED` ile değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.
</details>

<details>
<summary><b>Ölü mektup kutusu ne zaman temizlenir?</b></summary>

Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır.

Panelden tek tek "Unut" diyebilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce **neden** başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.
</details>

---

## Canlı Ortama Alırken

- [ ] `.env` içinde `APP_DEBUG=false` (veya satırı tümüyle silin)
- [ ] Worker'ı **systemd/supervisor** altında çalıştırın; `Restart=always` olsun
- [ ] Deploy sırasında worker'ı **önce durdurun**, sonra başlatın
- [ ] `STUCK_AFTER` değerini en uzun işinizden büyük seçin
- [ ] `bin/` klasörünün web'den erişilemediğini doğrulayın (403 dönmeli)
- [ ] `storage/` klasörünün web'den erişilemediğini doğrulayın
- [ ] `failed_jobs` tablosunu izleyin — büyüyorsa bir şey bozuktur
- [ ] Veritabanı için **root olmayan** bir kullanıcı açın
- [ ] HTTPS zorunlu olsun
- [ ] Demo hesaplarının parolalarını değiştirin veya hesapları silin

---

## Sorun Giderme

| Belirti | Sebep | Çözüm |
|---|---|---|
| İşler hep "Bekliyor" | Worker çalışmıyor | `php bin/worker.php` başlatın |
| İşler hep "Ertelenmiş" | `available_at` gelecekte | Gecikme verdiniz ya da geri çekilme sürüyor; bekleyin |
| Hepsi "Takılmış" | Worker çökmüş, kilit üzerinde kalmış | "Takılanları kurtar" düğmesine basın |
| Uzun iş ikinci kez çalışıyor | `STUCK_AFTER` işten kısa | Sabiti yükseltin |
| `Class not found` hatası | `job_class` yanlış veya sınıf silinmiş | Tam sınıf adını ve `app/Jobs/` içeriğini kontrol edin |
| `bin/worker.php` tarayıcıda 403 | Bilerek | CLI betiği web'e kapalıdır; terminalden çalıştırın |
| Tüm adresler 404 | `mod_rewrite` kapalı | Açın veya `APP_PRETTY_URLS=false` yapın |
| Sayfa boş / beyaz | PHP hatası, `display_errors` kapalı | `.env` içinde `APP_DEBUG=true` yapıp tekrar deneyin |

---

## Yol Haritası

- [ ] İş öncelikleri (aynı kuyruk içinde sıralama)
- [ ] Zincirleme işler (biri bitince diğeri)
- [ ] Toplu iş (batch) ve ilerleme yüzdesi
- [ ] Başarısız iş sayısı eşiği aşınca e-posta uyarısı
- [ ] MySQL 8 için `SKIP LOCKED` sürücüsü (isteğe bağlı)

---

## Katkı

Hata bildirimi ve öneriler için [issue açabilirsiniz](https://github.com/CilginYazilim/queue-worker-system/issues).

## Lisans

[MIT](LICENSE) — ticari projelerinizde de özgürce kullanabilirsiniz.

---

**[Çılgın Yazılım](https://cilginyazilim.com)** · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane) · [Tüm Örnekler](https://github.com/CilginYazilim)

## Sık Sorulan Sorular

### Worker'ı sürekli açık tutmak zorunda mıyım?

Hayır, iki seçeneğiniz var. Sürekli süreç önerilendir: php bin/worker.php bir kez başlar ve dinler, işler saniyeler içinde işlenir; systemd veya supervisor ile yeniden başlatılmasını garanti edin. Alternatif olarak her dakika php bin/worker.php --once çalıştıran bir cron kurabilirsiniz; kurulumu kolaydır ama işler en fazla bir dakika bekler.

### Aynı e-posta iki kez gitti, neden?

Neredeyse her zaman şu sebeptendir: iş gönderimi bitirdikten sonra ama satır silinmeden önce başarısız oldu. Kuyruk onu bitmemiş sayar ve tekrar dener. Bu kuyrukların yapısal bir gerçeğidir; "en az bir kez" teslim garantisi verilir. Çözüm işin kendisindedir: handle() yeniden çalıştırıldığında zararsız olmalıdır.

### Birden fazla worker çalıştırabilir miyim?

Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu UPDATE bunu garanti eder. Farklı kuyruklara farklı worker atamak da yaygındır: böylece dört dakika süren bir rapor, hoş geldin e-postasını bekletmez.

### Neden SKIP LOCKED kullanılmadı?

MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada. Koşullu UPDATE yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz reserve() metodunu değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.

### Demoda işler neden işlenmiyor?

Demo sunucusunda sürekli çalışan bir worker yoktur ve bu bilinçlidir: kuyruğun dolu görünmesi, işleri bekleten şeyin ne olduğunu somutlaştırır. Panel de bunu söyler ve çalıştırılacak komutu ekrana yazar. Projeyi indirip php bin/worker.php çalıştırdığınızda akışın tamamını canlı izlersiniz.

### Ölü mektup kutusu ne zaman temizlenir?

Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır. Panelden tek tek unutabilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce neden başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.

---

Kaynak: [İş Kuyruğu ve Worker Sistemi](https://cilginyazilim.com/kutuphane/php-queue-worker)
