• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tomdesair / tus-java-server / 30762898309

02 Aug 2026 07:13PM UTC coverage: 95.854% (-0.6%) from 96.487%
30762898309

Pull #111

github

web-flow
Merge 006364903 into 94d541272
Pull Request #111: feat: add S3 storage, locking, concatenation, and JSON metadata serialization

1714 of 1894 branches covered (90.5%)

Branch coverage included in aggregate %.

895 of 910 new or added lines in 14 files covered. (98.35%)

3835 of 3895 relevant lines covered (98.46%)

6.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.64
/src/main/java/me/desair/tus/server/upload/s3/S3StorageService.java
1
package me.desair.tus.server.upload.s3;
2

3
import io.minio.ComposeObjectArgs;
4
import io.minio.GetObjectArgs;
5
import io.minio.ListObjectsArgs;
6
import io.minio.MinioClient;
7
import io.minio.PutObjectArgs;
8
import io.minio.RemoveObjectArgs;
9
import io.minio.Result;
10
import io.minio.SourceObject;
11
import io.minio.StatObjectArgs;
12
import io.minio.StatObjectResponse;
13
import io.minio.errors.ErrorResponseException;
14
import io.minio.messages.Item;
15
import java.io.ByteArrayInputStream;
16
import java.io.File;
17
import java.io.FileInputStream;
18
import java.io.FileOutputStream;
19
import java.io.IOException;
20
import java.io.InputStream;
21
import java.io.OutputStream;
22
import java.io.SequenceInputStream;
23
import java.nio.charset.StandardCharsets;
24
import java.nio.file.Path;
25
import java.nio.file.Paths;
26
import java.util.ArrayList;
27
import java.util.List;
28
import java.util.Objects;
29
import me.desair.tus.server.checksum.ChecksumAlgorithm;
30
import me.desair.tus.server.exception.MaxAppendSizeExceededException;
31
import me.desair.tus.server.exception.MaxUploadLengthExceededException;
32
import me.desair.tus.server.exception.MinAppendSizeNotMetException;
33
import me.desair.tus.server.exception.MinUploadLengthNotReachedException;
34
import me.desair.tus.server.exception.TusException;
35
import me.desair.tus.server.exception.UploadNotFoundException;
36
import me.desair.tus.server.upload.UploadId;
37
import me.desair.tus.server.upload.UploadIdFactory;
38
import me.desair.tus.server.upload.UploadInfo;
39
import me.desair.tus.server.upload.UploadLockingService;
40
import me.desair.tus.server.upload.UploadStorageService;
41
import me.desair.tus.server.upload.UploadType;
42
import me.desair.tus.server.upload.UuidUploadIdFactory;
43
import me.desair.tus.server.upload.concatenation.UploadConcatenationService;
44
import me.desair.tus.server.util.UploadInfoJsonSerializer;
45
import org.apache.commons.io.IOUtils;
46
import org.slf4j.Logger;
47
import org.slf4j.LoggerFactory;
48

49
/**
50
 * MinIO S3-backed implementation of {@link UploadStorageService} using the lightweight MinIO Java
51
 * SDK.
52
 *
53
 * <p>Key Design Architecture & S3/MinIO Developer Guide:
54
 *
55
 * <ul>
56
 *   <li><b>Server-Side Object Composition ({@code composeObject})</b>: Instead of assembling
57
 *       multi-GB uploads locally on disk or in server RAM, completed chunk parts are combined
58
 *       directly on the S3 storage cluster using S3 server-side object composition. This yields
59
 *       zero server memory overhead and ultra-fast completion times.
60
 *   <li><b>Sub-5MB Incomplete Part Buffering</b>: AWS S3 and MinIO require every part chunk of a
61
 *       multipart upload to be at least 5 MB (5,242,880 bytes), except for the final part. To
62
 *       handle arbitrarily small client appends (e.g., 64 KB network packets or frequent small
63
 *       PATCH calls), sub-5MB tail bytes are buffered to S3 as a temporary {@code
64
 *       <metadataPrefix>/<UploadId>.part} object. When a subsequent PATCH arrives, this leftover
65
 *       part is fetched, prepended to the incoming payload stream, and processed seamlessly.
66
 *   <li><b>Dynamic Optimal Part Sizing</b>: Auto-scales part chunk sizes between 5 MB and 5 GB
67
 *       (capped at S3's maximum limit of 10,000 parts per object).
68
 *   <li><b>Zero-Byte & Checksum Deduplication Support</b>: Seamlessly manages 0-byte upload
69
 *       creation and index lookup for instant duplicate file matching.
70
 * </ul>
71
 */
72
public class S3StorageService implements UploadStorageService {
73

74
  private static final Logger log = LoggerFactory.getLogger(S3StorageService.class);
8✔
75

76
  // Key Prefixes for S3 object layout separation
77
  public static final String DEFAULT_OBJECT_PREFIX = "tus-uploads/";
78
  public static final String DEFAULT_METADATA_PREFIX = "metadata/";
79
  public static final String DEFAULT_CHECKSUMS_PREFIX = "checksums/";
80
  public static final String DEFAULT_LOCKS_PREFIX = "locks/";
81

82
  // Part Sizing Constraints (per AWS S3 & MinIO specifications)
83
  private static final long DEFAULT_MIN_PART_SIZE =
84
      5L * 1024 * 1024; // 5 MB (S3 minimum part limit)
85
  private static final long DEFAULT_PREFERRED_PART_SIZE =
86
      50L * 1024 * 1024; // 50 MB (Optimal chunk size)
87
  private static final long DEFAULT_MAX_PART_SIZE =
88
      5L * 1024 * 1024 * 1024L; // 5 GB (S3 maximum object/part limit)
89

90
  private final MinioClient minioClient;
91
  private final String bucket;
92
  private final String objectPrefix;
93
  private final String metadataPrefix;
94
  private final String checksumsPrefix;
95
  private final String locksPrefix;
96
  private final Path temporaryDirectory;
97

98
  private long minPartSize = DEFAULT_MIN_PART_SIZE;
6✔
99
  private long preferredPartSize = DEFAULT_PREFERRED_PART_SIZE;
6✔
100

101
  private Long maxUploadSize;
102
  private Long maxAppendSize;
103
  private Long minAppendSize;
104
  private Long minSize;
105
  private Long uploadExpirationPeriod;
106
  private boolean deduplicationEnabled = false;
6✔
107

108
  private UploadIdFactory idFactory = new UuidUploadIdFactory();
10✔
109
  private UploadConcatenationService concatenationService;
110

111
  /**
112
   * Basic constructor using default object key prefixes and standard system temp directory.
113
   *
114
   * @param minioClient Pre-configured MinIO Client
115
   * @param bucket S3 bucket name
116
   */
117
  public S3StorageService(MinioClient minioClient, String bucket) {
118
    this(
18✔
119
        minioClient,
120
        bucket,
121
        DEFAULT_OBJECT_PREFIX,
122
        DEFAULT_METADATA_PREFIX,
123
        DEFAULT_CHECKSUMS_PREFIX,
124
        DEFAULT_LOCKS_PREFIX,
125
        Paths.get(System.getProperty("java.io.tmpdir")));
8✔
126
  }
2✔
127

128
  /**
129
   * Full constructor allowing full customization of object prefixes and local disk buffer path.
130
   *
131
   * @param minioClient Pre-configured MinIO Client
132
   * @param bucket S3 bucket name
133
   * @param objectPrefix Key prefix for final completed file objects
134
   * @param metadataPrefix Key prefix for metadata (.info JSON and .part buffer) objects
135
   * @param checksumsPrefix Key prefix for checksum deduplication index objects
136
   * @param locksPrefix Key prefix for distributed lock lease objects
137
   * @param temporaryDirectory Local directory path for staging chunks before S3 upload
138
   */
139
  public S3StorageService(
140
      MinioClient minioClient,
141
      String bucket,
142
      String objectPrefix,
143
      String metadataPrefix,
144
      String checksumsPrefix,
145
      String locksPrefix,
146
      Path temporaryDirectory) {
4✔
147
    this.minioClient = Objects.requireNonNull(minioClient, "MinioClient must not be null");
12✔
148
    this.bucket = Objects.requireNonNull(bucket, "Bucket must not be null");
12✔
149
    this.objectPrefix = sanitizePrefix(objectPrefix);
10✔
150
    this.metadataPrefix = sanitizePrefix(metadataPrefix);
10✔
151
    this.checksumsPrefix = sanitizePrefix(checksumsPrefix);
10✔
152
    this.locksPrefix = sanitizePrefix(locksPrefix);
10✔
153
    this.temporaryDirectory =
2✔
154
        temporaryDirectory != null
4✔
155
            ? temporaryDirectory
4✔
156
            : Paths.get(System.getProperty("java.io.tmpdir"));
7✔
157

158
    this.concatenationService =
28✔
159
        new S3ConcatenationService(
160
            this.minioClient, this.bucket, this.objectPrefix, this, this.temporaryDirectory);
161
  }
2✔
162

163
  /**
164
   * Returns the S3 object key for the completed upload data of the given upload info. If the upload
165
   * was deduplicated, this returns the parent upload's physical S3 object key.
166
   *
167
   * @param uploadInfo The upload info object
168
   * @return The full S3 object key for the uploaded data
169
   */
170
  public String getS3ObjectKey(UploadInfo uploadInfo) {
171
    if (uploadInfo == null || uploadInfo.getId() == null) {
10!
172
      return null;
2✔
173
    }
174
    String id = uploadInfo.getId().toString();
8✔
175
    if (uploadInfo.getStorageUploadId() != null && !uploadInfo.getStorageUploadId().equals(id)) {
16✔
176
      return uploadInfo.getStorageUploadId();
6✔
177
    }
178
    return buildObjectKey(id);
8✔
179
  }
180

181
  /**
182
   * Return the S3 object key where the uploaded bytes are stored for the given upload URI.
183
   *
184
   * @param uploadUri The HTTP request URI of the upload
185
   * @return The target S3 object key or null if upload not found
186
   */
187
  public String getS3ObjectKey(String uploadUri) {
188
    try {
189
      UploadId uploadId = idFactory.readUploadId(uploadUri);
5✔
190
      if (uploadId == null) {
2✔
191
        return null;
2✔
192
      }
193
      UploadInfo uploadInfo = getUploadInfo(uploadId);
4✔
194
      return getS3ObjectKey(uploadInfo);
4✔
195
    } catch (IOException e) {
1✔
196
      log.debug("Error retrieving upload info for URI {}", uploadUri, e);
5✔
197
      return null;
2✔
198
    }
199
  }
200

201
  /**
202
   * Return the S3 object key where the uploaded bytes are stored for the given upload URI and owner
203
   * key.
204
   *
205
   * @param uploadUri The HTTP request URI of the upload
206
   * @param ownerKey The owner key of the upload
207
   * @return The target S3 object key or null if upload not found
208
   */
209
  public String getS3ObjectKey(String uploadUri, String ownerKey) {
210
    try {
211
      UploadInfo uploadInfo = getUploadInfo(uploadUri, ownerKey);
5✔
212
      return getS3ObjectKey(uploadInfo);
4✔
213
    } catch (IOException e) {
1✔
214
      log.debug("Error retrieving upload info for URI {}", uploadUri, e);
5✔
215
      return null;
2✔
216
    }
217
  }
218

219
  @Override
220
  public UploadInfo getUploadInfo(String uploadUrl, String ownerKey) throws IOException {
221
    UploadId uploadId = idFactory.readUploadId(uploadUrl);
10✔
222
    if (uploadId == null) {
4✔
223
      return null;
4✔
224
    }
225
    UploadInfo info = getUploadInfo(uploadId);
8✔
226
    // Enforce strict owner isolation if ownerKey is configured
227
    if (info != null && info.getOwnerKey() != null && !info.getOwnerKey().equals(ownerKey)) {
20✔
228
      return null;
2✔
229
    }
230
    return info;
4✔
231
  }
232

233
  @Override
234
  public UploadInfo getUploadInfo(UploadId id) throws IOException {
235
    if (id == null) {
4✔
236
      return null;
2✔
237
    }
238

239
    String metadataKey = buildMetadataKey(id.toString());
10✔
240
    String json;
241
    // Step 1: Read JSON metadata object from S3 (<metadataPrefix>/<UploadId>.info)
242
    try (InputStream stream =
4✔
243
        minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(metadataKey).build())) {
24✔
244
      json = IOUtils.toString(stream, StandardCharsets.UTF_8);
8✔
245
    } catch (ErrorResponseException e) {
2✔
246
      // Return null if key does not exist in S3
247
      if (S3Utils.parseErrorResponse(e) == S3ErrorType.NO_SUCH_KEY) {
8✔
248
        return null;
4✔
249
      }
250
      throw new IOException("Failed to fetch metadata object from S3 for ID " + id, e);
8✔
251
    } catch (Exception e) {
1✔
252
      throw new IOException("Failed to fetch metadata object from S3 for ID " + id, e);
8✔
253
    }
2✔
254

255
    // Step 2: Deserialize JSON into UploadInfo instance
256
    UploadInfo info = UploadInfoJsonSerializer.deserialize(json);
6✔
257
    if (info == null) {
4✔
258
      return null;
2✔
259
    }
260

261
    // Step 3: Dynamically compute uploaded byte offset if not explicitly set
262
    if (info.getOffset() == null) {
6✔
263
      calculateAndSetOffset(info);
3✔
264
    }
265
    return info;
4✔
266
  }
267

268
  @Override
269
  public String getUploadUri() {
270
    return idFactory != null ? idFactory.getUploadUri() : "/";
16!
271
  }
272

273
  @Override
274
  public UploadInfo create(UploadInfo info, String ownerKey) throws IOException {
275
    Objects.requireNonNull(info, "UploadInfo must not be null");
8✔
276

277
    // Assign new upload ID if missing
278
    if (info.getId() == null) {
6✔
279
      info.setId(idFactory.createId());
10✔
280
    }
281
    info.setOwnerKey(ownerKey);
6✔
282
    info.setStorageUploadId(info.getId().toString());
10✔
283

284
    // Persist initial UploadInfo metadata object (.info) to S3
285
    try {
286
      update(info);
6✔
287
    } catch (UploadNotFoundException e) {
1✔
288
      log.error("Unable to update UploadInfo for newly created upload ID " + info.getId(), e);
7✔
289
    }
2✔
290
    return info;
4✔
291
  }
292

293
  @Override
294
  public UploadInfo append(UploadInfo upload, InputStream inputStream)
295
      throws IOException, TusException {
296
    // Step 1: Verify upload existence and check configured size limits
297
    UploadInfo info = fetchAndValidateUpload(upload.getId());
10✔
298
    String id = info.getId().toString();
8✔
299
    String objectKey = getS3ObjectKey(info);
8✔
300
    String partObjectKey = buildIncompletePartKey(id);
8✔
301

302
    // Step 2: If a previous sub-5MB .part buffer exists in S3, download & prepend it to incoming
303
    // stream
304
    InputStream streamToRead = prepareStreamWithExistingIncompletePart(partObjectKey, inputStream);
10✔
305

306
    // Step 3: Process payload stream in optimal chunk parts and upload to S3
307
    AppendResult appendResult = processPayloadChunks(info, streamToRead, id, partObjectKey);
14✔
308

309
    // Step 4: Validate minimum append size constraints if configured
310
    if (minAppendSize != null && appendResult.totalBytesAppended < minAppendSize) {
13!
311
      throw new MinAppendSizeNotMetException(
9✔
312
          "Append payload size "
313
              + appendResult.totalBytesAppended
314
              + " is below minimum limit "
315
              + minAppendSize);
316
    }
317

318
    // Step 5: Recalculate total uploaded byte offset across all uploaded part objects in S3
319
    long newOffset = calculateCurrentOffset(objectKey, id, partObjectKey);
12✔
320
    info.setOffset(newOffset);
8✔
321

322
    // Step 6: If all expected bytes are uploaded, compose all part chunks into final S3 object
323
    finalizeCompletedUploadIfFinished(info, objectKey, id, appendResult, newOffset);
14✔
324
    update(info);
6✔
325
    return info;
4✔
326
  }
327

328
  @Override
329
  public void update(UploadInfo uploadInfo) throws IOException, UploadNotFoundException {
330
    if (uploadInfo == null || uploadInfo.getId() == null) {
10!
331
      return;
1✔
332
    }
333
    String metadataKey = buildMetadataKey(uploadInfo.getId().toString());
12✔
334
    String json = UploadInfoJsonSerializer.serialize(uploadInfo);
6✔
335
    byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
8✔
336

337
    // Upload JSON metadata object to S3
338
    try {
339
      minioClient.putObject(
8✔
340
          PutObjectArgs.builder().bucket(bucket).object(metadataKey).stream(
34✔
341
                  new ByteArrayInputStream(jsonBytes), (long) jsonBytes.length, -1L)
6✔
342
              .contentType("application/json")
4✔
343
              .build());
4✔
344
    } catch (Exception e) {
1✔
345
      throw new IOException(
3✔
346
          "Failed to write metadata object to S3 for ID " + uploadInfo.getId(), e);
6✔
347
    }
2✔
348

349
    // Index checksum for deduplication if upload is completed and deduplication is enabled
350
    if (isUploadDeduplicationEnabled()
8✔
351
        && uploadInfo.getChecksum() != null
6!
352
        && !uploadInfo.isUploadInProgress()
6✔
353
        && uploadInfo.getDuplicatesUploadId() == null) {
4!
354
      putChecksumIndex(
6✔
355
          uploadInfo.getChecksum(),
4✔
356
          uploadInfo.getChecksumAlgorithm(),
4✔
357
          uploadInfo.getId().toString());
4✔
358
    }
359
  }
2✔
360

361
  @Override
362
  public InputStream getUploadedBytes(String uploadUri, String ownerKey)
363
      throws IOException, UploadNotFoundException {
364
    UploadInfo info = getUploadInfo(uploadUri, ownerKey);
10✔
365
    if (info == null) {
4✔
366
      throw new UploadNotFoundException("Upload not found for URI " + uploadUri);
12✔
367
    }
368
    return getUploadedBytes(info.getId());
5✔
369
  }
370

371
  @Override
372
  public InputStream getUploadedBytes(UploadId id) throws IOException, UploadNotFoundException {
373
    UploadInfo info = getUploadInfo(id);
8✔
374
    if (info == null) {
4✔
375
      throw new UploadNotFoundException("Upload with ID " + id + " was not found");
7✔
376
    }
377

378
    // Resolve duplicate upload reference to parent upload if deduplicated
379
    if (info.getDuplicatesUploadId() != null) {
6✔
380
      return getUploadedBytes(info.getDuplicatesUploadId());
5✔
381
    }
382

383
    // Handle concatenated upload resolution if applicable
384
    if (UploadType.CONCATENATED.equals(info.getUploadType()) && info.getStorageUploadId() == null) {
16!
385
      if (concatenationService != null) {
3!
386
        concatenationService.merge(info);
4✔
387
        info = getUploadInfo(id);
4✔
388
      }
389
    }
390

391
    return fetchS3ByteStream(id, info);
10✔
392
  }
393

394
  @Override
395
  public void copyUploadTo(UploadInfo info, OutputStream outputStream)
396
      throws UploadNotFoundException, IOException {
397
    try (InputStream is = getUploadedBytes(info.getId())) {
10✔
398
      IOUtils.copy(is, outputStream);
8✔
399
    }
400
  }
2✔
401

402
  @Override
403
  public void cleanupExpiredUploads(UploadLockingService uploadLockingService) throws IOException {
404
    try {
405
      // List all metadata objects under metadataPrefix (e.g. metadata/*.info)
406
      Iterable<Result<Item>> results =
4✔
407
          minioClient.listObjects(
4✔
408
              ListObjectsArgs.builder().bucket(bucket).prefix(metadataPrefix).build());
20✔
409

410
      for (Result<Item> result : results) {
20✔
411
        Item item = result.get();
8✔
412
        if (item.objectName().endsWith(".info")) {
10✔
413
          String idStr =
2✔
414
              item.objectName()
6✔
415
                  .substring(
4✔
416
                      metadataPrefix.length(), item.objectName().length() - ".info".length());
14✔
417
          UploadId id = new UploadId(idStr);
10✔
418
          UploadInfo info = getUploadInfo(id);
8✔
419

420
          // Delete expired uploads if not currently locked by an active request
421
          if (info != null
6!
422
              && info.isExpired()
12✔
423
              && (uploadLockingService == null || !uploadLockingService.isLocked(id))) {
4!
424
            terminateUpload(info);
6✔
425
          }
426
        }
427
      }
2✔
428
    } catch (Exception e) {
1✔
429
      throw new IOException("Failed to cleanup expired S3 uploads", e);
6✔
430
    }
2✔
431
  }
2✔
432

433
  @Override
434
  public void removeLastNumberOfBytes(UploadInfo uploadInfo, long byteCount)
435
      throws UploadNotFoundException, IOException {
436
    if (uploadInfo == null || byteCount <= 0) {
12✔
437
      return;
2✔
438
    }
439
    String id = uploadInfo.getId().toString();
8✔
440
    String objectKey = getS3ObjectKey(uploadInfo);
8✔
441
    String partKey = buildIncompletePartKey(id);
8✔
442

443
    long newOffset = Math.max(0L, uploadInfo.getOffset() - byteCount);
16✔
444
    uploadInfo.setOffset(newOffset);
8✔
445
    update(uploadInfo);
6✔
446

447
    // If final completed object exists in S3, truncate it
448
    if (objectExists(objectKey)) {
8✔
449
      truncateFromCompletedObject(objectKey, partKey, newOffset);
10✔
450
      return;
2✔
451
    }
452

453
    // Otherwise truncate from incomplete .part object
454
    truncateFromIncompletePart(partKey, byteCount);
8✔
455
  }
2✔
456

457
  @Override
458
  public void terminateUpload(UploadInfo uploadInfo) throws UploadNotFoundException, IOException {
459
    if (uploadInfo == null || uploadInfo.getId() == null) {
10!
460
      return;
1✔
461
    }
462
    String id = uploadInfo.getId().toString();
8✔
463
    String objectKey = getS3ObjectKey(uploadInfo);
8✔
464
    String metadataKey = buildMetadataKey(id);
8✔
465
    String partKey = buildIncompletePartKey(id);
8✔
466

467
    // Delete final object, metadata object, and incomplete part object from S3
468
    deleteObjectQuietly(objectKey);
6✔
469
    deleteObjectQuietly(metadataKey);
6✔
470
    deleteObjectQuietly(partKey);
6✔
471

472
    // Delete all temporary part chunk objects (e.g. metadata/<id>.part.00001)
473
    deleteAllPartObjectsQuietly(id);
6✔
474

475
    // Delete checksum deduplication index object if present
476
    if (uploadInfo.getChecksum() != null && uploadInfo.getChecksumAlgorithm() != null) {
9!
477
      deleteObjectQuietly(
4✔
478
          buildChecksumKey(uploadInfo.getChecksum(), uploadInfo.getChecksumAlgorithm()));
4✔
479
    }
480
  }
2✔
481

482
  @Override
483
  public UploadInfo getUploadInfoByChecksum(String checksum, ChecksumAlgorithm algorithm)
484
      throws IOException {
485
    if (!isUploadDeduplicationEnabled() || checksum == null || algorithm == null) {
14!
486
      return null;
2✔
487
    }
488

489
    String checksumKey = buildChecksumKey(checksum, algorithm);
10✔
490
    String parentIdStr;
491
    try (InputStream stream =
4✔
492
        minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(checksumKey).build())) {
24✔
493
      parentIdStr = IOUtils.toString(stream, StandardCharsets.UTF_8).trim();
10✔
494
    } catch (ErrorResponseException e) {
1✔
495
      if (S3Utils.parseErrorResponse(e) == S3ErrorType.NO_SUCH_KEY) {
4!
NEW
496
        return null;
×
497
      }
498
      throw new IOException("Failed to read checksum index object from S3", e);
6✔
499
    } catch (Exception e) {
1✔
500
      throw new IOException("Failed to read checksum index object from S3", e);
6✔
501
    }
2✔
502

503
    UploadInfo parentInfo = getUploadInfo(new UploadId(parentIdStr));
14✔
504
    // Self-cleaning: if index points to missing parent upload or object, prune stale index
505
    if (parentInfo == null || !objectExists(buildObjectKey(parentIdStr))) {
16!
506
      deleteObjectQuietly(checksumKey);
3✔
507
      return null;
2✔
508
    }
509

510
    return parentInfo;
4✔
511
  }
512

513
  // CONFIGURATION SETTERS & GETTERS
514

515
  @Override
516
  public void setMaxUploadSize(Long maxUploadSize) {
517
    this.maxUploadSize = maxUploadSize;
6✔
518
  }
2✔
519

520
  @Override
521
  public long getMaxUploadSize() {
522
    return maxUploadSize != null ? maxUploadSize : 0L;
16!
523
  }
524

525
  @Override
526
  public void setMaxAppendSize(Long maxAppendSize) {
527
    this.maxAppendSize = maxAppendSize;
6✔
528
  }
2✔
529

530
  @Override
531
  public Long getMaxAppendSize() {
532
    return maxAppendSize != null ? maxAppendSize : (maxUploadSize != null ? maxUploadSize : null);
17!
533
  }
534

535
  @Override
536
  public void setMinAppendSize(Long minAppendSize) {
537
    this.minAppendSize = minAppendSize;
6✔
538
  }
2✔
539

540
  @Override
541
  public Long getMinAppendSize() {
542
    return minAppendSize;
6✔
543
  }
544

545
  @Override
546
  public void setMinSize(Long minSize) {
547
    this.minSize = minSize;
6✔
548
  }
2✔
549

550
  @Override
551
  public Long getMinSize() {
552
    return minSize;
6✔
553
  }
554

555
  @Override
556
  public void setUploadExpirationPeriod(Long uploadExpirationPeriod) {
557
    this.uploadExpirationPeriod = uploadExpirationPeriod;
6✔
558
  }
2✔
559

560
  @Override
561
  public Long getUploadExpirationPeriod() {
562
    return uploadExpirationPeriod;
6✔
563
  }
564

565
  @Override
566
  public void setUploadDeduplicationEnabled(boolean enabled) {
567
    this.deduplicationEnabled = enabled;
6✔
568
  }
2✔
569

570
  @Override
571
  public boolean isUploadDeduplicationEnabled() {
572
    return deduplicationEnabled;
6✔
573
  }
574

575
  @Override
576
  public void setUploadConcatenationService(UploadConcatenationService concatenationService) {
577
    this.concatenationService = concatenationService;
6✔
578
  }
2✔
579

580
  @Override
581
  public UploadConcatenationService getUploadConcatenationService() {
582
    return concatenationService;
6✔
583
  }
584

585
  @Override
586
  public void setIdFactory(UploadIdFactory idFactory) {
587
    if (idFactory != null) {
4!
588
      this.idFactory = idFactory;
6✔
589
    }
590
  }
2✔
591

592
  // PRIVATE HELPER METHODS & S3 PROCESSING LOGIC
593

594
  private UploadInfo fetchAndValidateUpload(UploadId uploadId)
595
      throws UploadNotFoundException, TusException, IOException {
596
    UploadInfo info = getUploadInfo(uploadId);
8✔
597
    if (info == null) {
4✔
598
      throw new UploadNotFoundException("Upload with ID " + uploadId + " was not found");
7✔
599
    }
600
    validateUploadLimits(info);
6✔
601
    return info;
4✔
602
  }
603

604
  private void validateUploadLimits(UploadInfo info) throws TusException {
605
    if (info.getLength() != null) {
6✔
606
      if (maxUploadSize != null && maxUploadSize > 0 && info.getLength() > maxUploadSize) {
34!
607
        throw new MaxUploadLengthExceededException(
3✔
608
            "Upload length " + info.getLength() + " exceeds max limit of " + maxUploadSize);
6✔
609
      }
610
      if (minSize != null && minSize > 0 && info.getLength() < minSize) {
20!
611
        throw new MinUploadLengthNotReachedException(
3✔
612
            "Upload length " + info.getLength() + " is below min limit of " + minSize);
6✔
613
      }
614
    }
615
  }
2✔
616

617
  /**
618
   * Check if a leftover sub-5MB .part buffer object exists from a previous incomplete PATCH
619
   * request. If found, downloads it to local disk, deletes the .part object from S3, and prepends
620
   * its bytes to the incoming input stream using {@link SequenceInputStream}.
621
   */
622
  private InputStream prepareStreamWithExistingIncompletePart(
623
      String partObjectKey, InputStream inputStream) throws IOException {
624
    try {
625
      StatObjectResponse partHead =
4✔
626
          minioClient.statObject(
4✔
627
              StatObjectArgs.builder().bucket(bucket).object(partObjectKey).build());
20✔
628
      if (partHead != null) {
4✔
629
        InputStream partStream =
4✔
630
            minioClient.getObject(
4✔
631
                GetObjectArgs.builder().bucket(bucket).object(partObjectKey).build());
20✔
632
        File tempPrependedFile =
8✔
633
            File.createTempFile("tus-s3-prep-", ".tmp", temporaryDirectory.toFile());
6✔
634
        tempPrependedFile.deleteOnExit();
4✔
635

636
        try (FileOutputStream fos = new FileOutputStream(tempPrependedFile)) {
10✔
637
          IOUtils.copy(partStream, fos);
8✔
638
        }
639
        deleteObjectQuietly(partObjectKey);
6✔
640
        return new SequenceInputStream(new FileInputStream(tempPrependedFile), inputStream);
18✔
641
      }
642
    } catch (ErrorResponseException e) {
2✔
643
      if ("NoSuchKey".equalsIgnoreCase(e.errorResponse().code())) {
12!
644
        // Normal case: no leftover .part object present in S3
645
      }
646
    } catch (Exception ignored) {
1✔
647
    }
3✔
648
    return inputStream;
4✔
649
  }
650

651
  /**
652
   * Reads bytes from the incoming stream into temporary local files of optimal part size (default
653
   * 50MB). Parts $\ge$ 5MB are uploaded immediately to S3 as part chunk objects. Any trailing chunk
654
   * under 5MB is saved as a temporary .part object unless it completes the overall upload.
655
   */
656
  private AppendResult processPayloadChunks(
657
      UploadInfo info, InputStream streamToRead, String id, String partObjectKey)
658
      throws IOException, MaxAppendSizeExceededException {
659

660
    List<String> partKeys = fetchExistingPartKeys(id);
8✔
661
    int nextPartNumber = partKeys.size() + 1;
10✔
662
    List<String> allPartKeys = new ArrayList<>(partKeys);
10✔
663

664
    long optimalPartSize = calcOptimalPartSize(info.getLength() != null ? info.getLength() : 0);
21✔
665
    byte[] buffer = new byte[8192];
6✔
666
    long totalBytesAppended = 0;
4✔
667

668
    boolean streamFinished = false;
4✔
669
    while (!streamFinished) {
4✔
670
      File tempChunkFile =
8✔
671
          File.createTempFile("tus-s3-chunk-", ".tmp", temporaryDirectory.toFile());
6✔
672
      tempChunkFile.deleteOnExit();
4✔
673

674
      long chunkBytesWritten = 0;
4✔
675
      try (FileOutputStream fos = new FileOutputStream(tempChunkFile)) {
10✔
676
        int bytesRead;
677
        while (chunkBytesWritten < optimalPartSize
12!
678
            && (bytesRead = streamToRead.read(buffer)) != -1) {
10✔
679
          if (maxAppendSize != null && (totalBytesAppended + bytesRead) > maxAppendSize) {
15!
680
            tempChunkFile.delete();
3✔
681
            throw new MaxAppendSizeExceededException(
7✔
682
                "Append payload exceeded limit of " + maxAppendSize);
683
          }
684
          fos.write(buffer, 0, bytesRead);
10✔
685
          chunkBytesWritten += bytesRead;
10✔
686
          totalBytesAppended += bytesRead;
12✔
687
        }
688

689
        if (chunkBytesWritten < optimalPartSize) {
8!
690
          streamFinished = true;
4✔
691
        }
692
      }
693

694
      if (chunkBytesWritten == 0) {
8✔
695
        tempChunkFile.delete();
6✔
696
        break;
2✔
697
      }
698

699
      long currentTotalOffset = info.getOffset() + totalBytesAppended;
12✔
700
      boolean isUploadComplete = info.getLength() != null && currentTotalOffset >= info.getLength();
26✔
701

702
      // AWS S3 / MinIO Rule: Parts must be >= 5 MB unless it's the final part completing the upload
703
      if (chunkBytesWritten >= minPartSize || (streamFinished && isUploadComplete)) {
18!
704
        String chunkKey = buildChunkPartKey(id, nextPartNumber);
10✔
705
        uploadChunkToS3(chunkKey, tempChunkFile, chunkBytesWritten);
10✔
706
        allPartKeys.add(chunkKey);
8✔
707
        nextPartNumber++;
2✔
708
      } else {
2✔
709
        // Store sub-5MB tail chunk as temporary .part object in S3 for subsequent appends
710
        storeIncompletePartToS3(partObjectKey, tempChunkFile, chunkBytesWritten);
10✔
711
      }
712
    }
2✔
713

714
    return new AppendResult(totalBytesAppended, allPartKeys);
12✔
715
  }
716

717
  private void uploadChunkToS3(String chunkKey, File tempChunkFile, long chunkLength)
718
      throws IOException {
719
    try (FileInputStream fis = new FileInputStream(tempChunkFile)) {
10✔
720
      minioClient.putObject(
8✔
721
          PutObjectArgs.builder().bucket(bucket).object(chunkKey).stream(fis, chunkLength, -1L)
28✔
722
              .build());
4✔
NEW
723
    } catch (Exception e) {
×
NEW
724
      throw new IOException("Failed to upload part chunk to S3 key " + chunkKey, e);
×
725
    } finally {
726
      tempChunkFile.delete();
6✔
727
    }
728
  }
2✔
729

730
  private void storeIncompletePartToS3(String partObjectKey, File tempChunkFile, long chunkLength)
731
      throws IOException {
732
    try (FileInputStream fis = new FileInputStream(tempChunkFile)) {
10✔
733
      minioClient.putObject(
8✔
734
          PutObjectArgs.builder().bucket(bucket).object(partObjectKey).stream(fis, chunkLength, -1L)
28✔
735
              .build());
4✔
736
    } catch (Exception e) {
1✔
737
      throw new IOException("Failed to write incomplete part object to S3 key " + partObjectKey, e);
7✔
738
    } finally {
739
      tempChunkFile.delete();
6✔
740
    }
741
  }
2✔
742

743
  /**
744
   * When all expected bytes have been received, this method combines all part chunk objects in S3
745
   * into the final destination object key using MinIO's {@code composeObject} API.
746
   */
747
  private void finalizeCompletedUploadIfFinished(
748
      UploadInfo info, String objectKey, String id, AppendResult appendResult, long newOffset)
749
      throws IOException {
750

751
    if (info.getLength() != null && newOffset >= info.getLength()) {
18✔
752
      List<String> partKeys = fetchExistingPartKeys(id);
8✔
753

754
      // If leftover sub-5MB .part exists, save it as final part chunk
755
      String leftoverPartKey = buildIncompletePartKey(id);
8✔
756
      if (objectExists(leftoverPartKey)) {
8✔
757
        int nextPartNum = partKeys.size() + 1;
5✔
758
        String finalChunkKey = buildChunkPartKey(id, nextPartNum);
5✔
759
        try (InputStream stream =
2✔
760
            minioClient.getObject(
2✔
761
                GetObjectArgs.builder().bucket(bucket).object(leftoverPartKey).build())) {
10✔
762
          byte[] bytes = IOUtils.toByteArray(stream);
3✔
763
          minioClient.putObject(
4✔
764
              PutObjectArgs.builder().bucket(bucket).object(finalChunkKey).stream(
16✔
765
                      new ByteArrayInputStream(bytes), (long) bytes.length, -1L)
3✔
766
                  .build());
2✔
767
          partKeys.add(finalChunkKey);
4✔
768
        } catch (Exception e) {
1✔
769
          throw new IOException("Failed to finalize incomplete part for ID " + id, e);
7✔
770
        }
1✔
771
        deleteObjectQuietly(leftoverPartKey);
3✔
772
      }
773

774
      if (!partKeys.isEmpty()) {
6!
775
        List<SourceObject> sources = new ArrayList<>();
8✔
776
        for (String pk : partKeys) {
20✔
777
          sources.add(SourceObject.builder().bucket(bucket).object(pk).build());
26✔
778
        }
2✔
779

780
        // Perform S3 server-side object composition (composeObject)
781
        try {
782
          minioClient.composeObject(
8✔
783
              ComposeObjectArgs.builder()
6✔
784
                  .bucket(bucket)
6✔
785
                  .object(objectKey)
6✔
786
                  .sources(sources)
2✔
787
                  .build());
4✔
788
        } catch (Exception e) {
1✔
789
          throw new IOException("Failed to compose final object " + objectKey, e);
7✔
790
        }
2✔
791

792
        // Clean up temporary part chunk objects in S3
793
        for (String pk : partKeys) {
20✔
794
          deleteObjectQuietly(pk);
6✔
795
        }
2✔
796
      }
797

798
      // Add checksum index if deduplication is enabled
799
      if (isUploadDeduplicationEnabled()
7✔
800
          && info.getChecksum() != null
3!
801
          && info.getDuplicatesUploadId() == null) {
2!
802
        putChecksumIndex(info.getChecksum(), info.getChecksumAlgorithm(), info.getId().toString());
9✔
803
      }
804
    }
805
  }
2✔
806

807
  private InputStream fetchS3ByteStream(UploadId id, UploadInfo info)
808
      throws UploadNotFoundException {
809
    String objectKey = getS3ObjectKey(info);
8✔
810
    if (objectKey == null && id != null) {
4!
NEW
811
      objectKey = buildObjectKey(id.toString());
×
812
    }
813
    try {
814
      // Step 1: Attempt to read from completed object key in S3
815
      return minioClient.getObject(
8✔
816
          GetObjectArgs.builder().bucket(bucket).object(objectKey).build());
20✔
817
    } catch (ErrorResponseException e) {
2✔
818
      if (S3Utils.parseErrorResponse(e) == S3ErrorType.NO_SUCH_KEY) {
8!
819
        // Step 2: Fallback to reading from incomplete .part object if upload is in-progress
820
        String partKey = buildIncompletePartKey(id.toString());
10✔
821
        try {
822
          return minioClient.getObject(
8✔
823
              GetObjectArgs.builder().bucket(bucket).object(partKey).build());
20✔
824
        } catch (ErrorResponseException ex) {
2✔
825
          if (S3Utils.parseErrorResponse(ex) == S3ErrorType.NO_SUCH_KEY) {
8!
826
            if (info != null && (info.getOffset() == null || info.getOffset() == 0L)) {
22!
827
              return new ByteArrayInputStream(new byte[0]);
12✔
828
            }
829
          }
NEW
830
        } catch (Exception ignored) {
×
NEW
831
        }
×
832
      }
NEW
833
      throw new UploadNotFoundException("Uploaded bytes object not found for ID " + id);
×
834
    } catch (Exception e) {
1✔
835
      throw new UploadNotFoundException("Uploaded bytes object not found for ID " + id);
7✔
836
    }
837
  }
838

839
  private void truncateFromCompletedObject(String objectKey, String partKey, long newOffset)
840
      throws IOException {
841
    if (newOffset > 0) {
8✔
842
      try (InputStream objStream =
4✔
843
          minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(objectKey).build())) {
24✔
844
        byte[] remainingBytes = new byte[(int) newOffset];
8✔
845
        IOUtils.readFully(objStream, remainingBytes);
6✔
846
        minioClient.putObject(
8✔
847
            PutObjectArgs.builder().bucket(bucket).object(partKey).stream(
32✔
848
                    new ByteArrayInputStream(remainingBytes), (long) remainingBytes.length, -1L)
6✔
849
                .build());
4✔
850
      } catch (Exception e) {
1✔
851
        throw new IOException("Failed to truncate completed object key " + objectKey, e);
7✔
852
      }
2✔
853
    }
854
    deleteObjectQuietly(objectKey);
6✔
855
  }
2✔
856

857
  private void truncateFromIncompletePart(String partKey, long byteCount) {
858
    try {
859
      StatObjectResponse head =
4✔
860
          minioClient.statObject(StatObjectArgs.builder().bucket(bucket).object(partKey).build());
24✔
861
      long partSize = head.size();
6✔
862

863
      if (byteCount >= partSize) {
8✔
864
        deleteObjectQuietly(partKey);
8✔
865
      } else {
866
        InputStream partStream =
4✔
867
            minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(partKey).build());
24✔
868
        byte[] bytes = IOUtils.toByteArray(partStream);
6✔
869
        int newLength = (int) (bytes.length - byteCount);
14✔
870
        byte[] remaining = java.util.Arrays.copyOf(bytes, newLength);
8✔
871

872
        minioClient.putObject(
8✔
873
            PutObjectArgs.builder().bucket(bucket).object(partKey).stream(
32✔
874
                    new ByteArrayInputStream(remaining), (long) remaining.length, -1L)
6✔
875
                .build());
4✔
876
      }
NEW
877
    } catch (ErrorResponseException ignored) {
×
878
    } catch (Exception e) {
1✔
879
      log.debug("Error truncating incomplete part object {}", partKey, e);
5✔
880
    }
2✔
881
  }
2✔
882

883
  private void calculateAndSetOffset(UploadInfo info) {
884
    String id = info.getId().toString();
4✔
885
    String objectKey = getS3ObjectKey(info);
4✔
886
    String partKey = buildIncompletePartKey(id);
4✔
887

888
    long offset = calculateCurrentOffset(objectKey, id, partKey);
6✔
889
    info.setOffset(offset);
4✔
890
  }
1✔
891

892
  private long calculateCurrentOffset(String objectKey, String id, String partKey) {
893
    long offset = 0;
4✔
894

895
    if (objectExists(objectKey)) {
8✔
896
      try {
897
        StatObjectResponse head =
2✔
898
            minioClient.statObject(
2✔
899
                StatObjectArgs.builder().bucket(bucket).object(objectKey).build());
10✔
900
        offset += head.size();
5✔
901
      } catch (Exception ignored) {
1✔
902
      }
1✔
903
    }
904

905
    List<String> partKeys = fetchExistingPartKeys(id);
8✔
906
    for (String pk : partKeys) {
20✔
907
      try {
908
        StatObjectResponse stat =
4✔
909
            minioClient.statObject(StatObjectArgs.builder().bucket(bucket).object(pk).build());
24✔
910
        offset += stat.size();
10✔
911
      } catch (Exception ignored) {
1✔
912
      }
2✔
913
    }
2✔
914

915
    if (!partKeys.contains(partKey)) {
8!
916
      try {
917
        StatObjectResponse partHead =
4✔
918
            minioClient.statObject(StatObjectArgs.builder().bucket(bucket).object(partKey).build());
24✔
919
        if (partHead != null) {
4✔
920
          offset += partHead.size();
10✔
921
        }
922
      } catch (ErrorResponseException ignored) {
2✔
923
      } catch (Exception e) {
1✔
924
        log.debug("Error reading head for incomplete part object {}", partKey, e);
5✔
925
      }
4✔
926
    }
927

928
    return offset;
4✔
929
  }
930

931
  private List<String> fetchExistingPartKeys(String id) {
932
    String prefix = metadataPrefix + id + ".part.";
10✔
933
    List<String> partKeys = new ArrayList<>();
8✔
934
    try {
935
      Iterable<Result<Item>> results =
4✔
936
          minioClient.listObjects(ListObjectsArgs.builder().bucket(bucket).prefix(prefix).build());
22✔
937
      for (Result<Item> res : results) {
20✔
938
        partKeys.add(res.get().objectName());
14✔
939
      }
2✔
940
    } catch (Exception ignored) {
1✔
941
    }
2✔
942
    return partKeys;
4✔
943
  }
944

945
  private void deleteAllPartObjectsQuietly(String id) {
946
    List<String> partKeys = fetchExistingPartKeys(id);
8✔
947
    for (String pk : partKeys) {
16✔
948
      deleteObjectQuietly(pk);
3✔
949
    }
1✔
950
  }
2✔
951

952
  private long calcOptimalPartSize(long totalSize) {
953
    long partSize = preferredPartSize;
6✔
954
    if (totalSize > 0 && totalSize / partSize >= 10000) {
20!
NEW
955
      partSize = (totalSize / 10000) + 1;
×
956
    }
957
    return Math.max(minPartSize, Math.min(partSize, DEFAULT_MAX_PART_SIZE));
14✔
958
  }
959

960
  private void putChecksumIndex(String checksum, ChecksumAlgorithm algorithm, String parentId) {
961
    String key = buildChecksumKey(checksum, algorithm);
10✔
962
    try {
963
      byte[] parentIdBytes = parentId.getBytes(StandardCharsets.UTF_8);
8✔
964
      minioClient.putObject(
6✔
965
          PutObjectArgs.builder().bucket(bucket).object(key).stream(
32✔
966
                  new ByteArrayInputStream(parentIdBytes), (long) parentIdBytes.length, -1L)
6✔
967
              .build());
4✔
968
    } catch (Exception e) {
1✔
969
      log.warn("Failed to write checksum index object to S3 key {}", key, e);
5✔
970
    }
1✔
971
  }
2✔
972

973
  private boolean objectExists(String key) {
974
    try {
975
      minioClient.statObject(StatObjectArgs.builder().bucket(bucket).object(key).build());
28✔
976
      return true;
4✔
977
    } catch (ErrorResponseException e) {
2✔
978
      if (S3Utils.parseErrorResponse(e) == S3ErrorType.NO_SUCH_KEY) {
8!
979
        return false;
4✔
980
      }
NEW
981
      return false;
×
982
    } catch (Exception e) {
1✔
983
      return false;
2✔
984
    }
985
  }
986

987
  private void deleteObjectQuietly(String key) {
988
    if (key == null) {
4!
NEW
989
      return;
×
990
    }
991
    try {
992
      minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucket).object(key).build());
26✔
993
    } catch (Exception e) {
1✔
994
      log.debug("Failed to delete S3 object key {}", key, e);
5✔
995
    }
2✔
996
  }
2✔
997

998
  private String sanitizePrefix(String prefix) {
999
    if (prefix == null || prefix.isEmpty()) {
10✔
1000
      return "";
2✔
1001
    }
1002
    String result = prefix.startsWith("/") ? prefix.substring(1) : prefix;
12!
1003
    return result.endsWith("/") ? result : result + "/";
14!
1004
  }
1005

1006
  private String buildObjectKey(String id) {
1007
    return objectPrefix + id;
10✔
1008
  }
1009

1010
  private String buildMetadataKey(String id) {
1011
    return metadataPrefix + id + ".info";
10✔
1012
  }
1013

1014
  private String buildIncompletePartKey(String id) {
1015
    return metadataPrefix + id + ".part";
10✔
1016
  }
1017

1018
  private String buildChunkPartKey(String id, int partNumber) {
1019
    return metadataPrefix + id + ".part." + String.format("%05d", partNumber);
28✔
1020
  }
1021

1022
  private String buildChecksumKey(String checksum, ChecksumAlgorithm algorithm) {
1023
    String algorithmName = algorithm != null ? algorithm.getTusName().toLowerCase() : "unknown";
14!
1024
    return checksumsPrefix + algorithmName + "/" + checksum;
12✔
1025
  }
1026

1027
  private static class AppendResult {
1028
    final long totalBytesAppended;
1029
    final List<String> allPartKeys;
1030

1031
    AppendResult(long totalBytesAppended, List<String> allPartKeys) {
4✔
1032
      this.totalBytesAppended = totalBytesAppended;
6✔
1033
      this.allPartKeys = allPartKeys;
6✔
1034
    }
2✔
1035
  }
1036
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc