Skip to content

fix(api): invalidate analytics cache on conversation create/delete#370

Merged
duyet merged 1 commit into
mainfrom
claude/w9-analytics-cache-invalidation
Jul 17, 2026
Merged

fix(api): invalidate analytics cache on conversation create/delete#370
duyet merged 1 commit into
mainfrom
claude/w9-analytics-cache-invalidation

Conversation

@duyet

@duyet duyet commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #352.

The dashboard analytics endpoint (GET /v1/projects/:id/analytics, packages/api/src/routes/analytics.ts) caches its aggregated result in AUTH_CACHE (KV) per project+range for 60-300s under the key analytics:public:{projectId}:{range}. Nothing ever busted that key on writes, so after a user deleted (or created) conversations, the dashboard could show stale — and in the delete case, too-high — counts for up to 5 minutes with no way to force a refresh.

Fix chosen: explicit invalidation on write

Three options were on the table: explicit invalidation, a shorter TTL, or a cache-key version bump. Explicit invalidation is the simplest fix that's actually correct:

  • A shorter TTL only shrinks the staleness window, it doesn't close it, and the endpoint already tiers TTL by range (60s/180s/300s) for a reason (heavier ranges are more expensive to recompute) — shrinking it further trades correctness for cost without fixing the bug.
  • A version-bump key (e.g. an epoch stored in KV/D1) adds a second piece of state to keep in sync and a read-time indirection, for no benefit over just deleting the stale key directly — over-engineering for what's a straightforward "delete on write" case.

Since the cache key is range-specific but a write doesn't know which range(s) a user has open, the fix busts all three known ranges (7d/30d/90d) for the project on write. This is centralized in a new packages/api/src/lib/analytics-cache.ts (analyticsCacheKey, invalidateAnalyticsCache) so the key format has one source of truth (routes/analytics.ts now uses it too) and is called from both createConversation and deleteConversation in services/conversations.ts — matching exactly the two write paths the issue names. Invalidation is fire-and-forget via executionCtx.waitUntil, consistent with how the route already populates the cache.

Scope notes (not fixed, flagged per review guidance)

  • Cloudflare Workers Cache (commit dcea999): verified this is not a factor here. That layer only sets Cache-Control: public on genuinely static, unauthenticated routes (/api health check, /llms.txt, /agents.md, /openapi.json); the analytics routes require a Clerk session or API key (Authorization/session cookie present), and per the Workers Cache docs/comments in that commit, requests carrying auth headers are never cached at that layer. So Workers Cache is not serving stale analytics — only the AUTH_CACHE KV layer this PR fixes was the culprit.
  • MCP write path: there's a second, separate conversation-service module (services/mcp-conversations.ts, used by the MCP tool store_conversation/delete) with its own createConversation/deleteConversation, kept intentionally separate from services/conversations.ts per an existing code comment (different response contracts, no webhook trigger). It writes to the same conversations/messages tables, so it has the same latent analytics-cache-staleness gap this issue described — just not the two functions the issue named. Left unaddressed here to keep this fix scoped to what's asked; worth a small follow-up if the MCP write path is a live traffic source for dashboard analytics viewers.

Changes

  • packages/api/src/lib/analytics-cache.ts (new): ANALYTICS_CACHE_RANGES, analyticsCacheKey(), invalidateAnalyticsCache().
  • packages/api/src/routes/analytics.ts: use analyticsCacheKey() instead of an inline template literal (DRY with the new invalidation helper).
  • packages/api/src/services/conversations.ts: createConversation and deleteConversation now accept the KV cache binding (and deleteConversation now also takes projectId + executionCtx, needed to invalidate) and call invalidateAnalyticsCache after a successful write.
  • packages/api/src/routes/conversations/crud.ts: thread c.env.AUTH_CACHE (and for delete, existing.projectId + c.executionCtx) through to the service calls.
  • packages/api/test/analytics.test.ts: removed the manual clearAnalyticsCache() test workaround (its own comment said "creating a conversation does not invalidate it" — that was the bug) and added a new test, "reflects deleted conversations immediately, with no stale cache (issue #352)", that populates the cache, deletes a conversation, and asserts the very next fetch (no manual cache clear) shows the decremented counts — this is the exact regression test for the reported bug. The two existing create-path tests were also tightened to assert no manual cache clear is needed anymore.

Test plan

  • bunx biome check packages/api/src/ — clean (one pre-existing, unrelated formatting diagnostic in lib/validation.ts predates this branch; confirmed via git show main:...validation.ts | biome check --stdin-file-path=validation.ts -, left untouched per surgical-changes convention)
  • bunx tsc --noEmit -p packages/api/tsconfig.json — no errors
  • cd packages/api && bunx vitest run354/354 tests passed (25 test files), including the new/updated analytics cache-invalidation tests
  • bun build intentionally not run per instructions

Co-Authored-By: Duyet Le me@duyet.net
Co-Authored-By: duyetbot bot@duyet.net

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @duyet, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@duyet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 124c201a-b33a-44d7-b017-fb555fa0089b

📥 Commits

Reviewing files that changed from the base of the PR and between 5e6abe4 and 8a929b6.

📒 Files selected for processing (5)
  • packages/api/src/lib/analytics-cache.ts
  • packages/api/src/routes/analytics.ts
  • packages/api/src/routes/conversations/crud.ts
  • packages/api/src/services/conversations.ts
  • packages/api/test/analytics.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/w9-analytics-cache-invalidation

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.

The dashboard analytics endpoint (GET /v1/projects/:id/analytics)
caches results in AUTH_CACHE for 60-300s under
analytics:public:{projectId}:{range}, but createConversation and
deleteConversation never busted it. Deleting conversations showed
stale, too-high counts for up to 5 minutes.

Explicit invalidation on write is simplest and correct here: the
range affected isn't known ahead of time, so bust all three cached
ranges (7d/30d/90d) for the project via a new
lib/analytics-cache.ts helper, fire-and-forget via executionCtx.

Closes #352

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
@duyet
duyet force-pushed the claude/w9-analytics-cache-invalidation branch from a3ba43a to 8a929b6 Compare July 17, 2026 02:12
duyet added a commit that referenced this pull request Jul 17, 2026
Plan 006 is superseded: main shipped a stronger fix for the same issue
(#289/#290) in aaf05ac (PR #355) after this plan's ce9a1fa baseline. The
Idempotency-Key claim is now embedded atomically in the same d1.batch() as
the mutation, rather than plan 006's separate claim-then-mutate step. Plan
007 was unaffected (#355 guarded a different code path) and ships as #382.

Plan 012's hard blocker dissolved: PR #380 implemented #344 and wrote
docs/webhooks.md with the timestamped signature scheme in the same PR, so
the docs were written against the correct format. Plan 012 is now UI-only.

Three plans went stale within hours of being written, each differently: 008
was wrong on arrival, 006 was overtaken by unrelated work, 012 by its own
dependency being solved better elsewhere. The drift check guards only the
first, and only when the executor's baseline is the pinned commit.

Also records that #370 and #380 both edit createConversation/deleteConversation
and will conflict, plus a suggested merge order.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>
@duyet
duyet merged commit 943f2d8 into main Jul 17, 2026
6 checks passed
@duyet
duyet deleted the claude/w9-analytics-cache-invalidation branch July 17, 2026 03:40
duyet added a commit that referenced this pull request Jul 17, 2026
* docs(plans): track the plans directory and add a status index

The 10 implementation plans were untracked, so they existed only on one
machine while every PR referenced them by path, and no worktree could see
them. Each plan also instructs its executor to "update this plan's row in
plans/README.md" — a file that never existed, which three separate executors
hit and correctly flagged rather than fabricating.

Adds plans/README.md: status table with issue links, the cross-plan
dependency graph, and a note that plan 008 is superseded (its target design
preserves the /watch deadlock in #360, so its own regression test would hang).

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

* docs(plans): add plans 011-014 for the held product/architecture issues

Plans for the four issues deliberately held out of the parallel fix batch
because they are product decisions rather than defined fixes:

- 011 dashboard UI for the 4 unexposed primitives (#284)
- 012 webhooks docs + dashboard UI (#285)
- 013 demo sandbox / pre-signup try-it (#287)
- 014 write atomicity via db.batch() (#342)

014 confirms D1 batch() semantics against Cloudflare docs: statements execute
sequentially as one all-or-nothing SQL transaction, but all statements must be
prepared before the call — no reading a result mid-batch and branching on it.
All three call sites are batchable as-is; updateConversationMessageCount is
already a blind relative SQL update rather than a read-then-write.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

* docs(plans): mark 006 superseded and 012 docs-done; record merge order

Plan 006 is superseded: main shipped a stronger fix for the same issue
(#289/#290) in aaf05ac (PR #355) after this plan's ce9a1fa baseline. The
Idempotency-Key claim is now embedded atomically in the same d1.batch() as
the mutation, rather than plan 006's separate claim-then-mutate step. Plan
007 was unaffected (#355 guarded a different code path) and ships as #382.

Plan 012's hard blocker dissolved: PR #380 implemented #344 and wrote
docs/webhooks.md with the timestamped signature scheme in the same PR, so
the docs were written against the correct format. Plan 012 is now UI-only.

Three plans went stale within hours of being written, each differently: 008
was wrong on arrival, 006 was overtaken by unrelated work, 012 by its own
dependency being solved better elsewhere. The drift check guards only the
first, and only when the executor's baseline is the pinned commit.

Also records that #370 and #380 both edit createConversation/deleteConversation
and will conflict, plus a suggested merge order.

Co-Authored-By: Duyet Le <me@duyet.net>
Co-Authored-By: duyetbot <bot@duyet.net>

---------

Co-authored-by: duyetbot <bot@duyet.net>
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.

[api] Public analytics KV cache never invalidated on writes (up to 5 min stale)

1 participant