Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
} from "../../types/dashboard.js";
import { stringifyUnknown } from "../errors.js";
import { resolveOrgRegion } from "../region.js";
import { invalidateCachedResponse } from "../response-cache.js";

import {
apiRequestToRegion,
Expand Down Expand Up @@ -125,14 +124,6 @@ export async function updateDashboard(
method: "PUT",
body,
});

Comment thread
cursor[bot] marked this conversation as resolved.
// Invalidate cached GET for this dashboard so subsequent view commands
// return fresh data instead of the pre-mutation cached response.
const normalizedBase = regionUrl.endsWith("/")
? regionUrl.slice(0, -1)
: regionUrl;
await invalidateCachedResponse(`${normalizedBase}/api/0${path}`);

return data;
}

Expand Down
104 changes: 5 additions & 99 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,11 @@ import type { SentryIssue } from "../../types/index.js";
import { applyCustomHeaders } from "../custom-headers.js";
import { ApiError, ValidationError } from "../errors.js";
import { resolveOrgRegion } from "../region.js";
import { invalidateCachedResponsesMatching } from "../response-cache.js";
import { getApiBaseUrl } from "../sentry-client.js";

import {
API_MAX_PER_PAGE,
apiRequest,
apiRequestToRegion,
buildApiUrl,
getOrgSdkConfig,
MAX_PAGINATION_PAGES,
type PaginatedResponse,
Expand Down Expand Up @@ -566,22 +563,14 @@ export async function updateIssueStatus(
`/organizations/${encodeURIComponent(options.orgSlug)}/issues/${encodeURIComponent(issueId)}/`,
{ method: "PUT", body }
);
await invalidateIssueCaches(regionUrl, options.orgSlug, issueId);
return data;
}

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

/** Result of a successful issue-merge operation. */
Expand Down Expand Up @@ -625,15 +614,6 @@ export async function mergeIssues(
method: "PUT",
body: { merge: 1 },
});
// Flush detail caches for every affected ID (detail-only avoids
Comment thread
cursor[bot] marked this conversation as resolved.
// N+1 list scans) then sweep the org-wide list once.
const affectedIds = data.merge.children.toSpliced(0, 0, data.merge.parent);
await Promise.all(
affectedIds.map((id) =>
invalidateIssueDetailCaches(regionUrl, orgSlug, id)
)
);
await invalidateOrgIssueList(regionUrl, orgSlug);
return data.merge;
} catch (error) {
// The bulk-mutate endpoint returns 204 when no matching issues are
Expand Down Expand Up @@ -693,77 +673,3 @@ export async function getSharedIssue(

return (await response.json()) as { groupID: string };
}

/**
* Flush both the org-scoped and legacy detail endpoints for one issue,
* including all `collapse` query-param variants (`getIssueInOrg` caches
* responses under URLs like `.../issues/{id}/?collapse=stats&...` so
* exact-match invalidation would miss them). Does NOT sweep the
* org-wide list — callers must call {@link invalidateOrgIssueList}
* once per operation. Never throws.
*/
async function invalidateIssueDetailCaches(
regionUrl: string,
orgSlug: string,
issueId: string
): Promise<void> {
try {
await Promise.all([
invalidateCachedResponsesMatching(
buildApiUrl(regionUrl, "organizations", orgSlug, "issues", issueId)
),
// Legacy `/api/0/issues/{id}/` is stored under the non-region base
// (see `apiRequest` → `getApiBaseUrl`), NOT the org's region URL.
invalidateCachedResponsesMatching(
buildApiUrl(getApiBaseUrl(), "issues", issueId)
),
]);
} catch {
/* best-effort: mutation already succeeded upstream */
}
}

/**
* Flush detail + list caches for one issue. Use for single-issue
* mutations (resolve, unresolve); batch mutations should use the
* detail-only helper plus one final {@link invalidateOrgIssueList}.
*
* Minor redundancy: the org-scoped half of
* {@link invalidateIssueDetailCaches} is already a prefix of the
* {@link invalidateOrgIssueList} sweep. Accepted because the helpers
* are each also used solo elsewhere, and the extra directory walk is
* negligible.
*
* Never throws.
*/
async function invalidateIssueCaches(
regionUrl: string,
orgSlug: string,
issueId: string
): Promise<void> {
try {
await Promise.all([
invalidateIssueDetailCaches(regionUrl, orgSlug, issueId),
invalidateOrgIssueList(regionUrl, orgSlug),
]);
} catch {
/* best-effort: mutation already succeeded upstream */
}
}

/**
* Sweep every paginated variant of the org's issue-list endpoint.
* Never throws.
*/
async function invalidateOrgIssueList(
regionUrl: string,
orgSlug: string
): Promise<void> {
try {
await invalidateCachedResponsesMatching(
buildApiUrl(regionUrl, "organizations", orgSlug, "issues")
);
} catch {
/* best-effort: mutation already succeeded upstream */
}
}
50 changes: 0 additions & 50 deletions src/lib/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,13 @@ import { cacheProjectsForOrg } from "../db/project-cache.js";
import { getCachedOrganizations } from "../db/regions.js";
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
import { invalidateCachedResponsesMatching } from "../response-cache.js";
import { getApiBaseUrl } from "../sentry-client.js";
import { buildProjectUrl } from "../sentry-urls.js";
import { isAllDigits } from "../utils.js";

import {
API_MAX_PER_PAGE,
apiRequestToRegion,
buildApiUrl,
Comment thread
cursor[bot] marked this conversation as resolved.
getOrgSdkConfig,
MAX_PAGINATION_PAGES,
ORG_FANOUT_CONCURRENCY,
Expand Down Expand Up @@ -169,7 +166,6 @@ export async function createProject(
body,
});
const data = unwrapResult(result, "Failed to create project");
await invalidateOrgProjectCache(orgSlug);
return data as unknown as SentryProject;
}

Expand Down Expand Up @@ -219,52 +215,6 @@ export async function deleteProject(
},
});
unwrapResult(result, "Failed to delete project");
await invalidateProjectCaches(orgSlug, projectSlug);
}

/**
* Flush the project-detail GET and the org-wide project list so
* follow-up `project list` / `project view` reads don't see the
* deleted project. Never throws.
*
* Uses prefix-matching on the project detail URL rather than an exact
* match because `getProject()` appends `?collapse=organization`, and
* the response cache keys entries by the full URL (including query
* string). A prefix sweep catches the collapsed variant plus any
* future query-parameter additions.
*/
async function invalidateProjectCaches(
orgSlug: string,
projectSlug: string
): Promise<void> {
try {
const regionUrl = await resolveOrgRegion(orgSlug);
await Promise.all([
invalidateCachedResponsesMatching(
buildApiUrl(regionUrl, "projects", orgSlug, projectSlug)
),
invalidateCachedResponsesMatching(
buildApiUrl(regionUrl, "organizations", orgSlug, "projects")
),
]);
} catch {
/* best-effort: mutation already succeeded */
}
}

/**
* Sweep every paginated variant of the org's project-list endpoint.
* Used by `project create`. Never throws.
*/
async function invalidateOrgProjectCache(orgSlug: string): Promise<void> {
try {
const regionUrl = await resolveOrgRegion(orgSlug);
await invalidateCachedResponsesMatching(
buildApiUrl(regionUrl, "organizations", orgSlug, "projects")
);
} catch {
/* best-effort: mutation already succeeded */
}
}

/** Result of searching for projects by slug across all organizations. */
Expand Down
143 changes: 143 additions & 0 deletions src/lib/cache-keys.ts
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/`],
},
];
Comment thread
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}`);
}
Comment thread
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;
Comment thread
BYK marked this conversation as resolved.
for (let i = parts.length; i >= floor; i--) {
yield `${parts.slice(0, i).join("/")}/`;
}
}
Loading
Loading