-
-
Notifications
You must be signed in to change notification settings - Fork 9
ref(cache): centralize mutation invalidation at the HTTP layer (#792) #801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
067d2fb
feat(cache): centralize mutation invalidation at the HTTP layer (#792)
BYK 1c42241
refactor(cache): cap hierarchy walk at owner level + drop defensive c…
BYK 2971946
chore(cache): trim AI-generated comment slop
BYK ae3fa96
fix(cache): skip invalidation for write-only endpoints
BYK ea6d92a
chore(cache): remove now-unused invalidateCachedResponse + buildApiUrl
BYK e20fc49
fix(cache): cross-origin legacy-issue invalidation + defensive try/catch
BYK 036b0e7
fix(cache): mergeIssues manually invalidates per-ID legacy caches
BYK File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| /** | ||
| * Compute cache-invalidation prefixes for a mutation URL. | ||
| * | ||
| * The HTTP layer calls {@link computeInvalidationPrefixes} after every | ||
| * successful non-GET request and feeds the result into | ||
| * `invalidateCachedResponsesMatching`. Two rules apply: | ||
| * | ||
| * 1. **Hierarchy walk.** Sweep the URL's own path and every ancestor | ||
| * up to `/api/0/`. A mutation on | ||
| * `/organizations/{org}/releases/1.0.0/deploys/` sweeps itself, | ||
| * `.../releases/1.0.0/`, and `.../releases/` — which catches the | ||
| * detail, deploys-list, and releases-list GET caches in one pass. | ||
| * | ||
| * 2. **Cross-endpoint rules.** A small hardcoded table for mutations | ||
| * whose effects cross URL trees. For example, creating a project | ||
| * under a team hits `/organizations/{org}/teams/{team}/projects/` | ||
| * but invalidates the org project list at | ||
| * `/organizations/{org}/projects/`. The table is tiny today | ||
| * (2 rules) and only grows when a new cross-tree relationship | ||
| * appears in the API surface. | ||
| * | ||
| * Prefixes are identity-scoped at the sweep layer | ||
| * (`invalidateCachedResponsesMatching` checks `entry.identity`), so a | ||
| * slightly broader sweep is safe — it can only touch the current | ||
| * identity's entries. Query strings on the mutation URL are dropped | ||
| * from the prefix (a prefix sweep on the path naturally catches every | ||
| * query-param variant cached under that path). | ||
| */ | ||
|
|
||
| const API_V0_SEGMENT = "/api/0/"; | ||
|
|
||
| /** | ||
| * Paths where a mutation doesn't change any cacheable state — typically | ||
| * write-only endpoints like chunk uploads and bundle assembly. Invalidation | ||
| * on these would pointlessly sweep the org's cache on every chunk of a | ||
| * sourcemap upload. | ||
| */ | ||
| const SKIP_INVALIDATION_PATTERNS: readonly RegExp[] = [ | ||
| // Sourcemap chunk upload + bundle assemble. Both are write-only in the | ||
| // sense that no GET endpoint reads cacheable state under these paths. | ||
| /\/chunk-upload\//, | ||
| /\/artifactbundle\/assemble\//, | ||
| ]; | ||
|
|
||
| /** Rule for mutations whose effects cross URL trees. Patterns match the path relative to `/api/0/`. */ | ||
| type CrossEndpointRule = { | ||
| match: RegExp; | ||
| extra: (matchGroups: RegExpMatchArray) => string[]; | ||
| }; | ||
|
|
||
| const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [ | ||
| // `POST teams/{org}/{team}/projects/` (create project in team) also | ||
| // invalidates the org project list at `organizations/{org}/projects/`. | ||
| { | ||
| match: /^teams\/([^/]+)\/[^/]+\/projects\/?$/, | ||
| extra: ([, org]) => [`organizations/${org}/projects/`], | ||
| }, | ||
| // `DELETE projects/{org}/{project}/` also invalidates the org project list. | ||
| { | ||
| match: /^projects\/([^/]+)\/[^/]+\/?$/, | ||
| extra: ([, org]) => [`organizations/${org}/projects/`], | ||
| }, | ||
| ]; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Compute the full set of cache-invalidation prefixes for a mutation | ||
| * URL. | ||
| * | ||
| * @param fullUrl - Fully-qualified URL of the mutation (absolute, | ||
| * including base). Query string is ignored. | ||
| * @returns Array of full-URL prefixes (including base and | ||
| * `/api/0/`) ready to pass to | ||
| * `invalidateCachedResponsesMatching`. Returns `[]` if the URL is | ||
| * not under `/api/0/` (e.g. sourcemap chunk upload to an arbitrary | ||
| * endpoint) or can't be parsed. | ||
| */ | ||
| export function computeInvalidationPrefixes(fullUrl: string): string[] { | ||
| let parsed: URL; | ||
| try { | ||
| parsed = new URL(fullUrl); | ||
| } catch { | ||
| return []; | ||
| } | ||
|
|
||
| const apiIdx = parsed.pathname.indexOf(API_V0_SEGMENT); | ||
| if (apiIdx === -1) { | ||
| return []; | ||
| } | ||
|
|
||
| if (SKIP_INVALIDATION_PATTERNS.some((p) => p.test(parsed.pathname))) { | ||
| return []; | ||
| } | ||
|
|
||
| const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`; | ||
| const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length); | ||
| if (relPath === "") { | ||
| return []; | ||
| } | ||
|
|
||
| const prefixes = new Set<string>(); | ||
| for (const segments of ancestorSegments(relPath)) { | ||
| prefixes.add(`${base}${segments}`); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| for (const rule of CROSS_ENDPOINT_RULES) { | ||
| const match = relPath.match(rule.match); | ||
| if (match) { | ||
| for (const extra of rule.extra(match)) { | ||
| prefixes.add(`${base}${extra}`); | ||
| } | ||
| } | ||
| } | ||
| return [...prefixes]; | ||
| } | ||
|
|
||
| /** | ||
| * Yield every path-prefix sequence of `relPath` in descending length, | ||
| * stopping at the "resource owner" level (typically `{root}/{owner}/`, | ||
| * e.g. `organizations/acme/`). The bare `organizations/` root is | ||
| * deliberately omitted — sweeping it on every mutation would evict | ||
| * unrelated cross-org caches, since a mutation under one org cannot | ||
| * invalidate another org's state. | ||
| * | ||
| * `"organizations/acme/releases/1.0.0/deploys/"` yields: | ||
| * - `"organizations/acme/releases/1.0.0/deploys/"` | ||
| * - `"organizations/acme/releases/1.0.0/"` | ||
| * - `"organizations/acme/releases/"` | ||
| * - `"organizations/acme/"` | ||
| * | ||
| * Single-segment paths (e.g. `"organizations/"`) still yield themselves | ||
| * — a mutation at the resource-owner root is rare but the sweep should | ||
| * still clear its cache. | ||
| */ | ||
| function* ancestorSegments(relPath: string): Generator<string> { | ||
| const trimmed = relPath.endsWith("/") ? relPath.slice(0, -1) : relPath; | ||
| if (trimmed === "") { | ||
| return; | ||
| } | ||
| const parts = trimmed.split("/"); | ||
| const floor = parts.length > 2 ? 2 : 1; | ||
|
BYK marked this conversation as resolved.
|
||
| for (let i = parts.length; i >= floor; i--) { | ||
| yield `${parts.slice(0, i).join("/")}/`; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.