Skip to content

Commit 60e6dbc

Browse files
authored
ref(cache): centralize mutation invalidation at the HTTP layer (#792) (#801)
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.
1 parent 3d71685 commit 60e6dbc

12 files changed

Lines changed: 631 additions & 234 deletions

src/lib/api/dashboards.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import {
2727
} from "../../types/dashboard.js";
2828
import { stringifyUnknown } from "../errors.js";
2929
import { resolveOrgRegion } from "../region.js";
30-
import { invalidateCachedResponse } from "../response-cache.js";
3130

3231
import {
3332
apiRequestToRegion,
@@ -125,14 +124,6 @@ export async function updateDashboard(
125124
method: "PUT",
126125
body,
127126
});
128-
129-
// Invalidate cached GET for this dashboard so subsequent view commands
130-
// return fresh data instead of the pre-mutation cached response.
131-
const normalizedBase = regionUrl.endsWith("/")
132-
? regionUrl.slice(0, -1)
133-
: regionUrl;
134-
await invalidateCachedResponse(`${normalizedBase}/api/0${path}`);
135-
136127
return data;
137128
}
138129

src/lib/api/infrastructure.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -179,27 +179,6 @@ export async function getOrgSdkConfig(orgSlug: string) {
179179
return getSdkConfig(regionUrl);
180180
}
181181

182-
/**
183-
* Build a full Sentry API URL from a region base and path segments.
184-
*
185-
* Each segment is passed through `encodeURIComponent`, so callers can
186-
* pass user-supplied slugs directly without worrying about slashes,
187-
* spaces, or other reserved characters. The `/api/0/` prefix and the
188-
* required trailing slash are added automatically.
189-
*
190-
* @example
191-
* buildApiUrl(regionUrl, "organizations", orgSlug, "projects")
192-
* // → `${regionUrl.replace(/\/$/,"")}/api/0/organizations/<org>/projects/`
193-
*/
194-
export function buildApiUrl(regionUrl: string, ...segments: string[]): string {
195-
const base = regionUrl.endsWith("/") ? regionUrl.slice(0, -1) : regionUrl;
196-
if (segments.length === 0) {
197-
return `${base}/api/0/`;
198-
}
199-
const path = segments.map(encodeURIComponent).join("/");
200-
return `${base}/api/0/${path}/`;
201-
}
202-
203182
/**
204183
* Maximum number of pages to follow when auto-paginating.
205184
*

src/lib/api/issues.ts

Lines changed: 17 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import {
1919
API_MAX_PER_PAGE,
2020
apiRequest,
2121
apiRequestToRegion,
22-
buildApiUrl,
2322
getOrgSdkConfig,
2423
MAX_PAGINATION_PAGES,
2524
type PaginatedResponse,
2625
unwrapPaginatedResult,
2726
} from "./infrastructure.js";
2827

28+
const TRAILING_SLASH_RE = /\/$/;
29+
2930
/**
3031
* Sort options for issue listing, derived from the @sentry/api SDK types.
3132
* Uses the SDK type directly for compile-time safety against parameter drift.
@@ -566,22 +567,14 @@ export async function updateIssueStatus(
566567
`/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(issueId)}/`,
567568
{ method: "PUT", body }
568569
);
569-
await invalidateIssueCaches(regionUrl, options.orgSlug, issueId);
570570
return data;
571571
}
572572

573-
// Legacy global endpoint — works without org but not region-aware,
574-
// so we can only flush the legacy issue-detail URL. Region-scoped
575-
// lists age out via their TTL. Prefix-sweep (not exact-match)
576-
// because `getIssue` caches under `.../issues/{id}/?collapse=...`.
577-
const legacyData = await apiRequest<SentryIssue>(
578-
`/issues/${encodeURIComponent(issueId)}/`,
579-
{ method: "PUT", body }
580-
);
581-
await invalidateCachedResponsesMatching(
582-
buildApiUrl(getApiBaseUrl(), "issues", issueId)
583-
);
584-
return legacyData;
573+
// Legacy global endpoint — works without org but not region-aware.
574+
return apiRequest<SentryIssue>(`/issues/${encodeURIComponent(issueId)}/`, {
575+
method: "PUT",
576+
body,
577+
});
585578
}
586579

587580
/** Result of a successful issue-merge operation. */
@@ -625,15 +618,21 @@ export async function mergeIssues(
625618
method: "PUT",
626619
body: { merge: 1 },
627620
});
628-
// Flush detail caches for every affected ID (detail-only avoids
629-
// N+1 list scans) then sweep the org-wide list once.
621+
// HTTP-layer invalidation covers the region-scoped caches via the
622+
// prefix sweep on `/organizations/{org}/issues/`, but it can't see
623+
// the affected IDs (they're in query params, stripped from the
624+
// URL the hook sees). Manually clear each affected issue's legacy
625+
// cross-origin cache so subsequent `getIssue(id)` doesn't serve
626+
// stale data.
627+
const apiBase = getApiBaseUrl().replace(TRAILING_SLASH_RE, "");
630628
const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent);
631629
await Promise.all(
632630
affectedIds.map((id) =>
633-
invalidateIssueDetailCaches(regionUrl, orgSlug, id)
631+
invalidateCachedResponsesMatching(
632+
`${apiBase}/api/0/issues/${encodeURIComponent(id)}/`
633+
)
634634
)
635635
);
636-
await invalidateOrgIssueList(regionUrl, orgSlug);
637636
return data.merge;
638637
} catch (error) {
639638
// The bulk-mutate endpoint returns 204 when no matching issues are
@@ -693,77 +692,3 @@ export async function getSharedIssue(
693692

694693
return (await response.json()) as { groupID: string };
695694
}
696-
697-
/**
698-
* Flush both the org-scoped and legacy detail endpoints for one issue,
699-
* including all `collapse` query-param variants (`getIssueInOrg` caches
700-
* responses under URLs like `.../issues/{id}/?collapse=stats&...` so
701-
* exact-match invalidation would miss them). Does NOT sweep the
702-
* org-wide list — callers must call {@link invalidateOrgIssueList}
703-
* once per operation. Never throws.
704-
*/
705-
async function invalidateIssueDetailCaches(
706-
regionUrl: string,
707-
orgSlug: string,
708-
issueId: string
709-
): Promise<void> {
710-
try {
711-
await Promise.all([
712-
invalidateCachedResponsesMatching(
713-
buildApiUrl(regionUrl, "organizations", orgSlug, "issues", issueId)
714-
),
715-
// Legacy `/api/0/issues/{id}/` is stored under the non-region base
716-
// (see `apiRequest` → `getApiBaseUrl`), NOT the org's region URL.
717-
invalidateCachedResponsesMatching(
718-
buildApiUrl(getApiBaseUrl(), "issues", issueId)
719-
),
720-
]);
721-
} catch {
722-
/* best-effort: mutation already succeeded upstream */
723-
}
724-
}
725-
726-
/**
727-
* Flush detail + list caches for one issue. Use for single-issue
728-
* mutations (resolve, unresolve); batch mutations should use the
729-
* detail-only helper plus one final {@link invalidateOrgIssueList}.
730-
*
731-
* Minor redundancy: the org-scoped half of
732-
* {@link invalidateIssueDetailCaches} is already a prefix of the
733-
* {@link invalidateOrgIssueList} sweep. Accepted because the helpers
734-
* are each also used solo elsewhere, and the extra directory walk is
735-
* negligible.
736-
*
737-
* Never throws.
738-
*/
739-
async function invalidateIssueCaches(
740-
regionUrl: string,
741-
orgSlug: string,
742-
issueId: string
743-
): Promise<void> {
744-
try {
745-
await Promise.all([
746-
invalidateIssueDetailCaches(regionUrl, orgSlug, issueId),
747-
invalidateOrgIssueList(regionUrl, orgSlug),
748-
]);
749-
} catch {
750-
/* best-effort: mutation already succeeded upstream */
751-
}
752-
}
753-
754-
/**
755-
* Sweep every paginated variant of the org's issue-list endpoint.
756-
* Never throws.
757-
*/
758-
async function invalidateOrgIssueList(
759-
regionUrl: string,
760-
orgSlug: string
761-
): Promise<void> {
762-
try {
763-
await invalidateCachedResponsesMatching(
764-
buildApiUrl(regionUrl, "organizations", orgSlug, "issues")
765-
);
766-
} catch {
767-
/* best-effort: mutation already succeeded upstream */
768-
}
769-
}

src/lib/api/projects.ts

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,13 @@ import { cacheProjectsForOrg } from "../db/project-cache.js";
2525
import { getCachedOrganizations } from "../db/regions.js";
2626
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
2727
import { logger } from "../logger.js";
28-
import { resolveOrgRegion } from "../region.js";
29-
import { invalidateCachedResponsesMatching } from "../response-cache.js";
3028
import { getApiBaseUrl } from "../sentry-client.js";
3129
import { buildProjectUrl } from "../sentry-urls.js";
3230
import { isAllDigits } from "../utils.js";
3331

3432
import {
3533
API_MAX_PER_PAGE,
3634
apiRequestToRegion,
37-
buildApiUrl,
3835
getOrgSdkConfig,
3936
MAX_PAGINATION_PAGES,
4037
ORG_FANOUT_CONCURRENCY,
@@ -169,7 +166,6 @@ export async function createProject(
169166
body,
170167
});
171168
const data = unwrapResult(result, "Failed to create project");
172-
await invalidateOrgProjectCache(orgSlug);
173169
return data as unknown as SentryProject;
174170
}
175171

@@ -219,52 +215,6 @@ export async function deleteProject(
219215
},
220216
});
221217
unwrapResult(result, "Failed to delete project");
222-
await invalidateProjectCaches(orgSlug, projectSlug);
223-
}
224-
225-
/**
226-
* Flush the project-detail GET and the org-wide project list so
227-
* follow-up `project list` / `project view` reads don't see the
228-
* deleted project. Never throws.
229-
*
230-
* Uses prefix-matching on the project detail URL rather than an exact
231-
* match because `getProject()` appends `?collapse=organization`, and
232-
* the response cache keys entries by the full URL (including query
233-
* string). A prefix sweep catches the collapsed variant plus any
234-
* future query-parameter additions.
235-
*/
236-
async function invalidateProjectCaches(
237-
orgSlug: string,
238-
projectSlug: string
239-
): Promise<void> {
240-
try {
241-
const regionUrl = await resolveOrgRegion(orgSlug);
242-
await Promise.all([
243-
invalidateCachedResponsesMatching(
244-
buildApiUrl(regionUrl, "projects", orgSlug, projectSlug)
245-
),
246-
invalidateCachedResponsesMatching(
247-
buildApiUrl(regionUrl, "organizations", orgSlug, "projects")
248-
),
249-
]);
250-
} catch {
251-
/* best-effort: mutation already succeeded */
252-
}
253-
}
254-
255-
/**
256-
* Sweep every paginated variant of the org's project-list endpoint.
257-
* Used by `project create`. Never throws.
258-
*/
259-
async function invalidateOrgProjectCache(orgSlug: string): Promise<void> {
260-
try {
261-
const regionUrl = await resolveOrgRegion(orgSlug);
262-
await invalidateCachedResponsesMatching(
263-
buildApiUrl(regionUrl, "organizations", orgSlug, "projects")
264-
);
265-
} catch {
266-
/* best-effort: mutation already succeeded */
267-
}
268218
}
269219

270220
/** Result of searching for projects by slug across all organizations. */

0 commit comments

Comments
 (0)