Skip to content

Commit 7000c40

Browse files
abnegateclaude
andcommitted
test(cache): check database query cache invalidation through reads
The installer and schema-mutation tests counted writes of the '#epoch' key and purges of the '#started' generation, and the shared-SQLite tests read the epoch back to match its 'blocked:' prefix: the same key-layout coupling Greptile flagged in QueryCacheTest, which breaks on any storage refactor without catching a behaviour change. Each mutation now proves it retires what the query cache held: a result filled under the database's scope before it is served, misses after it, and a fresh fill round-trips. The schema mutations become a data provider so each runs on its own collection and reports separately. Removing the query cache is checked by a detached cache keeping its result across a write; the shared-SQLite tests already prove the blocked epoch through their database read counts. With invalidation disabled in src every rewritten case fails. LeasableHashCache's purge and write counters lose their last reader and go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0156ffa commit 7000c40

2 files changed

Lines changed: 89 additions & 88 deletions

File tree

‎tests/unit/Cache/DatabaseQueryCacheTest.php‎

Lines changed: 89 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -148,69 +148,79 @@ public function testRandomOrderAlwaysBypassesQueryCache(): void
148148
$this->assertSame(2, $adapter->getObservedFinds());
149149
}
150150

151-
public function testSetQueryCacheInstallsOneInvalidatorAndRemovesIt(): void
151+
public function testSetQueryCacheInvalidatesWhileInstalledAndStopsOnceRemoved(): void
152152
{
153153
[$database, $queryAdapter] = $this->createDatabase(queryCache: false);
154-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
154+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
155155

156156
$queryCache = new QueryCache(new Cache($queryAdapter));
157157
$database->setQueryCache($queryCache);
158158
$database->setQueryCache($queryCache);
159159

160-
$queryAdapter->resetPurges();
161-
$database->createDocument('users', new Document(['$id' => 'a']));
162-
$this->assertSame(2, $queryAdapter->getWrites($this->collectionKey($database, 'users').'#epoch'));
160+
$this->assertMutationRetiresQueries(
161+
$database,
162+
static fn (Database $database) => $database->createDocument('users', new Document(['$id' => 'a'])),
163+
);
163164

164165
$database->setQueryCache(null);
165-
$queryAdapter->resetPurges();
166+
$scope = $this->scope($database);
167+
$before = $queryCache->getEntry($scope, 'users', []);
168+
$this->assertNotNull($before);
169+
$this->assertTrue($queryCache->set($before, [new Document(['$id' => 'detached'])], $queryCache->getGeneration($before)));
170+
166171
$database->createDocument('users', new Document(['$id' => 'b']));
167-
$this->assertSame(0, $queryAdapter->getWrites($this->collectionKey($database, 'users').'#epoch'));
172+
173+
$after = $queryCache->getEntry($scope, 'users', []);
174+
$this->assertNotNull($after);
175+
$this->assertSame(['detached'], $this->ids($queryCache->get($after) ?? []), 'A removed query cache must no longer be invalidated by writes');
168176
}
169177

170-
public function testSchemaAndCollectionMutationsInvalidateQueries(): void
178+
/**
179+
* @return array<string, array{callable(Database): mixed}>
180+
*/
181+
public static function schemaAndCollectionMutations(): array
171182
{
172-
[$database, $queryAdapter] = $this->createDatabase();
173-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
174-
$started = $this->collectionKey($database, 'users').'#started';
175-
176-
$queryAdapter->resetPurges();
177-
$database->updateCollection('users', $this->permissions(), false);
178-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
179-
180-
$queryAdapter->resetPurges();
181-
$database->createAttribute('users', Attribute::string(key: 'name'));
182-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
183-
184-
$queryAdapter->resetPurges();
185-
$database->updateAttribute('users', 'name', size: 128);
186-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
187-
188-
$queryAdapter->resetPurges();
189-
$database->createIndex('users', Index::key(key: 'name', attributes: ['name']));
190-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
191-
192-
$queryAdapter->resetPurges();
193-
$database->renameIndex('users', 'name', 'renamed');
194-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
183+
return [
184+
'update collection' => [static fn (Database $database) => $database->updateCollection('users', self::permissions(), false)],
185+
'create attribute' => [static fn (Database $database) => $database->createAttribute('users', Attribute::string(key: 'email'))],
186+
'update attribute' => [static fn (Database $database) => $database->updateAttribute('users', 'name', size: 128)],
187+
'delete attribute' => [static fn (Database $database) => $database->deleteAttribute('users', 'title')],
188+
'create index' => [static fn (Database $database) => $database->createIndex('users', Index::key(key: 'title', attributes: ['title']))],
189+
'rename index' => [static fn (Database $database) => $database->renameIndex('users', 'name', 'renamed')],
190+
'delete index' => [static fn (Database $database) => $database->deleteIndex('users', 'name')],
191+
];
192+
}
195193

196-
$queryAdapter->resetPurges();
197-
$database->deleteIndex('users', 'renamed');
198-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
194+
/**
195+
* @param callable(Database): mixed $mutation
196+
*/
197+
#[DataProvider('schemaAndCollectionMutations')]
198+
public function testSchemaAndCollectionMutationsInvalidateQueries(callable $mutation): void
199+
{
200+
[$database] = $this->createDatabase();
201+
$database->createCollection(new Collection(
202+
id: 'users',
203+
attributes: [
204+
Attribute::string(key: 'name'),
205+
Attribute::string(key: 'title'),
206+
],
207+
indexes: [Index::key(key: 'name', attributes: ['name'])],
208+
permissions: self::permissions(),
209+
documentSecurity: false,
210+
));
199211

200-
$queryAdapter->resetPurges();
201-
$database->deleteAttribute('users', 'name');
202-
$this->assertGreaterThan(0, $queryAdapter->getPurges($started));
212+
$this->assertMutationRetiresQueries($database, $mutation);
203213
}
204214

205215
public function testDeleteAndRecreateCannotReuseOldCollectionResults(): void
206216
{
207217
[$database] = $this->createDatabase();
208-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
218+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
209219
$database->createDocument('users', new Document(['$id' => 'old']));
210220
$this->assertSame(['old'], $this->ids($database->find('users')));
211221

212222
$database->deleteCollection('users');
213-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
223+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
214224
$database->createDocument('users', new Document(['$id' => 'new']));
215225

216226
$this->assertSame(['new'], $this->ids($database->find('users')));
@@ -219,7 +229,7 @@ public function testDeleteAndRecreateCannotReuseOldCollectionResults(): void
219229
public function testRolledBackTransactionCannotPoisonQueryCache(): void
220230
{
221231
[$database] = $this->createDatabase();
222-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
232+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
223233
$database->createDocument('users', new Document(['$id' => 'committed']));
224234
$this->assertSame(['committed'], $this->ids($database->find('users')));
225235

@@ -246,7 +256,7 @@ public function testMetadataReadStartedBeforePurgeCannotPublish(): void
246256
{
247257
$adapter = new ObservedMemory();
248258
[$database] = $this->createDatabase($adapter, queryCache: false, dataAdapter: new None());
249-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
259+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
250260
$database->purgeCachedCollection('users');
251261

252262
$adapter->observeMetadata('users', fn () => $database->purgeCachedCollection('users'));
@@ -262,7 +272,7 @@ public function testValidatorBuildStartedBeforePurgeCannotPublish(): void
262272
[$database] = $this->createDatabase($adapter, queryCache: false);
263273
$database->createCollection(new Collection(id: 'users', attributes: [
264274
Attribute::string(key: 'name'),
265-
], permissions: $this->permissions(), documentSecurity: false));
275+
], permissions: self::permissions(), documentSecurity: false));
266276
$database->getCollection('users');
267277

268278
$byName = static fn (string $name): array => [Query::select(['name']), Query::equal('name', [$name])];
@@ -286,7 +296,7 @@ public function testANarrowValidationStraddlingAPurgeKeepsNothing(): void
286296
[$database] = $this->createDatabase($adapter, queryCache: false);
287297
$database->createCollection(new Collection(id: 'users', attributes: [
288298
Attribute::string(key: 'name'),
289-
], permissions: $this->permissions(), documentSecurity: false));
299+
], permissions: self::permissions(), documentSecurity: false));
290300

291301
$adapter->observeMetadata('users', fn () => $database->createAttribute('users', Attribute::integer(key: 'age')));
292302
$this->assertSame([], $database->find('users', [Query::equal('name', ['first'])]));
@@ -358,7 +368,7 @@ public function testMemoryCacheSeparatesPointSelectionVariants(): void
358368
$database->createCollection(new Collection(id: 'users', attributes: [
359369
Attribute::string(key: 'name'),
360370
Attribute::string(key: 'email'),
361-
], permissions: $this->permissions(), documentSecurity: false));
371+
], permissions: self::permissions(), documentSecurity: false));
362372
$database->createDocument('users', new Document([
363373
'$id' => 'user',
364374
'name' => 'Alice',
@@ -440,8 +450,8 @@ public function testJoinQueriesAlwaysBypassCacheAfterRelatedMutation(): void
440450
{
441451
$adapter = new JoinMemory();
442452
[$database] = $this->createDatabase($adapter);
443-
$database->createCollection(new Collection(id: 'parents', permissions: $this->permissions(), documentSecurity: false));
444-
$database->createCollection(new Collection(id: 'children', permissions: $this->permissions(), documentSecurity: false));
453+
$database->createCollection(new Collection(id: 'parents', permissions: self::permissions(), documentSecurity: false));
454+
$database->createCollection(new Collection(id: 'children', permissions: self::permissions(), documentSecurity: false));
445455

446456
$queries = [Query::join('children', '$id', '$id')];
447457
$database->find('parents', $queries);
@@ -456,7 +466,7 @@ public function testMandatoryInvalidationFailureEscapesSilentScope(): void
456466
$cache = new FailingMemory();
457467
[$database] = $this->createDatabase(queryCache: false);
458468
$database->setQueryCache(new QueryCache(new Cache($cache)));
459-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
469+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
460470
$database->find('users');
461471
$cache->failBlocks();
462472

@@ -515,7 +525,7 @@ public function testDeleteRollsBackWhenMandatoryInvalidationFails(): void
515525

516526
public function testSharedBlockedEpochPreventsPreCommitStaleFill(): void
517527
{
518-
[$writer, $reader, $writerAdapter, $readerAdapter, $cache, $path] = $this->createSharedSQLiteDatabases();
528+
[$writer, $reader, $writerAdapter, $readerAdapter, , $path] = $this->createSharedSQLiteDatabases();
519529

520530
try {
521531
$this->assertSame(
@@ -525,10 +535,7 @@ public function testSharedBlockedEpochPreventsPreCommitStaleFill(): void
525535
$readerAdapter->observeFinds('users');
526536

527537
$duringCommit = [];
528-
$writerAdapter->pauseNextCommit(function () use ($reader, $cache, &$duringCommit): void {
529-
$epoch = $cache->load($this->collectionKey($reader, 'users').'#epoch', 3600);
530-
$this->assertIsString($epoch);
531-
$this->assertStringStartsWith('blocked:', $epoch);
538+
$writerAdapter->pauseNextCommit(function () use ($reader, &$duringCommit): void {
532539
$duringCommit = $this->names($reader->find('users', [Query::orderAsc('$id')]));
533540
});
534541

@@ -568,10 +575,6 @@ public function testActivationFailureAfterCommitLeavesSharedEpochBlocked(): void
568575
$this->assertStringContainsString('activate query cache', $exception->getMessage());
569576
}
570577

571-
$epoch = $cache->load($this->collectionKey($reader, 'users').'#epoch', 3600);
572-
$this->assertIsString($epoch);
573-
$this->assertStringStartsWith('blocked:', $epoch);
574-
575578
$readerAdapter->observeFinds('users');
576579
$this->assertSame(
577580
['existing' => 'updated'],
@@ -603,7 +606,7 @@ public function testPooledRollbackCannotPoisonPointOrQueryCaches(): void
603606
$database->getAuthorization()->addRole(Role::any()->toString());
604607
$database->createCollection(new Collection(id: 'users', attributes: [
605608
Attribute::string(key: 'name'),
606-
], permissions: $this->permissions(), documentSecurity: false));
609+
], permissions: self::permissions(), documentSecurity: false));
607610
$database->createDocument('users', new Document([
608611
'$id' => 'user',
609612
'name' => 'committed',
@@ -653,7 +656,7 @@ private function createDatabase(
653656

654657
private function createUsers(Database $database): void
655658
{
656-
$database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false));
659+
$database->createCollection(new Collection(id: 'users', permissions: self::permissions(), documentSecurity: false));
657660
foreach (['a', 'b', 'c'] as $id) {
658661
$database->createDocument('users', new Document(['$id' => $id]));
659662
}
@@ -675,7 +678,7 @@ private function assertMutationRollsBackOnInvalidationFailure(callable $mutation
675678
$database->getAuthorization()->addRole(Role::any()->toString());
676679
$database->createCollection(new Collection(id: 'users', attributes: [
677680
Attribute::string(key: 'name', required: true),
678-
], permissions: $this->permissions(), documentSecurity: false));
681+
], permissions: self::permissions(), documentSecurity: false));
679682
$database->createDocument('users', new Document([
680683
'$id' => 'existing',
681684
'name' => 'original',
@@ -745,7 +748,7 @@ private function createSharedSQLiteDatabases(): array
745748
$reader->getAuthorization()->addRole(Role::any()->toString());
746749
$writer->createCollection(new Collection(id: 'users', attributes: [
747750
Attribute::string(key: 'name', required: true),
748-
], permissions: $this->permissions(), documentSecurity: false));
751+
], permissions: self::permissions(), documentSecurity: false));
749752
$writer->createDocument('users', new Document([
750753
'$id' => 'existing',
751754
'name' => 'original',
@@ -767,20 +770,42 @@ private function removeSQLiteFiles(string $path): void
767770
}
768771
}
769772

770-
private function collectionKey(Database $database, string $collection): string
773+
private function scope(Database $database): Scope
771774
{
772775
$adapter = $database->getAdapter();
773776

774-
return (new QueryCache(new Cache(new None())))->getCollectionKey(new Scope(
777+
return new Scope(
775778
hostname: $adapter->supports(Capability::Hostname) ? $adapter->getHostname() : '',
776779
database: $adapter->getDatabase(),
777780
namespace: $adapter->getNamespace(),
778781
tenant: $adapter->getTenant(),
779-
), $collection);
782+
);
783+
}
784+
785+
/**
786+
* @param callable(Database): mixed $mutation
787+
*/
788+
private function assertMutationRetiresQueries(Database $database, callable $mutation): void
789+
{
790+
$queryCache = $database->getQueryCache();
791+
$this->assertNotNull($queryCache);
792+
$scope = $this->scope($database);
793+
$before = $queryCache->getEntry($scope, 'users', []);
794+
$this->assertNotNull($before);
795+
$this->assertTrue($queryCache->set($before, [new Document(['$id' => 'stale'])], $queryCache->getGeneration($before)));
796+
$this->assertSame(['stale'], $this->ids($queryCache->get($before) ?? []));
797+
798+
$mutation($database);
799+
800+
$after = $queryCache->getEntry($scope, 'users', []);
801+
$this->assertNotNull($after, 'The mutation must leave the query cache usable');
802+
$this->assertNull($queryCache->get($after), 'The mutation must retire what was cached before it');
803+
$this->assertTrue($queryCache->set($after, [new Document(['$id' => 'fresh'])], $queryCache->getGeneration($after)));
804+
$this->assertSame(['fresh'], $this->ids($queryCache->get($after) ?? []), 'The mutation must publish a fresh epoch');
780805
}
781806

782807
/** @return array<string> */
783-
private function permissions(): array
808+
private static function permissions(): array
784809
{
785810
return [
786811
Permission::read(Role::any()),

‎tests/unit/Cache/LeasableHashCache.php‎

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,6 @@ final class LeasableHashCache implements CacheAdapter, Leasable
1313
/** @var array<string, int> */
1414
private array $generations = [];
1515

16-
/** @var array<string, int> */
17-
private array $purges = [];
18-
19-
/** @var array<string, int> */
20-
private array $writes = [];
21-
2216
private bool $failActivations = false;
2317

2418
public function load(string $key, int $ttl, string $hash = ''): mixed
@@ -45,7 +39,6 @@ public function save(string $key, array|string $data, string $hash = '', int $tt
4539
}
4640

4741
$hash = $hash === '' ? $key : $hash;
48-
$this->writes[$key] = ($this->writes[$key] ?? 0) + 1;
4942
$this->store[$key][$hash] = ['time' => \time(), 'data' => $data];
5043

5144
return $data;
@@ -85,7 +78,6 @@ public function list(string $key): array
8578

8679
public function purge(string $key, string $hash = ''): bool
8780
{
88-
$this->purges[$key] = ($this->purges[$key] ?? 0) + 1;
8981
$this->generations[$key] = ($this->generations[$key] ?? 0) + 1;
9082

9183
if ($hash === '') {
@@ -120,22 +112,6 @@ public function getName(?string $key = null): string
120112
return 'leasable-hash';
121113
}
122114

123-
public function resetPurges(): void
124-
{
125-
$this->purges = [];
126-
$this->writes = [];
127-
}
128-
129-
public function getPurges(string $key): int
130-
{
131-
return $this->purges[$key] ?? 0;
132-
}
133-
134-
public function getWrites(string $key): int
135-
{
136-
return $this->writes[$key] ?? 0;
137-
}
138-
139115
public function failActivations(): void
140116
{
141117
$this->failActivations = true;

0 commit comments

Comments
 (0)