@@ -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
418608describe ( 'LIFO compensation (ADR-0119 D2 item 4)' , ( ) => {
0 commit comments