Queue dan Konteks Tenant
Queued job berjalan di luar request yang melakukan dispatch, sehingga tenant binding tidak ikut terbawa secara otomatis. PandaPanel\Tenancy\Tenancy menyimpan tenant di PandaPanel\Support\PanelContext, yang merupakan container binding dengan scope scoped() — sementara queue worker Laravel memanggil forgetScopedInstances() di antara job. Artinya, setiap job selalu dimulai tanpa tenant yang terikat. Halaman ini menjelaskan cara menanganinya.
Masuk ke tenant context di dalam job
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Workspace;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use PandaPanel\Tenancy\Tenancy;
final class RebuildWorkspaceIndex implements ShouldQueue
{
use Queueable;
public function __construct(private readonly int $workspaceKey) {}
public function handle(): void
{
$workspace = Workspace::query()->findOrFail($this->workspaceKey);
Tenancy::for($workspace, function (): void {
// Everything in here reads through the bound tenant.
DocumentResource::query()->each(/* ... */);
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RebuildWorkspaceIndex::dispatch($workspace->getKey());Bawa key, bukan model. Model yang diserialisasi akan di-load kembali di sisi worker menggunakan connection yang sedang aktif. Dalam arsitektur database-per-tenant, connection tersebut belum tentu sama dengan connection tempat model pertama kali diserialisasi.
Tenancy::for()
/**
* @template TReturn
*
* @param callable(): TReturn $callback
* @return TReturn
*/
public static function for(Model $tenant, callable $callback): mixed2
3
4
5
6
7
Method ini melakukan bind, menjalankan callback, lalu memulihkan binding sebelumnya di dalam finally:
public static function for(Model $tenant, callable $callback): mixed
{
$previous = self::current();
self::bind($tenant);
try {
return $callback();
} finally {
$context = app(PanelContext::class);
if ($previous === null) {
$context->set(self::KEY, null);
} else {
$context->set(self::KEY, $previous);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Pemulihan di dalam finally adalah bagian terpentingnya. Callback yang melempar exception tidak boleh membuat sisa proses tetap berada di scope tenant milik pihak lain — terlebih pada long-running worker, di mana kebocoran tersebut dapat memengaruhi semua job yang berjalan setelahnya, bukan hanya satu request.
Tenancy::bind($acme);
try {
Tenancy::for($beta, fn () => throw new RuntimeException('nope'));
} catch (RuntimeException) {
}
Tenancy::current()?->getKey(); // still Acme2
3
4
5
6
7
8
Nested call aman digunakan, dan jika sebelum pemanggilan tidak ada tenant yang terikat maka setelah selesai kondisi tersebut tetap kosong.
Panel juga penting
Job yang membaca sebuah resource membutuhkan konteks panel dan tenant. panel() adalah cara resource menemukan konfigurasi per-panel, URL, dan — melalui hasTenancy() — menentukan apakah tenant scope perlu diterapkan:
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
public function handle(PanelManager $manager): void
{
$manager->setCurrentPanel($manager->get('app'));
Tenancy::for($workspace, static fn () => DocumentResource::query()->count());
}2
3
4
5
6
7
8
9
Tanpa current panel, applyTenantScope() berhenti lebih awal dan query berjalan tanpa scope — tepat jenis kegagalan yang hendak dicegah mekanisme ini, dan lebih berbahaya karena job tetap terlihat berhasil. Selalu set keduanya, dalam urutan tersebut: panel terlebih dahulu, lalu tenant.
Job bawaan framework
Package menyediakan tiga job:
| Job | Membawa data | Menetapkan panel | Mengikat tenant |
|---|---|---|---|
PandaPanel\Jobs\RunPanelExport | exporter, resource, columns, format, owner, table state, keys, panel id | ya | tidak |
PandaPanel\Jobs\RunPanelImport | importer, path, mapping, owner, panel id | ya | tidak |
PandaPanel\Jobs\SendPanelIntegration | integration id, payload, timeout, delivery id | tidak | tidak |
public function handle(PanelManager $manager): void
{
$panel = $manager->get($this->panelId);
// A resource's scope, its table, and its URLs are all read through the
// current panel. Without this the job would be running outside any panel
// and `Resource::query()` would answer for none.
$manager->setCurrentPanel($panel);
// ...
}2
3
4
5
6
7
8
9
10
11
Job-job tersebut dibuat sebelum fitur tenancy tersedia sehingga tidak membawa tenant id. Dampaknya bergantung pada arsitektur Anda:
Database per tenant. QueueTenancyBootstrapper milik stancl/tenancy memulihkan connection di sekitar job yang di-dispatch dari tenant context. Karena job bawaan ini tidak menggunakan panel tenant scope, queued export tetap menghasilkan file yang benar. Tanpa bootstrapper tersebut, export berjalan terhadap central database dan dapat menghasilkan data yang salah tanpa error yang jelas.
Single database dengan scoped resource. Job menetapkan current panel, panel tersebut menggunakan tenancy, resource mendeklarasikan relationship, tetapi tidak ada tenant yang di-bind — akibatnya Tenancy::require() melempar PanelRegistrationException dan job gagal:
This panel is tenant-scoped, but no tenant is bound to this request…
Kegagalan yang jelas lebih baik daripada file yang berisi data seluruh tenant. Ada tiga opsi penyelesaian, berdasarkan urutan yang disarankan:
Jangan queue export atau import tersebut. Nilai negatif dari
queueAfter()membuat proses selalu berjalan pada request, berapa pun jumlah row:phpfinal class DocumentExporter extends Exporter { public static function queueAfter(): int { return -1; // always run in the request } }1
2
3
4
5
6
7Exporter::queueAfter()memiliki default2000, sedangkanImporter::queueAfter()default-nya500; keduanya mulai menggunakan queue jika jumlah record melampaui angka tersebut, sementara0berarti selalu queue.Dispatch job Anda sendiri yang membawa tenant key lalu membungkus pekerjaannya dengan
Tenancy::for(), menggunakanExportRun/ImportRunsecara langsung.Pulihkan binding dari queue hook milik aplikasi Anda jika infrastruktur yang digunakan memang sudah membawa tenant id pada setiap job.
Console command dan scheduler
Aturannya sama karena tidak ada request yang menyediakan konteks:
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Workspace;
use Illuminate\Console\Command;
use PandaPanel\Core\PanelManager;
use PandaPanel\Tenancy\Tenancy;
final class ReportPerWorkspace extends Command
{
protected $signature = 'workspaces:report';
public function handle(PanelManager $manager): int
{
$manager->setCurrentPanel($manager->get('app'));
Workspace::query()->each(function (Workspace $workspace): void {
Tenancy::for($workspace, function () use ($workspace): void {
$this->line($workspace->name.': '.DocumentResource::query()->count());
});
});
return self::SUCCESS;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
each() sengaja berada di luar Tenancy::for(): daftar tenant adalah pertanyaan central, sehingga membacanya dari dalam tenant context justru menembus boundary yang hendak dijaga desain ini.
Notification
Panel notification adalah notification Laravel biasa, sehingga mengikuti aturan Laravel. Ada dua hal yang perlu diperhatikan:
PandaPanel\Notifications\TwoFactorCodemengimplementasikanShouldQueue. Dalam arsitektur database-per-tenant, kode email disimpan ke cache; karena ituCacheTenancyBootstrapperbukan opsional — lihat Satu Database per Tenant.- Persistent notification adalah row pada tabel
notifications. Dalam arsitektur database-per-tenant tabel tersebut berada di database tenant, sehingga job yang menulisnya harus berjalan pada connection tenant yang benar.
Octane
Prinsip yang sama berlaku untuk Octane dan sudah ditangani oleh framework. PanelContext di-bind menggunakan scoped(), sehingga container membersihkannya di antara request, dan PandaPanel\Http\Middleware\ResetPanelContext berjalan di awal setiap request web sebagai lapisan pengaman kedua. Tidak ada state tenant yang disimpan di static property — itulah alasan Tenancy menggunakan context.
Catatan
- Job yang tidak membaca resource tidak membutuhkan mekanisme ini. Tenant binding hanya penting bagi
Resource::query()dan kode aplikasi yang membacaTenancy::current(); job yang sekadar menulis file dari data yang sudah diberikan tidak membutuhkan keduanya. Tenancy::bind()tanpa mekanisme restore hanya ditujukan untukResolveTenantdan test. Pada worker, pola tersebut dapat bocor ke job berikutnya dalam proses yang sama. Gunakanfor().- Queue connection bukan boundary tenant. Apakah job disimpan di tabel
jobscentral atau per tenant adalah keputusan terpisah — central lebih sederhana dan hanya membutuhkan satu worker; per tenant mencegah satu tenant memenuhi queue milik tenant lain. Tenancy::for()mengembalikan nilai callback, sehingga dapat digunakan secara komposisional:$count = Tenancy::for($workspace, fn (): int => DocumentResource::query()->count());- Fail loudly adalah bagian dari desain. Alternatif seperti melewati scope atau fallback ke "tanpa tenant" menghasilkan job yang sukses tetapi memproses row yang salah.