@@ -36,7 +36,6 @@ import { prepareProjectStorageMutationInTx } from '@/lib/billing/storage/trackin
3636import { processOutboxEventById } from '@/lib/core/outbox/service'
3737import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion'
3838import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
39- import * as storageService from '@/lib/uploads/core/storage-service'
4039import { UPLOAD_URL_TTL_MS } from '@/lib/uploads/upload-session/provider'
4140import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/types'
4241import { deleteUserAccount } from '@/lib/users/account-deletion'
@@ -456,26 +455,6 @@ describe('Private Project teardown and durable object cleanup', () => {
456455 await expect ( readdir ( join ( storageRoot , `project/${ f . projectId } ` ) ) ) . rejects . toMatchObject ( {
457456 code : 'ENOENT' ,
458457 } )
459- await mkdir ( dirname ( join ( storageRoot , upload . finalKey ) ) , { recursive : true } )
460- await writeFile ( join ( storageRoot , upload . finalKey ) , 'finished-after-empty-sweep' )
461- const reconciliationId = 'project-file.storage.reconcile'
462- await db
463- . update ( outboxEvent )
464- . set ( { availableAt : new Date ( 0 ) , attempts : 4 } )
465- . where ( eq ( outboxEvent . id , reconciliationId ) )
466- expect (
467- await processOutboxEventById ( reconciliationId , projectFilePrefixCleanupOutboxHandlers )
468- ) . toBe ( 'pending' )
469- await expect ( readFile ( join ( storageRoot , upload . finalKey ) ) ) . rejects . toMatchObject ( {
470- code : 'ENOENT' ,
471- } )
472- expect ( await readFile ( join ( storageRoot , live . keys [ 0 ] ) , 'utf8' ) ) . toBe ( 'new' )
473- const [ reconciled ] = await db
474- . select ( )
475- . from ( outboxEvent )
476- . where ( eq ( outboxEvent . id , reconciliationId ) )
477- expect ( reconciled . attempts ) . toBe ( 0 )
478- expect ( reconciled . availableAt . getTime ( ) ) . toBeGreaterThan ( Date . now ( ) )
479458 const unsafe = generateId ( )
480459 await db . insert ( outboxEvent ) . values ( {
481460 id : unsafe ,
@@ -543,7 +522,7 @@ describe('Private Project teardown and durable object cleanup', () => {
543522 }
544523 )
545524
546- check ( 'retirement batches finish while sharing one inventory reconciliation event ' , async ( ) => {
525+ check ( 'retirement batches finish without arming a bucket inventory ' , async ( ) => {
547526 const f = await fixture ( )
548527 await Promise . all (
549528 Array . from ( { length : 501 } , ( _ , index ) => seedUpload ( f , `batch-${ index } .bin` ) )
@@ -587,175 +566,29 @@ describe('Private Project teardown and durable object cleanup', () => {
587566 . select ( )
588567 . from ( outboxEvent )
589568 . where ( eq ( outboxEvent . eventType , 'project-file.storage.reconcile' ) )
590- expect ( reconciliations ) . toHaveLength ( 1 )
569+ expect ( reconciliations ) . toHaveLength ( 0 )
591570 } )
592571
593572 check (
594- 'retirement revives terminal reconciliation without replacing active progress ' ,
573+ 'a previously armed storage inventory drains without deleting unowned prefixes ' ,
595574 async ( ) => {
596- const initial = await fixture ( )
597- await erasePrivateProject ( initial )
598575 const reconciliationId = 'project-file.storage.reconcile'
599- for ( const status of [ 'completed' , 'dead_letter' , 'pending' , 'processing' ] ) {
600- const f = await fixture ( )
601- const lockedAt = new Date ( )
602- await db
603- . update ( outboxEvent )
604- . set ( { status, attempts : 4 , payload : { cursor : 'saved-progress' } , lockedAt } )
605- . where ( eq ( outboxEvent . id , reconciliationId ) )
606- await erasePrivateProject ( f )
607- const [ event ] = await db
608- . select ( )
609- . from ( outboxEvent )
610- . where ( eq ( outboxEvent . id , reconciliationId ) )
611- if ( status === 'completed' || status === 'dead_letter' ) {
612- expect ( event ) . toMatchObject ( {
613- status : 'pending' ,
614- attempts : 0 ,
615- payload : { } ,
616- lockedAt : null ,
617- } )
618- } else {
619- expect ( event ) . toMatchObject ( {
620- status,
621- attempts : 4 ,
622- payload : { cursor : 'saved-progress' } ,
623- lockedAt,
624- } )
625- }
626- }
627- await db
628- . update ( outboxEvent )
629- . set ( { status : 'pending' , attempts : 0 , payload : { } , lockedAt : null } )
630- . where ( eq ( outboxEvent . id , reconciliationId ) )
631- }
632- )
633-
634- check (
635- 'inventory reconciliation isolates a failed prefix and retries it next cycle while preserving live owners' ,
636- async ( ) => {
637- const retired = await fixture ( )
638- await erasePrivateProject ( retired )
639- const live = await fixture ( )
640- const reconciliationId = 'project-file.storage.reconcile'
641- const orphanIds = Array . from ( { length : 505 } , ( ) => generateId ( ) ) . sort ( )
642- const obstructed = join ( storageRoot , `project/${ orphanIds [ 0 ] } ` )
643- for ( const id of orphanIds ) {
644- const prefix = join ( storageRoot , `project/${ id } ` )
645- await mkdir ( prefix , { recursive : true } )
646- await writeFile ( join ( prefix , 'late.bin' ) , 'late' )
647- }
648- const deleteStoredFile = storageService . deleteFile
649- const deletion = vi . spyOn ( storageService , 'deleteFile' ) . mockImplementation ( ( params ) => {
650- if ( params . key . startsWith ( `project/${ orphanIds [ 0 ] } /` ) ) {
651- return Promise . reject (
652- Object . assign ( new Error ( 'Fixture prefix deletion failed' ) , { code : 'EACCES' } )
653- )
654- }
655- return deleteStoredFile ( params )
576+ const unowned = `project/${ generateId ( ) } /compiled/late.bin`
577+ await mkdir ( dirname ( join ( storageRoot , unowned ) ) , { recursive : true } )
578+ await writeFile ( join ( storageRoot , unowned ) , 'late' )
579+ await db . insert ( outboxEvent ) . values ( {
580+ id : reconciliationId ,
581+ eventType : reconciliationId ,
582+ payload : { } ,
583+ availableAt : new Date ( 0 ) ,
656584 } )
657585 const { projectFilePrefixCleanupOutboxHandlers } = await import (
658586 '@/lib/projects/files/prefix-cleanup'
659587 )
660- try {
661- await db
662- . update ( outboxEvent )
663- . set ( { availableAt : new Date ( 0 ) , payload : { } , attempts : 3 } )
664- . where ( eq ( outboxEvent . id , reconciliationId ) )
665- expect (
666- await processOutboxEventById ( reconciliationId , projectFilePrefixCleanupOutboxHandlers )
667- ) . toBe ( 'pending' )
668- const [ failed ] = await db
669- . select ( )
670- . from ( outboxEvent )
671- . where ( eq ( outboxEvent . id , reconciliationId ) )
672- expect ( failed . attempts ) . toBe ( 3 )
673- expect ( failed . payload ) . toMatchObject ( { failedPrefixes : 1 , cursor : expect . any ( String ) } )
674- let completedCycle = false
675- for ( let attempt = 0 ; attempt < 10 ; attempt ++ ) {
676- await db
677- . update ( outboxEvent )
678- . set ( { availableAt : new Date ( 0 ) } )
679- . where ( eq ( outboxEvent . id , reconciliationId ) )
680- expect (
681- await processOutboxEventById ( reconciliationId , projectFilePrefixCleanupOutboxHandlers )
682- ) . toBe ( 'pending' )
683- const [ continued ] = await db
684- . select ( )
685- . from ( outboxEvent )
686- . where ( eq ( outboxEvent . id , reconciliationId ) )
687- if ( continued . availableAt . getTime ( ) > Date . now ( ) + 60 * 60 * 1000 ) {
688- expect ( continued . attempts ) . toBe ( 3 )
689- completedCycle = true
690- break
691- }
692- expect ( continued . payload ) . toMatchObject ( { cursor : expect . any ( String ) } )
693- }
694- expect ( completedCycle ) . toBe ( true )
695- for ( const id of orphanIds . slice ( 1 ) )
696- await expect ( readdir ( join ( storageRoot , `project/${ id } ` ) ) ) . rejects . toMatchObject ( {
697- code : 'ENOENT' ,
698- } )
699- expect ( await readFile ( join ( storageRoot , live . keys [ 0 ] ) , 'utf8' ) ) . toBe ( 'new' )
700- } finally {
701- deletion . mockRestore ( )
702- }
703- expect ( await readFile ( join ( obstructed , 'late.bin' ) , 'utf8' ) ) . toBe ( 'late' )
704- await db
705- . update ( outboxEvent )
706- . set ( { availableAt : new Date ( 0 ) } )
707- . where ( eq ( outboxEvent . id , reconciliationId ) )
708588 expect (
709589 await processOutboxEventById ( reconciliationId , projectFilePrefixCleanupOutboxHandlers )
710- ) . toBe ( 'pending' )
711- await expect ( readdir ( obstructed ) ) . rejects . toMatchObject ( { code : 'ENOENT' } )
712- const [ recovered ] = await db
713- . select ( )
714- . from ( outboxEvent )
715- . where ( eq ( outboxEvent . id , reconciliationId ) )
716- expect ( recovered . attempts ) . toBe ( 0 )
717- }
718- )
719-
720- check (
721- 'periodic reconciliation recovery respects cooldown and active leases while preserving progress' ,
722- async ( ) => {
723- const f = await fixture ( )
724- await erasePrivateProject ( f )
725- const reconciliationId = 'project-file.storage.reconcile'
726- const { recoverProjectStorageReconciliation } = await import (
727- '@/lib/projects/files/prefix-cleanup'
728- )
729- const now = new Date ( )
730- const checkpoint = { cursor : 'provider-page' , page : [ f . projectId ] , index : 0 }
731- const old = new Date ( now . getTime ( ) - 25 * 60 * 60 * 1000 )
732- for ( const status of [ 'pending' , 'processing' , 'dead_letter' ] ) {
733- await db
734- . update ( outboxEvent )
735- . set ( { status, attempts : 10 , payload : checkpoint , processedAt : old , lockedAt : now } )
736- . where ( eq ( outboxEvent . id , reconciliationId ) )
737- await recoverProjectStorageReconciliation ( now )
738- const [ event ] = await db
739- . select ( )
740- . from ( outboxEvent )
741- . where ( eq ( outboxEvent . id , reconciliationId ) )
742- expect ( event . payload ) . toEqual ( checkpoint )
743- expect ( event ) . toMatchObject (
744- status === 'dead_letter'
745- ? { status : 'pending' , attempts : 0 , lockedAt : null , processedAt : null }
746- : { status, attempts : 10 , lockedAt : now , processedAt : old }
747- )
748- }
749- await db
750- . update ( outboxEvent )
751- . set ( { status : 'dead_letter' , attempts : 10 , processedAt : now } )
752- . where ( eq ( outboxEvent . id , reconciliationId ) )
753- await recoverProjectStorageReconciliation ( now )
754- const [ cooling ] = await db
755- . select ( )
756- . from ( outboxEvent )
757- . where ( eq ( outboxEvent . id , reconciliationId ) )
758- expect ( cooling ) . toMatchObject ( { status : 'dead_letter' , attempts : 10 , payload : checkpoint } )
590+ ) . toBe ( 'completed' )
591+ expect ( await readFile ( join ( storageRoot , unowned ) , 'utf8' ) ) . toBe ( 'late' )
759592 }
760593 )
761594
0 commit comments