Skip to content

Commit 413a945

Browse files
d-csclaude
andcommitted
fix(run-ops split): interlock split mode against native realtime backend
Enabling RUN_OPS_SPLIT_ENABLED without REALTIME_BACKEND_NATIVE_ENABLED silently breaks realtime: Electric replicates only from the control-plane DB, so NEW-resident (ksuid) runs on the dedicated run-ops DB are invisible and every realtime subscription hangs. Add a boot-time interlock that refuses split mode in that misconfiguration, mirroring the existing distinct-DB data-loss sentinel. The check is a pure predicate (assertSplitRealtimeInterlock) run synchronously inside assertRunOpsSplitSentinel on the same eager-boot path, failing fast before the async DB probe and before any run-ops routing is wired. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 302e86d commit 413a945

4 files changed

Lines changed: 73 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Add a boot-time interlock that refuses to enable the run-ops DB split
7+
(`RUN_OPS_SPLIT_ENABLED`) unless the native realtime backend
8+
(`REALTIME_BACKEND_NATIVE_ENABLED`) is also on. Electric replicates only from the
9+
control-plane database, so enabling the split without the native backend would
10+
leave NEW-resident (ksuid) runs invisible to realtime and hang every
11+
subscription. The check runs synchronously on the same eager-boot path as the
12+
existing distinct-DB sentinel and fails fast before any run-ops routing is wired.

apps/webapp/app/db.server.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import {
1919
logTransactionInfrastructureError,
2020
} from "./utils/prismaErrors";
2121
import { singleton } from "./utils/singleton";
22-
import { isSplitEnabled } from "./v3/runOpsMigration/splitMode.server";
22+
import {
23+
isSplitEnabled,
24+
assertSplitRealtimeInterlock,
25+
} from "./v3/runOpsMigration/splitMode.server";
2326
import { computeRunOpsSplitReadEnabled } from "./v3/runOpsMigration/runOpsSplitReadGate";
2427
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
2528
import { context, Span, trace } from "@opentelemetry/api";
@@ -283,6 +286,13 @@ export const runOpsSplitReadEnabled: boolean = computeRunOpsSplitReadEnabled({
283286
// call it from the eager-boot path before any run-ops routing is wired.
284287
export async function assertRunOpsSplitSentinel(): Promise<void> {
285288
if (!env.RUN_OPS_SPLIT_ENABLED) return;
289+
// Realtime interlock (synchronous): Electric replicates only from the control-plane
290+
// DB, so split-on without the native realtime backend leaves NEW-resident runs
291+
// invisible and hangs every subscription. Fail fast before the async DB probe.
292+
assertSplitRealtimeInterlock({
293+
splitEnabled: env.RUN_OPS_SPLIT_ENABLED,
294+
nativeRealtimeEnabled: env.REALTIME_BACKEND_NATIVE_ENABLED === "1",
295+
});
286296
const ok = await isSplitEnabled();
287297
if (!ok) {
288298
throw new Error(

apps/webapp/app/v3/runOpsMigration/splitMode.server.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ export async function computeSplitEnabled(
4040
return result.distinct === true;
4141
}
4242

43+
export type SplitRealtimeInterlockConfig = {
44+
splitEnabled: boolean;
45+
nativeRealtimeEnabled: boolean;
46+
};
47+
48+
/**
49+
* Boot-time realtime interlock (pure predicate). Split mode puts NEW-resident
50+
* (ksuid) runs on the dedicated run-ops DB, but Electric replicates only from the
51+
* control-plane DB — with the native realtime backend OFF those runs are invisible
52+
* and every realtime subscription hangs. Refuse split unless native is on; split-off
53+
* is always allowed regardless of the realtime backend.
54+
*/
55+
export function assertSplitRealtimeInterlock(config: SplitRealtimeInterlockConfig): void {
56+
if (!config.splitEnabled) {
57+
return;
58+
}
59+
if (!config.nativeRealtimeEnabled) {
60+
throw new Error(
61+
"RUN_OPS_SPLIT_ENABLED is on but the native realtime backend (REALTIME_BACKEND_NATIVE_ENABLED) is not enabled — Electric cannot serve NEW-resident runs; refusing to enable split."
62+
);
63+
}
64+
}
65+
4366
let cached: Promise<boolean> | undefined;
4467

4568
export function isSplitEnabled(): Promise<boolean> {

apps/webapp/test/runOpsSplitMode.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
22
// @testcontainers/postgresql resolves because it is declared in apps/webapp/package.json.
33
import { PostgreSqlContainer } from "@testcontainers/postgresql";
4-
import { computeSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
4+
import {
5+
computeSplitEnabled,
6+
assertSplitRealtimeInterlock,
7+
} from "~/v3/runOpsMigration/splitMode.server";
58
import { probeDistinctDatabases } from "~/v3/runOpsMigration/distinctDbSentinel.server";
69

710
describe("computeSplitEnabled (pure)", () => {
@@ -58,6 +61,29 @@ describe("computeSplitEnabled (pure)", () => {
5861
});
5962
});
6063

64+
describe("assertSplitRealtimeInterlock (pure)", () => {
65+
it("throws when split is on but the native realtime backend is off", () => {
66+
expect(() =>
67+
assertSplitRealtimeInterlock({ splitEnabled: true, nativeRealtimeEnabled: false })
68+
).toThrowError(/native realtime backend|REALTIME_BACKEND_NATIVE_ENABLED/i);
69+
});
70+
71+
it("does not throw when split is on and the native realtime backend is on", () => {
72+
expect(() =>
73+
assertSplitRealtimeInterlock({ splitEnabled: true, nativeRealtimeEnabled: true })
74+
).not.toThrow();
75+
});
76+
77+
it("does not throw when split is off, regardless of the native realtime backend", () => {
78+
expect(() =>
79+
assertSplitRealtimeInterlock({ splitEnabled: false, nativeRealtimeEnabled: false })
80+
).not.toThrow();
81+
expect(() =>
82+
assertSplitRealtimeInterlock({ splitEnabled: false, nativeRealtimeEnabled: true })
83+
).not.toThrow();
84+
});
85+
});
86+
6187
describe("distinct-DB sentinel (real Postgres)", () => {
6288
it("reports NOT distinct when both URLs hit the same physical cluster", async () => {
6389
const pg = await new PostgreSqlContainer("docker.io/postgres:14").start();

0 commit comments

Comments
 (0)