Tenant Context Troubles: Debugging Queue Deserialization in a Multi-Tenant Laravel App
In the Reimpact/platform project, we encountered a tricky issue with queue deserialization in our multi-tenant environment. This post details the problem, our investigation, and the solution we implemented.
The Problem
Our application uses a queue to generate reports. A GenerateReportJob is dispatched, serializing the GeneratedReport model. However, when the queue worker attempted to deserialize the job, it failed when interacting with tenant-specific tables. The worker was attempting to access the generated_reports table (a public table that doesn't actually exist) instead of tenant_id_generated_reports.
The Investigation
The root cause was that during queue deserialization, the TenantContext wasn't active. Our application uses a HasTenantTable trait to ensure that database tables are properly namespaced based on the current tenant. Without the active context, HasTenantTable::getTable() returned the non-tenant-specific table name.
To illustrate, consider a simplified example of how tenant context might be applied:
class TenantContext
{
private static ?int $tenantId = null;
public static function run(int $tenantId, callable $callback)
{
self::$tenantId = $tenantId;
try {
return $callback();
} finally {
self::$tenantId = null;
}
}
public static function getTenantId(): ?int
{
return self::$tenantId;
}
}
trait HasTenantTable
{
public function getTable()
{
$baseTable = 'base_table';
$tenantId = TenantContext::getTenantId();
return $tenantId ? "{$tenantId}_{$baseTable}" : $baseTable;
}
}
class MyModel extends \Illuminate\Database\Eloquent\Model
{
use HasTenantTable;
protected $table = 'base_table'; // Initial table name, but getTable() overrides it
}
In this example, MyModel uses the HasTenantTable trait. The getTable() method dynamically generates the table name based on the active TenantContext. The initial value of $table on the model is only used if there is no active tenant.
The Solution
Instead of serializing the entire GeneratedReport model, we opted to store only the user and report IDs. During the job's handle() method, we then resolved the models within the TenantContext::run() method. This ensures that the tenant context is active when accessing tenant-specific tables.
Here's how the updated job structure looks:
use App\Models\GeneratedReport;
use App\Models\User;
class GenerateReportJob implements ShouldQueue
{
public function __construct(public int $userId, public int $reportId) {}
public function handle(): void
{
TenantContext::run($this->getTenantId(), function () {
$user = User::find($this->userId);
$report = GeneratedReport::find($this->reportId);
// process the report here
});
}
private function getTenantId(): int
{
// Logic to determine the tenant ID based on user or report
return 123; // Example tenant ID
}
}
Additional Fixes
In addition to the queue deserialization issue, we also corrected the AdminPanelProvider to use RedirectDashboard instead of a missing default Dashboard class, improving the application's navigation flow within the Filament admin panel.
Key Takeaways
- When working with multi-tenant applications and queues, always ensure that the tenant context is active during deserialization.
- Avoid serializing entire models when tenant context is critical. Instead, serialize IDs and resolve the models within an active context.
- Double-check default configurations and class usages, particularly after updates or refactoring, to avoid unexpected errors, such as the incorrect dashboard redirection.