Skip to content

Add cache-aside helper#900

Merged
premtsd-code merged 1 commit into
feat/cached-findfrom
feat/cache-aside-helper
Jun 19, 2026
Merged

Add cache-aside helper#900
premtsd-code merged 1 commit into
feat/cached-findfrom
feat/cache-aside-helper

Conversation

@premtsd-code

@premtsd-code premtsd-code commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add Database::withCache(string $key, callable $callback, string $hash = '') as the public cache-aside API for keyed callback execution
  • run the callback on cache miss, save the callback result, and return cached values on later calls
  • make hash optional: callers can omit it for a single cached value, or pass it to keep multiple field-level payloads under one cache key
  • support optional cache hash fields so callers can keep multiple query-specific payloads under one collection-level cache key
  • wrap cached values internally so empty arrays and null can be cached while literal false remains the cache-miss sentinel
  • keep the implementation minimal by inlining cache payload wrap/unwrap behavior in withCache()
  • expose stable find cache key/field builders so external callers can compose cached find() flows without a dedicated findCached() API
  • keep cache failures fail-open so load/save/purge errors do not block the callback path
  • add focused unit coverage for cache miss/hit behavior, empty values, null, hash-field separation, and non-cacheable false values

Flow

withCache() is the generic cache boundary. For a single cached value, callers only need key and callback:

$value = $db->withCache(
    key: $cacheKey,
    callback: fn () => $loader(),
);

When callers need field-level cache entries under the same key, they can pass the optional hash field:

$value = $db->withCache(
    key: $cacheKey,
    callback: fn () => $loader(),
    hash: $cacheField,
);
withCache(key, callback, hash = '')
  -> load cache by key/hash
  -> return cached value on hit
  -> run callback on miss
  -> save callback result when it is cache-compatible
  -> return callback result

Callers that need cached find() results compose the key and field explicitly, and return the cache-safe shape they want from the callback:

$collection = $db->getCollection($collectionId);
$key = $db->getFindCacheKey($collectionId, $namespace);
$field = $db->getFindCacheField($collection, $queries, $roles, $fieldName);

$payload = $db->withCache(
    key: $key,
    callback: fn () => array_map(
        static fn (Document $document): array => $document->getArrayCopy(),
        $db->find($collectionId, $queries),
    ),
    hash: $field,
);

This keeps withCache() reusable for reads and writes while leaving find-specific query and payload decisions at the call site.

Cache Behavior

withCache() reads and writes through the configured Utopia cache adapter using the provided key and optional hash field. If the hash is omitted, cache adapters keep their existing behavior and use the key as the hash field.

The helper stores callback results in an internal value wrapper. That lets callers cache empty arrays and null without those values being confused with cache misses. A literal false callback result is not cached because false is the existing cache miss sentinel.

For cached find-style flows, getFindCacheKey() builds the collection-level key and getFindCacheField() builds the query-specific hash field. The field includes schema, roles, query state, and field type so different query shapes do not collide.

Tests

  • composer lint
  • composer format
  • vendor/bin/phpunit --configuration phpunit.xml tests/unit/ListCacheTest.php tests/unit/CacheKeyTest.php
  • vendor/bin/phpstan analyse --level 7 src/Database/Database.php tests/unit/ListCacheTest.php tests/unit/CacheKeyTest.php --memory-limit 2G
  • git diff --check

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • 0.69.x

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a766386b-5238-42e8-950c-d587a393ccf2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cache-aside-helper

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces findCached with a generic withCache / withCachedPayload cache-aside API on Database, wrapping callback results in an internal ['value' => $result] envelope so that null and empty arrays can be cached while false remains the miss sentinel. getFindCacheKey and getFindCacheField are kept public so callers can compose their own keyed find() flows.

  • withCache(key, callback, hash) is the new public entry point; it delegates to the private withCachedPayload which handles TTL clamping, load/save/purge, touch-on-hit, and fail-open error handling.
  • findCached is removed entirely; callers must now build the key and field themselves and pass a find() callback, rather than having the method do it internally.
  • Unit tests are rewritten to cover the new API: cache miss/hit round-trip, empty-array caching, null caching, hash-field isolation, and the non-cacheable false contract.

Confidence Score: 3/5

The change needs the removed authorization guard addressed before merging — callers can silently cache authorization-filtered rows and serve them to users with different permissions.

The old findCached refused to populate the cache when $this->authorization->getStatus() was true (the default), so authorization-filtered results were never written into the shared cache. withCache removes that guard with no replacement and no documented contract requiring callers to disable authorization first. A caller that forgets to wrap in getAuthorization()->skip() before calling withCache with a find() callback will cache user-A's filtered results and serve them to user-B on the next call.

src/Database/Database.php — specifically the withCache public method, which lacks the authorization-active bypass that findCached provided.

Security Review

  • Cross-user data leak via shared cache (src/Database/Database.php, withCache): findCached refused to cache when $this->authorization->getStatus() was true (the framework default), ensuring authorization-filtered results were never stored in a shared cache entry. withCache removes this guard entirely. A caller that invokes withCache while authorization is active will cache the current call's permission-filtered result; a subsequent caller with different permissions will receive those cached rows without running find() under their own context.

Important Files Changed

Filename Overview
src/Database/Database.php Replaces findCached with withCache/withCachedPayload; removes the authorization-active guard that prevented caching user-specific query results, shifting that responsibility entirely to callers without documenting the contract.
tests/unit/ListCacheTest.php Replaces integration-style findCached tests with focused unit tests for withCache; covers miss/hit, empty array, null, hash field separation, and false-value non-caching. touchOnHit branch has zero coverage (flagged in prior thread).

Reviews (7): Last reviewed commit: "Add cache-aside helper" | Re-trigger Greptile

Comment thread src/Database/Database.php
Comment thread tests/unit/ListCacheTest.php Outdated
Comment on lines +49 to +113
public function testWithCacheReturnsValidatedCachedValue(): void
{
$cache = new HashMemoryCache();
$database = $this->createDatabase($cache);
$cache->save('key', ['value' => 'cached'], 'field');

$callbackCalls = 0;
$hitPayload = null;

$value = $database->withCache(
key: 'key',
callback: function () use (&$callbackCalls): array {
$callbackCalls++;
return ['value' => 'fresh'];
},
ttl: 3600,
hash: 'field',
fromCache: static fn (mixed $cached): array|false => \is_array($cached) ? $cached : false,
onCacheHit: static function (array $value, mixed $cached) use (&$hitPayload): void {
$hitPayload = [$value, $cached];
},
);

$this->assertSame(['value' => 'cached'], $value);
$this->assertSame(0, $callbackCalls);
$this->assertSame([['value' => 'cached'], ['value' => 'cached']], $hitPayload);
}

public function testWithCacheRefreshesRejectedCachedValue(): void
{
$cache = new HashMemoryCache();
$database = $this->createDatabase($cache);
$cache->save('key', ['invalid' => true], 'field');

$callbackCalls = 0;

$value = $database->withCache(
key: 'key',
callback: function () use (&$callbackCalls): array {
$callbackCalls++;
return ['valid' => true];
},
ttl: 3600,
hash: 'field',
fromCache: static fn (mixed $cached): array|false => \is_array($cached) && ($cached['valid'] ?? false) === true ? $cached : false,
toCache: static fn (array $value): array => $value,
);

$this->assertSame(['valid' => true], $value);
$this->assertSame(1, $callbackCalls);

$value = $database->withCache(
key: 'key',
callback: function () use (&$callbackCalls): array {
$callbackCalls++;
return ['valid' => false];
},
ttl: 3600,
hash: 'field',
fromCache: static fn (mixed $cached): array|false => \is_array($cached) && ($cached['valid'] ?? false) === true ? $cached : false,
);

$this->assertSame(['valid' => true], $value);
$this->assertSame(1, $callbackCalls);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 touchOnHit path is not covered by any test

The new $touchOnHit parameter triggers $this->cache->touch() on a cache hit and is the only branch in withCache with zero test coverage. A test that verifies the TTL is extended (i.e., a near-expired entry is still returned after a touch) would confirm the feature works as advertised and guard against regressions if touch logic or the cache adapter changes.

@premtsd-code premtsd-code force-pushed the feat/cache-aside-helper branch 2 times, most recently from f4085bc to 29f1628 Compare June 19, 2026 11:27
Comment thread src/Database/Database.php Outdated
@premtsd-code premtsd-code force-pushed the feat/cache-aside-helper branch from 29f1628 to 4af25ad Compare June 19, 2026 11:40
@premtsd-code premtsd-code merged commit 4b5772a into feat/cached-find Jun 19, 2026
22 checks passed
@premtsd-code premtsd-code deleted the feat/cache-aside-helper branch June 19, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant