Bütün birim testlerimden, özellik testlerimden ve manuel testlerden geçen bir hatanın sonrasında, üretimde gerçek bir kullanıcının bir butona tıkladığında patlaması üzerinde bir postmortem.
Kurulum
Kurulum
Schoolytics isimli açık kaynaklı çoklu kiracı destek sistemi üzerinde çalışıyorum. Çoklu kiracılık, satır düzeyinde: tek bir Postgres veritabanı, her kiracıya özgü tabloda bir tenant_id sütunu bulunuyor ve her kiracı modeli, global kapsam ekleyen bir BelongsToTenant trait’ini kullanıyor:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $q) {
if ($tenantId = tenant('id')) {
$q->where($q->getModel()->getTable().'.tenant_id', $tenantId);
}
});
static::creating(function ($model) {
$model->tenant_id ??= tenant('id');
});
}
}
Basit ve etkili. Her kiracının sorgusu otomatik olarak kısıtlanıyor. tenant_id’yi unuttunuz mu? Başka bir kiracının verisini sızdırmanız fiziksel olarak imkânsız.
Özellik
Özellik
Bir veli, kamu portalı üzerinden bir sorun gönderdiğinde, IssueCreated olayı tetikleniyor. Bir sıraya alınmış dinleyici, bir Python mikroservisini çağırarak duygu analizini yapıyor ve ardından sonucu issue_ai_analysis satırına yazıyor. Basit bir olay → sıraya alınmış dinleyici → veritabanı yazımı.
class IssueCreated
{
public function __construct(public Issue $issue) {}
}
class PerformAiAnalysis implements ShouldQueue
{
use InteractsWithQueue, SerializesModels;
public function handle(IssueCreated $event): void
{
$score = Http::post(config('services.ai.url'), [
'text' => $event->issue->description,
])->json();
IssueAiAnalysis::updateOrCreate(
['issue_id' => $event->issue->id],
['sentiment' => $score['label'], 'confidence' => $score'confidence']]
);
}
}
Yeşil testler. Tinker’da çalışıyor. Yayınlandı.
Çöküş
Çöküş
Üretimdeki ilk gerçek gönderim:
Illuminate\Database\Eloquent\ModelNotFoundException
No query results for model [App\Models\Issue].
Ancak sorun var. SELECT * FROM issues WHERE id = 189 yapabiliyorum ve onu görebiliyorum. Dinleyici, açıkça 189 id’li bir sorunu referans alan bir olayla çağrılıyor — ve sonra Laravel onu yeniden oluştururken ModelNotFoundException fırlatıyor.
Tuzak
Tuzak
İşte sıraya alınmış bir dinleyicinin yaşam döngüsü:
- Kontrolcü
event(new IssueCreated($issue))çağrısını yapar - Laravel, dinleyicinin
ShouldQueueolduğunu görür, olayıSerializesModelsile serileştirir - Olay, tam
Issuenesnesi olarak değil — yalnızcaApp\Models\Issue+ birincil anahtar (189). Bu,SerializesModels‘in amacıdır; yükleri küçük ve her zaman taze tutar. - İşçi başlar, işi alır ve
(new Issue)->newQueryForRestoration(189)->firstOrFail()çağrısını yaparak modeli yeniden oluşturur.
4. aşamada hata meydana geliyor. İşçi süreci daha hiç kimse bağlamına sahip değil — henüz başlatıldı. Yani tenant('id') null döner. Bu da BelongsToTenant‘in global kapsamının aşağıdaki sorguyu oluşturmasına neden olur:
SELECT * FROM issues
WHERE id = 189
AND issues.tenant_id IS NULL -- 💀
LIMIT 1
Hiçbir satır. ModelNotFoundException.
Asıl sorun: bu, senkron modda asla başarısız olmaz (serileştirme gidiş dönüşü yoktur) ve testler genellikle Queue::fake() veya senkron sürücüleri kullanır. Hata, yalnızca gerçek bir kuyruk çalışanı gerçek bir kiracı isteği ile çalıştırıldığında görünmez hale gelir.
Laravel’ın stancl/tenancy paketi, çalışan üzerinde kiracı bağlamını geri yükleyen bir QueueTenancyBootstrapper ile birlikte gelir — ama bu, SerializesModels::restoreModel() çalıştıktan sonra çalışır. Çok geç. Model zaten ölmüştü.
Çözüm
Çözüm
Kiracıya özel bir Eloquent modelini doğrudan sıraya alınmış bir olayda veya işte koymayın. Ölçüleri kaydedin ve handle() içinde kiracılığı kendiniz başlatın:
class IssueCreated
{
public function __construct(
public readonly int $issueId,
public readonly string $tenantId,
) {}
}
class PerformAiAnalysis implements ShouldQueue
{
public function handle(IssueCreated $event): void
{
// Kiracı modeli kendisi kiracı-özel değildir - bu nedenle güvenli bir şekilde bulunur
$tenant = \App\Models\Tenant::find($event->tenantId);
tenancy()->initialize($tenant);
try {
$issue = Issue::findOrFail($event->issueId);
$score = Http::post(config(), [
=> $issue->description,
])->json();
IssueAiAnalysis::updateOrCreate(
[=> $issue->id],
[=> $score'label'], => $score'confidence']]
);
} finally {
tenancy()->end();
}
}
}
Ve bunu basit değerlerle yayınlayın:
event(new IssueCreated(
issueId: $issue->id,
tenantId: tenant(),
));
Artık kod incelemede uyguladığım üç kural var:
- Sıraya alınmış olaylar ve işler yalnızca ölçüleri saklar. Yapılandırıcı imzalarında Eloquent modelleri yok.
- Kiracı verilerini etkileyen her
handle()metodu, öncetenancy()->initialize()çağrısını yapmalı vefinallyiçindetenancy()->end()çağrısını gerçekleştirmelidir. Tenantmodeli kesinlikleBelongsToTenantkullanmamalıdır (aksi takdirde onu zaten kiracı bağlamına sahip olmadan bulamazsınız — tavuk-yumurta durumu).
Neden SerializesModels hâlâ doğru varsayılan
Neden
SerializesModels hâlâ doğru varsayılanTuzak gerçek, ancak trait’in iyi sebepleri vardır: küçük yükler, her zaman taze veriler, bayat özellik hataları yok. Çözüm, onu terk etmek değil — trait’in tek bir global veritabanı bağlamı varsaydığını kabul etmektir, bu da kiracı boyutu eklendiğinde bozulur.
Tek kiracı Laravel kullanıyorsanız, modelleri geçirmeye devam edin. Satır düzeyinde yalıtım sağlayan çoklu kiracı Laravel kullanıyorsanız, ölçekler + manuel tenancy()->initialize() sizin arkadaşınızdır.
Sorunu kalıcı hale getirmem
Sorunu kalıcı hale getirmem
Gerçekten kuyrukları çalıştıran basit bir özellik testi ekledim:
it(, function () {
$tenant = Tenant::factory()->create();
tenancy()->initialize($tenant);
Queue::connection()->... // bunu yakalamaz
// Gerçek veritabanı kuyruk sürücüsü kullanın:
config([=> ]);
$issue = Issue::factory()->create();
event(new IssueCreated(issueId: $issue->id, tenantId: $tenant->id));
tenancy()->end(); // bağlam olmadan çalışan simülasyonu
$this->artisan()->assertExitCode(0);
expect(IssueAiAnalysis::withoutGlobalScopes()
->where(, $issue->id)->exists())->toBeTrue();
});
Kiracılığı çalıştırmadan önce bitirmek kritik bir satırdır — bu üretimde Supervisor’ın yaptığı şeyi çoğaltır.
Özet: Eğer stancl/tenancy kullanıyorsanız ve satır düzeyinde yalıtım sağlıyorsanız, asla bir kiracıya özel Eloquent modelini sıraya alınmış yükün içine koymayın. ID + kiracı ID’yi ölçüler olarak geçin, handle()’da tenancy()->initialize() çağrısını yapın. Testleriniz bunu yakalamaz — yalnızca gerçek bir kuyruk işçi bunları yakalar.
Kaynak kod: https://github.com/sharjeelz/eliflammeem-git — desen app/Listeners/PerformAiAnalysis.php içindedir.
Eğer bu size 3 saat kazandırdıysa, repo üzerinde bir ⭐ bırakın. Eğer daha önce karşılaştıysanız, çözümünüzü yorumlarda duymak isterim.
Kaynak: Orijinal Makale


