Skip to content

Commit 1ed028e

Browse files
committed
refactor(cache): cap hierarchy walk at owner level + drop defensive catch
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.
1 parent d51ba3a commit 1ed028e

3 files changed

Lines changed: 46 additions & 33 deletions

File tree

src/lib/cache-keys.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,35 @@ export function computeInvalidationPrefixes(fullUrl: string): string[] {
125125

126126
/**
127127
* Yield every path-prefix sequence of `relPath` in descending length,
128-
* always ending with a trailing slash.
128+
* stopping at the "resource owner" level (typically `{root}/{owner}/`,
129+
* e.g. `organizations/acme/`). The bare `organizations/` root is
130+
* deliberately omitted — sweeping it on every mutation would evict
131+
* unrelated cross-org caches, since a mutation under one org cannot
132+
* invalidate another org's state.
129133
*
130134
* `"organizations/acme/releases/1.0.0/deploys/"` yields:
131135
* - `"organizations/acme/releases/1.0.0/deploys/"`
132136
* - `"organizations/acme/releases/1.0.0/"`
133137
* - `"organizations/acme/releases/"`
134138
* - `"organizations/acme/"`
135-
* - `"organizations/"`
139+
*
140+
* Single-segment paths (e.g. `"organizations/"`) still yield themselves
141+
* — a mutation at the resource-owner root is rare but the sweep should
142+
* still clear its cache.
136143
*/
137144
function* ancestorSegments(relPath: string): Generator<string> {
138145
const trimmed = relPath.endsWith("/") ? relPath.slice(0, -1) : relPath;
139146
if (trimmed === "") {
140147
return;
141148
}
142149
const parts = trimmed.split("/");
143-
for (let i = parts.length; i > 0; i--) {
150+
// Stop at 2 segments when the path has more — a mutation under
151+
// `organizations/acme/.../...` shouldn't sweep the bare
152+
// `organizations/` root (would evict other orgs' caches). Paths
153+
// with ≤ 2 segments (e.g. the `organizations/` root itself, or
154+
// `teams/acme/`) still walk all the way down.
155+
const floor = parts.length > 2 ? 2 : 1;
156+
for (let i = parts.length; i >= floor; i--) {
144157
yield `${parts.slice(0, i).join("/")}/`;
145158
}
146159
}

src/lib/sentry-client.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -293,21 +293,17 @@ function cacheResponse(
293293
/**
294294
* Auto-invalidate cache entries that a successful non-GET mutation
295295
* made stale. Awaited before returning the response so a subsequent
296-
* read in the same command sees fresh data (the whole point of
297-
* post-mutation invalidation).
296+
* read in the same command sees fresh data.
298297
*
299298
* Prefix computation lives in {@link computeInvalidationPrefixes}
300299
* (hierarchy walk + cross-endpoint rules). Each prefix runs through
301-
* `invalidateCachedResponsesMatching`, which is identity-gated so
302-
* a mutation by one account can't evict another account's cache.
300+
* `invalidateCachedResponsesMatching` — a no-throw helper that's
301+
* already identity-gated so a mutation by one account can't evict
302+
* another account's cache.
303303
*
304304
* GETs skip invalidation entirely; they go through the cache-write
305305
* path above. 4xx/5xx non-GETs skip too — a rejected mutation didn't
306306
* change server state so its cache is still accurate.
307-
*
308-
* Never throws: the helpers we call are already best-effort, but we
309-
* wrap defensively because a housekeeping error must never surface
310-
* as a user-visible failure after a successful mutation.
311307
*/
312308
async function invalidateAfterMutation(
313309
method: string,
@@ -318,16 +314,9 @@ async function invalidateAfterMutation(
318314
return;
319315
}
320316
const prefixes = computeInvalidationPrefixes(fullUrl);
321-
if (prefixes.length === 0) {
322-
return;
323-
}
324-
try {
325-
await Promise.all(
326-
prefixes.map((prefix) => invalidateCachedResponsesMatching(prefix))
327-
);
328-
} catch {
329-
/* best-effort: mutation already succeeded upstream */
330-
}
317+
await Promise.all(
318+
prefixes.map((prefix) => invalidateCachedResponsesMatching(prefix))
319+
);
331320
}
332321

333322
/** Build a `{ authorization }` header map from a bearer token, or `{}` if absent. */
@@ -363,8 +352,6 @@ async function fetchWithRetry(
363352
authHeaders(getAuthToken()),
364353
result.response
365354
);
366-
// Awaited so a subsequent read in the same command sees fresh
367-
// data. Never throws — own contract enforced by the helper.
368355
await invalidateAfterMutation(method, fullUrl, result.response);
369356
return result.response;
370357
}

test/lib/cache-keys.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,47 @@ import { computeInvalidationPrefixes } from "../../src/lib/cache-keys.js";
88
const BASE = "https://us.sentry.io/api/0/";
99

1010
describe("computeInvalidationPrefixes — hierarchy walk", () => {
11-
test("detail URL yields self + org list ancestor", () => {
11+
test("detail URL yields self + ancestors down to owner", () => {
1212
const prefixes = computeInvalidationPrefixes(
1313
`${BASE}organizations/acme/issues/12345/`
1414
);
1515
expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`);
1616
expect(prefixes).toContain(`${BASE}organizations/acme/issues/`);
1717
expect(prefixes).toContain(`${BASE}organizations/acme/`);
18-
expect(prefixes).toContain(`${BASE}organizations/`);
18+
// The bare `organizations/` root is deliberately NOT swept —
19+
// it would evict other orgs' caches.
20+
expect(prefixes).not.toContain(`${BASE}organizations/`);
1921
});
2022

21-
test("deeply nested path yields every ancestor", () => {
23+
test("deeply nested path yields every ancestor down to owner", () => {
2224
const prefixes = computeInvalidationPrefixes(
2325
`${BASE}organizations/acme/releases/1.0.0/deploys/`
2426
);
25-
// Five segments → five ancestor prefixes.
2627
expect(prefixes).toContain(
2728
`${BASE}organizations/acme/releases/1.0.0/deploys/`
2829
);
2930
expect(prefixes).toContain(`${BASE}organizations/acme/releases/1.0.0/`);
3031
expect(prefixes).toContain(`${BASE}organizations/acme/releases/`);
3132
expect(prefixes).toContain(`${BASE}organizations/acme/`);
32-
expect(prefixes).toContain(`${BASE}organizations/`);
33+
// Stop at the owner level, don't sweep bare `organizations/`.
34+
expect(prefixes).not.toContain(`${BASE}organizations/`);
3335
});
3436

3537
test("single-segment path yields only the segment itself", () => {
38+
// Paths with ≤ 2 segments walk all the way down — a mutation
39+
// targeting the root itself should still clear its own cache.
3640
const prefixes = computeInvalidationPrefixes(`${BASE}organizations/`);
3741
expect(prefixes).toEqual([`${BASE}organizations/`]);
3842
});
3943

44+
test("two-segment path walks to the top (owner-level mutation)", () => {
45+
// `organizations/acme/` is the resource owner; the floor kicks in
46+
// at length > 2, so this still yields both prefixes.
47+
const prefixes = computeInvalidationPrefixes(`${BASE}organizations/acme/`);
48+
expect(prefixes).toContain(`${BASE}organizations/acme/`);
49+
expect(prefixes).toContain(`${BASE}organizations/`);
50+
});
51+
4052
test("query string is stripped from the prefix set", () => {
4153
// Prefix-sweep against the path catches every cached query variant,
4254
// so the query itself is irrelevant for computing prefixes.
@@ -64,22 +76,23 @@ describe("computeInvalidationPrefixes — cross-endpoint rules", () => {
6476
`${BASE}teams/acme/backend/projects/`
6577
);
6678
expect(prefixes).toContain(`${BASE}organizations/acme/projects/`);
67-
// Plus the hierarchy walk:
79+
// Plus the hierarchy walk (capped at the owner level):
6880
expect(prefixes).toContain(`${BASE}teams/acme/backend/projects/`);
6981
expect(prefixes).toContain(`${BASE}teams/acme/backend/`);
7082
expect(prefixes).toContain(`${BASE}teams/acme/`);
71-
expect(prefixes).toContain(`${BASE}teams/`);
83+
expect(prefixes).not.toContain(`${BASE}teams/`);
7284
});
7385

7486
test("DELETE /projects/{org}/{project}/ invalidates org project list", () => {
7587
const prefixes = computeInvalidationPrefixes(
7688
`${BASE}projects/acme/frontend/`
7789
);
7890
expect(prefixes).toContain(`${BASE}organizations/acme/projects/`);
79-
// Plus the hierarchy walk from the mutation URL:
8091
expect(prefixes).toContain(`${BASE}projects/acme/frontend/`);
8192
expect(prefixes).toContain(`${BASE}projects/acme/`);
82-
expect(prefixes).toContain(`${BASE}projects/`);
93+
// Two segments: floor allows the top (projects/). Reasonable —
94+
// a DELETE on an owner-level resource does clear that root.
95+
expect(prefixes).not.toContain(`${BASE}projects/`);
8396
});
8497

8598
test("unrelated paths get no cross-endpoint sweep", () => {
@@ -133,7 +146,7 @@ describe("computeInvalidationPrefixes — edge cases", () => {
133146
"https://sentry.example.com/api/0/organizations/acme/issues/12345/"
134147
);
135148
expect(prefixes).toContain(
136-
"https://sentry.example.com/api/0/organizations/"
149+
"https://sentry.example.com/api/0/organizations/acme/"
137150
);
138151
});
139152
});

0 commit comments

Comments
 (0)