@cosmicdrift/kumiko-framework 0.122.1 → 0.122.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/db/queries/shadow-swap.ts +37 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.test.ts +10 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +48 -0
- package/src/pipeline/event-dispatcher.ts +8 -1
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.122.
|
|
3
|
+
"version": "0.122.3",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.122.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.122.3",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
// expressed in meta (hand-added in a migration) is not reconstructed, and a
|
|
19
19
|
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
20
|
|
|
21
|
+
import type { DbConnection, DbTx } from "../connection";
|
|
21
22
|
import type { EntityTableMeta } from "../entity-table-meta";
|
|
22
23
|
import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
|
|
23
24
|
import { renderTableDdl } from "../render-ddl";
|
|
25
|
+
import { columnNamesOf, tableExists } from "../schema-inspection";
|
|
24
26
|
import { quoteTableIdent } from "./table-ops";
|
|
25
27
|
|
|
26
28
|
export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
|
|
@@ -68,6 +70,41 @@ export function rebuildMetaOrThrow(table: unknown, projectionName: string): Enti
|
|
|
68
70
|
return meta;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Fence against a rebuild running with a registry that does not match the
|
|
74
|
+
// migrated live schema (#835): during a rolling deploy, a pod still running
|
|
75
|
+
// the previous build can pick up an async rebuild job; its shadow — built from
|
|
76
|
+
// the stale EntityTableMeta — would swap away a freshly-migrated column
|
|
77
|
+
// (recurrence class of #494). Compares COLUMN NAMES only; a type-/nullability-
|
|
78
|
+
// only drift passes (schema regression, not data loss — the boot gate of the
|
|
79
|
+
// next deploy catches it). A missing live table is fine: nothing to wipe.
|
|
80
|
+
// Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
|
|
81
|
+
// so the shadow search_path could not redirect it anyway.
|
|
82
|
+
export async function assertLiveColumnsMatchMeta(
|
|
83
|
+
db: DbConnection | DbTx,
|
|
84
|
+
meta: EntityTableMeta,
|
|
85
|
+
projectionName: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
// skip: no live table yet — nothing a stale-meta shadow could wipe
|
|
88
|
+
if (!(await tableExists(db, `public.${meta.tableName}`))) return;
|
|
89
|
+
const live = await columnNamesOf(db, meta.tableName);
|
|
90
|
+
const metaNames = new Set(meta.columns.map((c) => c.name));
|
|
91
|
+
const onlyLive = [...live].filter((c) => !metaNames.has(c));
|
|
92
|
+
const onlyMeta = [...metaNames].filter((c) => !live.has(c));
|
|
93
|
+
// skip: column sets match — this process's registry is in sync with the migrated table
|
|
94
|
+
if (onlyLive.length === 0 && onlyMeta.length === 0) return;
|
|
95
|
+
const detail = [
|
|
96
|
+
onlyLive.length > 0 ? `live-only: ${onlyLive.join(", ")}` : "",
|
|
97
|
+
onlyMeta.length > 0 ? `meta-only: ${onlyMeta.join(", ")}` : "",
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join("; ");
|
|
101
|
+
throw new Error(
|
|
102
|
+
`projection-rebuild "${projectionName}": columns of live table "${meta.tableName}" do not match this process's EntityTableMeta (${detail}). ` +
|
|
103
|
+
"Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
|
|
104
|
+
"Rebuild aborted; retry from a pod whose code matches the migrated schema.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
72
109
|
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
73
110
|
// rollback), drops any leftover shadow from a crashed run, then builds the
|
|
@@ -159,10 +159,20 @@ describe("event-dispatcher — happy path", () => {
|
|
|
159
159
|
await stack.eventDispatcher?.runOnce();
|
|
160
160
|
expect(captureA).toHaveLength(1);
|
|
161
161
|
|
|
162
|
+
const stateBefore = await getConsumerState(stack.db, qnA);
|
|
163
|
+
|
|
162
164
|
const second = await stack.eventDispatcher?.runOnce();
|
|
163
165
|
expect(second?.byConsumer[qnA]).toEqual({ processed: 0, failed: 0 });
|
|
164
166
|
// Still only one — consumer correctly saw "nothing new past my cursor".
|
|
165
167
|
expect(captureA).toHaveLength(1);
|
|
168
|
+
|
|
169
|
+
// No pending events means no write at all — an idle pass must not touch
|
|
170
|
+
// updated_at/status, otherwise every empty poll tick burns a WAL record.
|
|
171
|
+
const stateAfter = await getConsumerState(stack.db, qnA);
|
|
172
|
+
expect(stateAfter?.updatedAt.epochMilliseconds).toEqual(
|
|
173
|
+
stateBefore?.updatedAt.epochMilliseconds,
|
|
174
|
+
);
|
|
175
|
+
expect(stateAfter?.status).toBe(stateBefore?.status);
|
|
166
176
|
});
|
|
167
177
|
|
|
168
178
|
test("cursor advances only past successfully-consumed events", async () => {
|
|
@@ -890,3 +890,51 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
|
|
|
890
890
|
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "cutover_probe"`);
|
|
891
891
|
});
|
|
892
892
|
});
|
|
893
|
+
|
|
894
|
+
describe("rebuildProjection — stale-registry column fence (#835)", () => {
|
|
895
|
+
// Simulates the rolling-deploy race: a migration added a column to the live
|
|
896
|
+
// table, but the pod running the rebuild still carries the old registry
|
|
897
|
+
// (EntityTableMeta without that column). The rebuild must abort instead of
|
|
898
|
+
// swapping in a shadow that silently drops the migrated column (#494 class).
|
|
899
|
+
test("aborts when the live table has a column the registry meta lacks — data survives", async () => {
|
|
900
|
+
const group = "00000000-0000-4000-8000-000000000041";
|
|
901
|
+
await appendCreatedEvent(group, "item1");
|
|
902
|
+
await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
903
|
+
expect(await getCount(group)).toBe(1);
|
|
904
|
+
|
|
905
|
+
const raw = asRawClient(testDb.db);
|
|
906
|
+
await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" ADD COLUMN migrated_col text`);
|
|
907
|
+
try {
|
|
908
|
+
await raw.unsafe(
|
|
909
|
+
`UPDATE "read_rebuild_items_per_group" SET migrated_col = 'must-survive' WHERE group_id = $1::uuid`,
|
|
910
|
+
[group],
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
await expect(
|
|
914
|
+
rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry }),
|
|
915
|
+
).rejects.toThrow(/live-only: migrated_col/);
|
|
916
|
+
|
|
917
|
+
// Live table untouched: column AND its data still there.
|
|
918
|
+
const [row] = await raw.unsafe<{ migrated_col: string | null }>(
|
|
919
|
+
`SELECT migrated_col FROM "read_rebuild_items_per_group" WHERE group_id = $1::uuid`,
|
|
920
|
+
[group],
|
|
921
|
+
);
|
|
922
|
+
expect(row?.migrated_col).toBe("must-survive");
|
|
923
|
+
expect(await getCount(group)).toBe(1);
|
|
924
|
+
|
|
925
|
+
// Failure is visible to ops.
|
|
926
|
+
const state = await getProjectionState(testDb.db, qualifiedProjectionName);
|
|
927
|
+
expect(state?.status).toBe("failed");
|
|
928
|
+
expect(state?.lastError).toContain("migrated_col");
|
|
929
|
+
} finally {
|
|
930
|
+
await raw.unsafe(
|
|
931
|
+
`ALTER TABLE "read_rebuild_items_per_group" DROP COLUMN IF EXISTS migrated_col`,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Column sets aligned again → rebuild works.
|
|
936
|
+
const result = await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
|
|
937
|
+
expect(result.eventsProcessed).toBe(1);
|
|
938
|
+
expect(await getCount(group)).toBe(1);
|
|
939
|
+
});
|
|
940
|
+
});
|
|
@@ -524,9 +524,16 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
|
|
|
524
524
|
span.setAttribute("consumer.skip_reason", acquired.skip);
|
|
525
525
|
return;
|
|
526
526
|
}
|
|
527
|
-
await markProcessing(tx, consumer.name, instanceId);
|
|
528
527
|
|
|
529
528
|
const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
|
|
529
|
+
// skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
|
|
530
|
+
// so an idle consumer doesn't burn a WAL record on every poll tick.
|
|
531
|
+
if (events.length === 0) {
|
|
532
|
+
span.setAttribute("consumer.skip_reason", "no_pending_events");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await markProcessing(tx, consumer.name, instanceId);
|
|
536
|
+
|
|
530
537
|
const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
|
|
531
538
|
processed = outcome.processed;
|
|
532
539
|
failed = outcome.failed;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
updateConsumerRebuildCursor,
|
|
7
7
|
} from "../db/queries/event-consumer";
|
|
8
8
|
import {
|
|
9
|
+
assertLiveColumnsMatchMeta,
|
|
9
10
|
buildShadowTable,
|
|
10
11
|
ensureRebuildSchema,
|
|
11
12
|
rebuildMetaOrThrow,
|
|
@@ -147,6 +148,7 @@ export async function rebuildMultiStreamProjection(
|
|
|
147
148
|
await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
148
149
|
await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
149
150
|
|
|
151
|
+
await assertLiveColumnsMatchMeta(tx, meta, mspName);
|
|
150
152
|
await buildShadowTable(tx, meta);
|
|
151
153
|
|
|
152
154
|
const subscribedTypes = Object.keys(msp.apply);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
selectEventsForProjectionRebuildBatch,
|
|
8
8
|
} from "../db/queries/projection-rebuild";
|
|
9
9
|
import {
|
|
10
|
+
assertLiveColumnsMatchMeta,
|
|
10
11
|
buildShadowTable,
|
|
11
12
|
ensureRebuildSchema,
|
|
12
13
|
fenceLiveTable,
|
|
@@ -260,6 +261,7 @@ export async function rebuildProjection(
|
|
|
260
261
|
if (skipApplyErrors) await createRebuildDeadLetterTable(db);
|
|
261
262
|
await db.begin(async (tx: DbTx) => {
|
|
262
263
|
await markProjectionRebuilding(tx, projectionName);
|
|
264
|
+
await assertLiveColumnsMatchMeta(tx, meta, projectionName);
|
|
263
265
|
await buildShadowTable(tx, meta);
|
|
264
266
|
deps.__test_onBuildShadowTable?.();
|
|
265
267
|
|