Skip to content

Commit 57ee359

Browse files
committed
test(core): pin resume against the started-over chunk plan — a shrinking load, the journal's chunk size, the changed-plan control
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent de37d8a commit 57ee359

1 file changed

Lines changed: 190 additions & 0 deletions

File tree

‎packages/core/src/utils/migration-journal.test.ts‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
hashMigrationPlan,
4040
MigrationJournalRefusal,
4141
MigrationPlanRegistry,
42+
type MigrationChunkContext,
4243
type MigrationPlan,
4344
type MigrationPlanStep,
4445
} from './migration-journal';
@@ -413,6 +414,195 @@ describe('plan-hash mismatch on resume (acceptance case 3)', () => {
413414
});
414415
});
415416

417+
// ── a resume compares what the run started over (#21528) ─────────────────
418+
419+
/**
420+
* Run `plan` until a forward reaches run-global chunk `killAt`, and hand back
421+
* the database as a process killed there leaves it: the runner's own
422+
* `run_started` and every committed chunk, `chunk_started(killAt)` with no
423+
* `chunk_done`. The killed forward writes nothing before it stops, so the
424+
* tables at that moment ARE the post-crash state; a second engine gets a
425+
* copy, because the first one is still inside the dead chunk's transaction.
426+
*/
427+
async function crashAt(
428+
engine: FakeEngine,
429+
plan: MigrationPlan,
430+
killAt: number,
431+
options: { chunkSize?: number } = {},
432+
): Promise<{ restarted: FakeEngine; runId: string }> {
433+
let runId = '';
434+
let reached: () => void = () => {};
435+
const atKill = new Promise<void>((resolve) => { reached = resolve; });
436+
const crashing: MigrationPlan = {
437+
...plan,
438+
steps: plan.steps.map((s) => ({
439+
...s,
440+
async forward(rows: unknown[], ctx: MigrationChunkContext, e: Parameters<typeof runMigrationJournal>[0]) {
441+
if (ctx.chunkIndex !== killAt) return s.forward(rows, ctx, e);
442+
runId = ctx.runId;
443+
reached();
444+
return new Promise<void>(() => {});
445+
},
446+
})),
447+
};
448+
void runMigrationJournal(asEngine(engine), crashing, options);
449+
await atKill;
450+
const restarted = new FakeEngine();
451+
restarted.tables = new Map([...engine.tables].map(([k, v]) => [k, v.map((r) => ({ ...r }))]));
452+
return { restarted, runId };
453+
}
454+
455+
const ITEMS = 'items';
456+
const ITEMS_DONE = 'items_done';
457+
458+
/**
459+
* A step whose `load()` selects only the work still to do — `items` rows
460+
* without an `items_done` row — so it shrinks as the run commits chunks, the
461+
* shape `recorded-by`'s plan has. Its forward writes through the engine, so a
462+
* rolled-back chunk takes its writes with it.
463+
*/
464+
function makeShrinkingStep(
465+
engine: FakeEngine,
466+
n: number,
467+
overrides: Partial<MigrationPlanStep<{ id: string }>> = {},
468+
): MigrationPlanStep<{ id: string }> & { forwarded: string[][]; compensated: string[][] } {
469+
engine.tables.set(ITEMS, Array.from({ length: n }, (_, i) => ({ id: `i${i}` })));
470+
const forwarded: string[][] = [];
471+
const compensated: string[][] = [];
472+
return {
473+
name: 'shrinking',
474+
forwarded,
475+
compensated,
476+
async load(e) {
477+
const done = new Set((await e.find(ITEMS_DONE, {})).map((r: { id: string }) => r.id));
478+
return (await e.find(ITEMS, {}))
479+
.filter((r: { id: string }) => !done.has(r.id))
480+
.map((r: { id: string }) => ({ id: r.id }));
481+
},
482+
async forward(rows, ctx, e) {
483+
forwarded.push(rows.map((r) => r.id));
484+
for (const r of rows) await e.insert(ITEMS_DONE, { id: r.id }, { context: ctx.context as never });
485+
},
486+
async compensate(rows) { compensated.push(rows.map((r) => r.id)); },
487+
...overrides,
488+
};
489+
}
490+
491+
const doneIds = (e: FakeEngine) => (e.tables.get(ITEMS_DONE) ?? []).map((r) => r.id);
492+
const chunkEvents = async (e: FakeEngine, runId: string, kind: string) =>
493+
(await readRunJournal(asEngine(e), runId)).filter((ev) => ev.kind === kind).map((ev) => ev.chunk_index);
494+
495+
describe('a resume compares what the run started over, not what load() returns now (#21528)', () => {
496+
it('resumes a run killed after a committed chunk when load() selects only the remaining work', async () => {
497+
const engine = new FakeEngine();
498+
const step = makeShrinkingStep(engine, 5);
499+
const plan: MigrationPlan = { id: 'p', steps: [step], chunkSize: 2, onCrash: 'resume' };
500+
const { restarted, runId } = await crashAt(engine, plan, 1);
501+
expect(doneIds(restarted)).toEqual(['i0', 'i1']); // chunk 0 committed
502+
503+
const result = await resumeMigrationJournal(asEngine(restarted), plan, runId);
504+
505+
expect(result).toMatchObject({ status: 'completed', chunksTotal: 3, chunksCommitted: 3 });
506+
// The rows load() returns now belong, in order, to chunks 1 and 2; chunk 0
507+
// is not run again.
508+
expect(step.forwarded).toEqual([['i0', 'i1'], ['i2', 'i3'], ['i4']]);
509+
expect(doneIds(restarted)).toEqual(['i0', 'i1', 'i2', 'i3', 'i4']);
510+
expect(await chunkEvents(restarted, runId, 'chunk_started')).toEqual([0, 1, 1, 2]);
511+
expect(await chunkEvents(restarted, runId, 'chunk_done')).toEqual([0, 1, 2]);
512+
});
513+
514+
it('resumes a run whose load() does not shrink with each chunk\'s rows where the journal put them', async () => {
515+
const engine = new FakeEngine();
516+
const step = makeStep(6);
517+
const plan: MigrationPlan = { id: 'p', steps: [step], chunkSize: 2, onCrash: 'resume' };
518+
const { restarted, runId } = await crashAt(engine, plan, 1);
519+
520+
const result = await resumeMigrationJournal(asEngine(restarted), plan, runId);
521+
522+
expect(result).toMatchObject({ status: 'completed', chunksTotal: 3, chunksCommitted: 3 });
523+
expect(step.written).toEqual([0, 1, 2, 3, 4, 5]); // each row once; chunk 0 not redone
524+
});
525+
526+
it('resumes a run with the chunk size it started with, from the journal — not the plan\'s current one', async () => {
527+
const engine = new FakeEngine();
528+
const step = makeShrinkingStep(engine, 5);
529+
// Started at 2, the way `--chunk-size 2` starts one; the plan handed back
530+
// for the resume declares no size, so its own would be the default 200.
531+
const { restarted, runId } = await crashAt(engine, { id: 'p', steps: [step] }, 0, { chunkSize: 2 });
532+
533+
const result = await resumeMigrationJournal(asEngine(restarted), { id: 'p', steps: [step] }, runId);
534+
535+
expect(result).toMatchObject({ status: 'completed', chunksTotal: 3, chunksCommitted: 3 });
536+
expect(step.forwarded).toEqual([['i0', 'i1'], ['i2', 'i3'], ['i4']]);
537+
});
538+
539+
it('still refuses PLAN_CHANGED a plan whose declared identity changed (the control), and writes nothing', async () => {
540+
for (const changed of [
541+
(s: MigrationPlanStep<{ id: string }>): MigrationPlan => ({ id: 'p', steps: [{ ...s, name: 'shrinking, v2' }], chunkSize: 2 }),
542+
(s: MigrationPlanStep<{ id: string }>): MigrationPlan => ({ id: 'p.v2', steps: [s], chunkSize: 2 }),
543+
(s: MigrationPlanStep<{ id: string }>): MigrationPlan => ({ id: 'p', steps: [s, { ...s, name: 'added' }], chunkSize: 2 }),
544+
]) {
545+
const engine = new FakeEngine();
546+
const step = makeShrinkingStep(engine, 5);
547+
const { restarted, runId } = await crashAt(engine, { id: 'p', steps: [step], chunkSize: 2 }, 1);
548+
const before = (restarted.tables.get(JOURNAL) ?? []).length;
549+
550+
await expect(resumeMigrationJournal(asEngine(restarted), changed(step), runId))
551+
.rejects.toMatchObject({ name: 'MigrationJournalRefusal', code: 'PLAN_CHANGED' });
552+
expect((restarted.tables.get(JOURNAL) ?? []).length).toBe(before);
553+
expect(doneIds(restarted)).toEqual(['i0', 'i1']);
554+
}
555+
});
556+
557+
it('refuses PLAN_CHANGED when load() returns neither every row the run started over nor exactly the rows it had left', async () => {
558+
const engine = new FakeEngine();
559+
const step = makeShrinkingStep(engine, 5);
560+
const plan: MigrationPlan = { id: 'p', steps: [step], chunkSize: 2 };
561+
const { restarted, runId } = await crashAt(engine, plan, 1);
562+
// A row appeared under the run: it started over 5 rows and had 3 left,
563+
// and load() now returns 4.
564+
restarted.tables.get(ITEMS)!.push({ id: 'i5' });
565+
const before = (restarted.tables.get(JOURNAL) ?? []).length;
566+
567+
const refusal = await resumeMigrationJournal(asEngine(restarted), plan, runId).catch((e: unknown) => e);
568+
569+
expect(refusal).toBeInstanceOf(MigrationJournalRefusal);
570+
expect(refusal).toMatchObject({ code: 'PLAN_CHANGED' });
571+
expect((refusal as Error).message).toContain("step 'shrinking'"); // the binding refusal names its step
572+
expect((restarted.tables.get(JOURNAL) ?? []).length).toBe(before);
573+
});
574+
575+
it('halts the unwind at a chunk an earlier process committed, rather than compensating other rows', async () => {
576+
const engine = new FakeEngine();
577+
const step = makeShrinkingStep(engine, 5);
578+
const plan: MigrationPlan = { id: 'p', steps: [step], chunkSize: 2, onCrash: 'resume' };
579+
const { restarted, runId } = await crashAt(engine, plan, 1);
580+
const failing: MigrationPlan = {
581+
...plan,
582+
steps: [{
583+
...step,
584+
async forward(rows, ctx, e) {
585+
if (ctx.chunkIndex === 2) throw new Error('chunk 2 fails on the resume');
586+
return step.forward(rows, ctx, e);
587+
},
588+
}],
589+
};
590+
591+
const result = await resumeMigrationJournal(asEngine(restarted), failing, runId);
592+
593+
// Chunk 1, committed by this process, is compensated with its own rows.
594+
// Chunk 0's rows left load() when it committed, so nothing here knows
595+
// them: the run halts `failed` instead of compensating some other rows
596+
// and journalling a clean unwind.
597+
expect(result.status).toBe('failed');
598+
expect(step.compensated).toEqual([['i2', 'i3']]);
599+
expect(await chunkEvents(restarted, runId, 'compensated')).toEqual([1]);
600+
const failure = (await readRunJournal(asEngine(restarted), runId)).at(-1)!;
601+
expect(failure).toMatchObject({ kind: 'run_failed', chunk_index: 0 });
602+
expect(JSON.parse(failure.detail!)).toMatchObject({ phase: 'compensate', step: 'shrinking' });
603+
});
604+
});
605+
416606
// ── LIFO compensation ─────────────────────────────────────────────────────
417607

418608
describe('LIFO compensation (ADR-0119 D2 item 4)', () => {

0 commit comments

Comments
 (0)