Refactor refreshStatsCache: lazy on-demand caching with TTL#60
Conversation
- upsertAssetBatch: use RETURNING clause to collect rows directly from INSERT instead of separate post-upsert batch SELECT queries - Remove getAssetStats() and /asset-stats endpoint (no production consumers) - Fold totalAssets and blueprintCount into getStats() for reuse - Track blueprintCount incrementally in memory-index add/remove hooks Closes #57 tracks follow-up for refreshStatsCache timer refactoring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hing Replace the periodic 30s setInterval with a lazy getStatsCache() that computes stats on-demand with a 30s TTL. Decouple computeInheritanceDepth from stats caching into a debounced post-ingest trigger (5s debounce). Closes #57 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Review: Refactor refreshStatsCache — lazy on-demand caching with TTL
Overall this is a clean, well-motivated refactor. The RETURNING-based upsertAssetBatch optimization and the consolidation of getAssetStats() into getStats() are both good improvements. Passes all 55 existing tests, CI green. A few things to look at:
Bug: getStatsCache() can return null on first call if refreshStatsCache() fails
function getStatsCache() {
const now = Date.now();
if (statsCache && (now - statsCacheTime) < STATS_TTL_MS) {
return statsCache;
}
refreshStatsCache(); // catches errors internally, statsCache stays null
statsCacheTime = now;
return statsCache; // may be null
}refreshStatsCache() swallows errors and leaves statsCache = null. If the DB is unavailable on the very first /stats or /summary request, res.json(null) gets sent as a 200 — clients will see null instead of an error response.
The old setInterval had the same cold-start gap, but since the interval didn't call refreshStatsCache() at startup either, this isn't a regression — both paths have a window. Still worth hardening with a null guard in the endpoints:
app.get('/stats', (req, res) => {
try {
const data = getStatsCache();
if (!data) return res.status(503).json({ error: 'Stats not yet available' });
res.json(data);
} catch (err) { ... }
});Minor: app._depthDebounceTimer goes stale after the timer fires
Inside scheduleDepthRecompute():
depthDebounceTimer = setTimeout(() => {
depthDebounceTimer = null; // closure var cleared
...
}, DEPTH_DEBOUNCE_MS);
app._depthDebounceTimer = depthDebounceTimer; // set to the live timer IDAfter the callback runs, depthDebounceTimer (closure) is null, but app._depthDebounceTimer still holds the expired timer ID. clearTimeout on an already-fired timer is harmless, so the after() hook in the test works fine — but the exposed handle is misleading. Easy fix:
depthDebounceTimer = setTimeout(() => {
depthDebounceTimer = null;
app._depthDebounceTimer = null; // keep in sync
...
}, DEPTH_DEBOUNCE_MS);Test coverage gaps
The three unchecked items in the test plan are all observable behaviours that tests could cover without much effort:
- TTL cache hit: call
/statstwice within 30s, assertrefreshStatsCacheisn't invoked twice (e.g. spy / mockDate.nowto stay within TTL). This is the core invariant of the refactor. - Timer debounce: ingest a batch, assert
scheduleDepthRecomputeschedules exactly one timeout, not N. - No lingering timers: already covered by the
after()cleanup, but an explicit test thatapp._depthDebounceTimerisnullafter the timer fires would be worthwhile.
Not blocking, but given that the motivation for this PR is "no background work when idle", the TTL behaviour in particular deserves an automated assertion.
Positive notes
RETURNINGinupsertAssetBatchis a genuine win — eliminates the follow-up batch SELECT entirely.- Removing the
setIntervaleliminates idle CPU and makes the service easier to reason about during shutdown. scheduleDepthRecompute()decoupling from the stats cache is the right call — depth recomputation is write-path work; stats reads shouldn't trigger it.statsCacheTime = 0after trigram build to force the next request to recompute is correct.
There was a problem hiding this comment.
Requesting changes on two points before merging: the null-return path in getStatsCache() (sends a 200 with null body when the DB is unavailable at cold-start), and the stale app._depthDebounceTimer reference after the debounce fires. Both are small fixes. Happy to approve once those are addressed — the overall direction is good.
Handle the case where getStatsCache() returns null (e.g. DB unavailable at cold start) by returning 503 from /stats and /summary. Also clear app._depthDebounceTimer when the debounce callback fires to keep the exposed handle in sync with the closure variable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Both CHANGES_REQUESTED issues from the previous review are resolved in commit 548c38a:
- Null guard:
/statsand/summarynow return 503 whengetStatsCache()returns null — correct fix. - Stale timer ref:
app._depthDebounceTimer = nullis now cleared inside the debounce callback.
Minor nit (not blocking): statsCacheTime = now in getStatsCache() is set unconditionally even on failure. Not a correctness issue — the TTL check requires statsCache to be truthy, so null-cache calls always retry regardless. Just slightly misleading code.
Test coverage for the TTL hit behavior is still absent, but that was already called non-blocking. Could be a follow-up.
CI green, logic sound. Approving.
Summary
setIntervaltimer inrefreshStatsCache()with a lazygetStatsCache()function that computes stats on-demand with a 30s TTL — no background work when idlecomputeInheritanceDepth()from stats caching into a separate debounced post-ingest trigger (5s debounce after last ingest batch)/statsand/summaryendpoints to use the unified lazy cacheCloses #57
Test plan
npm test)/statsand/summaryreturn valid data on first call (lazy compute)/statscalls return cached data (TTL prevents recomputation)🤖 Generated with Claude Code