Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,
});

// 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
// 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,
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
159 changes: 159 additions & 0 deletions src/lib/cache-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* 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).
*/

/** Regex capturing the `/api/0/` boundary. Anchored so it matches only the canonical API prefix. */
const API_V0_SEGMENT = "/api/0/";

/**
* Cross-endpoint invalidation rules.
*
* Each rule is a pattern the mutation URL path must match, plus a
* function that returns additional prefixes to sweep. Patterns match
* against the *path*, not the full URL — so the prefix returned is
* prepended with the request's base later.
*
* Keep this table small. The hierarchy walk handles most cases; add a
* rule here only when the API's cross-tree relationships force it.
*/
type CrossEndpointRule = {
/** Matches the path relative to `/api/0/` (no leading slash). */
match: RegExp;
/** Returns additional path prefixes (relative to `/api/0/`) to sweep. */
extra: (matchGroups: RegExpMatchArray) => string[];
};

const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [
{
// POST /api/0/teams/{org}/{team}/projects/ (create a project in a team)
// invalidates the org project list at
// /api/0/organizations/{org}/projects/ which lives under a
// different URL tree.
match: /^teams\/([^/]+)\/[^/]+\/projects\/?$/,
extra: ([, org]) => [`organizations/${org}/projects/`],
},
{
// DELETE /api/0/projects/{org}/{project}/ (delete a project)
// invalidates the org project list at
// /api/0/organizations/{org}/projects/ (different URL tree).
match: /^projects\/([^/]+)\/[^/]+\/?$/,
extra: ([, org]) => [`organizations/${org}/projects/`],
},
];

/**
* 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 [];
}

// `base` includes origin + path up through and including `/api/0/`.
const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`;
// Path below `/api/0/`, leading slash trimmed, trailing slash kept
// so it matches against rules that anchor on `/?$`.
const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length);

// No relative path means the mutation hit `/api/0/` itself; nothing to sweep.
if (relPath === "") {
return [];
}

const prefixes = new Set<string>();

// Rule 1: hierarchy walk. Sweep the URL's own path plus every
// ancestor with at least one segment.
for (const segments of ancestorSegments(relPath)) {
prefixes.add(`${base}${segments}`);
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Rule 2: cross-endpoint table.
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("/");
// Stop at 2 segments when the path has more — a mutation under
// `organizations/acme/.../...` shouldn't sweep the bare
// `organizations/` root (would evict other orgs' caches). Paths
// with ≤ 2 segments (e.g. the `organizations/` root itself, or
// `teams/acme/`) still walk all the way down.
const floor = parts.length > 2 ? 2 : 1;
for (let i = parts.length; i >= floor; i--) {
yield `${parts.slice(0, i).join("/")}/`;
}
}
Loading
Loading