Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 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
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
34 changes: 34 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,40 @@ 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),
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"),

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
22 changes: 22 additions & 0 deletions apps/webapp/app/v3/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ 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",
// 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 @@ -153,6 +161,8 @@ 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"]),
};

export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
Expand Down Expand Up @@ -188,8 +198,20 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
FEATURE_FLAG.runOpsMintShardSetPrev,
FEATURE_FLAG.runOpsMintShardSetFlippedAt,
FEATURE_FLAG.runOpsMintShardOverride,
// The dial is deployment-wide; only snapshotStoreOrgMode is per-org.
FEATURE_FLAG.snapshotStoreMode,
Comment on lines +201 to +202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Org-only snapshot dial appears editable on the global flags page

snapshotStoreOrgMode is a per-organisation-only flag but is missing from GLOBAL_LOCKED_FLAGS, unlike every other org-scoped-only flag. The convention noted just below (featureFlags.ts) is that such flags go there so the global page renders them read-only. Instead the global flags page shows an editable control for it, and setting it fails the save with a 400 from globalOnlySnapshotStoreFlagError.

Prompt for agents
snapshotStoreOrgMode is a per-organisation-only flag: no save path reads it from the global row (globalOnlySnapshotStoreFlagError rejects it on the global routes). The comment above GLOBAL_LOCKED_FLAGS (apps/webapp/app/v3/featureFlags.ts around lines 170-173) states that org-scoped-only flags must be listed in GLOBAL_LOCKED_FLAGS so the global admin page (admin.feature-flags.tsx) renders them read-only rather than offering an inert editable control. snapshotStoreOrgMode was added to the catalog and to ORG_LOCKED_FLAGS, but not to GLOBAL_LOCKED_FLAGS, so the global page shows an editable enum control for it that then errors on save. Add FEATURE_FLAG.snapshotStoreOrgMode to GLOBAL_LOCKED_FLAGS to match the convention and the other org-scoped-only flags.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

];

/**
* 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 {
if (!(FEATURE_FLAG.snapshotStoreMode in values)) return values;
const { [FEATURE_FLAG.snapshotStoreMode]: _dropped, ...rest } = values;
return rest as T;
}

/**
* 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