@@ -30,6 +30,14 @@ import { match } from 'ts-pattern'
3030import { getDatabase } from './db'
3131import { env } from './env'
3232import { generateNumberId } from './helpers'
33+ import { logger } from './logger'
34+
35+ export class ObjectNotFoundError extends Error {
36+ constructor ( objectName : string ) {
37+ super ( `Object not found in storage: ${ objectName } ` )
38+ this . name = 'ObjectNotFoundError'
39+ }
40+ }
3341
3442export class Storage {
3543 adapter
@@ -208,22 +216,30 @@ export class Storage {
208216 . where ( 'id' , '=' , storageLocation . id )
209217 . execute ( )
210218
211- if ( storageLocation . mergedAt || storageLocation . mergeStartedAt )
212- return this . downloadFromCacheEntryLocation ( storageLocation )
219+ try {
220+ if ( storageLocation . mergedAt || storageLocation . mergeStartedAt )
221+ return await this . downloadFromCacheEntryLocation ( storageLocation )
213222
214- await this . db
215- . updateTable ( 'storage_locations' )
216- . set ( {
217- mergeStartedAt : Date . now ( ) ,
218- } )
219- . where ( 'id' , '=' , storageLocation . id )
220- . execute ( )
223+ // Verify all parts are still present before committing to the HTTP response.
224+ // If the storage backend has lost the object (incomplete upload, manual
225+ // deletion, lifecycle policy), failing here bubbles ObjectNotFoundError to
226+ // the outer catch, which yields a 404. Detecting this mid-stream would
227+ // truncate an already-started 200 response, leaving clients with
228+ // unrecoverable partial data.
229+ await this . ensurePartsExist ( storageLocation )
221230
222- const responseStream = new PassThrough ( )
223- const mergerStream = new PassThrough ( )
231+ await this . db
232+ . updateTable ( 'storage_locations' )
233+ . set ( {
234+ mergeStartedAt : Date . now ( ) ,
235+ } )
236+ . where ( 'id' , '=' , storageLocation . id )
237+ . execute ( )
224238
225- try {
226- const promise = this . adapter
239+ const responseStream = new PassThrough ( )
240+ const mergerStream = new PassThrough ( )
241+
242+ const mergePromise = this . adapter
227243 . uploadStream ( `${ storageLocation . folderName } /merged` , mergerStream )
228244 . then ( async ( ) => {
229245 await this . db
@@ -255,31 +271,38 @@ export class Storage {
255271 . execute ( )
256272 mergerStream . destroy ( )
257273 } )
258- this . mergeStreamPromises . add ( promise )
259- promise . finally ( ( ) => this . mergeStreamPromises . delete ( promise ) )
274+ this . mergeStreamPromises . add ( mergePromise )
275+ mergePromise . finally ( ( ) => this . mergeStreamPromises . delete ( mergePromise ) )
276+
277+ this . pumpPartsToStreams ( storageLocation , responseStream , mergerStream ) . catch ( ( err ) => {
278+ responseStream . destroy ( err )
279+ mergerStream . destroy ( err )
280+ if ( err instanceof ObjectNotFoundError )
281+ logger . warn ( `Stale cache entry ${ cacheEntryId } : ${ err . message } ` )
282+ } )
283+
284+ return responseStream
260285 } catch ( err ) {
261- await this . db
262- . updateTable ( 'storage_locations' )
263- . set ( {
264- mergedAt : null ,
265- mergeStartedAt : null ,
266- } )
267- . where ( 'id' , '=' , storageLocation . id )
268- . execute ( )
286+ if ( err instanceof ObjectNotFoundError ) {
287+ logger . warn ( `Stale cache entry ${ cacheEntryId } : ${ err . message } ` )
288+ return
289+ }
269290 throw err
270291 }
292+ }
271293
272- this . pumpPartsToStreams ( storageLocation , responseStream , mergerStream ) . catch ( ( err ) => {
273- responseStream . destroy ( err )
274- mergerStream . destroy ( err )
275- } )
276-
277- return responseStream
294+ private async ensurePartsExist ( location : StorageLocation ) {
295+ const partsFolder = `${ location . folderName } /parts`
296+ const actualPartCount = await this . adapter . countFilesInFolder ( partsFolder )
297+ if ( actualPartCount < location . partCount ) throw new ObjectNotFoundError ( partsFolder )
278298 }
279299
280300 private async downloadFromCacheEntryLocation ( location : StorageLocation ) {
281301 if ( location . mergedAt ) return this . adapter . createDownloadStream ( `${ location . folderName } /merged` )
282302
303+ // Parts-streaming path: the async generator is lazy, so a missing object
304+ // would only surface mid-stream. Pre-validate before returning the reader.
305+ await this . ensurePartsExist ( location )
283306 return Readable . from ( this . streamParts ( location ) )
284307 }
285308
@@ -522,15 +545,20 @@ class S3Adapter implements StorageAdapter {
522545 }
523546
524547 async createDownloadStream ( objectName : string ) {
525- const response = await this . s3 . send (
526- new GetObjectCommand ( {
527- Bucket : this . bucket ,
528- Key : `${ this . keyPrefix } /${ objectName } ` ,
529- } ) ,
530- )
531- if ( ! response . Body ) throw new Error ( 'No body in S3 get object response' )
548+ try {
549+ const response = await this . s3 . send (
550+ new GetObjectCommand ( {
551+ Bucket : this . bucket ,
552+ Key : `${ this . keyPrefix } /${ objectName } ` ,
553+ } ) ,
554+ )
555+ if ( ! response . Body ) throw new Error ( 'No body in S3 get object response' )
532556
533- return response . Body as Readable
557+ return response . Body as Readable
558+ } catch ( err : any ) {
559+ if ( err . name === 'NoSuchKey' ) throw new ObjectNotFoundError ( objectName )
560+ throw err
561+ }
534562 }
535563
536564 async deleteFolder ( folderName : string ) {
@@ -629,7 +657,13 @@ class FileSystemAdapter implements StorageAdapter {
629657 }
630658
631659 async createDownloadStream ( objectName : string ) {
632- return createReadStream ( path . join ( this . rootFolder , objectName ) )
660+ const filePath = path . join ( this . rootFolder , objectName )
661+ try {
662+ await fs . access ( filePath )
663+ } catch {
664+ throw new ObjectNotFoundError ( objectName )
665+ }
666+ return createReadStream ( filePath )
633667 }
634668
635669 async deleteFolder ( folderName : string ) {
@@ -656,11 +690,16 @@ class FileSystemAdapter implements StorageAdapter {
656690 }
657691
658692 async countFilesInFolder ( folderName : string ) {
659- const dir = await fs . readdir ( path . join ( this . rootFolder , folderName ) , {
660- withFileTypes : true ,
661- } )
662-
663- return dir . filter ( ( item ) => item . isFile ( ) ) . length
693+ try {
694+ const dir = await fs . readdir ( path . join ( this . rootFolder , folderName ) , {
695+ withFileTypes : true ,
696+ } )
697+ return dir . filter ( ( item ) => item . isFile ( ) ) . length
698+ } catch ( err : any ) {
699+ // Missing folder reports 0, matching S3/GCS list-by-prefix semantics.
700+ if ( err . code === 'ENOENT' ) return 0
701+ throw err
702+ }
664703 }
665704}
666705
@@ -690,7 +729,10 @@ class GcsAdapter implements StorageAdapter {
690729 }
691730
692731 async createDownloadStream ( objectName : string ) {
693- return this . bucket . file ( `${ this . keyPrefix } /${ objectName } ` ) . createReadStream ( )
732+ const file = this . bucket . file ( `${ this . keyPrefix } /${ objectName } ` )
733+ const [ exists ] = await file . exists ( )
734+ if ( ! exists ) throw new ObjectNotFoundError ( objectName )
735+ return file . createReadStream ( )
694736 }
695737
696738 async deleteFolder ( folderName : string ) {
0 commit comments