Skip to content

perf(store): add opt-in TTL cache to getStatus for polling callers#4

Open
lukeboyett wants to merge 4 commits intofix/db-transaction-typefrom
perf/status-ttl-cache
Open

perf(store): add opt-in TTL cache to getStatus for polling callers#4
lukeboyett wants to merge 4 commits intofix/db-transaction-typefrom
perf/status-ttl-cache

Conversation

@lukeboyett
Copy link
Copy Markdown
Owner

Summary

Adds an opt-in TTL cache to Store.getStatus() for callers that poll it. Default behavior is unchanged; every existing call site keeps fresh-each-call semantics.

Motivation

store.getStatus() runs a COUNT(DISTINCT d.hash) scan (via getHashesNeedingEmbedding) plus a GROUP BY collection and a total-doc count. On small indexes this is fast; on large ones (millions of documents, tens of GB of SQLite) each call is expensive.

Long-lived processes that repeatedly hit this path pay the full cost every time:

  • Upstream's own MCP server calls store.getStatus() from status/stats requests (src/mcp/server.ts:110, :515).
  • HTTP daemon wrappers / monitoring loops do the same.

For these pollers the result barely changes between calls. A short TTL cache is a large wall-clock win; for interactive CLI use the cache is unused and behavior is identical.

What's in it

  • Store.getStatus accepts an optional { ttlMs } parameter.
  • When ttlMs > 0, the result is cached per-Store instance; repeat calls within the window return the cached snapshot.
  • When ttlMs is omitted or 0, the call runs fresh and clears any previously-cached value, so interleaving fresh + cached calls stays coherent.
  • No call sites are changed. The cache is strictly opt-in for now so this PR can't regress anything unrelated.

Tests

test/status-cache.test.ts:

  • default calls (no options) re-query every time — vi.spyOn(db, "prepare") call count increases on each call
  • ttlMs > 0 skips re-query within the window (fake timers), then re-queries once the window expires
  • ttlMs = 0 behaves identically to no options
  • a fresh no-options call invalidates the cache so the next polling call re-populates

Notes

On large indexes (millions of rows, tens of GB of SQLite) the
`COUNT(DISTINCT d.hash)` scan inside `getHashesNeedingEmbedding`
dominates `getStatus()` cost. For long-lived processes that hit this
path repeatedly — upstream's own MCP server calls `store.getStatus()`
on status/stats requests, and external HTTP/daemon wrappers do too —
every poll pays the full cost even though the result barely changes
between calls.

Default behavior is unchanged. The `Store.getStatus` signature now
takes an optional `{ ttlMs }`; callers that want fresh results pass
nothing (or `ttlMs: 0`). Callers that know they poll can pass
`ttlMs: 5000` or similar to reuse a recent snapshot. Cache scope is
per-Store instance, not module-global, so multiple stores don't
cross-contaminate. A call that omits `ttlMs` (or passes 0) clears the
cached value so the next polling-mode call re-populates it.

No call sites are changed in this PR — the cache is opt-in. MCP,
CLI `qmd status`, and SDK callers keep their existing fresh-each-call
semantics until they explicitly opt in.

Test (`test/status-cache.test.ts`): uses vitest fake timers and a
spy on `db.prepare` to assert
  - default calls re-query every time,
  - `ttlMs > 0` skips re-query within the window,
  - `ttlMs=0` behaves identically to no options,
  - a fresh (no-options) call invalidates the cache so the next
    polling call re-populates.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60e9d479db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/store.ts Outdated
Comment on lines +1632 to +1633
if (ttl > 0 && statusCache && statusCache.expiresAt > now) {
return statusCache.value;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect per-call ttlMs when reusing cached status

getStatus returns the cached snapshot whenever statusCache.expiresAt > now, but that expiration was set by the previous caller’s TTL. If one caller populates the cache with a long TTL (e.g., 60s) and a later caller asks for a shorter TTL (e.g., 1s), this branch still serves data older than the later caller requested. This makes ttlMs non-authoritative in mixed-caller scenarios and can return unexpectedly stale status from a shared Store instance.

Useful? React with 👍 / 👎.

Codex P2 on #4: the original implementation stored the cache entry as
`expiresAt = fetchedAt + ttlMs` using the *first* caller's TTL. If a
later caller passed a shorter TTL, the cache was still considered
valid as long as the original expiry had not passed, so the second
caller could silently receive data older than their requested window.

Fix:
- Store `fetchedAt` (the snapshot's timestamp) rather than `expiresAt`.
- On each call compute `now - fetchedAt` and compare against *this*
  caller's `ttlMs`. Each caller's freshness contract is now honored
  regardless of what a previous caller asked for.

Test:
- New `each caller's ttlMs is authoritative against the data's age`
  covers the mixed-TTL scenario: caller A (60s TTL) seeds the cache,
  caller B (1s TTL) two seconds later must re-fetch, caller C (60s
  TTL) immediately after B reuses B's fresh snapshot.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 0f31594.

P2 "Respect per-call ttlMs when reusing cached status" — valid catch. The cache now stores fetchedAt (the snapshot's timestamp) instead of expiresAt, so each caller's ttlMs is evaluated against the data's actual age rather than whatever window the original populator happened to request. Mixed-caller scenarios are coherent: a long-TTL caller can't strand a short-TTL caller with stale data.

New test each caller's ttlMs is authoritative against the data's age walks through the A-60s / B-1s / C-60s mixed case with fake timers.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f31594436

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/store.ts Outdated
Comment on lines +1631 to +1636
const now = Date.now();
if (ttl > 0 && statusCache && (now - statusCache.fetchedAt) < ttl) {
return statusCache.value;
}
const fresh = getStatus(db);
statusCache = ttl > 0 ? { value: fresh, fetchedAt: now } : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record fetchedAt after status query completes

getStatus captures now before executing the expensive DB query and then stores that pre-query timestamp in statusCache.fetchedAt. When the query itself takes a meaningful amount of time (the exact large-index case this cache targets), the cached snapshot is immediately considered older than it really is, which shortens or can entirely negate the caller’s ttlMs window (e.g., query time >= TTL causes a miss on the very next call). Stamping fetchedAt after getStatus(db) returns keeps TTL behavior aligned with the actual data age.

Useful? React with 👍 / 👎.

Codex P2 on #4 round 2: the original fix captured `now = Date.now()`
before running the expensive `getStatus(db)` query and then wrote
that pre-query timestamp into `statusCache.fetchedAt`. The large-
index case that motivates this cache is exactly where the query
takes meaningful time, so the cached snapshot is aged by the
query's duration — a follow-up call with a short TTL can get a
cache miss on the very next invocation.

Fix: capture `Date.now()` a second time after `getStatus(db)`
returns and stamp with the post-query value. The cache check
(`Date.now() - fetchedAt < ttl`) is unchanged, so each call still
compares the snapshot's age against the caller's TTL.

Test: `fetchedAt is stamped after the query completes, not before`
uses a monotonic Date.now() spy that advances 100ms per call. After
the first `getStatus({ ttlMs: 10000 })` the stamp is strictly
greater than 0 (the spy's starting clock). An immediate follow-up
with `ttlMs: 200` sees an observed age of one tick (100ms) and
reuses the cached snapshot. A buggy pre-query stamp of 0 would
push the observed age above 200ms and cause an unintended fresh
query.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 2f384da.

P2 "Record fetchedAt after status query completes" — legit. Previously I captured Date.now() once at entry and reused it for both the cache check AND the post-query stamp. On a slow query (which is exactly the workload this cache targets) the stamp is the pre-query time, aging the cache by the query's duration. Fixed by calling Date.now() a second time after the query returns.

New test fetchedAt is stamped after the query completes, not before uses a monotonic Date.now() spy (100ms per call) to verify the stamp reflects post-query time, not entry time. An immediate follow-up call with a 200ms TTL observes an age of one tick and reuses the cache; with the pre-query-stamp bug the same scenario would miss the cache.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f384dad24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/status-cache.test.ts
let store: Store;

beforeAll(() => {
enableProductionMode();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset global production mode after status-cache tests

Calling enableProductionMode() here flips a module-global flag and this suite never restores it, so later tests in the same Vitest process can observe production mode unexpectedly. In particular, tests that rely on test-mode behavior (like the createStore()-without-path guard) become order-dependent and may fail when this file runs first or is run alongside them. Please reset with _resetProductionModeForTesting() in teardown (or otherwise isolate this state) to avoid cross-test contamination.

Useful? React with 👍 / 👎.

Codex P2 on #4 round 3: `enableProductionMode()` flips a module-
global flag. The new `test/status-cache.test.ts` suite enabled it in
`beforeAll` but never restored, leaking the flag into any later test
file running in the same vitest process (the repo sets
`fileParallelism: false`, so state carries over linearly). Tests
that rely on the test-mode `createStore()`-without-path guard would
become order-dependent.

Fix: call `_resetProductionModeForTesting()` in `afterAll` to
restore the pre-suite flag value. Matches the pattern in the
existing store/MCP suites.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in aa7e711: afterAll now calls _resetProductionModeForTesting() so the flag doesn't leak to later test files.

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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