Skip to content

Commit ee6c362

Browse files
committed
test(metadata-protocol): pin the recorded-by plan resuming after a committed chunk at a non-default chunk size
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57ee359 commit ee6c362

1 file changed

Lines changed: 43 additions & 1 deletion

File tree

‎packages/metadata-protocol/src/migrations/recorded-by-sentinel.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
*/
2222

2323
import { describe, it, expect } from 'vitest';
24-
import { runMigrationJournal, readRunJournal } from '@objectstack/core';
24+
import { runMigrationJournal, readRunJournal, resumeMigrationJournal, type MigrationPlan } from '@objectstack/core';
2525
// [#5619] The producer's OWN write-verb dispatch decisions. Imported from
2626
// `@objectstack/metadata-core` rather than `@objectstack/objectql` because
2727
// objectql depends on THIS package — the import that would pin these doubles to
@@ -236,6 +236,48 @@ describe('#4556 migration — sentinel → NULL', () => {
236236
expect(byId(engine, 'h2').recorded_by).toBe('usr_alice');
237237
});
238238

239+
it('[#21528] a run killed after a committed chunk, started at chunk size 2, resumes with the plan the owner registers', async () => {
240+
const engine = new FakeEngine();
241+
seedMixed(engine);
242+
// Started the way `os migrate recorded-by --apply --chunk-size 2` starts
243+
// it: chunk 0 converts h1+h3 and commits, and the process dies inside
244+
// chunk 1 (h5) before writing anything. The forward stops there for good,
245+
// so the tables at that moment are what the kill leaves; the resume gets
246+
// a copy, since this engine is still inside the dead chunk's transaction.
247+
const started = createRecordedBySentinelPlan({ chunkSize: 2 });
248+
let runId = '';
249+
let killed: () => void = () => {};
250+
const atKill = new Promise<void>((resolve) => { killed = resolve; });
251+
const crashing: MigrationPlan = {
252+
...started,
253+
steps: [{
254+
...started.steps[0]!,
255+
async forward(rows, ctx, e) {
256+
if (ctx.chunkIndex === 0) return started.steps[0]!.forward(rows, ctx, e);
257+
runId = ctx.runId;
258+
killed();
259+
return new Promise<void>(() => {});
260+
},
261+
}],
262+
};
263+
void runMigrationJournal(asEngine(engine), crashing);
264+
await atKill;
265+
const restarted = new FakeEngine();
266+
restarted.tables = new Map([...engine.tables].map(([k, v]) => [k, v.map((r) => ({ ...r }))]));
267+
expect(await findSentinelHistoryRows(asEngine(restarted))).toEqual([{ id: 'h5' }]);
268+
269+
// The registry holds the owner's plan at its default size, and load() now
270+
// returns only h5: the resume still runs the journal's two chunks.
271+
const result = await resumeMigrationJournal(asEngine(restarted), createRecordedBySentinelPlan(), runId);
272+
273+
expect(result).toMatchObject({ runId, status: 'completed', chunksTotal: 2, chunksCommitted: 2 });
274+
expect(restarted.history().filter((r) => r.recorded_by === RECORDED_BY_SENTINEL)).toEqual([]);
275+
expect(byId(restarted, 'h2').recorded_by).toBe('usr_alice');
276+
const events = await readRunJournal(asEngine(restarted), runId);
277+
expect(events.filter((e) => e.kind === 'chunk_done').map((e) => e.chunk_index)).toEqual([0, 1]);
278+
expect(events[events.length - 1]!.kind).toBe('run_done');
279+
});
280+
239281
it('findSentinelHistoryRows selects only sentinel rows, by id', async () => {
240282
const engine = new FakeEngine();
241283
seedMixed(engine);

0 commit comments

Comments
 (0)