fix(appkit): honor TTL in CacheManager.getOrExecute#326
Conversation
CacheManager.getOrExecute calls storage.get() and returns the cached
value without checking expiry. InMemoryStorage.get() returns entries
unconditionally by design ("expiry check is done by CacheManager"),
but the check was missing on the getOrExecute path — only the
standalone CacheManager.get() honored TTL.
With the in-memory backend (the default when Lakebase is not
configured), this caused cached entries to live until LRU eviction at
maxSize or process restart, regardless of the configured ttl. Apps
using the analytics plugin's default cache (enabled, ttl: 3600) would
serve stale query results indefinitely.
Add an explicit expiry check in getOrExecute: if storage returns an
expired entry, delete it and fall through to a normal cache miss so
fn() re-executes.
Adds a regression test in the getOrExecute describe block. The
existing TTL test only covered the set/get pair, which is why this
was not caught.
Fixes databricks#325
Signed-off-by: Tim Hoare <t.hoare@codat.io>
MarioCadenas
left a comment
There was a problem hiding this comment.
hey @Tim-Hoare, first of all thanks so much for reporting the bug and fixing it, sorry it took a bit to review 😅
LGTM on the fix — the bug is real and the change correctly aligns getOrExecute with the same expiry contract that get() and has() already use.
Suggestion: extract a getValid() helper
You called this out in the PR description, and I think it's worth doing — the Date.now() > entry.expiry + storage.delete + treat-as-miss pattern now lives in three places (get, has, and the new branch in getOrExecute). If a fourth caller appears, this becomes a bug magnet (exactly how this PR happened).
A small private helper collapses it:
private async getValid<T>(
key: string,
): Promise<{ value: T; expiry: number } | null> {
const entry = await this.storage.get<T>(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
await this.storage.delete(key);
return null;
}
return entry;
}As I said, up to you if you want to refactor it and implement the getValid method 😄 , happy to merge as is
Address review feedback on databricks#326: collapse the duplicated 'storage.get + expiry check + delete-on-expired' pattern in get(), has(), and getOrExecute() into a single private getValid() helper to avoid future drift. Signed-off-by: TimHoare <t.hoare@codat.io>
|
Thanks @MarioCadenas! I have added the getValid helper if you would like to re-review |
|
Thanks a lot @Tim-Hoare ! approving and merging 😄 |
Fixes #325.
Problem
CacheManager.getOrExecutereturns cached entries without checking expiry, so whenInMemoryStorageis the backend (the default when Lakebase isn't configured) cached values are served indefinitely — until LRU eviction atmaxSizeor process restart. The configuredttlis silently ignored on this code path.This affects every AppKit app using the analytics plugin's default cache (
enabled: true, ttl: 3600) without Lakebase. In a deployed Databricks App we observed query results staying cached for >19 hours despite a 1-hour TTL.Why it slipped through
InMemoryStorage.get()is intentionally a raw lookup — the existing testshould return expired entry on get (expiry check is done by CacheManager)makes that contract explicit. The standaloneCacheManager.get()honors it correctly. ButCacheManager.getOrExecutedidn't, and the existing TTL test ("should respect TTL expiry") only covered theset/getpair, notgetOrExecute.Fix
In
getOrExecute, afterstorage.get(cacheKey)returns an entry, checkDate.now() > cached.expiry. If expired: delete it and fall through to the normal cache-miss path sofn()re-executes. Otherwise return the cached value as before.Net change is small and stays within the existing "expiry handling lives at the CacheManager layer" contract — no signature changes, no storage changes.
Test
Added a regression test in the
describe("getOrExecute", …)block: caches a value with a 1ms TTL, waits, callsgetOrExecuteagain, and asserts thatfnwas invoked twice and returns the new value.Confirmed:
expected 'result-1' to be 'result-2').appkitpackage tests continue to pass.Lint (
biome check) and typecheck (tsc --noEmit) clean.Notes
getValid()helper used by bothgetandgetOrExecute) if a different shape is preferred.