@@ -854,6 +854,182 @@ describe('Blob test', () => {
854854 setDeletionDelay ( 500 ) ; // restore original
855855 } ) ;
856856} ) ;
857+
858+ describe ( 'saveBlob with idle source stream (replication wedge regression)' , ( ) => {
859+ let WedgeTable ;
860+ let savedIdleTimeoutEnv ;
861+ before ( function ( ) {
862+ setupTestDBPath ( ) ;
863+ // Enable the source-stream idle timeout for these tests so the wedge case has a finite
864+ // settle deadline. The value must be short enough that the 'never-ended' test settles
865+ // inside its 3s wait.
866+ savedIdleTimeoutEnv = process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS ;
867+ process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS = '1500' ;
868+ WedgeTable = table ( {
869+ table : 'WedgeTable' ,
870+ database : 'test' ,
871+ attributes : [
872+ { name : 'id' , isPrimaryKey : true } ,
873+ { name : 'blob' , type : 'Blob' } ,
874+ ] ,
875+ } ) ;
876+ } ) ;
877+ after ( function ( ) {
878+ if ( savedIdleTimeoutEnv === undefined ) delete process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS ;
879+ else process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS = savedIdleTimeoutEnv ;
880+ } ) ;
881+
882+ it ( 'settles saveBlob.saving when the source PassThrough was destroyed before save started' , async ( ) => {
883+ // Mirrors the replication-receive race: the BLOB_CHUNK handler creates a PassThrough in
884+ // blobsInFlight; a later chunk with `finished:true, error:"..."` calls stream.destroy(err).
885+ // When the audit entry then arrives, receiveBlobs retrieves the destroyed stream and
886+ // saveBlob's pipeline runs over an already-destroyed source. Without the idle watchdog,
887+ // pipeline() may not observe the destroy, saveBlob.saving never settles, and the per-
888+ // (sender, receiver, database) replication tuple wedges at status "Receiving".
889+ const stream = new PassThrough ( ) ;
890+ stream . on ( 'error' , ( ) => { } ) ; // suppress 'unhandled error' from the manual destroy
891+ stream . destroy ( new Error ( 'Blob error: simulated upstream tear-down' ) ) ;
892+ const blob = await createBlob ( stream ) ;
893+ const info = decodeFromDatabase ( ( ) => saveBlob ( blob ) , WedgeTable . primaryStore . rootStore ) ;
894+
895+ let state = 'pending' ;
896+ // eslint-disable-next-line promise/catch-or-return
897+ ( info . saving ?? Promise . resolve ( ) )
898+ . then ( ( ) => {
899+ state = 'resolved' ;
900+ } )
901+ . catch ( ( ) => {
902+ state = 'rejected' ;
903+ } ) ;
904+
905+ await delay ( 2000 ) ;
906+ assert . notStrictEqual (
907+ state ,
908+ 'pending' ,
909+ 'saveBlob.saving never settled; in replication this wedges the per-database receive consumer indefinitely'
910+ ) ;
911+ } ) ;
912+
913+ it ( 'settles saveBlob.saving when the source stream has chunks but is never ended' , async ( ) => {
914+ // Production scenario: a sender's BLOB_CHUNK frames arrive partial. Some content lands but
915+ // the closing `finished:true` (or error) frame never does. The PassThrough sits idle:
916+ // neither ended nor destroyed. Without the idle watchdog, pipeline waits forever and the
917+ // tracked saveBlob.saving promise pins outstandingBlobsToFinish, stalling the apply
918+ // consumer's drain await with no log signature.
919+ const stream = new PassThrough ( ) ;
920+ stream . write ( Buffer . from ( 'chunk-but-no-finish' ) ) ;
921+ // NO destroy, NO end: prod-observed state of an abandoned blob stream.
922+
923+ const blob = await createBlob ( stream ) ;
924+ const info = decodeFromDatabase ( ( ) => saveBlob ( blob ) , WedgeTable . primaryStore . rootStore ) ;
925+
926+ let state = 'pending' ;
927+ // eslint-disable-next-line promise/catch-or-return
928+ ( info . saving ?? Promise . resolve ( ) )
929+ . then ( ( ) => {
930+ state = 'resolved' ;
931+ } )
932+ . catch ( ( ) => {
933+ state = 'rejected' ;
934+ } ) ;
935+
936+ await delay ( 3000 ) ;
937+ assert . notStrictEqual (
938+ state ,
939+ 'pending' ,
940+ 'saveBlob.saving did not settle within 3s for an idle source stream; pipeline waits forever and wedges the per-database replication apply consumer (production: lastReceivedStatus stuck on "Receiving")'
941+ ) ;
942+ } ) ;
943+
944+ it ( 'settles when a mid-stream chunk arrives, then a destroy, then no further chunks' , async ( ) => {
945+ // More faithful repro of the receive path: PassThrough is created in blobsInFlight, some
946+ // chunks arrive, the stream is destroyed (e.g. by a sender-side error frame), then
947+ // saveBlob is started by the audit-record receive. No further chunks ever land. In the
948+ // production receiver this leaves pipeline() waiting on a torn-down source that never
949+ // ends nor errors from this side, holding outstandingBlobsToFinish forever.
950+ const stream = new PassThrough ( ) ;
951+ stream . on ( 'error' , ( ) => { } ) ;
952+
953+ stream . write ( Buffer . from ( 'partial-blob-payload-' ) ) ;
954+ stream . destroy ( new Error ( 'Blob error: simulated tear-down mid-stream' ) ) ;
955+
956+ const blob = await createBlob ( stream ) ;
957+ const info = decodeFromDatabase ( ( ) => saveBlob ( blob ) , WedgeTable . primaryStore . rootStore ) ;
958+
959+ let state = 'pending' ;
960+ // eslint-disable-next-line promise/catch-or-return
961+ ( info . saving ?? Promise . resolve ( ) )
962+ . then ( ( ) => {
963+ state = 'resolved' ;
964+ } )
965+ . catch ( ( ) => {
966+ state = 'rejected' ;
967+ } ) ;
968+
969+ await delay ( 3000 ) ;
970+ assert . notStrictEqual (
971+ state ,
972+ 'pending' ,
973+ 'saveBlob.saving never settled with a partially-written-then-destroyed source: replication wedge'
974+ ) ;
975+ } ) ;
976+ } ) ;
977+
978+ describe ( 'saveBlob source-idle watchdog is opt-in (off by default, per-stream arm)' , ( ) => {
979+ let OptInTable ;
980+ let savedIdleTimeoutEnv ;
981+ before ( function ( ) {
982+ setupTestDBPath ( ) ;
983+ // Deliberately NO HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS: the watchdog must be OFF unless the owning
984+ // caller arms the specific source. writeBlobWithStream is the generic primitive for every blob
985+ // write (HTTP upload, origin-fetch cache fill, replication receive); bounding a source is the
986+ // caller's job, not the primitive's. (The process-wide env override is exercised in the block above.)
987+ savedIdleTimeoutEnv = process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS ;
988+ delete process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS ;
989+ OptInTable = table ( {
990+ table : 'OptInTable' ,
991+ database : 'test' ,
992+ attributes : [
993+ { name : 'id' , isPrimaryKey : true } ,
994+ { name : 'blob' , type : 'Blob' } ,
995+ ] ,
996+ } ) ;
997+ } ) ;
998+ after ( function ( ) {
999+ if ( savedIdleTimeoutEnv === undefined ) delete process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS ;
1000+ else process . env . HARPER_BLOB_STREAM_IDLE_TIMEOUT_MS = savedIdleTimeoutEnv ;
1001+ } ) ;
1002+
1003+ it ( 'does NOT destroy an unarmed idle source (a slow non-replication write is left alone)' , async ( ) => {
1004+ const stream = new PassThrough ( ) ;
1005+ stream . write ( Buffer . from ( 'slow-source-no-arm' ) ) ; // chunk lands, never ended, never armed
1006+ const blob = await createBlob ( stream ) ;
1007+ const info = decodeFromDatabase ( ( ) => saveBlob ( blob ) , OptInTable . primaryStore . rootStore ) ;
1008+ let state = 'pending' ;
1009+ // eslint-disable-next-line promise/catch-or-return
1010+ ( info . saving ?? Promise . resolve ( ) ) . then ( ( ) => ( state = 'resolved' ) ) . catch ( ( ) => ( state = 'rejected' ) ) ;
1011+ await delay ( 1500 ) ;
1012+ assert . strictEqual ( state , 'pending' , 'an unarmed idle source must NOT be force-destroyed by the watchdog' ) ;
1013+ stream . destroy ( ) ; // clean up the deliberately-stuck write so the blob lock is released
1014+ await delay ( 50 ) ;
1015+ } ) ;
1016+
1017+ it ( 'settles when the owning caller arms the source via stream.blobStreamIdleTimeoutMs' , async ( ) => {
1018+ // How the replication receive path opts in: it sets this on its PassThrough; other callers stay off.
1019+ const stream = new PassThrough ( ) ;
1020+ stream . blobStreamIdleTimeoutMs = 800 ;
1021+ stream . on ( 'error' , ( ) => { } ) ;
1022+ stream . write ( Buffer . from ( 'armed-but-never-finished' ) ) ; // chunk lands, then idle, never ended
1023+ const blob = await createBlob ( stream ) ;
1024+ const info = decodeFromDatabase ( ( ) => saveBlob ( blob ) , OptInTable . primaryStore . rootStore ) ;
1025+ let state = 'pending' ;
1026+ // eslint-disable-next-line promise/catch-or-return
1027+ ( info . saving ?? Promise . resolve ( ) ) . then ( ( ) => ( state = 'resolved' ) ) . catch ( ( ) => ( state = 'rejected' ) ) ;
1028+ await delay ( 2500 ) ;
1029+ assert . notStrictEqual ( state , 'pending' , 'an armed idle source should be destroyed within its timeout and settle' ) ;
1030+ } ) ;
1031+ } ) ;
1032+
8571033function delay ( ms ) {
8581034 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ; // wait for audit log removal and deletion
8591035}
0 commit comments