Skip to content

Commit 6489e43

Browse files
authored
feat: implement NFS- and SMB-safe lease file locking (LeaseFileLockingService) (#119)
* feat: implement NFS- and SMB-safe lease file locking (LeaseFileLockingService) - Implement LeaseFileLockingService using atomic directory creation and TTL-based lease metadata - Implement LeaseFileUploadLock with background heartbeat watchdog renewal - Decouple DiskStorageService upload metadata I/O from kernel FileLock - Set LeaseFileLockingService as default in TusFileUploadService.withStoragePath - Calibrate lock retry budget to 40 iterations (8.0s) for NFS/SMB attribute caching - Add comprehensive unit tests, integration tests (Tus 1.0.0 & RUFH), and legacy regression tests - Add docs/DISK_BASED_LOCKING.md and update README.md, docs/LOCKING.md, and CHANGELOG.md * test: add comprehensive branch and error case coverage for lease file locking * refactor: reduce constructors in LeaseFileUploadLock to 2 and streamline lease creation * tests: lower required coverage * fix(locking): mitigate TOCTOU races in LeaseFileLockingService and S3LockingService * docs: add TOCTOU race mitigation and atomic directory staging to DISK_BASED_LOCKING.md * feat: Code review * feat: make lock acquisition retries configurable via withMaxLockRetries * fix: Code formatting
1 parent 7345ee4 commit 6489e43

23 files changed

Lines changed: 2938 additions & 42 deletions

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## [2.0.0]
66

7-
### Added
7+
### New
8+
9+
- **NFS- & SMB-Safe Lease Locking (`LeaseFileLockingService`)**: Added distributed, container-safe filesystem locking using atomic directory creation (`mkdir`) and TTL-based JSON lease files with background heartbeat renewal. Operates reliably across multi-server replicas on NFS (v3/v4), AWS EFS, Azure Files, Windows SMB/CIFS, and local disks without requiring Redis, ZooKeeper, or OS-level `FileLock` daemons. Comprehensive guide and legacy opt-out instructions available in `docs/DISK_BASED_LOCKING.md`.
810
- **S3-Compatible Storage & Distributed Locking**: Added native S3 storage support via `S3StorageService` (MinIO SDK), distributed locking via `S3LockingService` (S3 conditional writes with TTL leases and interrupt signals for multi-replica container deployments), S3-native concatenation via `S3ConcatenationService`, and complete documentation in `docs/S3_STORAGE.md`.
911
- **Azure Blob Storage & Distributed Leases**: Added native Azure Blob Storage support via `AzureBlobStorageService` (Block Blob staging with streaming appends, sub-threshold buffering, truncation, and deduplication), distributed locking via `AzureBlobLockingService` (Azure Blob Leases with auto-renewal, JVM interruption, cross-replica `.stop` signals, and clean shutdown), zero-copy server-side concatenation via `AzureBlobConcatenationService` (`stageBlockFromUrl`), and comprehensive documentation in `docs/AZURE_BLOB_STORAGE.md`.
1012
- **IETF Resumable Uploads for HTTP (RUFH) Protocol**: Implemented full support for the official IETF Resumable Uploads for HTTP specification (`draft-ietf-httpbis-resumable-upload-12`).
@@ -15,6 +17,10 @@ All notable changes to this project will be documented in this file.
1517
- **User Migration & Interim Responses Documentation**: Added `docs/MIGRATION.md` and `docs/INTERIM_RESPONSES.md` detailing migration strategies, HTTP 104 status frames under IETF RUFH, Tomcat/Servlet container limitations, cached reflection optimizations, and Spring Boot Tomcat Valve integration.
1618
- **JSON Serialization**: Support storing `UploadInfo` objects as JSON files in the storage backend using `TusFileUploadService.withJsonSerialization(true)`.
1719

20+
### Changed
21+
- **Default Disk-Based Locking**: `TusFileUploadService.withStoragePath(String)` now defaults to `LeaseFileLockingService` instead of `DiskLockingService` for out-of-the-box Kubernetes, container, and shared network storage compatibility. See `docs/DISK_BASED_LOCKING.md` for legacy opt-out instructions.
22+
- **Calibrated Retry Budget**: Extended `TusFileUploadService` lock acquisition retry budget to 8.0 seconds (40 retries x 200ms) to ensure reliable contention resolution over network storage.
23+
1824
### Breaking
1925
- **Downloads**: In order to support both the Tus protocol and RUFH protocol, the unofficial download extension will not return a HTTP status code `204` for uploads that are still in progress and will not contain the response header `Tus-Resumable`. Removed the `UploadInProgressException` class.
2026

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ The Javadoc of this library can be found at https://tus.desair.me/. As of versio
99

1010
`tus-java-server` provides pluggable storage architecture supporting multiple backend storage options:
1111

12-
1. **File Disk Storage** (`DiskStorageService` & `DiskLockingService`):
12+
1. **File Disk & Network Storage** (`DiskStorageService` & `LeaseFileLockingService`):
1313
- **Local File System**: Direct disk storage on application server instance.
14-
- **Shared NFS Network Drives**: Network file storage for multi-server setups.
14+
- **Shared NFS Network Drives**: Distributed, container-safe lease locking for multi-server setups (NFSv3/v4, AWS EFS, Azure Files, SMB/CIFS). See [Disk & Network Storage Locking Guide](docs/DISK_BASED_LOCKING.md).
1515
- **Kubernetes Persistent Volume**: Mounted volume (`ReadWriteMany` / `ReadWriteOnce`) for containerized applications.
1616
2. **S3-Compatible Object Storage** (`S3StorageService`, `S3LockingService`, & `S3ConcatenationService`):
1717
- **Cloud & On-Premise S3**: AWS S3, MinIO, Cloudflare R2, Ceph, or Google Cloud Storage.
@@ -120,6 +120,7 @@ The first step is to create a `TusFileUploadService` object using its constructo
120120
* `withSupportedProtocolVersions(ProtocolVersion)`: Configure supported protocol versions (`ProtocolVersion.AUTO` for automatic header-based detection, `ProtocolVersion.TUS_1_0_0` for Tus 1.0.0 only, or `ProtocolVersion.IETF` for IETF Resumable Uploads only).
121121
* `withMaxUploadSize(Long)`: Specify the maximum number of bytes that can be uploaded per upload. If you don't call this method, the maximum number of bytes is `Long.MAX_VALUE`.
122122
* `withStoragePath(String)`: If you're using the default file system-based storage service, you can use this method to specify the path where to store the uploaded bytes and upload information.
123+
* `withMaxLockRetries(int)`: Specify the maximum number of retries the service will attempt to acquire an upload lock before failing with an `UploadAlreadyLockedException` during lock contention resolution (e.g. for `HEAD` or `DELETE` requests). Default is `40` retries (with a 200ms sleep between retries, resulting in an 8.0-second retry budget).
123124
* `withChunkedTransferDecoding`: You can enable or disable the decoding of chunked HTTP requests by this library. Enable this feature in case the web container in which this service is running does not decode chunked transfers itself. By default, chunked decoding via this library is disabled (as modern frameworks tend to already do this for you).
124125
* `withThreadLocalCache(Boolean)`: Optionally you can enable (or disable) an in-memory (thread local) cache of upload request data to reduce load on the storage backend and potentially increase performance when processing upload requests.
125126
* `withUploadExpirationPeriod(Long)`: You can set the number of milliseconds after which an upload is considered as expired and available for cleanup. Applies to both Tus 1.0.0 (`Upload-Expires` response header) and IETF RUFH (`max-age` parameter in `Upload-Limit` response header).
@@ -156,7 +157,7 @@ public TomcatServletWebServerFactory tomcatFactory(TusFileUploadService tusFileU
156157
```
157158

158159

159-
The library provides both filesystem-based storage (`DiskStorageService` / `DiskLockingService`) and S3-compatible object storage (`S3StorageService` / `S3LockingService`). See the **[S3 Storage Guide](docs/S3_STORAGE.md)** for detailed instructions on using AWS S3, MinIO, Cloudflare R2, multi-replica container deployments in Kubernetes, and post-upload processing. You can also provide custom implementations of `UploadStorageService` and `UploadLockingService` using `withUploadStorageService(UploadStorageService)` and `withUploadLockingService(UploadLockingService)`.
160+
The library provides filesystem-based storage (`DiskStorageService` / `LeaseFileLockingService`), S3-compatible object storage (`S3StorageService` / `S3LockingService`), and Azure Blob Storage (`AzureBlobStorageService` / `AzureBlobLockingService`). See the **[Disk & Network Storage Locking Guide](docs/DISK_BASED_LOCKING.md)**, **[S3 Storage Guide](docs/S3_STORAGE.md)**, and **[Azure Blob Storage Guide](docs/AZURE_BLOB_STORAGE.md)** for detailed instructions on multi-replica container deployments in Kubernetes, post-upload processing, and legacy locking opt-out. You can also provide custom implementations of `UploadStorageService` and `UploadLockingService` using `withUploadStorageService(UploadStorageService)` and `withUploadLockingService(UploadLockingService)`.
160161

161162
### 2. Processing an upload
162163
To process an upload request you have to pass the current `jakarta.servlet.http.HttpServletRequest` and `jakarta.servlet.http.HttpServletResponse` objects to the `me.desair.tus.server.TusFileUploadService.process()` method. Typical places were you can do this are inside Servlets, Filters or REST API Controllers (see [examples](#quick-start-and-examples)).

docs/DISK_BASED_LOCKING.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Disk & Shared Network Storage Locking (`LeaseFileLockingService`)
2+
3+
This document provides architectural details, distributed concurrency mechanics, network mount configurations, and migration/opt-out instructions for filesystem-backed locking in `tus-java-server`.
4+
5+
---
6+
7+
## 1. Overview & Why `LeaseFileLockingService`
8+
9+
Starting in version **2.0.0**, `LeaseFileLockingService` is the **default** locking implementation instantiated by `TusFileUploadService.withStoragePath(String)`.
10+
11+
### The Problem with POSIX `FileLock` on Network Storage
12+
The legacy `DiskLockingService` relies on OS kernel-level file locks (`java.nio.channels.FileLock` via POSIX `fcntl` on Linux and `LockFileEx` on Windows). While reliable on local disks, kernel file locks frequently fail on shared network storage (NFS, AWS EFS, Azure Files, SMB/CIFS):
13+
14+
- **Unprivileged Containers & Kubernetes**: Pods running in isolated network namespaces lack `rpc.statd` / NLM daemons, causing `FileChannel.tryLock()` to fail with `IOException: No locks available` (`ENOLCK`).
15+
- **`nolock` Mounts**: Shares mounted with the `nolock` performance option (e.g. AWS EFS defaults) ignore or reject POSIX file locks.
16+
- **Ungraceful Crashes**: Pod crashes (`kill -9`, OOM killer, node eviction) leave locks stuck in NFS server state for minutes or indefinitely.
17+
- **Cross-Pod Coordination**: Kernel file locks are tracked in OS memory and do not coordinate cleanly across multi-replica container clusters.
18+
19+
### The Solution: Application-Level Lease Directories
20+
`LeaseFileLockingService` replaces OS kernel locks with atomic directory creation (`mkdir`) and JSON lease files with background heartbeat renewal. This provides **zero external dependencies** (no Redis, ZooKeeper, or etcd cluster required) and works seamlessly on both local disks and distributed network shares.
21+
22+
---
23+
24+
## 2. Lock Directory Layout & Mechanics
25+
26+
Locks are structured as a dedicated directory containing a JSON lease metadata file:
27+
28+
```
29+
<storagePath>/locks/
30+
├── <UploadId>.lock/ # Dedicated lock directory (Atomic existence primitive)
31+
│ └── lease.json # JSON lease metadata (holderId, expiresAt, acquiredAt)
32+
└── <UploadId>.stop # Empty signal file for lock contention interruption
33+
```
34+
35+
### Example `lease.json`:
36+
```json
37+
{
38+
"holderId": "pod-tus-backend-7d9c6-thread-14",
39+
"requestUri": "/files/upload/018f3b2a-7140-7e1d-8f92-5cb640d28362",
40+
"storagePath": "/mnt/uploads/locks/018f3b2a-7140-7e1d-8f92-5cb640d28362.lock",
41+
"leaseDurationMs": 30000,
42+
"expiresAt": 1723401234567,
43+
"acquiredAt": 1723401204567
44+
}
45+
```
46+
47+
### Architectural Rationale:
48+
1. **Universal Atomic Directory Staging & Renames**: Rather than creating an empty directory directly at `<UploadId>.lock` and subsequently writing metadata into it (which creates a window where concurrent nodes observe an empty, uninitialized directory), new locks are staged in a temporary sibling directory (`<UploadId>.lock.stage.<uuid>`) with `lease.json` pre-written, then moved atomically into place (`Files.move` with `StandardCopyOption.ATOMIC_MOVE`). Directory moves map directly to atomic server-side RPCs on both POSIX NFS (`rename(2)`) and Windows SMB (`SetFileInformationByHandle`), ensuring `<UploadId>.lock` is born on disk 100% complete and valid.
49+
2. **Clean Encapsulation**: Placing `lease.json` inside `<UploadId>.lock/` prevents metadata clutter and guarantees that lease updates and watchdog renewals are scoped directly to the lock entity.
50+
3. **Atomic Eviction & Move Isolation**: Stale lock cleanup isolates the target directory by atomically renaming it (`StandardCopyOption.ATOMIC_MOVE` to `.evicting.<uuid>`) before inspecting and deleting its contents. This isolates expired state and allows post-move rollback verification, preventing race collisions between multiple recovering nodes.
51+
52+
---
53+
54+
## 3. Distributed Concurrency & Contention Resolution
55+
56+
### 1. Lock Acquisition Flow (Atomic Directory Staging)
57+
To ensure that an observing process never encounters an empty or partially written lock directory, lock creation uses **Atomic Directory Staging**:
58+
1. Extract `UploadId` from the request URI.
59+
2. Verify that `<storagePath>/locks/<UploadId>.lock` does not already exist. If it exists:
60+
- If `lease.json` is unexpired: Lock is actively held on another replica $\rightarrow$ throw `UploadAlreadyLockedException`.
61+
- If `lease.json` is expired: Holder crashed $\rightarrow$ proceed to **Safe Atomic Eviction** and retry acquisition.
62+
3. Create a unique temporary staging directory: `<storagePath>/locks/<UploadId>.lock.stage.<uuid>`.
63+
4. Write the complete `lease.json` file inside the staging directory.
64+
5. Execute `Files.move(stageDir, lockDirPath, StandardCopyOption.ATOMIC_MOVE)`.
65+
- **Success**: The lock directory appears on disk atomically with a valid, fully populated `lease.json` already inside it. Start the background heartbeat daemon (renews every $\text{leaseDuration} / 3$) and return `LeaseFileUploadLock`.
66+
- **Collision (Already Exists)**: `Files.move` fails because another node acquired the lock in the interim. Clean up `stageDir` and throw `UploadAlreadyLockedException`.
67+
68+
### 2. TOCTOU Mitigation in Expired Lock Eviction (Post-Move Verification & Rollback)
69+
When multiple cluster nodes concurrently discover an expired lock left behind by a crashed pod, a **Time-of-Check to Time-of-Use (TOCTOU)** race condition can arise:
70+
1. **Time of Check (TOC)**: Node A and Node B both inspect `<UploadId>.lock` and observe that its lease has expired.
71+
2. **Node A Wins**: Node A renames the expired directory to `.evicting.<uuid-a>`, deletes it, and stages/moves a brand-new active lock.
72+
3. **Time of Use (TOU) Hazard**: Node B (having verified expiration in Step 1) executes eviction on Node A's **active** directory. Without post-move verification, Node B destroys Node A's directory and acquires a second lock handle, causing dual ownership.
73+
74+
**The Solution: Post-Move Verification & Rollback**:
75+
- When Node B isolates the directory via `Files.move(lockDirPath, evictPath, ATOMIC_MOVE)`, it immediately re-inspects `evictPath` post-move.
76+
- If `evictPath` contains an active lease (created by Node A right before Node B's move), Node B recognizes that it lost the race.
77+
- Node B immediately rolls back the move via `Files.move(evictPath, lockDirPath, ATOMIC_MOVE)` and aborts eviction.
78+
- Exactly one node wins the eviction and acquisition, preserving single-owner lock exclusivity.
79+
80+
### 3. Heartbeat Lease Auto-Renewal
81+
Active streaming uploads periodically renew their lease by updating `expiresAt` in `lease.json` every $\text{leaseDuration} / 3$ (default: every 10 seconds for a 30s lease). When the request completes, `lock.close()` stops the daemon and removes the lock directory.
82+
83+
### 4. Lock Contention & `.stop` Signal Files
84+
When a client sends a `HEAD` or `DELETE` request to resume or cancel an upload while a stalled `PATCH` stream holds the lock:
85+
1. The resuming server catches `UploadAlreadyLockedException` and calls `requestLockRelease(requestUri)`.
86+
2. It interrupts any JVM-local stream and writes `<storagePath>/locks/<UploadId>.stop`.
87+
3. A background watchdog thread on the holding replica (polling every 1.5 seconds) detects `.stop` and calls `stream.interrupt()`.
88+
4. The stalled `PATCH` stream aborts and releases its lock. The server's 8.0-second retry budget ($40 \times 200\text{ms}$) allows the `HEAD` or `DELETE` request to acquire the lock and succeed seamlessly.
89+
90+
---
91+
92+
## 4. Production Network Mount Configuration Guide
93+
94+
### Linux / NFSv4 Mount Recommendations
95+
For multi-replica deployments connecting to shared NFS storage (e.g. AWS EFS, Azure NetApp Files, on-premise NFS):
96+
97+
```bash
98+
mount -t nfs4 -o rw,hard,intr,rsize=1048576,wsize=1048576,actimeo=3 nfs-server:/data/uploads /mnt/tus-uploads
99+
```
100+
101+
- `hard,intr`: Prevents silent I/O failure on transient network disconnects and allows graceful thread interruption.
102+
- `actimeo=3`: Attribute caching timeout of 3s ensures `.stop` signal files and lease renewals propagate rapidly across pods.
103+
- `rsize=1048576,wsize=1048576`: 1MB I/O buffers for maximum streaming append throughput.
104+
105+
### Windows / SMB 3.x Configuration
106+
For Windows Server SMB shares or Azure Files SMB:
107+
108+
```powershell
109+
New-SmbMapping -RemotePath "\\smb-server\uploads" -LocalPath "Z:" -Persistent $True
110+
```
111+
112+
---
113+
114+
## 5. Opt-Out & Backward Compatibility Guide
115+
116+
If you are running single-node deployments on local disk and specifically require legacy OS-level `FileLock` (`fcntl` / `LockFileEx`), you can easily opt out of `LeaseFileLockingService` and restore `DiskLockingService`.
117+
118+
### 1. Programmatic Java Configuration (Opt-Out):
119+
```java
120+
String storagePath = "/var/data/tus-uploads";
121+
122+
TusFileUploadService tus = new TusFileUploadService()
123+
.withUploadStorageService(new DiskStorageService(storagePath))
124+
.withUploadLockingService(new DiskLockingService(storagePath)); // Opt-out to legacy FileLock
125+
```
126+
127+
### 2. Spring Boot Bean Configuration (Opt-Out):
128+
```java
129+
@Configuration
130+
public class TusConfig {
131+
132+
@Bean
133+
public TusFileUploadService tusFileUploadService(@Value("${tus.storage.path}") String storagePath) {
134+
return new TusFileUploadService()
135+
.withUploadUri("/files/upload")
136+
.withUploadStorageService(new DiskStorageService(storagePath))
137+
.withUploadLockingService(new DiskLockingService(storagePath)); // Opt-out to legacy FileLock
138+
}
139+
}
140+
```

0 commit comments

Comments
 (0)