TUS Uploads#

Gradle module: source/extn/tusmgmt. Maven coordinate (placeholder, pending extraction): com.palmyralabs.palmyra.extn:palmyra-ext-tusmgmt:<version>.

Implements the TUS resumable-upload protocol for Spring Boot 3 / Jakarta EE 10. Wraps me.desair.tus:tus-java-server with:

  • A relative-path Location header on create (survives reverse proxies).
  • A metadata SPI (IndexStorageService) so the app owns its own attachment table — the extension doesn’t impose one.
  • A per-subtype handler registry (@TusUpload("subType") + FileUploadHandler) so each domain module overrides file-path layout and on-completion behaviour without touching the controller.
  • A scheduled expiration sweeper that reaps orphaned uploads and stale disk locks.

This extension does not depend on palmyra-spring, palmyra-dbpwd-mgmt, or palmyra-dbacl-mgmt — it slots into any Spring Boot 3 app.

Pages#

Page Role
Integration guide Step-by-step consumer walkthrough — from dependency add to first successful PATCH

Public URL shape#

{prefix}/tusupload/{subType}/{ciId}          POST (create), HEAD (status), OPTIONS
{prefix}/tusupload/{subType}/{ciId}/{fid}    PATCH (chunk), HEAD, DELETE, OPTIONS
  • {prefix} = ${palmyra.servlet.prefix-path:/api} — TUS URLs sit alongside your palmyra CRUD URLs.
  • {subType} = a string key the app registered via @TusUpload("...").
  • {ciId} = the row id on the owning table (e.g. exam id, gateway id).
  • {fid} = UUID string returned in the Location header of the POST response.

The ownerKey handed to the TUS library internally is the colon-joined reference:ciType:citId:ciId from FileOwnerInfo. The stock controller sets reference = ciType = subType and citId = 0; apps that need a distinct reference can override TusUploadController or set the ownerKey out-of-band.

Request lifecycle#

┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. POST /api/tusupload/{subType}/{ciId}                                     │
│      headers: Tus-Resumable, Upload-Length, Upload-Metadata                 │
│                                                                             │
│   TusUploadController.dispatch → TusUploadService.process                   │
│      → ExtCreationPostRequestHandler.process                                │
│        → DirectoryStorageService.create                                     │
│          → IndexStorageService.create(info, ownerKey)                       │
│             • registry.resolve(subType) → FileUploadHandler                 │
│             • handler.generateFilePath(owner, filename, metadata)           │
│             • persist row: fid, path, size, offset=0, status=0              │
│          → create empty file on disk at {root}/{relativePath}               │
│                                                                             │
│   ← 201 Created, Location: {requestURI}/{fid}                               │
├─────────────────────────────────────────────────────────────────────────────┤
│ 2. PATCH /api/tusupload/{subType}/{ciId}/{fid}                              │
│      headers: Tus-Resumable, Upload-Offset, Content-Type                    │
│      body: raw chunk bytes                                                  │
│                                                                             │
│   → AbstractDiskStorageService.append → writes bytes to disk                │
│   → DirectoryStorageService.update                                          │
│     → IndexStorageService.update                                            │
│        • updates file_offset, status (percent complete)                     │
│        • when offset == length → handler.onComplete(owner, ..., attachment) │
│                                                                             │
│   ← 204 No Content, Upload-Offset: {new offset}                             │
├─────────────────────────────────────────────────────────────────────────────┤
│ 3. HEAD /api/tusupload/{subType}/{ciId}/{fid}                               │
│   ← 200 + Upload-Offset + Upload-Length                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│ 4. @Scheduled sweeper (every sweep-interval-ms)                             │
│   TusExpirationSweeper.sweep → TusUploadService.cleanup                     │
│     → DirectoryStorageService.cleanupExpiredUploads                         │
│       → IndexStorageService.forEachExpiredUpload                            │
│         → terminateUpload per row (delete DB row + on-disk file)            │
└─────────────────────────────────────────────────────────────────────────────┘

Properties — palmyra.tus.*#

Bound to TusMgmtProperties:

Property Default Purpose
palmyra.tus.upload-location /opt/palmyra/upload Root dir for uploaded bytes + TUS lock files
palmyra.tus.expiration-ms 604800000 (7 days) Age past which an in-progress upload is reaped
palmyra.tus.sweep-interval-ms 900000 (15 min) TusExpirationSweeper poll interval
palmyra.tus.sweep-initial-delay-ms 60000 (1 min) Delay before the first sweep after boot
palmyra.tus.url-pattern /.*/tusupload/[a-zA-Z_]+/[0-9]+/? Regex the TUS lib uses to extract fid from request URIs

SPIs the consuming app implements#

IndexStorageService (exactly one bean)#

DB-backed metadata operations: create, update, getUploadInfo, delete, isUploadIdExists, getRelativePath, forEachExpiredUpload, forEachUpload.

create(info, ownerKey) is the hook that wires the FileUploadHandler.generateFilePath callback into the persisted row; update(info) fires handler.onComplete when offset reaches length. See Integration guide § 3 for the skeleton.

FileUploadHandler (one bean per subtype)#

@Component
@TusUpload("Order")
public class OrderUploadHandler implements FileUploadHandler {
    @Override
    public String generateFilePath(FileOwnerInfo owner, String filename, Map<String,String> metadata) {
        return owner.getDefaultFilePath(filename);  // default: {subType}/{ciId}/{filename}
    }

    @Override
    public void onComplete(FileOwnerInfo owner, String filename, Map<String,String> metadata, Object attachment) {
        // fire domain event, trigger indexer, publish SQS message, …
    }
}

Multiple handlers co-exist — the registry keys by @TusUpload.value(). Missing subtype → IllegalArgumentException on first request.

Layout (reference)#

tusmgmt/
└── src/main/java/com/palmyralabs/palmyra/ext/tusmgmt/
    ├── FileOwnerInfo.java              immutable reference:ciType:citId:ciId parser
    ├── FileUploadHandler.java          per-domain SPI (generateFilePath + onComplete)
    ├── TusUpload.java                  @TusUpload("subType") marker (meta-@Component)
    ├── FileUploadHandlerRegistry.java  resolves subType → handler
    ├── IndexStorageService.java        metadata-storage contract (app implements)
    ├── TusUploadService.java           TusFileUploadService subclass with extension stack
    ├── TusUploadController.java        Spring @Controller dispatcher
    ├── TusUploadConfig.java            @EnableConfigurationProperties + bean wiring
    ├── TusMgmtProperties.java          @ConfigurationProperties("palmyra.tus")
    ├── TusExpirationSweeper.java       @Scheduled cleanup
    └── storage/
        ├── AbstractDiskStorageService.java  disk I/O base
        ├── DirectoryStorageService.java     disk + IndexStorageService bridge
        ├── ExtCreationExtension.java        swaps in relative-Location POST handler
        └── ExtCreationPostRequestHandler.java

Framework dependencies#

Pulls in: me.desair.tus:tus-java-server:1.0.0-3.0, spring-web, spring-context, spring-boot, commons-lang3, slf4j-api, jakarta.servlet-api. Lombok is compile/test only.

Does NOT depend on: palmyra-spring / palmyra-rest / palmyra-standard, any app-specific JPA entities / repos, palmyra-dbacl-mgmt / palmyra-dbpwd-mgmt. ACL and auth are the host application’s concern — the controller method is gated by whatever Spring Security chain the app wires up.