Skip to content

Commit ab27e4e

Browse files
committed
fix(webapp,run-engine): address review findings on the snapshot store wiring
The sweep handler caught its own failure, logged it and returned, which acknowledges the job. The dead-letter path is what re-enqueues the next occurrence, and only a throw reaches it, so one transient failure would have stopped the sweep for good. It now rethrows after the outcome is recorded. The save guard only covered the global dial, so an organisation could be set past off with no connection configured and silently do nothing, which is the exact silence the guard exists to prevent. It now covers both keys and runs on the organisation routes as well. A companion check refuses the per-organisation key on a global save, where nothing reads it. Invalidation read the replica immediately after a primary write, so replica lag could re-cache the value the write had just replaced and hold it for the whole cache window. That is worse than not invalidating at all. Invalidation now drops the entry and re-reads from the primary; the background warm-up keeps the replica. A pass cancelled at shutdown reported itself as failed. It now reports aborted. Metric attributes are bounded at the adapter and in the engine's option type. The values were already bounded in practice, but both sides typed them as plain strings, so an unrecognised value now collapses to a single bucket rather than minting a time series. Also drops mocks from the boot test, which the injected dependencies made unnecessary, and replaces a dial position in the run-store test that no longer exists.
1 parent 3277ac3 commit ab27e4e

13 files changed

Lines changed: 248 additions & 53 deletions

apps/webapp/app/routes/admin.api.v1.feature-flags.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
withoutDerivedKeys,
1111
} from "~/v3/featureFlags.server";
1212
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
13-
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
13+
import {
14+
globalOnlySnapshotStoreFlagError,
15+
snapshotStoreFlagSaveError,
16+
} from "~/v3/snapshotStoreFlagGuard.server";
1417

1518
export async function action({ request }: ActionFunctionArgs) {
1619
await requireAdminApiRequest(request);
@@ -31,6 +34,11 @@ export async function action({ request }: ActionFunctionArgs) {
3134
);
3235
}
3336

37+
const globalOnlyError = globalOnlySnapshotStoreFlagError(body as Record<string, unknown>);
38+
if (globalOnlyError) {
39+
return json({ error: globalOnlyError }, { status: 400 });
40+
}
41+
3442
const snapshotStoreError = snapshotStoreFlagSaveError(body as Record<string, unknown>, {
3543
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
3644
});

apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.feature-flags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
66
import { prisma } from "~/db.server";
77
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
88
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
9+
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
910
import { invalidateSnapshotStoreOrgMode } from "~/v3/snapshotStoreMode.server";
1011
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
1112
import { validatePartialFeatureFlags, withoutOrgForbiddenSnapshotKeys } from "~/v3/featureFlags";
@@ -77,6 +78,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
7778

7879
const requestedFlags = withoutOrgForbiddenSnapshotKeys(rawRequestedFlags);
7980

81+
const snapshotStoreError = snapshotStoreFlagSaveError(requestedFlags, {
82+
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
83+
});
84+
if (snapshotStoreError) {
85+
return json({ error: snapshotStoreError }, { status: 400 });
86+
}
87+
8088
// Seed the flip baseline from the current GLOBAL mint flags so an org's FIRST per-org override
8189
// is graced from the currently-effective global kind, not the hardcoded default "cuid".
8290
const globalFlags = (await getGlobalFlags()) as Record<string, unknown>;

apps/webapp/app/routes/admin.api.v2.orgs.$organizationId.feature-flags.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
66
import { prisma } from "~/db.server";
77
import { requireUser } from "~/services/session.server";
88
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
9+
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
910
import { invalidateSnapshotStoreOrgMode } from "~/v3/snapshotStoreMode.server";
1011
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
1112
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
@@ -146,6 +147,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
146147

147148
const requestedFlags = withoutOrgForbiddenSnapshotKeys(rawRequestedFlags);
148149

150+
const snapshotStoreError = snapshotStoreFlagSaveError(requestedFlags, {
151+
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
152+
});
153+
if (snapshotStoreError) {
154+
return json({ error: snapshotStoreError }, { status: 400 });
155+
}
156+
149157
// Seed the flip baseline from the current GLOBAL mint flags so an org's FIRST per-org override
150158
// is graced from the currently-effective global kind, not the hardcoded default "cuid".
151159
const globalFlags = (await getGlobalFlags()) as Record<string, unknown>;

apps/webapp/app/routes/admin.feature-flags.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import {
1717
lockedFlagsInPayload,
1818
validatePartialFeatureFlags,
1919
} from "~/v3/featureFlags";
20-
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
20+
import {
21+
globalOnlySnapshotStoreFlagError,
22+
snapshotStoreFlagSaveError,
23+
} from "~/v3/snapshotStoreFlagGuard.server";
2124
import { flags as getGlobalFlags, replaceGlobalFeatureFlags } from "~/v3/featureFlags.server";
2225
import { featuresForRequest } from "~/features.server";
2326
import { Button } from "~/components/primitives/Buttons";
@@ -130,6 +133,11 @@ export const action = dashboardAction(
130133
);
131134
}
132135

136+
const globalOnlyError = globalOnlySnapshotStoreFlagError(parsed.data.flags);
137+
if (globalOnlyError) {
138+
return json({ error: globalOnlyError }, { status: 400 });
139+
}
140+
133141
const snapshotStoreError = snapshotStoreFlagSaveError(parsed.data.flags, {
134142
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
135143
});

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,26 @@ export function snapshotStoreFlagSaveError(
1313
return undefined;
1414
}
1515

16-
const mode = requested[FEATURE_FLAG.snapshotStoreMode];
17-
if (typeof mode === "string" && mode !== "off") {
18-
return `Cannot set ${FEATURE_FLAG.snapshotStoreMode} to "${mode}": RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST is not configured in this deployment, so the snapshot store is never constructed and the flag would have no effect.`;
16+
// Both keys, because either one past `off` is equally silent without a connection.
17+
for (const key of [FEATURE_FLAG.snapshotStoreMode, FEATURE_FLAG.snapshotStoreOrgMode] as const) {
18+
const value = requested[key];
19+
if (typeof value === "string" && value !== "off") {
20+
return `Cannot set ${key} to "${value}": RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST is not configured in this deployment, so the snapshot store is never constructed and the flag would have no effect.`;
21+
}
1922
}
2023

2124
return undefined;
2225
}
26+
27+
/**
28+
* Refuses an organisation-only key on a global save. Nothing reads the global row for it, so a
29+
* value saved there is inert, and an inert control an operator can set is worse than no control.
30+
*/
31+
export function globalOnlySnapshotStoreFlagError(
32+
requested: Record<string, unknown>
33+
): string | undefined {
34+
if (FEATURE_FLAG.snapshotStoreOrgMode in requested) {
35+
return `${FEATURE_FLAG.snapshotStoreOrgMode} is per-organisation only; nothing reads it from the global flags, so setting it here would have no effect.`;
36+
}
37+
return undefined;
38+
}

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

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,61 @@ import type { DecoratorMetrics, SnapshotStoreMetrics } from "@internal/run-store
33

44
export type SnapshotSweepCounts = Record<string, number | boolean>;
55

6+
// Metric attributes must be bounded: every one of these is a time series. The store and the
7+
// decorator type their outcome strings loosely, so anything unrecognised collapses to "other"
8+
// rather than minting a series.
9+
const APPEND_OUTCOMES = ["written", "duplicate", "forked", "skippedNoKeyspace"] as const;
10+
const APPEND_TTLS = ["none", "completion", "reapplied"] as const;
11+
const WRITE_OUTCOMES = ["written", "staged", "post_expiry", "skipped", "failed"] as const;
12+
const READ_SOURCES = ["redis", "postgres"] as const;
13+
const SWEEP_OUTCOMES = [
14+
"completed",
15+
"partial",
16+
"skipped_locked",
17+
"failed",
18+
"unbound",
19+
"aborted",
20+
] as const;
21+
const SWEEP_FIELDS = [
22+
"scanned",
23+
"expired",
24+
"deleted",
25+
"skipped",
26+
"pendingDeletion",
27+
"nodes",
28+
] as const;
29+
30+
const WRITE_SITES = [
31+
"createRun",
32+
"createCancelledRun",
33+
"completeAttemptSuccess",
34+
"expireRun",
35+
"expireParkedRun",
36+
"rescheduleRun",
37+
"lockRunToWorker",
38+
"createExecutionSnapshot",
39+
"runInTransaction",
40+
] as const;
41+
const READ_METHODS = [
42+
"findLatestExecutionSnapshot",
43+
"findExecutionSnapshot",
44+
"findManyExecutionSnapshots",
45+
"findSnapshotCompletedWaitpointIds",
46+
"findSnapshotCompletedWaitpointIdsWithPresence",
47+
] as const;
48+
const SNAPSHOT_OPS = [
49+
"append",
50+
"getById",
51+
"getLatest",
52+
"getSince",
53+
"getSinceCreatedAt",
54+
"getSnapshotWaitpointIds",
55+
] as const;
56+
57+
function bounded(value: string, allowed: readonly string[]): string {
58+
return allowed.includes(value) ? value : "other";
59+
}
60+
661
/**
762
* Every instrument is created inside this function. At module scope they would register on every
863
* boot, including deployments with no snapshot-store Redis configured.
@@ -23,37 +78,48 @@ export function createSnapshotStoreMetrics(meter: Meter) {
2378
const sweepCounts = meter.createHistogram("run_engine.snapshot_store.sweep_counts");
2479

2580
const store: SnapshotStoreMetrics = {
26-
recordAppend: (outcome, ttl) => appendTotal.add(1, { outcome, ttl }),
81+
recordAppend: (outcome, ttl) =>
82+
appendTotal.add(1, {
83+
outcome: bounded(outcome, APPEND_OUTCOMES),
84+
ttl: bounded(ttl, APPEND_TTLS),
85+
}),
2786
recordEntryBytes: (bytes) => entryBytes.record(bytes),
2887
recordCycleKeyBytes: (bytes) => cycleKeyBytes.record(bytes),
2988
recordCycleCount: (count) => cycleCount.record(count),
3089
recordSkippedNoKeyspace: () => skippedNoKeyspace.add(1),
3190
recordCycleMismatch: () => cycleMismatch.add(1),
32-
recordLatency: (op, ms) => opLatency.record(ms, { op }),
91+
recordLatency: (op, ms) => opLatency.record(ms, { op: bounded(op, SNAPSHOT_OPS) }),
3392
};
3493

3594
const decorator: DecoratorMetrics = {
3695
recordWrite: (site, outcome) => {
37-
appendTotal.add(1, { site, outcome });
96+
appendTotal.add(1, {
97+
site: bounded(site, WRITE_SITES),
98+
outcome: bounded(outcome, WRITE_OUTCOMES),
99+
});
38100
if (outcome === "post_expiry") {
39101
postExpiryWrite.add(1);
40102
}
41103
if (outcome === "staged") {
42104
flushStaged.add(1);
43105
}
44106
},
45-
recordAppendFailed: (site) => appendFailed.add(1, { site }),
46-
recordRead: (method, source) => readSource.add(1, { method, source }),
107+
recordAppendFailed: (site) => appendFailed.add(1, { site: bounded(site, WRITE_SITES) }),
108+
recordRead: (method, source) =>
109+
readSource.add(1, {
110+
method: bounded(method, READ_METHODS),
111+
source: bounded(source, READ_SOURCES),
112+
}),
47113
};
48114

49115
/** One emitter per pass, so a pass that throws is distinguishable from one that succeeded. */
50116
function recordSweepPass(outcome: string, counts?: SnapshotSweepCounts): void {
51-
sweepPass.add(1, { outcome });
117+
sweepPass.add(1, { outcome: bounded(outcome, SWEEP_OUTCOMES) });
52118
if (!counts) {
53119
return;
54120
}
55121
for (const [field, value] of Object.entries(counts)) {
56-
if (typeof value === "number") {
122+
if (typeof value === "number" && SWEEP_FIELDS.includes(field as never)) {
57123
sweepCounts.record(value, { field });
58124
}
59125
}

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

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { LRUCache } from "lru-cache";
22
import type { SnapshotStoreMode, SnapshotStoreModeResolver } from "@internal/run-store";
3-
import { $replica } from "~/db.server";
3+
import { $replica, prisma } from "~/db.server";
44
import { env } from "~/env.server";
55
import { logger } from "~/services/logger.server";
66
import { singleton } from "~/utils/singleton";
@@ -40,11 +40,16 @@ type OrgModeSource = {
4040
get(organizationId: string): CachedOrgMode | undefined;
4141
/** Fire-and-forget, de-duplicated per organisation, never throws. */
4242
refresh(organizationId: string): void;
43+
/** Re-reads from the primary after a write, so replica lag cannot re-cache the old value. */
44+
invalidate(organizationId: string): void;
4345
};
4446

47+
/** What resolution needs. Invalidation is a save-path concern, not a read-path one. */
48+
type ResolverOrgSource = Pick<OrgModeSource, "get" | "refresh">;
49+
4550
export function buildSnapshotStoreModeResolver(deps: {
4651
globalMode: () => DialMode | undefined;
47-
orgMode: OrgModeSource;
52+
orgMode: ResolverOrgSource;
4853
envFloor: DialMode;
4954
}): SnapshotStoreModeResolver {
5055
return {
@@ -89,33 +94,42 @@ function createOrgModeSource(): OrgModeSource {
8994

9095
return {
9196
get: (organizationId) => cache.get(organizationId),
97+
invalidate: (organizationId) => {
98+
// Drop first, so a resolve between now and the re-read falls back rather than serving a
99+
// value the write just replaced.
100+
cache.delete(organizationId);
101+
void load(organizationId, prisma);
102+
},
92103
refresh: (organizationId) => {
93104
if (inFlight.has(organizationId)) {
94105
return;
95106
}
96107
inFlight.add(organizationId);
97108

98-
void $replica.organization
99-
.findFirst({ where: { id: organizationId }, select: { featureFlags: true } })
100-
.then((row) => {
101-
// Only the narrow per-org key. The blob is never passed as `overrides` for the global
102-
// key, where a parsing override would win outright.
103-
const raw = (row?.featureFlags as Record<string, unknown> | null | undefined)?.[
104-
FEATURE_FLAG.snapshotStoreOrgMode
105-
];
106-
cache.set(organizationId, cachedOrgModeFor(raw));
107-
})
108-
.catch((error) => {
109-
logger.warn("snapshotStoreMode: organisation override refresh failed", {
110-
organizationId,
111-
error,
112-
});
113-
})
114-
.finally(() => {
115-
inFlight.delete(organizationId);
116-
});
109+
void load(organizationId, $replica).finally(() => {
110+
inFlight.delete(organizationId);
111+
});
117112
},
118113
};
114+
115+
function load(organizationId: string, client: typeof prisma | typeof $replica) {
116+
return client.organization
117+
.findFirst({ where: { id: organizationId }, select: { featureFlags: true } })
118+
.then((row) => {
119+
// Only the narrow per-org key. The blob is never passed as `overrides` for the global
120+
// key, where a parsing override would win outright.
121+
const raw = (row?.featureFlags as Record<string, unknown> | null | undefined)?.[
122+
FEATURE_FLAG.snapshotStoreOrgMode
123+
];
124+
cache.set(organizationId, cachedOrgModeFor(raw));
125+
})
126+
.catch((error) => {
127+
logger.warn("snapshotStoreMode: organisation override read failed", {
128+
organizationId,
129+
error,
130+
});
131+
});
132+
}
119133
}
120134

121135
/** Built on first use, never at import: importing this module must have no side effect. */
@@ -134,5 +148,5 @@ export const snapshotStoreModeResolver: SnapshotStoreModeResolver = buildSnapsho
134148

135149
/** Called by the organisation flag save path so the writing process sees a dial change at once. */
136150
export function invalidateSnapshotStoreOrgMode(organizationId: string): void {
137-
orgModeSource().refresh(organizationId);
151+
orgModeSource().invalidate(organizationId);
138152
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export function buildSnapshotSweepRunner(deps: {
4444
}
4545
return { outcome: "completed", counts };
4646
} catch (error) {
47+
if (signal.aborted) {
48+
return { outcome: "aborted" };
49+
}
4750
logger.error("snapshot orphan sweep pass failed", { error });
4851
return { outcome: "failed" };
4952
} finally {

0 commit comments

Comments
 (0)