← Semua pembelajaran / Laravel Nol → Enterprise
Fase 8 · FrankenPHP & RoadRunner

Jebakan state di worker mode

Ini materi terpenting di seluruh fase ini. Worker mode cepat karena aplikasimu tetap hidup di memori — dan setiap bug di halaman ini berasal dari kalimat yang sama.

Sumber asli laravel.com Resmi Rangkuman ~8 menit baca

Intisari

  • Provider hanya di-boot sekali. Apa pun yang dibaca dari $request di sana akan beku selamanya.
  • singleton yang menyimpan data permintaan = kebocoran antar pengguna. Pakai scoped.
  • Properti statis dan variabel global bertahan antar permintaan; array statis yang terus diisi = kebocoran memori.
  • Menyuntikkan Request ke konstruktor singleton membekukan permintaan pertama.
  • Pengaman berlapis: --max-requests, batas memori, dan tes yang menjalankan dua permintaan berurutan.

Jebakan 1 — singleton yang menyimpan data pengguna

// BERBAHAYA
class KonteksTenant
{
    public ?Tenant $tenant = null;
}

$this->app->singleton(KonteksTenant::class);      // ← satu objek, seumur proses
// Middleware mengisinya per permintaan
app(KonteksTenant::class)->tenant = $request->user()->tenant;

Di PHP-FPM ini bekerja sempurna: prosesnya mati setelah respons. Di worker mode, objek itu bertahan. Permintaan berikutnya — dari pengguna lain — mendapati tenant masih terisi milik pengguna sebelumnya. Kalau permintaan itu kebetulan lolos dari middleware yang menimpanya, pengguna B melihat data tenant A.

// BENAR — scoped: dibuat ulang untuk setiap permintaan
$this->app->scoped(KonteksTenant::class);

Ini bug paling berbahaya di worker mode karena tidak terlihat saat pengujian. Di laptop, kamu satu orang; setiap permintaanmu menimpa state dengan datamu sendiri. Ia baru muncul di produksi, hanya sesekali, dan hanya saat dua pengguna berbeda kebetulan dilayani worker yang sama secara berurutan. Aturannya: kalau isinya bergantung pada siapa yang meminta, ia tidak boleh singleton.

Jebakan 2 — Request di konstruktor

// BERBAHAYA
class PenyusunLaporan
{
    public function __construct(private Request $request) {}   // beku pada permintaan pertama
}

$this->app->singleton(PenyusunLaporan::class);
// BENAR — terima Request sebagai argumen method
class PenyusunLaporan
{
    public function untuk(Request $request): Laporan { /* ... */ }
}

// Atau ambil nilainya saat dibutuhkan, bukan saat objek dibangun
$this->app->singleton(PenyusunLaporan::class, fn ($app) => new PenyusunLaporan(
    fn () => $app->make('request')->user(),      // closure, dievaluasi belakangan
));

Jebakan 3 — provider yang membaca permintaan

// BERBAHAYA — boot() hanya berjalan sekali, saat worker menyala
public function boot(): void
{
    $tenant = request()->getHost();               // host permintaan PERTAMA, selamanya
    config(['app.tenant' => $tenant]);
}
// BENAR — putuskan per permintaan, di middleware
class TentukanTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        app(KonteksTenant::class)->tenant = Tenant::dariHost($request->getHost());

        return $next($request);
    }
}

Provider tidak boleh tahu apa-apa tentang permintaan. Aturan ini sudah benar di PHP-FPM juga — di sana ia cuma tidak terasa, karena provider kebetulan di-boot pada permintaan yang sama. Worker mode mengubah kebiasaan buruk yang tidak berbahaya menjadi bug yang nyata.

Jebakan 4 — kebocoran memori

// BERBAHAYA — array ini tidak pernah dikosongkan
class Metrik
{
    public static array $peristiwa = [];

    public static function catat(string $nama): void
    {
        self::$peristiwa[] = ['nama' => $nama, 'waktu' => microtime(true)];
    }
}

Setiap permintaan menambah baris. Setelah sepuluh ribu permintaan, array itu berisi sepuluh ribu entri; setelah sejuta, prosesnya mati kehabisan memori. Di PHP-FPM masalah ini tidak pernah ada karena array-nya lenyap tiap permintaan.

Sumber kebocoranPerbaikan
Array statis yang terus diisiKosongkan di akhir permintaan, atau jangan statis
Pendengar event yang didaftarkan tiap permintaanDaftarkan di provider, bukan di controller
Cache dalam memori tanpa batasBeri batas ukuran, atau pakai Redis
Berkas atau soket yang tidak ditutupTutup di blok finally
Referensi melingkargc_collect_cycles() berkala

Mendeteksi sebelum produksi

// Rute diagnostik sementara
Route::get('/memori', fn () => [
    'sekarang_mb' => round(memory_get_usage(true) / 1048576, 1),
    'puncak_mb'   => round(memory_get_peak_usage(true) / 1048576, 1),
]);
# Panggil seribu kali dan lihat apakah angkanya naik terus
for i in $(seq 1 1000); do curl -s localhost:8000/memori | tail -c 40; echo; done

Angka yang naik lalu mendatar adalah normal — itu memori yang dipakai ulang. Angka yang naik terus tanpa henti adalah kebocoran. Perhatikan juga apakah ia turun setelah --max-requests tercapai; kalau ya, jaring pengamannya bekerja, tapi penyebabnya tetap perlu dicari.

Menguji kebocoran state antar permintaan

it('tidak membocorkan tenant antar permintaan', function () {
    $a = User::factory()->create();
    $b = User::factory()->create();     // tenant berbeda

    $this->actingAs($a)->get('/dasbor')->assertSee($a->tenant->nama);
    $this->actingAs($b)->get('/dasbor')->assertDontSee($a->tenant->nama);
});

Tes seperti ini tidak sepenuhnya membuktikan keamanannya di worker mode, karena suite tes juga mereset container antar tes. Cara yang benar-benar meyakinkan: jalankan aplikasi dengan octane:start --workers=1, lalu kirim permintaan dua pengguna berbeda secara bergantian dengan curl. Satu worker memaksa keduanya berbagi proses — persis kondisi yang memunculkan bugnya.

Daftar periksa sebelum menyalakan worker mode

  1. Cari semua singleton; ubah jadi scoped kalau isinya bergantung pengguna.
  2. Cari semua static di app/; pastikan tidak ada yang bertumbuh.
  3. Cari request() di dalam service provider; pindahkan ke middleware.
  4. Periksa paket pihak ketiga — cari yang menyatakan dukungan Octane secara eksplisit.
  5. Setel --max-requests sebagai jaring pengaman, bukan sebagai solusi.
  6. Pasang alarm pemakaian memori per kontainer.
  7. Jalankan uji beban yang panjang, bukan cuma beberapa ratus permintaan.
grep -rn "singleton(" app/ | grep -v scoped
grep -rn "static \$" app/
grep -rn "request()" app/Providers/

Latihan: buat kelas KonteksTenant sebagai singleton, isi dari middleware, jalankan octane:start --workers=1, lalu panggil sebagai dua pengguna berbeda secara bergantian. Amati kebocorannya terjadi. Ubah jadi scoped dan buktikan bahwa ia berhenti.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.