From 0913614d5f0327261500816e816884df46687847 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 11 Jun 2026 11:05:51 +0200 Subject: [PATCH] Test pulling pending tenants concurrently --- tests/Etc/pull-worker.php | 34 ++++++++++++++++ tests/PendingTenantsTest.php | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/Etc/pull-worker.php diff --git a/tests/Etc/pull-worker.php b/tests/Etc/pull-worker.php new file mode 100644 index 00000000..aed9cb9c --- /dev/null +++ b/tests/Etc/pull-worker.php @@ -0,0 +1,34 @@ + ` + * + * Outputs the key of the pulled tenant, or "null" if nothing was pulled. + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Tests\TestCase; + +$startAt = (float) ($argv[1] ?? 0); +$firstOrCreate = ($argv[2] ?? '0') === '1'; + +// createApplication() replays the suite's central-MySQL config without running setUp(), +// so the pending tenants the parent test created survive into this process. +(new class('pull-worker') extends TestCase {})->createApplication(); + +// Wait so that every worker pulls at the same time +if ($startAt > 0.0) { + time_sleep_until($startAt); +} + +$tenant = Tenant::pullPendingFromPool($firstOrCreate); + +fwrite(STDOUT, $tenant?->getKey() ?? 'null'); diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index b04f8bc4..6892ddb3 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Database\QueryException; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -28,6 +29,7 @@ use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Events\TenancyEnded; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Symfony\Component\Process\Process; beforeEach($cleanup = function () { Tenant::$extraCustomColumns = []; @@ -126,6 +128,82 @@ test('a new tenant gets created while pulling a pending tenant if the pending po expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants }); +/** + * Spawn $count separate PHP processes that all call pullPendingFromPool() at the same + * time and return the keys of pulled tenants to simulate concurrent pulls. + * + * @see tests/Etc/pull-worker.php + */ +function runConcurrentPulls(int $count, bool $firstOrCreate = false): array +{ + $worker = __DIR__ . '/Etc/pull-worker.php'; + + // Shared start instant + $startAt = (string) (microtime(true) + 3.0); + + /** @var Process[] $processes */ + $processes = []; + + for ($i = 0; $i < $count; $i++) { + $process = new Process( + ['php', $worker, $startAt, $firstOrCreate ? '1' : '0'] + ); + $process->start(); + $processes[] = $process; + } + + $pulledTenants = []; + + foreach ($processes as $process) { + $process->wait(); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()); + + $output = trim($process->getOutput()); + + if ($output !== 'null' && $output !== '') { + // If a tenant was pulled, add its key to the results + $pulledTenants[] = $output; + } + } + + return $pulledTenants; +} + +test('concurrent pulls each claim a distinct pending tenant', function (bool $firstOrCreate) { + Tenant::createPending(); + Tenant::createPending(); + Tenant::createPending(); + + expect(Tenant::onlyPending()->count())->toBe(3); + + runConcurrentPulls(8, $firstOrCreate); + + expect(Tenant::onlyPending()->count())->toBe(0); + expect(Tenant::withoutPending()->count())->toBe($firstOrCreate ? 8 : 3); +})->with([ + 'pull pending' => false, + 'pull pending or create' => true, +]); + +test('a failed attribute write rolls back the claim and leaves the tenant pending', function () { + // The claim and the attribute write share one transaction, so if applying the attributes + // fails the claim must roll back and the tenant must stay in the pool. + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->nullable()->unique(); + }); + + Tenant::$extraCustomColumns = ['slug']; + + Tenant::create(['slug' => 'taken']); + Tenant::createPending(); + + expect(fn () => Tenant::pullPendingFromPool(false, ['slug' => 'taken'])) + ->toThrow(QueryException::class); + + expect(Tenant::onlyPending()->count())->toBe(1); +}); + test('withoutPending chained with where clauses returns correct results', function () { $tenant = Tenant::create(); $pendingTenant = Tenant::createPending();