Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
b50613f
feat(run-store): accept a caller-supplied execution-snapshot id
d-cs Aug 24, 2026
db8390c
feat(run-store): add a generated pass-through RunStore base for decor…
d-cs Aug 24, 2026
e8ac9b3
feat(run-store): build snapshot entries from write-site inputs, with …
d-cs Aug 24, 2026
95dd2a6
feat(run-store): add a snapshotWrites flag that omits every snapshot …
d-cs Aug 24, 2026
6cb49c8
feat(run-store): dual-write execution snapshots in a crash-safe order
d-cs Aug 24, 2026
fbe91ac
test(run-store): cover the snapshot staging facade and the wrapped st…
d-cs Aug 24, 2026
ac7b37b
feat(run-store): read a snapshot window by createdAt cursor
d-cs Aug 24, 2026
f73d806
feat(run-store): serve snapshot reads from Redis with a Postgres fall…
d-cs Aug 24, 2026
910039b
feat(run-store): reap orphaned snapshot keyspaces under both sweep rules
d-cs Aug 24, 2026
8119466
test(run-engine): run the snapshot flows against the decorator with r…
d-cs Aug 24, 2026
aee3f07
fix(run-store): give a snapshot one identity and one instant across b…
d-cs Aug 24, 2026
ea0e17b
chore(run-store): treat the run-store scripts directory as an entry p…
d-cs Aug 24, 2026
1d4eb7b
fix(run-store): address review on the sweep, and make the timestamp p…
d-cs Aug 24, 2026
94b9c68
test(run-store): prove interface parity in the compiler, not in a fake
d-cs Aug 25, 2026
f01d299
fix(run-store): restore a name dropped from the generated list
d-cs Aug 25, 2026
4f7162c
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-d…
d-cs Aug 25, 2026
b02b426
fix(run-store): keep the fork guard on a staged append, and survive a…
d-cs Aug 25, 2026
7bba6a8
chore(run-store): drop the pass-through generator, keep its output as…
d-cs Aug 25, 2026
a553562
test(run-engine): guard the sweeper's copy of the terminal-status list
d-cs Aug 25, 2026
0f6c6d1
refactor(run-store): type every forwarder on the pass-through base
d-cs Aug 25, 2026
e03a185
fix(run-store): forward the parameters two members were dropping
d-cs Aug 25, 2026
e9ce909
fix(run-store): keep completed waitpoints that have no batch index
d-cs Aug 25, 2026
57018fc
fix(run-store): three more index-less waitpoint losses on the read path
d-cs Aug 25, 2026
cff7fb9
fix(run-store): hydrate the column the read path was omitting
d-cs Aug 25, 2026
bde40b3
fix(run-store): four defects from an adversarial sweep of the read path
d-cs Aug 25, 2026
c4469f7
fix(run-store): a refused wait cycle no longer leaves an unreadable head
d-cs Aug 25, 2026
b964f86
fix(run-store): ignore the read cohort at the last dial position
d-cs Aug 25, 2026
a229d99
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-d…
d-cs Aug 25, 2026
ba91f75
fix(run-store): make the snapshot sweep and store correct against a R…
d-cs Aug 25, 2026
aa71fd7
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-d…
d-cs Aug 25, 2026
2ffd275
feat(redis): add a cluster-capable client to the shared package
d-cs Aug 25, 2026
0f79a27
feat(webapp): add the snapshot-store dial flags, and strip the global…
d-cs Aug 25, 2026
bbcd8c7
feat(run-store): resolve the snapshot dial per write through an injec…
d-cs Aug 25, 2026
06b825e
feat(webapp): add the snapshot-store env block and refuse a silent di…
d-cs Aug 25, 2026
253466c
feat(webapp): resolve the snapshot-store dial synchronously, never on…
d-cs Aug 25, 2026
9129315
feat(webapp): add the snapshot-store metrics factory
d-cs Aug 25, 2026
b9c79ca
feat(webapp): construct the snapshot store above the router, gated on…
d-cs Aug 25, 2026
29bbeec
feat(run-engine): schedule the snapshot orphan sweep on the existing …
d-cs Aug 25, 2026
3d5c9a7
feat(webapp): run the snapshot orphan sweep under a fenced exclusion …
d-cs Aug 25, 2026
d4bf1a4
feat(webapp,run-engine): bind the append-failure hook to the existing…
d-cs Aug 25, 2026
bea262f
feat(webapp): add the snapshot-store boot checks
d-cs Aug 25, 2026
6447afb
fix(webapp): omit the sweep options entirely when the snapshot store …
d-cs Aug 25, 2026
f901708
style(redis): reflow the ioredis import
d-cs Aug 25, 2026
f7292ac
fix(run-store): keep an omitted completedWaitpointOrder omitted on th…
d-cs Aug 26, 2026
243d175
fix(webapp,run-engine): make the unconfigured and dial-off states gen…
d-cs Aug 26, 2026
2267b18
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-d…
d-cs Aug 26, 2026
07c3398
chore(run-store): apply oxfmt to the two new sweeper test files
d-cs Aug 26, 2026
1bba0a0
docs(run-store): correct two stale comments on the orphan-marker path
d-cs Aug 26, 2026
511b5fe
Merge remote-tracking branch 'origin/feat/snapshot-store-decorator-tr…
d-cs Aug 26, 2026
6e976d0
feat(run-store): drop the unimplemented compare dial position, cover …
d-cs Aug 26, 2026
6cc24de
fix(webapp): stop the dial resolver constructing its cache at module …
d-cs Aug 26, 2026
ea6de93
feat(webapp,run-engine): take up the store and sweeper connection con…
d-cs Aug 26, 2026
dd927b3
fix(webapp): decouple the dial resolver from the run-store type, and …
d-cs Aug 26, 2026
3624cd3
chore(webapp): probe which dial positions CI resolves
d-cs Aug 26, 2026
7885f4e
Merge remote-tracking branch 'origin/feat/snapshot-store-decorator-tr…
d-cs Aug 26, 2026
3277ac3
fix(webapp): drop the compare dial position, and remove the probes
d-cs Aug 26, 2026
ab27e4e
fix(webapp,run-engine): address review findings on the snapshot store…
d-cs Aug 26, 2026
835c88a
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-w…
d-cs Aug 26, 2026
f944a54
fix(webapp,run-engine): bound the sweep count fields, and guard the o…
d-cs Aug 26, 2026
ba146e1
fix(run-engine): derive the sweep count field type from its list
d-cs Aug 26, 2026
22c5a87
fix(webapp,run-engine): split the append and write counters, derive t…
d-cs Aug 26, 2026
663b344
fix(run-store): resolve the fatal birth-append refusal per organisation
d-cs Aug 26, 2026
e7d040a
fix(webapp): close the snapshot store clients on shutdown
d-cs Aug 26, 2026
8d075eb
fix(run-store,webapp): record every append outcome the store can return
d-cs Aug 26, 2026
41b883a
feat(webapp): make the snapshot store metrics and the append alarm ob…
d-cs Aug 27, 2026
ded343a
fix(webapp): lock the per-organisation snapshot dial on the global fl…
d-cs Aug 27, 2026
ad9c11d
fix(webapp): let a saved organisation dial win over a lagging replica…
d-cs Aug 27, 2026
a710097
chore(webapp): drop an alert rule whose metric has no producer
d-cs Aug 27, 2026
cd3af25
feat(webapp): add a local Redis cluster service for snapshot store va…
d-cs Aug 27, 2026
e79da5b
fix(webapp): match the snapshot store alert rules to the exported met…
d-cs Aug 27, 2026
9573375
fix(webapp,redis): fail a snapshot append fast instead of hanging the…
d-cs Aug 27, 2026
0153adc
fix(webapp): stop dropping the snapshot read metric on a label collision
d-cs Aug 27, 2026
41bdb6e
fix(run-store): decide a run store at birth, not on every write
d-cs Aug 27, 2026
d683c49
test(run-store): show reads serving a keyspace that refuses writes
d-cs Aug 27, 2026
b90a5a9
fix(run-store): read snapshots through the same liveness test writes use
d-cs Aug 27, 2026
5556c4c
test(webapp): boot must assert the snapshot store cannot evict keys
d-cs Aug 27, 2026
73edb2a
feat(webapp): refuse to boot when the snapshot store can evict keys
d-cs Aug 27, 2026
187f5d3
chore(run-store): oxfmt the read-liveness test
d-cs Aug 27, 2026
e1d610d
test(run-store): a global dial-down to off must not freeze a resident…
d-cs Aug 27, 2026
261fd1b
test(run-store): the per-organisation ramp is refused at the transiti…
d-cs Aug 27, 2026
bc9bbcd
fix(run-store): a transition consults residency, never the dial
d-cs Aug 27, 2026
1a52ecc
test(webapp): the snapshot store needs a hard stop it can reach witho…
d-cs Aug 27, 2026
8eb8b60
feat(webapp): a hard stop for the execution-snapshot store
d-cs Aug 27, 2026
0c37edf
test(webapp): a forked snapshot append must page
d-cs Aug 27, 2026
c186110
fix(run-store,webapp): treat a forked snapshot append as an anomaly, …
d-cs Aug 27, 2026
9724285
test(run-store): a lost append must be recoverable by re-appending th…
d-cs Aug 27, 2026
7c1f080
fix(run-store,run-engine): make the snapshot repair actually re-appen…
d-cs Aug 27, 2026
721d31d
fix(run-store,run-engine): heal the mirror head even after the run ha…
d-cs Aug 27, 2026
f10572e
test(run-store): container-level proof of the snapshot repair against…
d-cs Aug 27, 2026
6107f53
chore(webapp): keep the eviction report type local to its module
d-cs Aug 27, 2026
1984260
fix(run-store): heal a forked append instead of only reporting it
d-cs Aug 27, 2026
6e3c8ce
fix(webapp,run-store): report a repair write under its own site, and …
d-cs Aug 27, 2026
3585eaa
fix(webapp): validate the snapshot store configuration as soon as a h…
d-cs Aug 27, 2026
a16f0d8
fix(run-store,run-engine): contain a sweep failure to the keyspace th…
d-cs Aug 28, 2026
1cb15ab
fix(webapp): make the snapshot store hard stop a flag and nothing else
d-cs Aug 28, 2026
64fbff7
perf(run-store): take Redis off the run path for a non-resident run
d-cs Aug 28, 2026
653dd94
fix(run-store): fall back to Postgres when the snapshot store stops a…
d-cs Aug 28, 2026
7a6541e
fix(run-store): stop serving a short history from a keyspace that los…
d-cs Aug 28, 2026
ff161df
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-w…
d-cs Aug 28, 2026
469bb49
test(run-store): a checkpoint must survive a snapshot served from Redis
d-cs Aug 28, 2026
e4140b7
fix(run-store,webapp): address the review round
d-cs Aug 28, 2026
4ebf719
Merge remote-tracking branch 'origin/main' into feat/snapshot-store-w…
d-cs Aug 28, 2026
ad5861f
fix(run-store,webapp): read the organisation dial on a miss before a …
d-cs Aug 28, 2026
d9ad46e
fix(run-store,run-engine,webapp): second review round, with tests tha…
d-cs Aug 28, 2026
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
14 changes: 14 additions & 0 deletions apps/webapp/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import type { OperatingSystemPlatform } from "./components/primitives/OperatingSystemProvider";
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
import { assertRunOpsSplitSentinel, Prisma } from "./db.server";
import { assertSnapshotStoreBootFromEnv } from "./v3/snapshotStoreBoot.server";
import { registerSnapshotStoreWiring } from "./v3/snapshotStoreWiring.server";
import { env } from "./env.server";
import { eventLoopMonitor, eventLoopUtilizationMonitor } from "./eventLoopMonitor.server";
import { logger } from "./services/logger.server";
Expand Down Expand Up @@ -325,6 +327,18 @@ singleton("AssertRunOpsSplitSentinel", () => {
return true;
});

singleton("SnapshotStoreWiring", registerSnapshotStoreWiring);

// Ordered after the wiring above: the boot check asserts the repair binding is set, and the
// binding is what the wiring installs.
singleton("AssertSnapshotStoreBoot", () => {
assertSnapshotStoreBootFromEnv().catch((error) => {
logger.error("Snapshot store boot check failed; refusing to start", { error });
process.exit(1);
});
return true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
// Attach the realtime run-changed publish delegations to the engine event bus.
Expand Down
49 changes: 49 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,55 @@ const EnvironmentSchema = z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),

// Execution-snapshot store. MODE here is only the FLOOR: the operational dial is the
// snapshotStoreMode feature flag, so it can move without a deploy.
RUN_ENGINE_SNAPSHOT_STORE_MODE: z
.enum(["off", "dual-write", "redis-read", "redis-only"])
.default("off"),
RUN_ENGINE_SNAPSHOT_STORE_COMPLETED_TTL_MS: z.coerce
.number()
.int()
.default(72 * 60 * 60 * 1000),
RUN_ENGINE_SNAPSHOT_STORE_ORPHAN_AGE_MS: z.coerce
.number()
.int()
.default(24 * 60 * 60 * 1000),
RUN_ENGINE_SNAPSHOT_STORE_CONFIRM_ORPHAN_AFTER_MS: z.coerce
.number()
.int()
.default(2 * 60 * 60 * 1000),
RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_SCHEDULE: z.string().default("0 */6 * * *"),
RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_JITTER_IN_MS: z.coerce.number().int().default(60_000),
// An existing run costs ~4 serial round trips and the orphan-marker clear cannot be batched
// (cross-slot pipelines are rejected), so a full pass is hours, not minutes. A budget that
// truncates every pass stops rule 2 converging, because it needs consecutive sightings.
RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_BUDGET_MS: z.coerce.number().int().default(10_800_000),
/**
* RETIRED. The hard stop is the snapshotStoreHalt feature flag and nothing else: an environment
* variable converged over a rolling deploy rather than a flag interval, and during that window a
* stopped process skips a transition while a running one asserts a head that was never written.
*
* Kept in the schema for one purpose only, so boot can REFUSE to start when it is still set to
* "1". A variable that no longer stops anything leaves an operator believing the mirror is
* stopped while it runs. Nothing else reads it, and a value of "0" carries no intent so it is
* ignored.
*/
RUN_ENGINE_SNAPSHOT_STORE_HALT: z.string().optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
RUN_ENGINE_SNAPSHOT_STORE_ORG_MODE_CACHE_TTL_MS: z.coerce.number().int().default(30_000),
RUN_ENGINE_SNAPSHOT_STORE_ORG_MODE_CACHE_MAX: z.coerce.number().int().default(10_000),
// No fallback to REDIS_*: this is a distinct durable endpoint and must be set explicitly, or
// execution state silently lands on the general-purpose cache.
RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST: z.string().optional(),
RUN_ENGINE_SNAPSHOT_STORE_REDIS_PORT: z.coerce.number().optional(),
RUN_ENGINE_SNAPSHOT_STORE_REDIS_USERNAME: z.string().optional(),
RUN_ENGINE_SNAPSHOT_STORE_REDIS_PASSWORD: z.string().optional(),
RUN_ENGINE_SNAPSHOT_STORE_REDIS_TLS_DISABLED: z.string().default("false"),
RUN_ENGINE_SNAPSHOT_STORE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
// Fails an append fast rather than letting it wait on an unreachable endpoint. Postgres is
// authoritative below the final dial position, so a refused append costs a mirrored write; a
// blocked one costs the request.
RUN_ENGINE_SNAPSHOT_STORE_REDIS_COMMAND_TIMEOUT_MS: z.coerce.number().int().default(500),

RUN_ENGINE_DEV_PRESENCE_REDIS_HOST: z
.string()
.optional()
Expand Down
16 changes: 16 additions & 0 deletions apps/webapp/app/routes/admin.api.v1.feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
withoutDerivedKeys,
} from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import {
globalOnlySnapshotStoreFlagError,
snapshotStoreFlagSaveError,
} from "~/v3/snapshotStoreFlagGuard.server";

export async function action({ request }: ActionFunctionArgs) {
await requireAdminApiRequest(request);
Expand All @@ -30,6 +34,18 @@ export async function action({ request }: ActionFunctionArgs) {
);
}

const globalOnlyError = globalOnlySnapshotStoreFlagError(body as Record<string, unknown>);
if (globalOnlyError) {
return json({ error: globalOnlyError }, { status: 400 });
}

const snapshotStoreError = snapshotStoreFlagSaveError(body as Record<string, unknown>, {
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
});
if (snapshotStoreError) {
return json({ error: snapshotStoreError }, { status: 400 });
}

// Both the strip and the branch derive from the graced-group table, so adding a group needs
// no edit here. Naming the keys inline is how a new group ends up writing its stamp straight
// from the request body, with no lock.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { env } from "~/env.server";
import { prisma } from "~/db.server";
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
import { invalidateSnapshotStoreOrgMode } from "~/v3/snapshotStoreMode.server";
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import { validatePartialFeatureFlags, withoutOrgForbiddenSnapshotKeys } from "~/v3/featureFlags";
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";

const ParamsSchema = z.object({
Expand Down Expand Up @@ -71,9 +73,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
const {
runOpsMintKindPrev: _ignoredPrev,
runOpsMintKindFlippedAt: _ignoredFlippedAt,
...requestedFlags
...rawRequestedFlags
} = validationResult.data;

const requestedFlags = withoutOrgForbiddenSnapshotKeys(rawRequestedFlags);

const snapshotStoreError = snapshotStoreFlagSaveError(requestedFlags, {
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
});
if (snapshotStoreError) {
return json({ error: snapshotStoreError }, { status: 400 });
}

// Seed the flip baseline from the current GLOBAL mint flags so an org's FIRST per-org override
// is graced from the currently-effective global kind, not the hardcoded default "cuid".
const globalFlags = (await getGlobalFlags()) as Record<string, unknown>;
Expand Down Expand Up @@ -129,6 +140,7 @@ export async function action({ request, params }: ActionFunctionArgs) {

// Org feature flags are embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(organizationId);
invalidateSnapshotStoreOrgMode(organizationId);

const updatedFlagsResult = updatedOrganization.featureFlags
? validatePartialFeatureFlags(updatedOrganization.featureFlags as Record<string, unknown>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { env } from "~/env.server";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { snapshotStoreFlagSaveError } from "~/v3/snapshotStoreFlagGuard.server";
import { invalidateSnapshotStoreOrgMode } from "~/v3/snapshotStoreMode.server";
import { selectMintBaselineSource, stampMintKindFlip } from "~/v3/runOpsMigration/mintFlipGrace";
import { flags as getGlobalFlags } from "~/v3/featureFlags.server";
import {
FEATURE_FLAG,
validatePartialFeatureFlags,
withoutOrgForbiddenSnapshotKeys,
getAllFlagControlTypes,
} from "~/v3/featureFlags";
import { featuresForRequest } from "~/features.server";
Expand Down Expand Up @@ -123,6 +126,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
}

controlPlaneResolver.invalidateOrganization(organizationId);
invalidateSnapshotStoreOrgMode(organizationId);
return json({ success: true });
}

Expand All @@ -138,9 +142,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
const {
runOpsMintKindPrev: _ignoredPrev,
runOpsMintKindFlippedAt: _ignoredFlippedAt,
...requestedFlags
...rawRequestedFlags
} = validationResult.data;

const requestedFlags = withoutOrgForbiddenSnapshotKeys(rawRequestedFlags);

const snapshotStoreError = snapshotStoreFlagSaveError(requestedFlags, {
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
});
if (snapshotStoreError) {
return json({ error: snapshotStoreError }, { status: 400 });
}

// Seed the flip baseline from the current GLOBAL mint flags so an org's FIRST per-org override
// is graced from the currently-effective global kind, not the hardcoded default "cuid".
const globalFlags = (await getGlobalFlags()) as Record<string, unknown>;
Expand Down Expand Up @@ -181,6 +194,7 @@ export async function action({ request, params }: ActionFunctionArgs) {

// Org feature flags are embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(organizationId);
invalidateSnapshotStoreOrgMode(organizationId);

return json({ success: true });
}
16 changes: 16 additions & 0 deletions apps/webapp/app/routes/admin.feature-flags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
lockedFlagsInPayload,
validatePartialFeatureFlags,
} from "~/v3/featureFlags";
import {
globalOnlySnapshotStoreFlagError,
snapshotStoreFlagSaveError,
} from "~/v3/snapshotStoreFlagGuard.server";
import { flags as getGlobalFlags, replaceGlobalFeatureFlags } from "~/v3/featureFlags.server";
import { featuresForRequest } from "~/features.server";
import { Button } from "~/components/primitives/Buttons";
Expand Down Expand Up @@ -129,6 +133,18 @@ export const action = dashboardAction(
);
}

const globalOnlyError = globalOnlySnapshotStoreFlagError(parsed.data.flags);
if (globalOnlyError) {
return json({ error: globalOnlyError }, { status: 400 });
}

const snapshotStoreError = snapshotStoreFlagSaveError(parsed.data.flags, {
redisHostConfigured: !!env.RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST,
});
if (snapshotStoreError) {
return json({ error: snapshotStoreError }, { status: 400 });
}

await replaceGlobalFeatureFlags(prisma, {
requestedFlags: validationResult.data as Record<string, unknown>,
catalogKeys: Object.keys(getAllFlagControlTypes()) as FeatureFlagKey[],
Expand Down
38 changes: 38 additions & 0 deletions apps/webapp/app/v3/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ export const FEATURE_FLAG = {
// System-wide kill switch for additional (scoped) environment API-key lookup.
// Defaults off; enable during rollout once the new lookup path is trusted.
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
// The execution-snapshot store rollout dial. A flag rather than an environment variable because
// a sustained append failure burns a task attempt per transition, so dial-down is a correctness
// control and cannot wait for a deploy.
snapshotStoreMode: "snapshotStoreMode",
// The hard stop for the execution-snapshot store, deployment-wide. Separate from the dial because
// the dial governs births only: turning it down cannot stop a resident run from mirroring, and
// must not, or every resident head freezes while Postgres advances.
snapshotStoreHalt: "snapshotStoreHalt",
// Per-org override, read from the org blob only. Deliberately narrower than the global key:
// snapshot reads are global, so an org at a read position would read state its own writes never
// created. Stripped from org payloads by withoutOrgForbiddenSnapshotKeys.
snapshotStoreOrgMode: "snapshotStoreOrgMode",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} as const;

export const FeatureFlagCatalog = {
Expand Down Expand Up @@ -163,6 +175,11 @@ export const FeatureFlagCatalog = {
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
[FEATURE_FLAG.snapshotStoreMode]: z.enum(["off", "dual-write", "redis-read", "redis-only"]),
[FEATURE_FLAG.snapshotStoreOrgMode]: z.enum(["off", "dual-write"]),
// Strict, like the other kill switches: a stringified "false" read as true would freeze every
// resident run's Redis head.
[FEATURE_FLAG.snapshotStoreHalt]: z.boolean(),
};

export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
Expand All @@ -181,6 +198,9 @@ export const GLOBAL_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.runOpsMintKindFlippedAt,
FEATURE_FLAG.runOpsMintShardSetPrev,
FEATURE_FLAG.runOpsMintShardSetFlippedAt,
// Read from the org blob only, and refused outright on a global save, so an editable control here
// would offer a setting whose only outcome is a 400.
FEATURE_FLAG.snapshotStoreOrgMode,
];

// Flags that are read-only on the org-level dialog.
Expand All @@ -198,8 +218,26 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.runOpsMintShardSetPrev,
FEATURE_FLAG.runOpsMintShardSetFlippedAt,
FEATURE_FLAG.runOpsMintShardOverride,
// The dial and the hard stop are deployment-wide; only snapshotStoreOrgMode is per-org.
FEATURE_FLAG.snapshotStoreMode,
FEATURE_FLAG.snapshotStoreHalt,
];

/**
* Drops keys an organisation must never supply. ORG_LOCKED_FLAGS is a UI predicate and no save path
* consults it, so the line is held here — the same way the mint grace stamps are stripped.
*/
export function withoutOrgForbiddenSnapshotKeys<T extends Record<string, unknown>>(values: T): T {
const forbidden = [FEATURE_FLAG.snapshotStoreMode, FEATURE_FLAG.snapshotStoreHalt] as const;
if (!forbidden.some((key) => key in values)) return values;

const rest = { ...values };
for (const key of forbidden) {
delete rest[key];
}
return rest;
}

/**
* Flag groups where the operator sets a `primary` and the server computes the rest. The topology
* lives here, not in the server module, because the admin page needs it too: unsetting a primary
Expand Down
18 changes: 18 additions & 0 deletions apps/webapp/app/v3/runEngine.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { runEnginePendingVersionLookup } from "./runEnginePendingVersionLookup.s
import { pickRunOpsStoreForCompletion } from "./runOpsMigration/crossSeamGuard.server";
import { runEngineControlPlaneResolver } from "./runOpsMigration/runEngineControlPlaneResolver.server";
import { runStore } from "./runStore.server";
import { getSnapshotSweepRunner } from "./snapshotStoreBindings.server";
import { getSnapshotStoreConfig } from "./snapshotStoreInstance.server";
import { meter, tracer } from "./tracer.server";

export const engine = singleton("RunEngine", createRunEngine);
Expand Down Expand Up @@ -241,6 +243,22 @@ function createRunEngine() {
randomize: true,
},
},
// Omitted entirely when the snapshot store is unconfigured: passing a runner would register the
// cron job and log an unbound pass every interval on every install that does not use the store.
snapshotStore: getSnapshotStoreConfig().configured
? {
runSweep: async (opts) => {
const run = getSnapshotSweepRunner();
if (!run) {
return { outcome: "unbound" };
}
return run(opts);
},
sweepSchedule: env.RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_SCHEDULE,
sweepJitterInMs: env.RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_JITTER_IN_MS,
sweepBudgetMs: env.RUN_ENGINE_SNAPSHOT_STORE_GC_SWEEP_BUDGET_MS,
}
: undefined,
// Debounce configuration
debounce: {
maxDebounceDurationMs: env.RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS,
Expand Down
12 changes: 11 additions & 1 deletion apps/webapp/app/v3/runStore.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "~/db.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { decorateWithSnapshotStore } from "./snapshotStoreInstance.server";
import {
resilienceForClient,
type TransactionResilienceConfig,
Expand Down Expand Up @@ -173,7 +174,12 @@ function tryResolveRunOpsHandles() {
}
}

export const runStore: RunStore = singleton("RunStore", () => {
/**
* The router with no snapshot decorator. One intended consumer: the orphan sweeper's rule-2
* lookup, which must ask Postgres whether a run row exists and must never be able to ask Redis
* whether Redis is an orphan. Every other caller wants `runStore`.
*/
export const runStoreWithoutSnapshotDecorator: RunStore = singleton("RunStore.undecorated", () => {
const handles = ROUTING_ENABLED ? tryResolveRunOpsHandles() : null;
// Single-store passthrough: self-host (one DB), or a context without run-ops handles.
if (!handles) {
Expand Down Expand Up @@ -202,3 +208,7 @@ export const runStore: RunStore = singleton("RunStore", () => {
legacyResilience: resilienceForClient(handles.legacyWriter),
});
});

export const runStore: RunStore = singleton("RunStore", () =>
decorateWithSnapshotStore(runStoreWithoutSnapshotDecorator)
);
31 changes: 31 additions & 0 deletions apps/webapp/app/v3/snapshotStoreBindings.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { SnapshotRepairEnqueuer } from "@internal/run-store";

export type SweepPassOutcome = {
outcome: "completed" | "partial" | "skipped_locked" | "failed" | "unbound" | "aborted";
counts?: Record<string, number | boolean>;
};

export type SweepRunner = (opts: {
deadline: number;
signal: AbortSignal;
}) => Promise<SweepPassOutcome>;

/** Late-bound so the run store never has to import the engine. A third module wires both at boot. */
let repairEnqueuer: SnapshotRepairEnqueuer | undefined;
let sweepRunner: SweepRunner | undefined;

export function setSnapshotRepairEnqueuer(fn: SnapshotRepairEnqueuer): void {
repairEnqueuer = fn;
}

export function getSnapshotRepairEnqueuer(): SnapshotRepairEnqueuer | undefined {
return repairEnqueuer;
}

export function setSnapshotSweepRunner(fn: SweepRunner): void {
sweepRunner = fn;
}

export function getSnapshotSweepRunner(): SweepRunner | undefined {
return sweepRunner;
}
Loading