@@ -415,6 +415,216 @@ describe('LifecycleService.sweep — reap guard', () => {
415415 } ) ;
416416} ) ;
417417
418+ // [#5535] Two registrars on ONE object. Before this, `registerReapGuard` was a
419+ // single-slot `set()`: the second registrant silently unhooked the first — on
420+ // `sys_file` that means the byte-reclaim guard stops running while the rows
421+ // (the only pointer to those bytes) keep being deleted. Guards now compose by
422+ // intersection, which is what "confirm before delete" already implied.
423+ describe ( 'LifecycleService.sweep — reap guard composition' , ( ) => {
424+ const guarded : LifecycleObjectLike [ ] = [
425+ { name : 'sys_file' , lifecycle : { class : 'transient' , ttl : { field : 'deleted_at' , expireAfter : '30d' } } } ,
426+ ] ;
427+
428+ const rowsOf = ( ...ids : string [ ] ) => ids . map ( ( id ) => ( { id, deleted_at : '2020-01-01T00:00:00Z' } ) ) ;
429+
430+ /** A store the sweep actually drains, so "retried next sweep" is observable. */
431+ function backedEngine ( seed : Array < Record < string , unknown > > ) {
432+ const store = [ ...seed ] ;
433+ const captured = captureEngine ( guarded , {
434+ findImpl : ( ) => store . slice ( ) ,
435+ deleteImpl : ( _object , options ) => {
436+ const idx = store . findIndex ( ( r ) => r . id === options ?. where ?. id ) ;
437+ if ( idx >= 0 ) store . splice ( idx , 1 ) ;
438+ return { deletedCount : idx >= 0 ? 1 : 0 } ;
439+ } ,
440+ } ) ;
441+ return { ...captured , store } ;
442+ }
443+
444+ const idsOf = ( rows : Array < Record < string , unknown > > ) => rows . map ( ( r ) => r . id as string ) ;
445+
446+ it ( 'deletes only ids EVERY guard confirmed; a veto by either one keeps the row' , async ( ) => {
447+ const rows = rowsOf ( 'f1' , 'f2' , 'f3' ) ;
448+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => rows } ) ;
449+ const svc = service ( engine ) ;
450+ // Each guard vetoes a different id — neither alone would keep both.
451+ svc . registerReapGuard ( 'sys_file' , async ( _o , r ) => idsOf ( r ) . filter ( ( id ) => id !== 'f2' ) ) ;
452+ svc . registerReapGuard ( 'sys_file' , async ( _o , r ) => idsOf ( r ) . filter ( ( id ) => id !== 'f3' ) ) ;
453+
454+ const report = await svc . sweep ( ) ;
455+
456+ expect ( deletes . map ( ( d ) => d . where ) ) . toEqual ( [ { id : 'f1' } ] ) ;
457+ expect ( report . swept [ 0 ] . deleted ) . toBe ( 1 ) ;
458+ expect ( report . errors ) . toEqual ( [ ] ) ;
459+ } ) ;
460+
461+ it ( 'is order-independent: the same two guards registered the other way round agree' , async ( ) => {
462+ const vetoF2 = async ( _o : string , r : Array < Record < string , unknown > > ) =>
463+ idsOf ( r ) . filter ( ( id ) => id !== 'f2' ) ;
464+ const vetoF3 = async ( _o : string , r : Array < Record < string , unknown > > ) =>
465+ idsOf ( r ) . filter ( ( id ) => id !== 'f3' ) ;
466+
467+ const deleteSets : string [ ] [ ] = [ ] ;
468+ for ( const order of [ [ vetoF2 , vetoF3 ] , [ vetoF3 , vetoF2 ] ] ) {
469+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => rowsOf ( 'f1' , 'f2' , 'f3' ) } ) ;
470+ const svc = service ( engine ) ;
471+ for ( const g of order ) svc . registerReapGuard ( 'sys_file' , g ) ;
472+ await svc . sweep ( ) ;
473+ deleteSets . push ( deletes . map ( ( d ) => d . where . id as string ) ) ;
474+ }
475+
476+ expect ( deleteSets [ 0 ] ) . toEqual ( [ 'f1' ] ) ;
477+ expect ( deleteSets [ 1 ] ) . toEqual ( deleteSets [ 0 ] ) ;
478+ } ) ;
479+
480+ it ( 'asks a guard only about rows the guards before it confirmed' , async ( ) => {
481+ // The point of the narrowing: a guard's confirmation is the RECEIPT for
482+ // cleanup it has already performed. Showing guard 2 a row guard 1 vetoed
483+ // would have it reclaim/de-index a row that then survives the sweep.
484+ const rows = rowsOf ( 'f1' , 'f2' , 'f3' ) ;
485+ const { engine } = captureEngine ( guarded , { findImpl : ( ) => rows } ) ;
486+ const svc = service ( engine ) ;
487+ const first = vi . fn ( async ( _o : string , r : Array < Record < string , unknown > > ) =>
488+ idsOf ( r ) . filter ( ( id ) => id !== 'f2' ) ,
489+ ) ;
490+ const second = vi . fn ( async ( _o : string , r : Array < Record < string , unknown > > ) => idsOf ( r ) ) ;
491+ svc . registerReapGuard ( 'sys_file' , first ) ;
492+ svc . registerReapGuard ( 'sys_file' , second ) ;
493+
494+ await svc . sweep ( ) ;
495+
496+ expect ( first ) . toHaveBeenCalledWith ( 'sys_file' , rows ) ; // the full candidate batch
497+ expect ( second ) . toHaveBeenCalledTimes ( 1 ) ;
498+ expect ( idsOf ( second . mock . calls [ 0 ] [ 1 ] ) ) . toEqual ( [ 'f1' , 'f3' ] ) ; // f2 already vetoed
499+ } ) ;
500+
501+ it ( 'a guard that vetoes the whole batch spares the later guards the call' , async ( ) => {
502+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => rowsOf ( 'f1' , 'f2' ) } ) ;
503+ const svc = service ( engine ) ;
504+ const second = vi . fn ( async ( _o : string , r : Array < Record < string , unknown > > ) => idsOf ( r ) ) ;
505+ svc . registerReapGuard ( 'sys_file' , async ( ) => [ ] ) ;
506+ svc . registerReapGuard ( 'sys_file' , second ) ;
507+
508+ const report = await svc . sweep ( ) ;
509+
510+ expect ( second ) . not . toHaveBeenCalled ( ) ;
511+ expect ( deletes ) . toHaveLength ( 0 ) ;
512+ expect ( report . swept [ 0 ] . deleted ) . toBe ( 0 ) ;
513+ } ) ;
514+
515+ it ( 'a row one guard vetoed is retried by the next sweep and deleted once both confirm' , async ( ) => {
516+ const { engine, store } = backedEngine ( rowsOf ( 'f1' , 'f2' ) ) ;
517+ const svc = service ( engine ) ;
518+ let firstSweep = true ;
519+ svc . registerReapGuard ( 'sys_file' , async ( _o , r ) =>
520+ idsOf ( r ) . filter ( ( id ) => ! ( firstSweep && id === 'f2' ) ) ,
521+ ) ;
522+ svc . registerReapGuard ( 'sys_file' , async ( _o , r ) => idsOf ( r ) ) ;
523+
524+ const one = await svc . sweep ( ) ;
525+ expect ( idsOf ( store ) ) . toEqual ( [ 'f2' ] ) ; // vetoed, still there
526+ expect ( one . swept [ 0 ] . deleted ) . toBe ( 1 ) ;
527+
528+ firstSweep = false ;
529+ const two = await svc . sweep ( ) ;
530+ expect ( store ) . toEqual ( [ ] ) ; // retried and reaped
531+ expect ( two . swept [ 0 ] . deleted ) . toBe ( 1 ) ;
532+ } ) ;
533+
534+ it ( 'a throwing guard deletes nothing — not even ids an earlier guard confirmed' , async ( ) => {
535+ // Same fail-safe as the single-guard case: the error reaches the per-object
536+ // handler in sweep() and no row is deleted. The earlier guard's external
537+ // cleanup for this batch is simply retried next sweep — never paid out in
538+ // a delete on a batch no one finished confirming.
539+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => rowsOf ( 'f1' , 'f2' ) } ) ;
540+ const svc = service ( engine ) ;
541+ const first = vi . fn ( async ( _o : string , r : Array < Record < string , unknown > > ) => idsOf ( r ) ) ;
542+ svc . registerReapGuard ( 'sys_file' , first ) ;
543+ svc . registerReapGuard ( 'sys_file' , async ( ) => {
544+ throw new Error ( 'index unreachable' ) ;
545+ } ) ;
546+
547+ const report = await svc . sweep ( ) ;
548+
549+ expect ( first ) . toHaveBeenCalledTimes ( 1 ) ;
550+ expect ( deletes ) . toHaveLength ( 0 ) ;
551+ expect ( report . swept ) . toEqual ( [ ] ) ;
552+ expect ( report . errors ) . toEqual ( [ { object : 'sys_file' , error : 'index unreachable' } ] ) ;
553+ } ) ;
554+
555+ it ( 'registering the identical guard twice runs it once (re-run wiring, not a second opinion)' , async ( ) => {
556+ const { engine, deletes } = captureEngine ( guarded , { findImpl : ( ) => rowsOf ( 'f1' ) } ) ;
557+ const svc = service ( engine ) ;
558+ const guard = vi . fn ( async ( _o : string , r : Array < Record < string , unknown > > ) => idsOf ( r ) ) ;
559+ svc . registerReapGuard ( 'sys_file' , guard ) ;
560+ svc . registerReapGuard ( 'sys_file' , guard ) ;
561+
562+ await svc . sweep ( ) ;
563+
564+ // Called twice, its external cleanup would run twice per batch.
565+ expect ( guard ) . toHaveBeenCalledTimes ( 1 ) ;
566+ expect ( deletes . map ( ( d ) => d . where ) ) . toEqual ( [ { id : 'f1' } ] ) ;
567+ } ) ;
568+
569+ it ( 'multiple guards on an engine without find are skipped, never blind-deleted' , async ( ) => {
570+ const { engine, deletes } = captureEngine ( guarded ) ; // no findImpl → no engine.find
571+ const svc = service ( engine ) ;
572+ svc . registerReapGuard ( 'sys_file' , async ( ) => [ 'f1' ] ) ;
573+ svc . registerReapGuard ( 'sys_file' , async ( ) => [ 'f1' ] ) ;
574+
575+ const report = await svc . sweep ( ) ;
576+
577+ expect ( deletes ) . toHaveLength ( 0 ) ;
578+ expect ( report . skipped ) . toEqual ( [ { object : 'sys_file' , reason : 'reap-guard-unsupported' } ] ) ;
579+ } ) ;
580+
581+ it ( 'keeps byte reclaim running when a second consumer registers (the #5535 shape)' , async ( ) => {
582+ // service-storage's `sys_file` guard, in the same shape but stubbed here:
583+ // reclaim the bytes FIRST, confirm only if that succeeded (the row is the
584+ // only pointer to the bytes, so a failed reclaim must veto). The second
585+ // registrar is the ADR-0057 §3.3 domain callback #4672 will add — it
586+ // de-indexes by id. Under the old single-slot registry the byte reclaim
587+ // was unhooked wholesale by that second call: rows deleted, bytes leaked.
588+ const bytes = new Map ( [
589+ [ 'f1' , 'k1' ] ,
590+ [ 'f2' , 'k2' ] ,
591+ [ 'f3' , 'k3' ] ,
592+ ] ) ;
593+ const index = new Set ( [ 'f1' , 'f2' , 'f3' ] ) ;
594+ const { engine, store } = backedEngine ( rowsOf ( 'f1' , 'f2' , 'f3' ) ) ;
595+ const svc = service ( engine ) ;
596+
597+ svc . registerReapGuard ( 'sys_file' , async ( _o , rows ) => {
598+ const confirmed : string [ ] = [ ] ;
599+ for ( const row of rows ) {
600+ const id = row . id as string ;
601+ if ( id === 'f2' ) continue ; // storage.delete threw → veto, keep the pointer
602+ bytes . delete ( id ) ;
603+ confirmed . push ( id ) ;
604+ }
605+ return confirmed ;
606+ } ) ;
607+ svc . registerReapGuard ( 'sys_file' , async ( _o , rows ) => {
608+ const confirmed : string [ ] = [ ] ;
609+ for ( const row of rows ) {
610+ index . delete ( row . id as string ) ;
611+ confirmed . push ( row . id as string ) ;
612+ }
613+ return confirmed ;
614+ } ) ;
615+
616+ const report = await svc . sweep ( ) ;
617+
618+ expect ( idsOf ( store ) ) . toEqual ( [ 'f2' ] ) ; // vetoed row retained for the next sweep
619+ expect ( [ ...bytes . keys ( ) ] ) . toEqual ( [ 'f2' ] ) ; // …with its bytes intact — no leak
620+ // …and the de-indexer was never shown f2, so a surviving row keeps its
621+ // index entry rather than silently disappearing from search.
622+ expect ( [ ...index ] ) . toEqual ( [ 'f2' ] ) ;
623+ expect ( report . swept [ 0 ] . deleted ) . toBe ( 2 ) ;
624+ expect ( report . errors ) . toEqual ( [ ] ) ;
625+ } ) ;
626+ } ) ;
627+
418628describe ( 'LifecycleService.sweep — Archiver (P3)' , ( ) => {
419629 const AUDIT_OBJ : LifecycleObjectLike = {
420630 name : 'sys_audit_log' ,
0 commit comments