Skip to content

Commit 7345ee4

Browse files
authored
fix: persist partial bytes and update offset on interrupted input stream across all storage backends (#117)
* fix(ci): update maven-dependency-submission-action to v5.0.0 * fix: Code duplication * fix: persist partial bytes and update offset on interrupted input stream across all storage backends
1 parent 9bf7cb5 commit 7345ee4

12 files changed

Lines changed: 401 additions & 42 deletions

src/main/java/me/desair/tus/server/upload/UploadLockingService.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public interface UploadLockingService {
4343

4444
/**
4545
* Register the input stream associated with the active request URI so that it can be interrupted
46-
* if lock contention occurs.
46+
* if lock contention occurs or during service shutdown.
4747
*
4848
* @param requestUri The request URI of the active request
4949
* @param inputStream The input stream of the active request
@@ -53,8 +53,9 @@ default void registerInputStream(String requestUri, java.io.InputStream inputStr
5353
}
5454

5555
/**
56-
* Request that the lock for the given request URI be released. This might involve interrupting
57-
* the active request's input stream.
56+
* Request that the lock for the given request URI be released. This interrupts the active
57+
* request's input stream locally or signals other cluster replicas to interrupt their active
58+
* stream via a stop signal.
5859
*
5960
* @param requestUri The request URI of the upload lock to release
6061
*/
@@ -63,8 +64,8 @@ default void requestLockRelease(String requestUri) {
6364
}
6465

6566
/**
66-
* Closes resources and shuts down any background watchdog threads associated with this locking
67-
* service.
67+
* Closes resources, interrupts any active in-flight request streams, and shuts down background
68+
* watchdog threads associated with this locking service.
6869
*
6970
* @throws IOException If closing fails
7071
*/

src/main/java/me/desair/tus/server/upload/UploadStorageService.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ public interface UploadStorageService {
3737
String getUploadUri();
3838

3939
/**
40-
* Append the bytes in the give {@link InputStream} to the upload with the given ID starting at
40+
* Append the bytes in the given {@link InputStream} to the upload with the given ID starting at
4141
* the provided offset. This method also updates the {@link UploadInfo} corresponding to this
4242
* upload. The Upload Storage server should not exceed its max upload size when writing bytes.
4343
*
44+
* <p>If the input stream is interrupted or encounters an {@link IOException} during reading (e.g.
45+
* network disconnect, socket closed, JVM shutdown signal, or lock release interruption), the
46+
* storage service MUST cleanly persist all bytes received up to the interruption point, update
47+
* the {@link UploadInfo} offset and metadata accordingly, and re-throw the exception.
48+
*
4449
* @param upload The ID of the upload
4550
* @param inputStream The input stream containing the bytes to append
4651
* @return The new {@link UploadInfo} for this upload
52+
* @throws IOException If saving bytes or reading from the input stream fails
53+
* @throws TusException If upload validation fails or storage constraints are violated
4754
*/
4855
UploadInfo append(UploadInfo upload, InputStream inputStream) throws IOException, TusException;
4956

src/main/java/me/desair/tus/server/upload/azure/AzureBlobLockingService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ protected void cleanupOnClose() throws IOException {
9696
Utils.interruptThread(watchdogThread);
9797
watchdogThread = null;
9898
}
99+
for (WeakReference<InterruptibleInputStream> streamRef : activeStreams.values()) {
100+
if (streamRef != null) {
101+
Utils.interruptStream(streamRef.get());
102+
}
103+
}
99104
activeStreams.clear();
100105
}
101106

src/main/java/me/desair/tus/server/upload/azure/AzureBlobStorageService.java

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,15 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream)
210210

211211
long totalAppended = 0L;
212212
boolean streamFinished = false;
213+
IOException streamException = null;
213214

214215
File firstChunkFile = File.createTempFile("tus-azure-chunk-", ".tmp", tempBufferDir.toFile());
215216
try {
216217
// 4. Read first chunk from incoming payload stream into local disk buffer
217-
long firstChunkSize = readChunk(inputStream, firstChunkFile, optimalBlockSize);
218+
ReadChunkResult firstChunkResult = readChunk(inputStream, firstChunkFile, optimalBlockSize);
219+
long firstChunkSize = firstChunkResult.bytesRead;
218220
totalAppended += firstChunkSize;
221+
streamException = firstChunkResult.exception;
219222

220223
validateMaxAppendSize(totalAppended, effectiveMaxAppendSize);
221224

@@ -227,17 +230,25 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream)
227230
streamFinished = true;
228231
}
229232

230-
if (totalBuffered < optimalBlockSize && !isUploadComplete && streamFinished) {
233+
// If the cumulative buffered data is below optimalBlockSize and the upload is not yet
234+
// finished, and the stream has reached EOF or was interrupted by an IOException:
235+
// Keep data in the temporary .part blob instead of committing a sub-optimal block to the
236+
// Block Blob.
237+
if (totalBuffered < optimalBlockSize
238+
&& !isUploadComplete
239+
&& (streamFinished || streamException != null)) {
231240
// Small append under block size threshold: buffer data to .part blob directly
232-
bufferToPartBlob(partBlob, existingPartSize, firstChunkFile, firstChunkSize);
241+
if (firstChunkSize > 0) {
242+
bufferToPartBlob(partBlob, existingPartSize, firstChunkFile, firstChunkSize);
243+
}
233244
} else {
234245
// Data exceeds block size threshold: stage blocks to Azure Block Blob
235246
stagePartBlobIfPresent(partBlob, existingPartSize, blockBlobClient, blockIds);
236247
stageChunkFile(firstChunkFile, firstChunkSize, blockBlobClient, blockIds);
237248

238249
// Process any remaining chunks from input stream
239-
if (!streamFinished) {
240-
totalAppended +=
250+
if (!streamFinished && streamException == null) {
251+
ProcessChunksResult remainingResult =
241252
processRemainingChunks(
242253
inputStream,
243254
optimalBlockSize,
@@ -247,13 +258,21 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream)
247258
blockBlobClient,
248259
blockIds,
249260
totalAppended);
261+
totalAppended += remainingResult.additionalAppended;
262+
if (remainingResult.exception != null) {
263+
streamException = remainingResult.exception;
264+
}
250265
}
251266

252267
// Commit updated block ID list on Azure so staged blocks become committed and readable
253-
blockBlobClient.commitBlockList(blockIds, true);
268+
if (!blockIds.isEmpty()) {
269+
blockBlobClient.commitBlockList(blockIds, true);
270+
}
254271
}
255272

256-
validateMinAppendSize(totalAppended);
273+
if (streamException == null) {
274+
validateMinAppendSize(totalAppended);
275+
}
257276

258277
// 5. Update UploadInfo offset, expiration timestamp, and optional deduplication state
259278
upload.setOffset(upload.getOffset() + totalAppended);
@@ -268,6 +287,11 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream)
268287
}
269288

270289
saveUploadInfo(upload);
290+
291+
if (streamException != null) {
292+
throw streamException;
293+
}
294+
271295
return upload;
272296
} finally {
273297
deleteFileQuietly(firstChunkFile);
@@ -702,10 +726,31 @@ private long getPartBlobSize(BlobClient partBlob) {
702726
}
703727
}
704728

705-
/** Reads up to maxBytes from InputStream into target File. */
706-
private long readChunk(InputStream is, File targetFile, long maxBytes) throws IOException {
729+
private static class ReadChunkResult {
730+
final long bytesRead;
731+
final IOException exception;
732+
733+
ReadChunkResult(long bytesRead, IOException exception) {
734+
this.bytesRead = bytesRead;
735+
this.exception = exception;
736+
}
737+
}
738+
739+
private static class ProcessChunksResult {
740+
final long additionalAppended;
741+
final IOException exception;
742+
743+
ProcessChunksResult(long additionalAppended, IOException exception) {
744+
this.additionalAppended = additionalAppended;
745+
this.exception = exception;
746+
}
747+
}
748+
749+
/** Reads up to maxBytes from InputStream into target File, preserving read count on exception. */
750+
private ReadChunkResult readChunk(InputStream is, File targetFile, long maxBytes) {
707751
long totalRead = 0L;
708752
byte[] buffer = new byte[8192];
753+
IOException readException = null;
709754
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
710755
while (totalRead < maxBytes) {
711756
int lenToRead = (int) Math.min(buffer.length, maxBytes - totalRead);
@@ -716,8 +761,10 @@ private long readChunk(InputStream is, File targetFile, long maxBytes) throws IO
716761
fos.write(buffer, 0, read);
717762
totalRead += read;
718763
}
764+
} catch (IOException e) {
765+
readException = e;
719766
}
720-
return totalRead;
767+
return new ReadChunkResult(totalRead, readException);
721768
}
722769

723770
/** Retrieves committed block IDs from Azure Block Blob. */
@@ -786,8 +833,8 @@ private void stageChunkFile(
786833
}
787834
}
788835

789-
/** Processes remaining payload chunks from stream until EOF. */
790-
private long processRemainingChunks(
836+
/** Processes remaining payload chunks from stream until EOF or interruption. */
837+
private ProcessChunksResult processRemainingChunks(
791838
InputStream inputStream,
792839
long optimalBlockSize,
793840
Long effectiveMaxAppendSize,
@@ -796,14 +843,19 @@ private long processRemainingChunks(
796843
BlockBlobClient blockBlobClient,
797844
List<String> blockIds,
798845
long currentTotalAppended)
799-
throws IOException, MaxAppendSizeExceededException {
846+
throws MaxAppendSizeExceededException, IOException {
800847
long additionalAppended = 0L;
801848
boolean streamFinished = false;
849+
IOException exception = null;
802850

803851
while (!streamFinished) {
804-
File chunkFile = File.createTempFile("tus-azure-chunk-", ".tmp", tempBufferDir.toFile());
852+
File chunkFile = null;
805853
try {
806-
long chunkSize = readChunk(inputStream, chunkFile, optimalBlockSize);
854+
chunkFile = File.createTempFile("tus-azure-chunk-", ".tmp", tempBufferDir.toFile());
855+
ReadChunkResult chunkResult = readChunk(inputStream, chunkFile, optimalBlockSize);
856+
long chunkSize = chunkResult.bytesRead;
857+
exception = chunkResult.exception;
858+
807859
if (chunkSize <= 0) {
808860
break;
809861
}
@@ -820,11 +872,17 @@ private long processRemainingChunks(
820872
} else {
821873
stageChunkFile(chunkFile, chunkSize, blockBlobClient, blockIds);
822874
}
875+
876+
if (exception != null) {
877+
break;
878+
}
823879
} finally {
824-
deleteFileQuietly(chunkFile);
880+
if (chunkFile != null) {
881+
deleteFileQuietly(chunkFile);
882+
}
825883
}
826884
}
827-
return additionalAppended;
885+
return new ProcessChunksResult(additionalAppended, exception);
828886
}
829887

830888
/** Buffers incoming data to temporary .part blob when under block threshold. */

src/main/java/me/desair/tus/server/upload/disk/DiskLockingService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ protected void cleanupOnClose() throws IOException {
6161
Utils.interruptThread(watchdogThread);
6262
watchdogThread = null;
6363
}
64+
for (WeakReference<InterruptibleInputStream> streamRef : activeLocks.values()) {
65+
if (streamRef != null) {
66+
Utils.interruptStream(streamRef.get());
67+
}
68+
}
6469
activeLocks.clear();
6570
}
6671

src/main/java/me/desair/tus/server/upload/s3/S3LockingService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ private void writeStopSignal(UploadId uploadId) {
313313
@Override
314314
protected void cleanupOnClose() throws IOException {
315315
Utils.shutdownExecutor(watchdogExecutor);
316+
for (InputStream stream : activeInputStreams.values()) {
317+
Utils.interruptStream(stream);
318+
}
316319
activeInputStreams.clear();
317320
}
318321

src/main/java/me/desair/tus/server/upload/s3/S3StorageService.java

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -308,26 +308,38 @@ public UploadInfo append(UploadInfo upload, InputStream inputStream)
308308
InputStream streamToRead = prepareStreamWithExistingIncompletePart(partObjectKey, inputStream);
309309

310310
// Step 3: Process payload stream in optimal chunk parts and upload to S3
311-
AppendResult appendResult =
312-
processPayloadChunks(info, streamToRead, info.getId(), partObjectKey);
313-
314-
// Step 4: Validate minimum append size constraints if configured
315-
if (minAppendSize != null && appendResult.totalBytesAppended < minAppendSize) {
316-
throw new MinAppendSizeNotMetException(
317-
"Append payload size "
318-
+ appendResult.totalBytesAppended
319-
+ " is below minimum limit "
320-
+ minAppendSize);
321-
}
311+
boolean successfullyFinished = false;
312+
try {
313+
AppendResult appendResult =
314+
processPayloadChunks(info, streamToRead, info.getId(), partObjectKey);
315+
316+
// Step 4: Validate minimum append size constraints if configured
317+
if (minAppendSize != null && appendResult.totalBytesAppended < minAppendSize) {
318+
throw new MinAppendSizeNotMetException(
319+
"Append payload size "
320+
+ appendResult.totalBytesAppended
321+
+ " is below minimum limit "
322+
+ minAppendSize);
323+
}
322324

323-
// Step 5: Recalculate total uploaded byte offset across all uploaded part objects in S3
324-
long newOffset = calculateCurrentOffset(objectKey, info.getId(), partObjectKey);
325-
info.setOffset(newOffset);
325+
// Step 5: Recalculate total uploaded byte offset across all uploaded part objects in S3
326+
long newOffset = calculateCurrentOffset(objectKey, info.getId(), partObjectKey);
327+
info.setOffset(newOffset);
328+
upload.setOffset(newOffset);
326329

327-
// Step 6: If all expected bytes are uploaded, compose all part chunks into final S3 object
328-
finalizeCompletedUploadIfFinished(info, objectKey, info.getId(), appendResult, newOffset);
329-
update(info);
330-
return info;
330+
// Step 6: If all expected bytes are uploaded, compose all part chunks into final S3 object
331+
finalizeCompletedUploadIfFinished(info, objectKey, info.getId(), appendResult, newOffset);
332+
update(info);
333+
successfullyFinished = true;
334+
return info;
335+
} finally {
336+
if (!successfullyFinished) {
337+
long newOffset = calculateCurrentOffset(objectKey, info.getId(), partObjectKey);
338+
info.setOffset(newOffset);
339+
upload.setOffset(newOffset);
340+
update(info);
341+
}
342+
}
331343
}
332344

333345
@Override
@@ -678,6 +690,7 @@ private AppendResult processPayloadChunks(
678690
tempChunkFile.deleteOnExit();
679691

680692
long chunkBytesWritten = 0;
693+
IOException readException = null;
681694
try (FileOutputStream fos = new FileOutputStream(tempChunkFile)) {
682695
int bytesRead;
683696
while (chunkBytesWritten < optimalPartSize
@@ -698,13 +711,19 @@ private AppendResult processPayloadChunks(
698711
if (chunkBytesWritten < optimalPartSize) {
699712
streamFinished = true;
700713
}
714+
} catch (IOException e) {
715+
readException = e;
716+
streamFinished = true;
701717
}
702718

703719
if (chunkBytesWritten == 0) {
704720
boolean deleted = tempChunkFile.delete();
705721
if (!deleted) {
706722
log.warn("Failed to delete temp chunk file {}", tempChunkFile.getAbsolutePath());
707723
}
724+
if (readException != null) {
725+
throw readException;
726+
}
708727
break;
709728
}
710729

@@ -721,6 +740,10 @@ private AppendResult processPayloadChunks(
721740
// Store sub-5MB tail chunk as temporary .part object in S3 for subsequent appends
722741
storeIncompletePartToS3(partObjectKey, tempChunkFile, chunkBytesWritten);
723742
}
743+
744+
if (readException != null) {
745+
throw readException;
746+
}
724747
}
725748

726749
return new AppendResult(totalBytesAppended, allPartKeys);

src/test/java/me/desair/tus/server/upload/azure/AzureBlobLockingServiceTest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ public void closeInterruptsActiveWatchdogThread() throws Exception {
105105
InterruptibleInputStream stream = new InterruptibleInputStream(bais);
106106

107107
lockingService.registerInputStream("/test/upload/88888", stream);
108-
// KISS: verifying close cleanly shuts down active watchdog thread without exception
108+
org.junit.Assert.assertFalse(stream.isInterrupted());
109+
109110
lockingService.close();
111+
org.junit.Assert.assertTrue(stream.isInterrupted());
110112
}
111113
}

0 commit comments

Comments
 (0)