Skip to content

Commit f944a54

Browse files
committed
fix(webapp,run-engine): bound the sweep count fields, and guard the org cache against a stale read
Two review findings, plus a correction of my own. The engine recorded whatever count keys a pass returned, and each key is a metric attribute, so an unrecognised one would mint a time series. Keys are now filtered against a fixed list at the point of record, matching the allowlist the webapp adapter already applies. A replica read that started before an invalidation could land after the primary read and put the superseded value back in the cache, holding it for the rest of the window. Each organisation now carries a generation that invalidation increments, and a load whose generation is no longer current discards its own result. The correction: an earlier commit made the sweep failure path rethrow, on the stated grounds that only the dead-letter path re-enqueues the next cron occurrence. That is wrong. The worker reschedules a cron job on the acknowledge path as well, so returning normally always continued the chain. Throwing only added a dead-letter entry for every transient failure. Both rethrows are reverted and the reason is recorded at each site, because the claim reads plausibly and would otherwise be reintroduced.
1 parent 835c88a commit f944a54

4 files changed

Lines changed: 35 additions & 8 deletions

File tree

apps/webapp/app/v3/snapshotStoreMode.server.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,28 +91,38 @@ function createOrgModeSource(): OrgModeSource {
9191
ttl: env.RUN_ENGINE_SNAPSHOT_STORE_ORG_MODE_CACHE_TTL_MS ?? DEFAULT_CACHE_TTL_MS,
9292
});
9393
const inFlight = new Set<string>();
94+
// A replica read that started before an invalidation can land after the primary read and put the
95+
// superseded value back. A per-organisation generation lets a stale load discard its own result.
96+
const generations = new Map<string, number>();
97+
const generationOf = (organizationId: string) => generations.get(organizationId) ?? 0;
9498

9599
return {
96100
get: (organizationId) => cache.get(organizationId),
97101
invalidate: (organizationId) => {
98102
// Drop first, so a resolve between now and the re-read falls back rather than serving a
99103
// value the write just replaced.
104+
const generation = generationOf(organizationId) + 1;
105+
generations.set(organizationId, generation);
100106
cache.delete(organizationId);
101-
void load(organizationId, prisma);
107+
void load(organizationId, prisma, generation);
102108
},
103109
refresh: (organizationId) => {
104110
if (inFlight.has(organizationId)) {
105111
return;
106112
}
107113
inFlight.add(organizationId);
108114

109-
void load(organizationId, $replica).finally(() => {
115+
void load(organizationId, $replica, generationOf(organizationId)).finally(() => {
110116
inFlight.delete(organizationId);
111117
});
112118
},
113119
};
114120

115-
function load(organizationId: string, client: typeof prisma | typeof $replica) {
121+
function load(
122+
organizationId: string,
123+
client: typeof prisma | typeof $replica,
124+
generation: number
125+
) {
116126
return client.organization
117127
.findFirst({ where: { id: organizationId }, select: { featureFlags: true } })
118128
.then((row) => {
@@ -121,6 +131,10 @@ function createOrgModeSource(): OrgModeSource {
121131
const raw = (row?.featureFlags as Record<string, unknown> | null | undefined)?.[
122132
FEATURE_FLAG.snapshotStoreOrgMode
123133
];
134+
// A newer invalidation happened while this read was in flight, so its answer is stale.
135+
if (generation < generationOf(organizationId)) {
136+
return;
137+
}
124138
cache.set(organizationId, cachedOrgModeFor(raw));
125139
})
126140
.catch((error) => {

apps/webapp/test/snapshotSweepRunner.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ redisTest("reports failed and still releases its own lock", async ({ redisOption
7777
lockTtlMs: 60_000,
7878
});
7979

80+
// Resolving is deliberate: the worker reschedules a cron job on acknowledge as well as on the
81+
// dead-letter path, so a failure needs no throw to keep the chain alive.
8082
expect((await runner(opts())).outcome).toBe("failed");
8183
expect(await client.get(LOCK_KEY)).toBeNull();
8284
} finally {

internal-packages/run-engine/src/engine/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
} from "./controlPlaneResolver.js";
9494
import { TtlSystem } from "./systems/ttlSystem.js";
9595
import { WaitpointSystem } from "./systems/waitpointSystem.js";
96+
import { SNAPSHOT_SWEEP_COUNT_FIELDS } from "./types.js";
9697
import type {
9798
EngineWorker,
9899
HeartbeatTimeouts,
@@ -2976,16 +2977,16 @@ export class RunEngine {
29762977
outcome = result.outcome;
29772978
counts = result.counts;
29782979
} catch (error) {
2980+
// Deliberately not rethrown. Both the acknowledge path and the dead-letter path reschedule a
2981+
// cron job, so returning here continues the chain; throwing would only add a dead-letter
2982+
// entry for every transient blip. The outcome metric is the signal.
29792983
this.logger.error("sweepSnapshotOrphans threw", { error });
2980-
// Rethrow after the finally records the outcome. Acknowledging here would skip the
2981-
// dead-letter path, and that path is what re-enqueues the next occurrence, so one transient
2982-
// failure would stop the sweep for good.
2983-
throw error;
29842984
} finally {
29852985
globalThis.clearTimeout(abortAt);
29862986
this.snapshotSweepPassCounter?.add(1, { outcome });
29872987
for (const [field, value] of Object.entries(counts ?? {})) {
2988-
if (typeof value === "number") {
2988+
// Each field is a metric attribute, so an unrecognised key would mint a time series.
2989+
if (typeof value === "number" && SNAPSHOT_SWEEP_COUNT_FIELDS.includes(field as never)) {
29892990
this.snapshotSweepCountsHistogram?.record(value, { field });
29902991
}
29912992
}

internal-packages/run-engine/src/engine/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ export type SnapshotSweepOutcome =
5858
| "unbound"
5959
| "aborted";
6060

61+
export const SNAPSHOT_SWEEP_COUNT_FIELDS = [
62+
"scanned",
63+
"expired",
64+
"deleted",
65+
"skipped",
66+
"pendingDeletion",
67+
"nodes",
68+
"partial",
69+
] as const;
70+
6171
export type SnapshotSweepCountField =
6272
| "scanned"
6373
| "expired"

0 commit comments

Comments
 (0)