Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,3 @@ buildNumber.properties
__pycache__/
.venv/
*.pyc
CONFORMITY_TEST_IMPROVEMENTS.md
S3_STORAGE_ANALYSIS.md
AZURE_BLOB_STORAGE_ANALYSIS.md
AZURE_BLOB_STORAGE_IMPROVEMENTS.md
SFTP_STORAGE_ANALYSIS.md
NFS_LOCKING.md
RELEASE-REVIEW.md
ASYNC_REVIEW.md
ASYNC_REVIEW_2.md
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ To maintain a clear separation between fast, offline unit tests and containerize
- Whenever acquiring an `UploadLock` (e.g. via `lockingService.lockUpload(uploadId)` or `lockingService.lockUploadByUri(uri)`), always manage the lock lifecycle using a `try-with-resources` block (e.g. `try (UploadLock lock = lockingService.lockUpload(id)) { ... }`).
- This guarantees timely and reliable lock release upon normal completion or abrupt exceptions without requiring verbose and error-prone `try ... finally { if (lock != null) lock.release(); }` boilerplates.

### 25. UploadLock Cleanup & Non-Null UploadLockingService Invariant
- For all storage backends (Disk, S3, Azure), `terminateUpload` strictly manages storage resources (data, metadata, deduplication index) and does not perform lock cleanup. Active uploads are protected by an `UploadLock` whose close/release handle cleans up the active lock upon completion.
- Across all storage backends, `cleanupExpiredUploads()` explicitly calls `uploadLockingService.cleanupLock(uploadId)` to remove orphaned locks and stale index entries.
- `uploadLockingService` is guaranteed to never be null across storage backends (storage services initialize or fallback to default locking via `Utils.getLockingServiceOrDefault(uploadLockingService)`). Do NOT introduce redundant `if (uploadLockingService != null)` guard checks.


## IETF Resumable Uploads for HTTP (RUFH) Spec Maintenance & Update Playbook

Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,20 @@ See [docs/UPGRADING.md](docs/UPGRADING.md) for details and code examples.
- **Azure Blob Storage**: `AzureBlobStorageService`, `AzureBlobLockingService` (Blob Leases with auto-renewal and cross-replica stop signals) and zero-copy `AzureBlobConcatenationService`. See [docs/AZURE_BLOB_STORAGE.md](docs/AZURE_BLOB_STORAGE.md).
- **`UploadCompletionListener`**: register via `withUploadCompletionListener(...)` / `addUploadCompletionListener(...)`. Listeners run after the lock is released, so they can stream and delete the bytes immediately. Added `getUploadedBytes(UploadInfo)` and `deleteUpload(UploadInfo)` overloads.
- **`withLockWaitTimeout(Duration)`** (default 60 s): maximum time a request waits for a contended upload lock; also derives the cloud chunk drain timeout (`lockWaitTimeout - 5 s`) and retry budget. See [docs/LOCKING.md](docs/LOCKING.md).
- **Automatic lock cleanup** on completion, termination (`DELETE`) and expiration across all locking services (`cleanupLock(...)`), plus a fallback sweeper for abandoned locks.
- **Automatic lock lifecycle cleanup** on release, termination (`DELETE`), and expiration across all locking services, deleting lock artifacts immediately without leaving lingering locks, plus a fallback sweeper for abandoned locks.
- **JSON serialization**: store `UploadInfo` as JSON with `withJsonSerialization(true)`.
- **Absolute base URLs**: `withUploadUri(String)` accepts e.g. `https://upload.example.com/files`, returning full `Location` URLs for Tus and RUFH; relative paths keep working.
- **Multi-Backend Upload Deduplication & Lifecycle Cleanup**: Content-based upload deduplication with `withUploadDeduplication(true)` across Disk, S3, and Azure Blob storage. Features child reference markers, parent tombstoning, and complete index directory cleanup when uploads expire or are deleted. See [docs/DEDUPLICATION.md](docs/DEDUPLICATION.md).
- **`NoOpUploadLockingService`**: Zero-overhead Null Object implementation of `UploadLockingService` for unit tests and custom environments, making locking service usage mandatory and eliminating defensive null checks across all storage backends, request handlers, and deduplication logic.

### Fixed
- **Azure Lock Cleanup**: eliminated lingering `.lock` blobs in Azure Blob Storage by having `AzureBlobUploadLock.release()` delete the actively leased blob directly using lease conditions upon lock release, and cleaning up orphaned lock artifacts in `cleanupExpiredUploads()`.
- **Storage Backend Utilities**: introduced dedicated `DiskUtils` and expanded `AzureUtils` alongside `S3Utils`, centralizing safe path resolution, storage directory initialization, path component validation, checksum normalization, blob existence checks, and quiet resource deletion across storage, checksum, and locking backends.
- **S3 Upload Performance**: reduced S3 upload latency by ~70% (~3x speedup in conformity benchmarks) via optimistic conditional writes (`If-None-Match: *`) during lock acquisition, caching part sizes to avoid repeated `statObject` roundtrips, and stream-based byte truncation.
- **Lock Lifecycle & Expired Upload Cleanup**: unified `cleanupExpiredUploads()` across Disk, S3, and Azure backends to clean up orphaned locks and prune stale checksum index entries, while ensuring `terminateUpload()` relies on the active `UploadLock` lifecycle.
- **Async Chunk Uploading**: hardened `AsyncChunkUploader` against servlet thread pool starvation under `CallerRunsPolicy` and fixed race conditions during temporary chunk file deletions.
- **Network Filesystem Locking**: prevented `FileChannel.lock()` `ENOLCK` failures on distributed filesystems (NFS, AWS EFS, Kubernetes PVs) by restricting kernel file locks exclusively to legacy `DiskLockingService`.
- **Deduplication Resilience**: protected deduplication traversal against infinite cyclic recursion (`StackOverflowError`) and handled missing concatenation parts safely.
- **Content-Type matching**: media types are matched per RFC 9110 and tolerate parameters such as `;charset=UTF-8` (added by Spring `CharacterEncodingFilter`, proxies or clients) instead of returning `406`.
- **Undertow & other containers**: `Content-Length` is cleared before `sendError(...)` ([#40](https://github.com/tomdesair/tus-java-server/issues/40)).
- **Disk**: truncate underflow guard in `DiskStorageService`; `FileChannel` leak on lock acquisition errors fixed in `FileBasedLock`.
Expand Down
36 changes: 34 additions & 2 deletions docs/S3_STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,43 @@ To guarantee waterproof single-winner lock exclusivity across cloud providers an
| **Wasabi** | Silently ignored (last-write-wins overwrite) | Layer 2 jitter arbitration or `withS3ConditionalWritesSupported(false)` |
- On backends without conditional write support, contender nodes sleep for a brief randomized jitter (default: 20–60ms) to allow competing writes to settle, then re-read `GetObject`.
- If the remote `holderId` does not match its own, the node detects that it was overtaken, rejects the acquisition, and leaves the winner's lock intact.
3. **Safe Expired Lock Eviction**:
- When evicting an expired lock, the lock object's expiration is verified again immediately before deletion to prevent evicting a fresh lock created by a winning peer.
3. **Safe Expired Lock Eviction with Sibling Mutex**:
- Evicting an expired lock requires a dedicated sibling mutex (`locks/<uploadId>.mutex`) using atomic conditional write (`If-None-Match: *`) to serialize eviction and prevent split-brain race conditions (see detailed explanation below).
4. **Owner-Safe Lock Release**:
- In `S3UploadLock.close()`, the node verifies that the remote lock is still owned by its own `holderId` before deleting it. If its lease expired while the process was paused and another node took over ownership, the previous node will never delete the new owner's active lock.

### Why Was the Mutex Required in evictExpiredLock? (Preventing Split-Brain on Eviction)

Unlike Azure Blob Storage (which supports conditional lease deletion via `x-ms-lease-id`), AWS S3 **does not support conditional `DELETE` operations** (such as `DeleteObject If-Match: <etag>`). S3's `DeleteObject` is always unconditional.

This limitation introduces a critical concurrency hazard when multiple cluster nodes concurrently discover that an existing lock has expired:

```
Thread / Node A Thread / Node B
---------------- ----------------
1. Reads expired lock L1. 1. Reads expired lock L1.
2. Evicts L1 (unconditional DELETE).
3. Writes new active lock L2 via
conditional PUT (If-None-Match: *).
4. Now holds active lock L2!
2. Executes its delayed unconditional DELETE for L1,
BLOWING AWAY Thread A's active lock L2!
3. Thread C arrives, sees no lock, and acquires L3.
==> SPLIT-BRAIN: Both A and C believe they hold the lock!
```

#### How the Mutex Solves This
To eliminate this race condition, `S3LockingService` introduces an ephemeral **sibling mutex object** (`locks/<uploadId>.mutex`):
1. **Contender Mutex Election**: Before performing eviction, every contender attempts to conditionally create `locks/<uploadId>.mutex` using `If-None-Match: *`.
2. **Single Evictor**: Exactly one contender successfully writes the mutex. All other contenders fail with `412 Precondition Failed` and immediately back off with `false`, abandoning eviction.
3. **Protected Eviction**: Under the protection of the mutex, the winning node re-inspects the lock in S3. If it is still expired, the winner deletes the expired lock object and immediately releases the mutex.
4. **Self-Healing TTL**: If a node crashes mid-eviction while holding the mutex, the mutex contains a creation timestamp with a 5-second grace period (`MUTEX_GRACE_PERIOD_MS = 5000L`). Subsequent eviction attempts can automatically reclaim a stale mutex without deadlocks.

#### Why `tryAcquireLock` Does NOT Require a Mutex
On the normal (uncontended or active) acquisition path, `tryAcquireLock` writes the lock lease directly using S3 atomic conditional `PUT` (`If-None-Match: *`). S3 natively guarantees that only one request creates the object, returning `HTTP 412 Precondition Failed` to all concurrent contenders.

Because S3's conditional write provides atomic Compare-And-Swap (CAS) for `PUT`, **normal lock acquisition does not require any mutex**, preserving maximum throughput and single-roundtrip performance. The mutex is solely required to serialize the unconditional `DELETE` during expired lease eviction.

### Jitter Configuration & Zero-Jitter Performance Tuning

The jitter window can be customized via `.withJitter(minMs, maxMs)` on `S3LockingService`:
Expand Down
98 changes: 98 additions & 0 deletions scripts/rufh_conformity_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,65 @@ def test_paused_upload_head_offset_and_resume(self, target_url, request):
assert resp2.get(UPLOAD_OFFSET) == "92"
assert resp2.get(UPLOAD_COMPLETE) == TRUE

def test_interrupted_upload_complete_patch_does_not_prematurely_complete(self, target_url, request):
"""
§4.2 & §5: Interrupted upload-completing PATCH must not prematurely finalize upload.
When a client sends a PATCH with Upload-Complete: ?1 and Content-Length: 100, but the
connection is prematurely closed after sending only 40 bytes, the server must persist the
partial 40 bytes, must NOT mark the upload as completed, and must leave the length
deferred so subsequent PATCH requests can resume and complete the upload.
"""
upload_uri = create_partial_upload(target_url, test_name=request.node.name)

parsed = urlparse(upload_uri)
host = parsed.hostname or "localhost"
port = parsed.port or 80
path = parsed.path + ("?" + parsed.query if parsed.query else "")

s = socket.create_connection((host, port), timeout=10)
try:
req_lines = [
f"PATCH {path} HTTP/1.1",
f"Host: {host}:{port}",
f"{UPLOAD_OFFSET}: 0",
f"{UPLOAD_COMPLETE}: {TRUE}",
f"{CONTENT_TYPE}: {APPLICATION_PARTIAL_UPLOAD}",
"Content-Length: 100",
"Connection: close",
"",
""
]
req_data = "\r\n".join(req_lines).encode("latin1")
s.sendall(req_data)
# Send only 40 bytes out of the 100 bytes declared in Content-Length
s.sendall(b"0123456789" * 4)
time.sleep(0.1)
# Abruptly close the socket to interrupt the stream
s.close()
except Exception:
pass

# Give server a brief moment to commit partial data
time.sleep(0.5)

# Check upload status via HEAD
status_h, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name)
assert status_h in (200, 204)
assert h_headers.get(UPLOAD_OFFSET) == "40", "Server must have saved the 40 received bytes"
assert h_headers.get(UPLOAD_COMPLETE) == FALSE, "Interrupted upload must NOT be marked complete"

# Now resume from offset 40 and complete the remaining 60 bytes
status_resume, resp_resume, _, _ = http_request(
"PATCH",
upload_uri,
headers={UPLOAD_OFFSET: "40", UPLOAD_COMPLETE: TRUE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD},
body=b"abcdefghij" * 6, # 60 bytes
test_name=request.node.name,
)
assert status_resume in (200, 201, 204)
assert resp_resume.get(UPLOAD_OFFSET) == "100"
assert resp_resume.get(UPLOAD_COMPLETE) == TRUE


class TestHttpDigests:
"""Tests for HTTP Digests (RFC 9530)."""
Expand Down Expand Up @@ -1267,6 +1326,45 @@ def make_abs(loc):
assert get_body == payload
http_request("DELETE", new_loc, test_name=request.node.name)

def test_content_digest_deduplication_active_child_preserved(self, target_url, request):
"""
RFC 9530 & RUFH: Deduplication Active Child Verification.
Uploads parent P and duplicate child C with identical Content-Digest.
Verifies both stream the exact payload. Preserves the active child and parent
in storage so that checksum index entries with children remain present for inspection.
"""
payload = b"Preserved active RUFH deduplicated child test payload: " + str(time.time()).encode("ascii")
sha256_b64 = base64.b64encode(hashlib.sha256(payload).digest()).decode("ascii")
headers = {
UPLOAD_COMPLETE: TRUE,
UPLOAD_LENGTH: str(len(payload)),
"Content-Digest": f"sha-256=:{sha256_b64}:",
"Repr-Digest": f"sha-256=:{sha256_b64}:",
CONTENT_TYPE: "text/plain",
}

def make_abs(loc):
if not loc.startswith("http"):
parsed = urlparse(target_url)
return f"{parsed.scheme}://{parsed.netloc}{loc}"
return loc

# 1. Create parent upload P
status1, resp1, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name)
assert status1 in (200, 201)
p_loc = make_abs(resp1.get(LOCATION))

# 2. Create child duplicate upload C
status2, resp2, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name)
assert status2 in (200, 201)
c_loc = make_abs(resp2.get(LOCATION))

# 3. Verify GET on P and C returns identical payload bytes
for loc in (p_loc, c_loc):
get_status, _, get_body, _ = http_request("GET", loc, test_name=request.node.name)
assert get_status == 200, f"GET {loc} must return 200, got {get_status}"
assert get_body == payload, "Child must stream full payload from shared parent data"


# CLI Entry Point & Custom Formatted Summary Reporter
if __name__ == "__main__":
Expand Down
38 changes: 38 additions & 0 deletions scripts/tus_conformity_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,44 @@ def test_checksum_deduplication_and_parent_child_lifecycle(self, target_url):
# Clean up
http_request("DELETE", new_url, headers={TUS_RESUMABLE: TUS_API_VERSION})

def test_checksum_deduplication_active_child_preserved(self, target_url):
"""
Deduplication Active Child Verification:
Uploads parent P and duplicate child C with identical SHA1 checksum.
Verifies both stream the exact payload. Preserves the active child and parent
in storage so that checksum index entries with children remain present for inspection.
"""
payload = b"Preserved active deduplicated child test payload: " + str(time.time()).encode("ascii")
digest_bytes = hashlib.sha1(payload).digest()
b64_digest = base64.b64encode(digest_bytes).decode("ascii")
checksum_header = f"sha1 {b64_digest}"

# 1. Upload parent P
p_url, p_headers = create_upload(
target_url,
upload_length=str(len(payload)),
content_type=APPLICATION_OFFSET_OCTET_STREAM,
extra_headers={UPLOAD_CHECKSUM: checksum_header},
body=payload,
)
assert p_headers.get(UPLOAD_OFFSET) == str(len(payload))

# 2. Upload duplicate child C
c_url, c_headers = create_upload(
target_url,
upload_length=str(len(payload)),
content_type=APPLICATION_OFFSET_OCTET_STREAM,
extra_headers={UPLOAD_CHECKSUM: checksum_header},
body=payload,
)
assert c_headers.get(UPLOAD_OFFSET) == str(len(payload))

# 3. Verify GET returns 200 with identical bytes for both parent and child
for loc in (p_url, c_url):
status, _, body = http_request("GET", loc, headers={TUS_RESUMABLE: TUS_API_VERSION})
assert status == 200, f"GET {loc} must return 200, got {status}"
assert body == payload, "GET must return identical body bytes"


class TestTerminationExtension:
"""Termination Extension compliance tests (§7.5)."""
Expand Down
Loading
Loading