Skip to content

Commit 9dd0c15

Browse files
nficanocursoragent
andcommitted
fix: side-effect-free idempotency lookup and session-scoped lease use (#82 #110)
- EventLog::lookupIdempotent() no longer deletes expired rows; it returns null and defers eviction to a new purgeExpiredIdempotent() sweep. rememberIdempotent upserts so a stale expired row is refreshed in place. - LeaseManager::ensureUsable() accepts an optional SessionId and routes through getForSession() so a lease id from another session cannot pass scope checks. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f8aa081 commit 9dd0c15

4 files changed

Lines changed: 84 additions & 9 deletions

File tree

src/Runtime/LeaseManager.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,20 @@ public function getForSession(LeaseId $id, SessionId $sessionId): LeaseGranted
7979
return $lease;
8080
}
8181

82-
public function ensureUsable(LeaseId $id, LeaseScope $scope): LeaseGranted
82+
/**
83+
* Resolve a lease and assert it matches the requested scope. When
84+
* `$sessionId` is supplied the lookup is session-scoped (leases are
85+
* session-scoped per RFC §15.5), so a caller holding a lease id from a
86+
* different session cannot pass scope checks.
87+
*
88+
* @throws PermissionDeniedException when the lease belongs to another
89+
* session or the scope does not match.
90+
*/
91+
public function ensureUsable(LeaseId $id, LeaseScope $scope, ?SessionId $sessionId = null): LeaseGranted
8392
{
84-
$lease = $this->get($id);
93+
$lease = $sessionId instanceof SessionId
94+
? $this->getForSession($id, $sessionId)
95+
: $this->get($id);
8596
if (
8697
$lease->permission !== $scope->permission
8798
|| $lease->resource !== $scope->resource

src/Store/EventLog.php

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,16 @@ public function rememberIdempotent(IdempotencyRecord $record): ?string
305305
if ($existing !== null) {
306306
return $existing;
307307
}
308+
// Upsert: lookupIdempotent() returns null for an expired row without
309+
// deleting it (#110), so refresh any stale entry in place rather than
310+
// colliding with the (principal, idempotency_key) primary key.
308311
$stmt = $this->pdo->prepare(<<<'SQL'
309312
INSERT INTO idempotency_cache
310313
(principal, idempotency_key, outcome_message_id, expires_at)
311314
VALUES (:principal, :key, :outcome, :expires)
315+
ON CONFLICT(principal, idempotency_key) DO UPDATE SET
316+
outcome_message_id = excluded.outcome_message_id,
317+
expires_at = excluded.expires_at
312318
SQL);
313319
$stmt->execute([
314320
':principal' => $record->principal,
@@ -319,6 +325,23 @@ public function rememberIdempotent(IdempotencyRecord $record): ?string
319325
return null;
320326
}
321327

328+
/**
329+
* Delete idempotency-cache rows whose retention horizon has passed.
330+
* Intended to be driven by a periodic sweep rather than read paths.
331+
*
332+
* @return int number of rows purged
333+
*/
334+
public function purgeExpiredIdempotent(): int
335+
{
336+
$stmt = $this->pdo->prepare(
337+
'DELETE FROM idempotency_cache WHERE expires_at <= :now',
338+
);
339+
$stmt->execute([
340+
':now' => $this->clock->now()->format(\DateTimeInterface::RFC3339_EXTENDED),
341+
]);
342+
return $stmt->rowCount();
343+
}
344+
322345
public function lookupIdempotent(string $principal, string $idempotencyKey): ?string
323346
{
324347
$stmt = $this->pdo->prepare(<<<'SQL'
@@ -342,12 +365,8 @@ public function lookupIdempotent(string $principal, string $idempotencyKey): ?st
342365
}
343366
$expires = new \DateTimeImmutable($expiresStr);
344367
if ($expires <= $this->clock->now()) {
345-
// Lazy GC of an expired entry.
346-
$del = $this->pdo->prepare(<<<'SQL'
347-
DELETE FROM idempotency_cache
348-
WHERE principal = :principal AND idempotency_key = :key
349-
SQL);
350-
$del->execute([':principal' => $principal, ':key' => $idempotencyKey]);
368+
// Treat an expired entry as absent. Deletion is deferred to
369+
// purgeExpiredIdempotent() so this lookup stays side-effect free.
351370
return null;
352371
}
353372
return $outcome;

tests/Unit/EventLogTest.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ public function testIdempotencyCacheExpiresLazily(): void
156156
$this->log->lookupIdempotent('alice', 'refund-1'),
157157
'expired entry should not be returned',
158158
);
159-
// After lazy GC, a fresh remember succeeds with the new outcome.
159+
// An expired entry is overwritten in place, so a fresh remember
160+
// succeeds with the new outcome.
160161
$newExpires = $this->clock->now()->modify('+1 hour');
161162
self::assertNull($this->log->rememberIdempotent(
162163
new IdempotencyRecord('alice', 'refund-1', 'msg_y', $newExpires),
@@ -177,4 +178,31 @@ public function testIdempotencyCachePartitionsByPrincipal(): void
177178
self::assertSame('msg_a', $this->log->lookupIdempotent('alice', 'refund-1'));
178179
self::assertSame('msg_b', $this->log->lookupIdempotent('bob', 'refund-1'));
179180
}
181+
182+
public function testLookupIdempotentDoesNotDeleteExpiredRows(): void
183+
{
184+
$this->log->rememberIdempotent(
185+
new IdempotencyRecord('alice', 'refund-1', 'msg_x', $this->clock->now()->modify('+5 seconds')),
186+
);
187+
$this->clock->advance(10);
188+
189+
// A read must not mutate the store: the expired row survives lookup
190+
// and is only removed by an explicit purge.
191+
self::assertNull($this->log->lookupIdempotent('alice', 'refund-1'));
192+
self::assertSame(1, $this->log->purgeExpiredIdempotent());
193+
}
194+
195+
public function testPurgeExpiredIdempotentRemovesOnlyExpiredRows(): void
196+
{
197+
$this->log->rememberIdempotent(
198+
new IdempotencyRecord('alice', 'expired', 'msg_old', $this->clock->now()->modify('+5 seconds')),
199+
);
200+
$this->log->rememberIdempotent(
201+
new IdempotencyRecord('alice', 'live', 'msg_new', $this->clock->now()->modify('+1 hour')),
202+
);
203+
$this->clock->advance(10);
204+
205+
self::assertSame(1, $this->log->purgeExpiredIdempotent());
206+
self::assertSame('msg_new', $this->log->lookupIdempotent('alice', 'live'));
207+
}
180208
}

tests/Unit/LeaseManagerTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ public function testEnsureUsableValidatesScope(): void
8181
);
8282
}
8383

84+
public function testEnsureUsableRejectsForeignSession(): void
85+
{
86+
$clock = new FakeClock();
87+
$mgr = new LeaseManager($clock);
88+
$lease = $this->makeLease($clock);
89+
$owner = \Arcp\Ids\SessionId::random();
90+
$other = \Arcp\Ids\SessionId::random();
91+
$mgr->register($lease, $owner);
92+
93+
$this->expectException(PermissionDeniedException::class);
94+
$mgr->ensureUsable(
95+
$lease->leaseId,
96+
new LeaseScope($lease->permission, $lease->resource, $lease->operation),
97+
$other,
98+
);
99+
}
100+
84101
public function testExtendUpdatesExpiry(): void
85102
{
86103
$clock = new FakeClock();

0 commit comments

Comments
 (0)