ref(cache): centralize mutation invalidation at the HTTP layer (#792)#801
ref(cache): centralize mutation invalidation at the HTTP layer (#792)#801
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨Cache
Other
Bug Fixes 🐛Init
Other
Documentation 📚
Internal Changes 🔧
🤖 This preview updates automatically when you update the PR. |
|
Codecov Results 📊✅ 138 passed | Total: 138 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
✨ No test changes detected All tests are passing successfully. ✅ Patch coverage is 99.03%. Project has 1948 uncovered lines. Files with missing lines (1)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
+ Coverage 95.18% 95.25% +0.07%
==========================================
Files 283 284 +1
Lines 40960 40971 +11
Branches 0 0 —
==========================================
+ Hits 38985 39023 +38
- Misses 1975 1948 -27
- Partials 0 0 —Generated by Codecov Action |
…atch Two review-feedback fixes on #801: 1. **Cap the hierarchy walk.** Before: every mutation swept the bare top-level prefix (`/api/0/organizations/`, `/api/0/teams/`, etc.), which would evict cross-org caches on any single-org mutation. Now the walk stops at 2 segments — owner level (`organizations/{org}/`). Paths that are already ≤ 2 segments still walk to their root (a mutation targeting the root itself should clear its cache). Added a new test for the two-segment case. 2. **Drop the defensive try/catch in `invalidateAfterMutation`.** `invalidateCachedResponsesMatching` is already contractually no-throw; wrapping its `Promise.all` in another try/catch was dead code. Matches BYK's brevity preference in the lore. No behavior change beyond the walk cap. Tests updated accordingly.
Follow-up to #788. The per-site `invalidate*` helpers in `api/issues.ts`, `api/projects.ts`, and `api/dashboards.ts` are replaced by a single post-mutation hook in `authenticatedFetch` that auto-invalidates the cache for every successful non-GET. `invalidateAfterMutation` fires in `sentry-client.ts` after `fetchWithRetry` returns a 2xx non-GET response. Prefix computation is delegated to a new `src/lib/cache-keys.ts` module: - **Hierarchy walk.** For a mutation on `/api/0/organizations/{org}/releases/1.0.0/deploys/`, sweep the URL path plus every ancestor: `releases/1.0.0/`, `releases/`, `organizations/{org}/`, `organizations/`. Catches the corresponding GET caches in a single pass. - **Cross-endpoint rules.** Tiny table for the 2 cases where a mutation affects a different URL tree: - `POST /teams/{org}/{team}/projects/` invalidates `/organizations/{org}/projects/` - `DELETE /projects/{org}/{project}/` invalidates `/organizations/{org}/projects/` The hook is awaited so a subsequent read in the same command sees fresh data. Identity-gated via the existing sweep primitive, so a mutation by one account can't evict another account's cache. `classifyUrl === "no-cache"` paths (autofix/root-cause) skip naturally because reads to those URLs aren't cached either. Issue #792 proposed an optional `invalidates` callback on `buildCommand` for cross-endpoint fan-outs. Turned out the 2 hardcoded rules above cover every current case; deferring the callback API to when a use case actually emerges. Every mutation in `src/lib/api/` is now covered for free, including mutations that had no invalidation before (`releases`, `teams`, `dashboards` create, `trials`). `sentry api -X POST/PUT/DELETE ...` also gets auto-invalidation — users of the raw escape hatch no longer need `--fresh` on follow-up reads. - `api/issues.ts`: `invalidateIssueCaches`, `invalidateIssueDetailCaches`, `invalidateOrgIssueList` plus their call sites in `updateIssueStatus` and `mergeIssues`. - `api/projects.ts`: `invalidateProjectCaches`, `invalidateOrgProjectCache` plus call sites in `createProject` and `deleteProject`. - `api/dashboards.ts`: inline `invalidateCachedResponse` in `updateDashboard`. Net: -156 lines source + new `cache-keys.ts` (~130 lines). - `test/lib/cache-keys.test.ts` — 13 tests covering the hierarchy walk, cross-endpoint rules, query-string stripping, unparseable URLs, self-hosted bases, and dedup. - `test/lib/sentry-client.invalidation.test.ts` — 5 integration tests: successful mutation clears self + list, failed mutation leaves cache alone, GET doesn't invalidate, cross-endpoint rule fires, identity isolation holds. Full unit suite: 5545 passing. Closes #792.
…atch Two review-feedback fixes on #801: 1. **Cap the hierarchy walk.** Before: every mutation swept the bare top-level prefix (`/api/0/organizations/`, `/api/0/teams/`, etc.), which would evict cross-org caches on any single-org mutation. Now the walk stops at 2 segments — owner level (`organizations/{org}/`). Paths that are already ≤ 2 segments still walk to their root (a mutation targeting the root itself should clear its cache). Added a new test for the two-segment case. 2. **Drop the defensive try/catch in `invalidateAfterMutation`.** `invalidateCachedResponsesMatching` is already contractually no-throw; wrapping its `Promise.all` in another try/catch was dead code. Matches BYK's brevity preference in the lore. No behavior change beyond the walk cap. Tests updated accordingly.
1ed028e to
1c42241
Compare
Tighten over-explained JSDoc and inline commentary in the PR's new
files:
- `cache-keys.ts`: drop the misleading "anchored regex" comment on
`API_V0_SEGMENT` (it's a literal string + indexOf, not a regex),
collapse verbose "Rule 1/Rule 2" section markers that restate the
function names, trim per-step inline comments on variable
assignments that just restate the code, remove redundant
JSDoc-duplicating inline comment inside `ancestorSegments`.
- `sentry-client.ts`: shrink `invalidateAfterMutation` JSDoc from
~14 lines to 4 — matches the neighboring `cacheResponse` style
(3-line doc).
- `sentry-client.invalidation.test.ts`: hoist the dynamic
`await import("../../src/lib/sentry-client.js")` to a top-level
static import (the "reset module state between tests" comment was
wrong — dynamic import doesn't reset module-level caches).
`runAuthenticatedFetch` is now synchronous. Drop unused
`headers` parameter on `makeResponse`. Trim navigational
comments that restate the code.
- `cache-keys.test.ts`: trim purely-restating comments.
Net: -95 lines. No behavior change.
Review-feedback fix on #801: The hierarchy walk broadcasts invalidation up the URL tree, which is correct for mutations that modify cacheable state — but sourcemap chunk uploads and artifact-bundle assembly are write-only (no GET reads anything under those paths). Without this carve-out, a single `sentry sourcemap upload` run would sweep the org's cached state hundreds of times (one sweep per chunk POST × concurrency), nuking the user's cache for an operation that changed nothing observable. `SKIP_INVALIDATION_PATTERNS` short-circuits `computeInvalidationPrefixes` for these paths. Test added. Also clarified the fetch-mockability comment in sentry-client.invalidation.test.ts — the mock works because `fetchWithTimeout` calls `fetch(...)` as a bare global reference, not because `resetAuthenticatedFetch` does anything fetch-related.
Cursor-bot findings on #801: both helpers became dead code when this PR removed their last callers (the per-site invalidation helpers in api/issues.ts, api/projects.ts, api/dashboards.ts). - `invalidateCachedResponse` (exact-match variant): replaced by `invalidateCachedResponsesMatching` everywhere. The exact-match variant silently misses URLs with query strings (getIssue caches under `?collapse=...`), so callers were already migrating to the prefix-sweep form anyway. - `buildApiUrl`: the HTTP-layer hook works on full URLs parsed by `new URL()`, not template-string construction. If a future caller needs safe Sentry-API URL construction from slugs, resurrect it via git log — the tests document the contract. Net: -60 lines.
Two Cursor-bot findings on ea6d92a: 1. **Cross-origin legacy issue cache not invalidated (LOW)**. The old per-site `invalidateIssueDetailCaches` explicitly cleared both the org-scoped URL at the region (e.g. `us.sentry.io`) and the legacy `/issues/{id}/` URL under the control-silo base (e.g. `sentry.io`). On SaaS those are different origins. My HTTP-layer hook only saw the mutation URL's origin, so `updateIssueStatus` / `mergeIssues` would leave stale legacy `getIssue()` cache under a different origin. Fix: extend the cross-endpoint rule table to support `extraAbsolute` (full-URL outputs, not just relative paths). Added a rule for `organizations/{org}/issues/{id}/` that emits `{apiBaseUrl}/api/0/issues/{id}/` as an additional prefix. `computeInvalidationPrefixes` now takes `apiBaseUrl` as a parameter; `invalidateAfterMutation` passes `getApiBaseUrl()`. Test added. 2. **Missing try/catch around `invalidateAfterMutation` (MEDIUM)**. Re-added the defensive wrapper. Today's code provably can't throw (all inputs are safe, both helpers have internal try/catch), but a post-mutation housekeeping error converting a successful mutation into a user-visible failure is exactly the thing we want belt-and-suspenders protection against. Defense-in-depth. Also fixed two lint errors introduced by the cross-endpoint rule expansion: - Hoist inline regex `/\/$/` to `TRAILING_SLASH_RE` at module scope. - Extract cross-endpoint rule application to `applyCrossEndpointRules` generator to keep `computeInvalidationPrefixes` under Biome's cognitive-complexity ceiling.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e20fc49. Configure here.
Cursor-bot finding on e20fc49: the HTTP-layer hook only sees the mutation URL, which for bulk merge is `organizations/{org}/issues/` (affected IDs live in query params, stripped by `URL.pathname`). The cross-endpoint rule that catches the legacy cross-origin URL needs a specific issue ID segment, so merge doesn't fire it. Result: subsequent `getIssue(id)` calls could serve stale data from the control-silo cache. Fix: after a successful merge, explicitly sweep each affected ID's legacy `/issues/{id}/` URL under `getApiBaseUrl()`. The affected IDs are only known from the response body, so this can't live in `cache-keys.ts` (URL-only logic) — it's a genuine command-layer invalidation case. Matches the old `invalidateIssueDetailCaches` behavior for per-ID legacy invalidation. Added a regression test: seed legacy cache entries for parent + children, run mergeIssues, verify all three are cleared.

Closes #792. Follow-up to #788.
Summary
The per-site
invalidate*helpers inapi/issues.ts,api/projects.ts, andapi/dashboards.tsare replaced by a single post-mutation hook inauthenticatedFetchthat auto-invalidates the cache for every successful non-GET. Prefix computation lives in a newsrc/lib/cache-keys.tsmodule.Layer 1: HTTP-layer hook (new)
invalidateAfterMutationfires afterfetchWithRetryreturns a 2xx non-GET response:/api/0/organizations/{org}/releases/1.0.0/deploys/, sweeps the URL path plus every ancestor down to the owner level (releases/1.0.0/,releases/,organizations/{org}/). The bare top-levelorganizations/root is deliberately not swept — sweeping it on every mutation would evict unrelated cross-org caches.POST /api/0/teams/{org}/{team}/projects/invalidates/api/0/organizations/{org}/projects/DELETE /api/0/projects/{org}/{project}/invalidates/api/0/organizations/{org}/projects/computeInvalidationPrefixes. Without this, every chunk of a sourcemap upload (hundreds of POSTs at N-way concurrency) would sweep the org's cache.The hook is awaited before returning the response, so a subsequent read in the same command sees fresh data. Identity-gated via the existing sweep primitive (no cross-account eviction).
Layer 2: Command-level override (deferred)
Issue #792 proposed an optional
invalidatescallback onbuildCommandfor cross-endpoint fan-outs the HTTP layer can't know about. Turned out the 2 hardcoded rules above cover every current case — deferred the callback API to when a real use case emerges.Coverage
Every mutation in
src/lib/api/is now covered for free, including ones that had no invalidation before:release create/update/delete,release deploy,set-commits*team create,member adddashboard createtrial startsentry api -X POST/PUT/DELETE ...also gets auto-invalidation — users of the raw escape hatch no longer need--freshon follow-up reads.Removed
api/issues.ts:invalidateIssueCaches,invalidateIssueDetailCaches,invalidateOrgIssueList+ their call sites inupdateIssueStatus/mergeIssues.api/projects.ts:invalidateProjectCaches,invalidateOrgProjectCache+ call sites increateProject/deleteProject.api/dashboards.ts: inlineinvalidateCachedResponseinupdateDashboard.Tests
test/lib/cache-keys.test.ts— 15 tests: hierarchy walk (with the owner-level cap), cross-endpoint rules, write-only skip, query-string stripping, unparseable URLs, self-hosted bases, dedup.test/lib/sentry-client.invalidation.test.ts— 5 integration tests: successful mutation clears self + list, failed mutation leaves cache alone, GET doesn't invalidate, cross-endpoint rule fires, identity isolation holds.Full unit suite: 5706 passing.
bun run typecheck,bun run lintclean.Follow-ups
One minor optimization deferred: when the hook computes multiple prefixes,
Promise.allkicks off independentinvalidateCachedResponsesMatchingcalls, each doing its ownreaddir+ per-file parse. For typical cache sizes (few hundred entries) this is negligible, but the natural shape is "read the dir once, match every entry against ALL prefixes, unlink if any match." A futureinvalidateCachedResponsesMatchingAny(prefixes: string[])API would eliminate the redundant I/O. Not done here to keep the PR scoped.