Skip to content

ref(cache): centralize mutation invalidation at the HTTP layer (#792)#801

Merged
BYK merged 7 commits intomainfrom
cli-ux-centralize-cache-invalidation
Apr 22, 2026
Merged

ref(cache): centralize mutation invalidation at the HTTP layer (#792)#801
BYK merged 7 commits intomainfrom
cli-ux-centralize-cache-invalidation

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented Apr 21, 2026

Closes #792. Follow-up to #788.

Summary

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. Prefix computation lives in a new src/lib/cache-keys.ts module.

Layer 1: HTTP-layer hook (new)

invalidateAfterMutation fires after fetchWithRetry returns a 2xx non-GET response:

  • Hierarchy walk — for /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-level organizations/ root is deliberately not swept — sweeping it on every mutation would evict unrelated cross-org caches.
  • Cross-endpoint rules — tiny table for the 2 cases where a mutation affects a different URL tree:
    • 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/
  • Write-only endpoints skip invalidation — sourcemap chunk uploads and artifact-bundle assembly don't modify any cacheable state, so they're short-circuited at the top of 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 invalidates callback on buildCommand for 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 add
  • dashboard create
  • trial start

sentry api -X POST/PUT/DELETE ... also gets auto-invalidation — users of the raw escape hatch no longer need --fresh on follow-up reads.

Removed

  • api/issues.ts: invalidateIssueCaches, invalidateIssueDetailCaches, invalidateOrgIssueList + their call sites in updateIssueStatus / mergeIssues.
  • api/projects.ts: invalidateProjectCaches, invalidateOrgProjectCache + call sites in createProject / deleteProject.
  • api/dashboards.ts: inline invalidateCachedResponse in updateDashboard.

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 lint clean.

Follow-ups

One minor optimization deferred: when the hook computes multiple prefixes, Promise.all kicks off independent invalidateCachedResponsesMatching calls, each doing its own readdir + 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 future invalidateCachedResponsesMatchingAny(prefixes: string[]) API would eliminate the redundant I/O. Not done here to keep the PR scoped.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Cache

Other

Bug Fixes 🐛

Init

  • Send dirListing/fileCache/existingSentry via initialState by betegon in #796
  • Force process exit after wizard completes by betegon in #782

Other

Documentation 📚

  • Fix auth token precedence, update stale architecture tree, and documentation audit report by cursor in #783

Internal Changes 🔧

  • (init) Trim deprecated --features help entries by MathurAditya724 in #781
  • (issue) Skip redundant API lookups via project+issue-org caches by BYK in #794
  • Regenerate docs by github-actions[bot] in 58a84035

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-801/

Built to branch gh-pages at 2026-04-22 21:50 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Codecov Results 📊

138 passed | Total: 138 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

✅ Patch coverage is 99.03%. Project has 1948 uncovered lines.
✅ Project coverage is 95.25%. Comparing base (base) to head (head).

Files with missing lines (1)
File Patch % Lines
src/lib/cache-keys.ts 98.72% ⚠️ 1 Missing
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

Comment thread src/lib/cache-keys.ts
BYK added a commit that referenced this pull request Apr 21, 2026
…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.
@BYK BYK changed the title feat(cache): centralize mutation invalidation at the HTTP layer (#792) ref(cache): centralize mutation invalidation at the HTTP layer (#792) Apr 22, 2026
BYK added 2 commits April 22, 2026 14:02
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.
@BYK BYK force-pushed the cli-ux-centralize-cache-invalidation branch from 1ed028e to 1c42241 Compare April 22, 2026 14:08
BYK added 2 commits April 22, 2026 20:26
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.
Comment thread src/lib/api/dashboards.ts
Comment thread src/lib/api/projects.ts
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.
Comment thread src/lib/cache-keys.ts
Comment thread src/lib/sentry-client.ts
Comment thread src/lib/cache-keys.ts
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.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/lib/api/issues.ts
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.
@BYK BYK merged commit 60e6dbc into main Apr 22, 2026
26 checks passed
@BYK BYK deleted the cli-ux-centralize-cache-invalidation branch April 22, 2026 21:58
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.

Centralize mutation cache invalidation via a command-level helper

1 participant