Skip to content

Commit 5e0fd2a

Browse files
committed
A lost rebuild claim reports the persisted catalog only when it is whole
1 parent d2dcf24 commit 5e0fd2a

2 files changed

Lines changed: 104 additions & 8 deletions

File tree

‎packages/core/sdk/src/executor.test.ts‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,9 +1049,10 @@ describe("createExecutor", () => {
10491049
expect(rowsAfter.map((row) => row.generation).sort(), "A did not replace the rows").toEqual(
10501050
rowsBefore.map((row) => row.generation).sort(),
10511051
);
1052-
// And A reported the persisted rows, not the ones it discovered and
1053-
// failed to write — so its caller cannot disagree with the next list.
1054-
// A discovered `extra`; persisted has no `extra`; the report must not.
1052+
// And A reported the persisted rows, VALIDATED against the manifest,
1053+
// not the ones it discovered and failed to write — so its caller
1054+
// cannot disagree with the next list. A discovered `extra`; persisted
1055+
// has no `extra`; the report must not.
10551056
expect(
10561057
reported.map((tool) => String(tool.name)).sort(),
10571058
"refresh reports what is persisted",
@@ -1086,6 +1087,77 @@ describe("createExecutor", () => {
10861087
}),
10871088
);
10881089

1090+
// D1 commits the winner's manifest before its row batch. A loser that
1091+
// reads the rows in that window must NOT report the previous build's rows
1092+
// as if they were current: `describeAll` refuses that state (manifest names
1093+
// the new generation, rows carry the old), so the loser reports nothing.
1094+
it.effect("a lost claim reports nothing while the winner's rows have not landed", () =>
1095+
Effect.gen(function* () {
1096+
const config = makeTestConfig({ plugins: [demoPlugin] as const });
1097+
const raceState: { armed: boolean } = { armed: false };
1098+
const wrap = (inner: FumaDb): FumaDb =>
1099+
new Proxy(inner, {
1100+
get(target, prop) {
1101+
if (prop === "withContext") {
1102+
return (context: unknown) =>
1103+
wrap((target.withContext as (c: unknown) => FumaDb)(context));
1104+
}
1105+
if (prop === "transaction") {
1106+
return (run: (tx: FumaDb) => Promise<unknown>) =>
1107+
(target.transaction as (r: (tx: FumaDb) => Promise<unknown>) => Promise<unknown>)(
1108+
(tx) => run(wrap(tx)),
1109+
);
1110+
}
1111+
if (prop === "replaceMany") {
1112+
return async (plan: unknown) => {
1113+
if (raceState.armed) {
1114+
raceState.armed = false;
1115+
// B claims AND has already committed its manifest for a
1116+
// generation whose rows are not in the table yet — the D1
1117+
// guard-then-batch window.
1118+
await (target.updateMany as (t: unknown, q: unknown) => Promise<unknown>)(
1119+
"connection",
1120+
{
1121+
where: (b: { (c: string, op: string, v: unknown): unknown }) =>
1122+
b("integration", "=", String(INTEG)),
1123+
set: {
1124+
tools_synced_at: null,
1125+
tools_rebuild: "build-B",
1126+
tools_manifest: { generation: "gen-B", tools: 2, definitions: 6 },
1127+
},
1128+
},
1129+
);
1130+
}
1131+
return (target.replaceMany as (p: unknown) => Promise<{ applied: boolean }>)(plan);
1132+
};
1133+
}
1134+
return Reflect.get(target, prop);
1135+
},
1136+
});
1137+
const executor = yield* createExecutor({ ...config, db: wrap(config.db) });
1138+
yield* executor.demo.seed();
1139+
yield* executor.connections.create({
1140+
owner: "org",
1141+
name: CONN,
1142+
integration: INTEG,
1143+
template: TEMPLATE,
1144+
from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("v") },
1145+
});
1146+
expect((yield* executor.tools.describeAll()).length).toBeGreaterThan(0);
1147+
1148+
raceState.armed = true;
1149+
const reported = yield* executor.connections.refresh({
1150+
owner: "org",
1151+
integration: INTEG,
1152+
name: CONN,
1153+
});
1154+
expect(raceState.armed, "B interleaved").toBe(false);
1155+
// The rows in the table are the OLD build's; the manifest is B's. That
1156+
// is not a servable catalog, and the loser must not pretend it is.
1157+
expect(reported, "nothing is reported while the winner lands").toEqual([]);
1158+
}),
1159+
);
1160+
10891161
it.effect("execute dispatches a connection-produced tool to the owning plugin", () =>
10901162
Effect.gen(function* () {
10911163
const executor = yield* makeTestExecutor({

‎packages/core/sdk/src/executor.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3813,12 +3813,36 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
38133813
}),
38143814
);
38153815
// A build that lost its claim persisted nothing; what it discovered
3816-
// is not the catalog. Report what IS persisted — the winner's rows, or
3817-
// nothing if the winner is still landing — so a caller never sees
3818-
// tools the next list will contradict.
3816+
// is not the catalog. Report what IS persisted — but only if it is a
3817+
// whole build, checked the way every list checks it (manifest vs
3818+
// rows). A raw row scan could, on D1, catch the winner between its
3819+
// committed manifest and its row batch and hand back a catalog the
3820+
// next list refuses. While the winner is still landing the loser
3821+
// reports an EMPTY catalog rather than a wrong one; the caller's next
3822+
// list (or the stale scan) picks up the winner's finished build. This
3823+
// cannot go through `describeAll`: that runs the stale sync, which
3824+
// would re-enter this very connection's in-flight production.
38193825
if (!applied) {
3820-
const persisted = yield* core.findMany("tool", { where });
3821-
return persisted.map((row) => rowToTool(row as ConnectionToolRow));
3826+
const [rows, definitionRows, connectionRow] = yield* Effect.all([
3827+
core.findMany("tool", { where, select: [...TOOL_INVOCATION_COLUMNS, "generation"] }),
3828+
core.findMany("definition", { where }),
3829+
findConnectionRow(ref),
3830+
]);
3831+
const manifest = connectionRow
3832+
? Option.getOrNull(
3833+
decodeCatalogManifest(decodeJsonColumn(connectionRow.tools_manifest)),
3834+
)
3835+
: null;
3836+
if (
3837+
!manifest ||
3838+
rows.length !== manifest.tools ||
3839+
definitionRows.length !== manifest.definitions ||
3840+
rows.some((row) => row.generation !== manifest.generation) ||
3841+
definitionRows.some((row) => row.generation !== manifest.generation)
3842+
) {
3843+
return [];
3844+
}
3845+
return rows.map((row) => rowToTool(row));
38223846
}
38233847

38243848
return result.tools.map((tool: ToolDef) =>

0 commit comments

Comments
 (0)