Skip to content

Commit 6973ca5

Browse files
committed
fix(webapp): exempt aliased shards from the shard boot interlock
An aliased shard owns no database: it shares its target's client by reference, so its rows are still read with the split off and nothing is dropped. Feeding every configured key to the interlock refused an alias-only config for no reason. The exemption now lives inside the interlock rather than at its call site, so it takes the raw descriptors and no caller can forget to apply it. This matches where the distinctness sentinel, the coresidency loop and replication already draw the line.
1 parent 50d18a8 commit 6973ca5

3 files changed

Lines changed: 51 additions & 16 deletions

File tree

apps/webapp/app/db.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ export async function assertRunOpsSplitSentinel(): Promise<void> {
622622
// checked BEFORE the split-off early return below, which would otherwise skip it in silence.
623623
assertShardsRequireSplit({
624624
splitFlagEnabled: env.RUN_OPS_SPLIT_ENABLED,
625-
shardKeys: env.RUN_OPS_SHARDS.map((shard) => shard.key),
625+
shards: env.RUN_OPS_SHARDS,
626626
});
627627
if (!env.RUN_OPS_SPLIT_ENABLED) return;
628628
// Realtime interlock (synchronous): Electric replicates only from the control-plane

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import { env } from "~/env.server";
88
import { logger } from "~/services/logger.server";
99
import { probeDistinctStores as defaultProbe } from "./distinctDbSentinel.server";
10-
import { nonAliasedShards, type ShardTarget } from "~/v3/runOpsShards.server";
10+
import {
11+
nonAliasedShards,
12+
type RunOpsShardDescriptor,
13+
type ShardTarget,
14+
} from "~/v3/runOpsShards.server";
1115

1216
export type SplitModeConfig = {
1317
flagEnabled: boolean;
@@ -75,7 +79,8 @@ export function assertSplitRealtimeInterlock(config: SplitRealtimeInterlockConfi
7579

7680
export type ShardsRequireSplitConfig = {
7781
splitFlagEnabled: boolean;
78-
shardKeys: string[];
82+
/** Raw descriptors. The alias exemption is applied here so no call site can forget it. */
83+
shards: RunOpsShardDescriptor[];
7984
};
8085

8186
/**
@@ -86,11 +91,18 @@ export type ShardsRequireSplitConfig = {
8691
* distinct) already refuse to boot; this closes the one that does not.
8792
*/
8893
export function assertShardsRequireSplit(config: ShardsRequireSplitConfig): void {
89-
if (config.splitFlagEnabled || config.shardKeys.length === 0) {
94+
if (config.splitFlagEnabled) {
95+
return;
96+
}
97+
// An aliased shard owns no database: it shares its target's client by reference, so its rows are
98+
// still read with the split off and nothing is dropped. Exempt here exactly as it is exempt from
99+
// the distinctness sentinel, the coresidency loop and replication.
100+
const owning = nonAliasedShards(config.shards).map((shard) => shard.key);
101+
if (owning.length === 0) {
90102
return;
91103
}
92104
throw new Error(
93-
`RUN_OPS_SHARDS configures shard(s) ${config.shardKeys.join(", ")} but RUN_OPS_SPLIT_ENABLED is off, so no shard client is built and rows on those databases would be silently missing; refusing to start.`
105+
`RUN_OPS_SHARDS configures shard(s) ${owning.join(", ")} but RUN_OPS_SPLIT_ENABLED is off, so no shard client is built and rows on those databases would be silently missing; refusing to start.`
94106
);
95107
}
96108

apps/webapp/test/runOpsSplitMode.test.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,32 +157,55 @@ describe("assertSplitRealtimeInterlock (pure)", () => {
157157
});
158158

159159
describe("assertShardsRequireSplit (pure)", () => {
160+
const owning = (key: string) => ({
161+
key,
162+
region: "local",
163+
url: `postgres://${key}`,
164+
replication: { slotName: `s_${key}`, publicationName: `p_${key}`, originGeneration: 2 },
165+
});
166+
const aliased = (key: string) => ({ key, region: "local", aliasOf: "new" as const });
167+
160168
it("allows shards when the split flag is on", () => {
161169
expect(() =>
162-
assertShardsRequireSplit({ splitFlagEnabled: true, shardKeys: ["a"] })
170+
assertShardsRequireSplit({ splitFlagEnabled: true, shards: [owning("a")] })
163171
).not.toThrow();
164172
});
165173

166174
it("allows the split flag off when no shard is configured", () => {
167-
expect(() =>
168-
assertShardsRequireSplit({ splitFlagEnabled: false, shardKeys: [] })
169-
).not.toThrow();
175+
expect(() => assertShardsRequireSplit({ splitFlagEnabled: false, shards: [] })).not.toThrow();
170176
});
171177

172-
// Shards are only ever built on the split-on arm of selectRunOpsTopology, so configuring one
173-
// while the split flag is off silently drops it: no shard client, no shard leg, and any row
174-
// already resident on that database disappears from every list with no error.
175-
it("refuses to boot when a shard is configured but the split flag is off", () => {
178+
// Shards are only built on the split-on arm of selectRunOpsTopology, so configuring one while
179+
// the split flag is off silently drops it: no client, no leg, and any row already resident on
180+
// that database disappears from every list with no error.
181+
it("refuses to boot when a shard that owns a database is configured but the split flag is off", () => {
176182
expect(() =>
177-
assertShardsRequireSplit({ splitFlagEnabled: false, shardKeys: ["a", "b"] })
183+
assertShardsRequireSplit({ splitFlagEnabled: false, shards: [owning("a"), owning("b")] })
178184
).toThrow(/RUN_OPS_SHARDS/);
179185
});
180186

181-
it("names the configured shards so the operator can see which were dropped", () => {
187+
it("names the dropped shards so the operator can see which ones they are", () => {
182188
expect(() =>
183-
assertShardsRequireSplit({ splitFlagEnabled: false, shardKeys: ["a", "b"] })
189+
assertShardsRequireSplit({ splitFlagEnabled: false, shards: [owning("a"), owning("b")] })
184190
).toThrow(/a, b/);
185191
});
192+
193+
// An aliased shard owns no database: it shares its target's client by reference, so its rows are
194+
// still read with the split off. Refusing to boot for one is a false positive.
195+
it("allows an alias-only config with the split flag off", () => {
196+
expect(() =>
197+
assertShardsRequireSplit({ splitFlagEnabled: false, shards: [aliased("a")] })
198+
).not.toThrow();
199+
});
200+
201+
it("refuses only for the owning shards when the config mixes both", () => {
202+
expect(() =>
203+
assertShardsRequireSplit({
204+
splitFlagEnabled: false,
205+
shards: [aliased("a"), owning("b")],
206+
})
207+
).toThrow(/shard\(s\) b /);
208+
});
186209
});
187210

188211
describe("distinct-DB sentinel (real Postgres)", () => {

0 commit comments

Comments
 (0)