diff --git a/src/Database/Database.php b/src/Database/Database.php index 217351fed..74e75d883 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8709,6 +8709,16 @@ public function withCache( } } + // Capture the generation before the callback runs its read: if a + // concurrent write purges this query key in between, saveWithLease() + // below rejects the now-stale list instead of re-poisoning the cache. + $generation = '0'; + try { + $generation = $this->cache->getGeneration($key); + } catch (Throwable $e) { + Console::warning('Warning: Failed to get cache generation: ' . $e->getMessage()); + } + $callbackValue = $callback(); if ($callbackValue !== false) { @@ -8795,7 +8805,7 @@ public function withCache( } if ($encoded !== false) { - $this->cache->save($key, $encoded, $hash); + $this->cache->saveWithLease($key, $encoded, $hash, $generation); } } catch (Throwable $e) { Console::warning('Warning: Failed to save cache value: ' . $e->getMessage()); diff --git a/tests/unit/WithCacheLeaseTest.php b/tests/unit/WithCacheLeaseTest.php new file mode 100644 index 000000000..8eafd3540 --- /dev/null +++ b/tests/unit/WithCacheLeaseTest.php @@ -0,0 +1,207 @@ +cacheAdapter = new LeasableMemoryCache(); + $this->database = new Database(new DatabaseMemory(), new Cache($this->cacheAdapter)); + $this->database + ->setDatabase('utopiaTests') + ->setNamespace('with_cache_' . \uniqid()); + + $this->database->create(); + $this->database->createCollection('projects'); + $this->database->createAttribute('projects', 'name', Database::VAR_STRING, 255, false); + $this->database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'fresh', + ])); + + $this->key = $this->database->getQueryCacheKey('projects'); + } + + public function testStaleListWriteAfterConcurrentPurgeIsRejected(): void + { + $hash = 'list-hash'; + + $document = $this->database->getDocument('projects', 'project'); + + // The callback stands in for the database read of an older request: a + // concurrent writer purges the query key after the read started but + // before the result is cached. Without a lease the stale list below + // would land in the cache after the purge. + $result = $this->database->withCache($this->key, function () use ($hash, $document) { + $this->cacheAdapter->purge($this->key, $hash); + + return [$document]; + }, $hash); + + $this->assertCount(1, $result); + $this->assertFalse( + $this->cacheAdapter->load($this->key, Database::TTL, $hash), + 'A list read whose query key was purged mid-flight must not be re-cached.' + ); + } + + public function testListWriteLandsWhenNoConcurrentPurge(): void + { + $hash = 'list-hash'; + + $document = $this->database->getDocument('projects', 'project'); + + $result = $this->database->withCache($this->key, fn () => [$document], $hash); + + $this->assertCount(1, $result); + $this->assertNotFalse( + $this->cacheAdapter->load($this->key, Database::TTL, $hash), + 'A list read with no concurrent purge must populate the cache.' + ); + } +} + +class LeasableMemoryCache implements Adapter, Leasable +{ + private const string GENERATION_FIELD = '__utopia_gen__'; + + /** + * @var array> + */ + private array $store = []; + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + if ($hash === '') { + $hash = $key; + } + + if (! isset($this->store[$key][$hash])) { + return false; + } + + $saved = $this->store[$key][$hash]; + + return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; + } + + public function save(string $key, array|string $data, string $hash = ''): bool|string|array + { + if (empty($key) || empty($data)) { + return false; + } + + if ($hash === '') { + $hash = $key; + } + + if ($hash === self::GENERATION_FIELD) { + return false; + } + + $this->store[$key][$hash] = ['time' => \time(), 'data' => $data]; + + return $data; + } + + public function getGeneration(string $key): string + { + return $this->store[$key][self::GENERATION_FIELD]['data'] ?? '0'; + } + + public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array + { + if (empty($key) || empty($data)) { + return false; + } + + if ($this->getGeneration($key) !== $generation) { + return false; + } + + return $this->save($key, $data, $hash); + } + + public function touch(string $key, string $hash = ''): bool + { + if ($hash === '') { + $hash = $key; + } + + if (! isset($this->store[$key][$hash])) { + return false; + } + + $this->store[$key][$hash]['time'] = \time(); + + return true; + } + + /** + * @return string[] + */ + public function list(string $key): array + { + return \array_values(\array_filter( + \array_keys($this->store[$key] ?? []), + fn (string $field): bool => $field !== self::GENERATION_FIELD + )); + } + + public function purge(string $key, string $hash = ''): bool + { + $generation = (string) (((int) $this->getGeneration($key)) + 1); + + if ($hash !== '' && $hash !== self::GENERATION_FIELD) { + unset($this->store[$key][$hash]); + } else { + $this->store[$key] = []; + } + + $this->store[$key][self::GENERATION_FIELD] = ['time' => \time(), 'data' => $generation]; + + return true; + } + + public function flush(): bool + { + $this->store = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'leasable-memory'; + } +}