Skip to content

Commit 04da9c8

Browse files
lukinovecstancl
andauthored
[MINOR BC] Fix pending tenant pull race conditions (#1463)
> Minor breaking change: clearing pending_since no longer fires Eloquent events, PullingPendingTenant is now fired at a different point in the lifecycle and does not guarantee the tenant will actually be pulled. `pullPendingFromPool` had a race condition when user A attempted to pull a tenant at the same time as user B. Both could end up grabbing the same tenant, and the result was unexpected, e.g. one of them ending up with no pending tenant pulled at all even though there was a pending tenant in the pool. instead of selecting a pending tenant and updating the same model, we now run `update()` conditionally -- it clears `pending_since` _only_ if the tenant is still pending, and we check the affected row count. Only one process can get a row back, the other gets 0 and retries with the next pending candidate in the pool. The loop always terminates since every lost claim means the pool shrank by one. Eventually it's empty and we create a new tenant (or return null). The claim and the attribute update happen in a single transaction now, so if updating `$attributes` fails, the claim rolls back and the tenant stays in the pool. Added a regression test that simulates a concurrent "steal" synchronously via a PullingPendingTenant listener. Fails with the old code, passes with the HasPending changes. Very minor BC: - Clearing `pending_since` no longer fires model updating/updated events (since the update goes through query builder). `PendingTenantPulled` still fires the same as before and is the listener you'd want to use anyway. - `PullingPendingTenant` now fires before the claim (and outside the transaction), so it can fire more than once with concurrent pulls (e.g. when a tenant gets claimed by someone else). `PendingTenantPulled` is still the one that fires exactly once for the actually pulled tenant. --------- Co-authored-by: Samuel Stancl <samuel@archte.ch>
1 parent 652bc98 commit 04da9c8

2 files changed

Lines changed: 88 additions & 17 deletions

File tree

src/Database/Concerns/HasPending.php

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -100,27 +100,51 @@ public static function pullPending(array $attributes = []): Model&Tenant
100100
*/
101101
public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant
102102
{
103-
$tenant = DB::transaction(function () use ($attributes): ?Tenant {
104-
/** @var (Model&Tenant)|null $tenant */
105-
$tenant = static::onlyPending()->first();
106-
107-
if ($tenant !== null) {
108-
event(new PullingPendingTenant($tenant));
109-
$tenant->update(array_merge($attributes, [
110-
'pending_since' => null,
111-
]));
103+
// Attempt pulling a pending tenant.
104+
// The loop handles the case where a single tenant is being pulled by multiple processes at the same time.
105+
// If a tenant was pulled by a concurrent process, try pulling the next one in the pool.
106+
while (true) {
107+
/** @var (Model&Tenant)|null $pullCandidate */
108+
$pullCandidate = static::onlyPending()->first();
109+
110+
if ($pullCandidate === null) {
111+
return $firstOrCreate ? static::create($attributes) : null;
112112
}
113113

114-
return $tenant;
115-
});
114+
// Fired before the claim, so it can fire once per attempt, including for a candidate
115+
// that ends up being claimed by a different process (in which case the loop retries).
116+
// PendingTenantPulled (below) fires exactly once, for the actually pulled tenant.
117+
event(new PullingPendingTenant($pullCandidate));
116118

117-
if ($tenant === null) {
118-
return $firstOrCreate ? static::create($attributes) : null;
119-
}
119+
$tenant = DB::transaction(function () use ($pullCandidate, $attributes): ?Tenant {
120+
$tenantWasPulled = static::onlyPending()
121+
->whereKey($pullCandidate->getKey())
122+
->update([$pullCandidate->getColumnForQuery('pending_since') => null]) > 0;
123+
124+
if (! $tenantWasPulled) {
125+
return null;
126+
}
127+
128+
// The tenant's pending_since was just cleared, and a PullingPendingTenant listener
129+
// may have made changes to the tenant, so re-fetch it to make sure it's up to date.
130+
/** @var Model&Tenant $pulledTenant */
131+
$pulledTenant = static::findOrFail($pullCandidate->getKey());
132+
133+
if (! empty($attributes)) {
134+
$pulledTenant->update($attributes);
135+
}
120136

121-
// Only triggered if a tenant that was pulled from the pool is returned
122-
event(new PendingTenantPulled($tenant));
137+
return $pulledTenant;
138+
});
123139

124-
return $tenant;
140+
if ($tenant === null) {
141+
// If another pull claimed this tenant first, try claiming the next one
142+
continue;
143+
}
144+
145+
event(new PendingTenantPulled($tenant));
146+
147+
return $tenant;
148+
}
125149
}
126150
}

tests/PendingTenantsTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Database\QueryException;
66
use Illuminate\Database\Schema\Blueprint;
77
use Illuminate\Support\Facades\Artisan;
8+
use Illuminate\Support\Facades\DB;
89
use Illuminate\Support\Facades\Event;
910
use Illuminate\Support\Facades\Schema;
1011
use Illuminate\Support\Str;
@@ -126,6 +127,52 @@
126127
expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants
127128
});
128129

130+
test('pulling a pending tenant retries when the tenant is claimed concurrently', function () {
131+
Tenant::createPending();
132+
Tenant::createPending();
133+
134+
$stolenId = null;
135+
136+
Event::listen(PullingPendingTenant::class, function (PullingPendingTenant $event) use (&$stolenId) {
137+
if ($stolenId !== null) {
138+
return;
139+
}
140+
141+
$stolenId = $event->tenant->id;
142+
143+
// Steal the tenant like a concurrent process would
144+
Tenant::onlyPending()
145+
->whereKey($event->tenant->id)
146+
->update([$event->tenant->getColumnForQuery('pending_since') => null]);
147+
});
148+
149+
$pulled = Tenant::pullPendingFromPool();
150+
151+
expect($pulled)->not()->toBeNull();
152+
expect($pulled->id)->not()->toBe($stolenId); // Stolen tenant was skipped, the next one was claimed by the pull
153+
expect(Tenant::onlyPending()->count())->toBe(0); // Both tenants claimed
154+
});
155+
156+
test('the pull is rolled back and the tenant stays in the pool if setting attributes fails', function () {
157+
// Pulling a tenant and setting its attributes happen in one transaction,
158+
// so if setting the attributes fails, the whole pull rolls back and the tenant stays in the pool.
159+
Schema::table('tenants', function (Blueprint $table) {
160+
$table->string('slug')->nullable()->unique();
161+
});
162+
163+
Tenant::$extraCustomColumns = ['slug'];
164+
165+
Tenant::create(['slug' => 'taken']);
166+
Tenant::createPending();
167+
168+
// During the pull, set slug to 'taken', which is already used by another tenant to make the attribute update throw
169+
expect(fn () => Tenant::pullPendingFromPool(false, ['slug' => 'taken']))
170+
->toThrow(QueryException::class);
171+
172+
// The pull rolled back, so the tenant is still pending
173+
expect(Tenant::onlyPending()->count())->toBe(1);
174+
});
175+
129176
test('withoutPending chained with where clauses returns correct results', function () {
130177
$tenant = Tenant::create();
131178
$pendingTenant = Tenant::createPending();

0 commit comments

Comments
 (0)