Elarion

Blob uploads

Pre-upload files over an open, resumable transport (tus) or a minimal direct endpoint, reference them when creating an entity, and reclaim abandoned uploads automatically with a pending/commit/TTL lifecycle.

A common requirement is to create an entity (say a Contract) and attach files to it. Over a JSON transport (JSON-RPC) the entity and its files cannot travel together — JSON cannot carry binary. So the file is uploaded separately, the upload returns a reference, and the entity-creation call carries that reference. This is the same shape S3 apps use: pre-upload, then reference.

The hard part is the orphan: if the user uploads a file but never finishes creating the entity (they cancel, or the browser closes), there is no backend signal. Elarion answers this the way S3 lifecycle rules do — with time plus an explicit promote:

Upload produces a Pending blob with a time-to-live.
Commit promotes it to Committed inside the same transaction that creates the referencing entity — atomic with the entity insert.
Garbage collection deletes any Pending blob whose TTL elapses without a commit.

A referenced upload is kept forever; an abandoned one is reclaimed. The TTL is the missing signal. This builds directly on blob storage and stays S3-free — the upload protocol lives only at the HTTP layer.

Package split

PackageUse it inContains
Elarion.BlobsApplicationBlobLifecycleState, IBlobLifecycle (CommitAsync/DeleteExpiredPendingAsync), the additive BlobUploadRequest.InitialState/ExpiresAt, and the protocol-neutral staged-upload seam (IStagedUploadStore) with its in-memory default and the garbage collectors.
Elarion.Blobs.PostgreSqlInfrastructure / hostThe lifecycle on the PostgreSQL store (state/expires_at columns with a partial index, AddElarionPostgreSqlBlobLifecycle) and durable staged-upload staging (UseElarionStagedUploads, AddElarionPostgreSqlStagedUploads) so in-progress uploads survive restarts.
Elarion.Blobs.AzureInfrastructure / hostThe same two roles on Azure Blob Storage: blob store + lifecycle over blob metadata, and native staging over append blobs (AddElarionAzureBlobLifecycle, AddElarionAzureStagedUploads).
Elarion.Blobs.TusHostThe tus 1.0 resumable transport (MapElarionResumableBlobUploads, AddElarionResumableBlobUploads) — a pure protocol adapter over the staging seam.
Elarion.Blobs.AspNetCoreHostThe minimal direct-upload endpoint (MapElarionBlobUploads, AddElarionBlobUploads).
<ItemGroup>
  <PackageReference Include="Elarion.Blobs" Version="0.2.6" />
  <PackageReference Include="Elarion.Blobs.PostgreSql" Version="0.2.6" />
  <PackageReference Include="Elarion.Blobs.Tus" Version="0.2.6" />
</ItemGroup>

The lifecycle

The lifecycle is a small capability over IBlobStore, provider-neutral and S3-free. BlobUploadRequest gains InitialState (defaults to Committed, so a plain SaveAsync stays permanent) and ExpiresAt; upload transports save with InitialState = Pending and an ExpiresAt. IBlobLifecycle adds two operations:

MethodPurpose
CommitAsync(blobRef, ct)Promotes a Pending blob to Committed and clears its expiry. Idempotent; participates in the caller's transaction (it mutates within the ambient transaction and persists on the caller's SaveChangesAsync), so the commit and the entity insert are atomic. Returns false when the blob no longer exists.
DeleteExpiredPendingAsync(olderThanUtc, batchSize, ct)The garbage-collection entry point.

A Pending blob has two fates. Either an application commits it — the pre-upload-then-reference pattern below, which keeps it forever — or it is discarded: a consumed temp file is deleted explicitly (IBlobStore.DeleteAsync) and anything left over is reclaimed by the collector after its TTL (see temp-file flows). A blob is never both.

Register the PostgreSQL lifecycle and its background collector:

services.AddElarionPostgreSqlBlobLifecycle<AppDbContext>(); // store + lifecycle + GC sweeper
// (streaming reads clone the context's connection — no other wiring, ADR-0041)
// in OnModelCreating:
modelBuilder.UseElarionBlobStorage();

This adds state and expires_at columns plus a partial index over pending rows; create a migration (or EnsureCreated for throwaway databases) as for any model change.

Attaching a file to an entity

public async ValueTask<Result<CreateContractResponse>> HandleAsync(CreateContract.Command request, CancellationToken ct) {
    db.Contracts.Add(contract);

    // Promote the pre-uploaded blob in the same transaction as the entity insert.
    if (!await blobLifecycle.CommitAsync(new BlobRef { Value = request.AttachmentBlobRef }, ct)) {
        return AppError.NotFound("attachment no longer available");
    }

    await db.SaveChangesAsync(ct); // commit + insert are atomic
    return new CreateContractResponse(contract.Id);
}

If the handler rolls back, the blob stays Pending and the collector reclaims it after Ttl + SafetyMargin. The collector deletes only rows still pending, so a commit that lands first wins the race and a never-committed upload is always reclaimed.

The blob store is registered against your DbContext as a scoped service, so CommitAsync shares the same DbContext and transaction as your handler — no extra wiring. Wrap the handler in a transaction decorator (or rely on SaveChangesAsync's implicit transaction) so the promote and the insert commit together.

Upload transports

The lifecycle is transport-neutral. Two open transports produce Pending blobs over it; both stay S3-free. Pick by client needs.

tus — the resumable standard

tus 1.0 is the open, resumable upload protocol built by the Uppy authors and the basis of the IETF Resumable Uploads draft. It is resumable, handles large files, and survives a browser close mid-upload, and is supported natively by Uppy (@uppy/tus) and tus-js-client. This is the recommended transport for new frontends.

services.AddElarionResumableBlobUploads();              // in-memory staging by default
app.MapElarionResumableBlobUploads().RequireAuthorization();

MapElarionResumableBlobUploads implements Creation, Core, Expiration, and Termination: OPTIONS advertises capabilities, POST creates an upload (baking the current user's id), PATCH/HEAD stream and resume, DELETE aborts. When an upload completes, the staged bytes are written as a Pending blob and its reference is returned in the Elarion-Blob-Ref response header (also available on HEAD) — the handle the client passes to entity creation.

Under the endpoints sits the protocol-neutral IStagedUploadStore seam: offset-guarded appends, an explicit idempotent completion (so a crash between the last chunk and completion self-heals on the next status probe), and deferred-length sessions — the shape of the IETF Resumable Uploads draft ("tus 2.0"), which will be a second adapter over the same seam. All policy (upload expiry, pending-blob TTL, completed- session retention) lives in ResumableBlobUploadOptions and reaches the store as data, so staging backends are protocol-free and interchangeable.

The default in-memory staging keeps in-progress uploads in process. For resumability across restarts and instances, add the durable PostgreSQL staging store — it persists staged bytes and reaps both incomplete sessions (the analog of S3's abort incomplete multipart upload) and completed sessions once past a retention window:

services.AddElarionResumableBlobUploads();
services.AddElarionPostgreSqlStagedUploads<AppDbContext>();
modelBuilder.UseElarionBlobStorage();  // blob tables, in OnModelCreating
modelBuilder.UseElarionStagedUploads(); // staging table, in OnModelCreating

AddElarionPostgreSqlStagedUploads also wires the PostgreSQL blob lifecycle and its BlobGarbageCollector (idempotently, via AddElarionPostgreSqlBlobLifecycle) — a completed upload produces a pending blob, so without that collector an abandoned upload would leak its blob forever. Map the blob tables with UseElarionBlobStorage() alongside the staging table. Both wiring methods take optional tableName (plus contentTableName on the blob side) and schema parameters and a snakeCase toggle (default true; false switches to PascalCase names — StagedUploads, StoredBlobs/BlobContents); the stores' raw Npgsql SQL (content streaming and the staging conditional append) is built from the EF model, so the overrides apply there too. On a [GenerateDbSets] context, [GenerateElarionBlobStorage] and [GenerateElarionStagedUploads] (each with optional SnakeCase/TableName/Schema — the blob attribute also takes ContentTableName) replace the hand-written calls: the bundled generators emit the DbSets and apply the same configuration through the EF generator's model-config seam (ELBLB001/ELBLB002 if [GenerateDbSets] is missing). A completed session row is retained for ResumableBlobUploadOptions.CompletedSessionRetention (default 1 hour) so a client HEAD can still fetch the reference, then reclaimed; the session collector applies a StagedUploadGcOptions.SafetyMargin (default 1 minute) to the expiry cutoff, mirroring the blob collector.

Prefer Azure Blob Storage? AddElarionAzureStagedUploads(connectionString) stages each session as an append blob — the offset guard is Azure's server-side If-Append-Position-Equal precondition, and completion is a server-side copy into the final pending blob, so bytes never round-trip through the application. The matching AzureBlobStore serves reads and the commit lifecycle over blob metadata (commit takes effect immediately rather than joining a caller transaction — the documented delta from the PostgreSQL tier).

Cross-origin frontends must expose the reference header via CORS: Access-Control-Expose-Headers: Upload-Offset, Location, Upload-Expires, Elarion-Blob-Ref. Behind a reverse proxy, enable UseForwardedHeaders so the Location header reflects the public scheme and host rather than the internal one.

Direct upload — minimal endpoint

For FilePond's process/revert and plain fetch/<form> clients, a single "accept bytes, return a reference" endpoint is the smallest path. It is not a new protocol.

services.AddElarionBlobUploads(o => o.MaxContentLength = 25 * 1024 * 1024);
app.MapElarionBlobUploads().RequireAuthorization();

POST (multipart or raw body) writes a Pending blob and returns its id as text/plain; DELETE /{id} cancels an owner's pending upload. Both enforce authentication, the configured size cap, and an optional content-type allow-list. Ownership is recorded as a dedicated BlobUploadRequest.OwnerId (surfaced as BlobMetadata.OwnerId) and compared exactly on cancel — not parsed from the stored name — so an id containing the naming separator cannot be forged, and a blob with no recorded owner is denied to everyone. The tus transport applies the same owner-exact, fail-closed check to its HEAD/PATCH/DELETE.

Both transports bake the current user's id into the upload, so the host must register an ICurrentUser (via AddElarionCurrentUser or the optional Identity integration) and opt the route group into .RequireAuthorization(...). Per-endpoint authorization is the host's job, as for all generated routes.

Client adapters

  • Uppy@uppy/tus against MapElarionResumableBlobUploads (recommended), or @uppy/aws-s3's getUploadParameters / @uppy/xhr-upload against the direct endpoint.
  • FilePondserver.processPOST the direct endpoint (the response body is the file id); server.revertDELETE /{id}. FilePond's chunked mode is tus-derived but not identical, so its resumable path needs a small client adapter.

Streaming downloads and temp-file flows

The staging area is also the framework's answer for large files in handler flows — the tier above the in-memory ElarionFile payload (which buffers and is the right default only up to a few megabytes). Handlers never carry big payloads; they carry pointers into the staging area, and the pending state doubles as temp-file semantics: what is never committed is garbage-collected after its TTL.

The read side is the owner-scoped streaming download endpoint:

app.MapElarionBlobDownloads().RequireAuthorization();   // GET {prefix}/{blobId}

GET /{blobId} streams the blob with its content type and leaf file name (Content-Disposition: attachment), applying the same exact-owner, fail-closed check as cancel — a missing, foreign-container, unowned, or someone else's blob is 404, so ownership is never leaked.

Import (large upload → processing handler):

  1. The client uploads via tus (or the direct endpoint) into the staging area → gets a pending blob reference.
  2. It calls the processing handler (any transport) with that reference as a plain string field.
  3. The handler streams from IBlobStore.OpenReadAsync(...), processes, and disposes of the temp file.

A consumed import is the mirror image of attaching a file to an entity: you never keep it, so do not CommitAsync it — commit means "promote to permanent and clear the expiry," the opposite of what a throwaway import wants. There are two ways to reclaim it:

  • Explicitly — delete it the moment you are done, so a large temp file is not left occupying storage for the whole TTL:
public async ValueTask<Result<Unit>> HandleAsync(ImportRows.Command command, CancellationToken ct) {
    var blobRef = new BlobRef { Value = command.BlobRef };
    await using var download = await blobs.OpenReadAsync(blobRef, ct);
    if (download is null) {
        return AppError.NotFound("upload expired");
    }

    await importer.IngestAsync(download.Content, ct);   // stream and process
    await blobs.DeleteAsync(blobRef, ct);               // reclaim now — no waiting for the GC
    return Unit.Value;
}
  • Implicitly — do nothing, and the blob garbage collector reclaims the pending blob once its ExpiresAt passes. This is also the safety net if the process crashes before the explicit delete.

Whether that delete is transactional depends on the backend. On a same-database store (PostgreSQL) it joins the caller's unit of work: inside a command's transaction the reclaim commits atomically with the imported rows and rolls back with them if the import fails, leaving the file in place to retry — so calling DeleteAsync inside the handler is safe. On an external store (Azure Blob Storage) the delete is an immediate side effect, not part of your database transaction, so delete only after the import transaction commits — or skip the explicit delete and let the garbage collector reclaim it.

Note the handler works with the pending blob the upload produced, never the staging session: by the time it runs, the resumable upload has already been sealed into that blob (the session is cleaned up separately by the staged-upload collector, and must outlive the client's completion probe). "Removing it from staging" therefore means deleting the pending blob, exactly as above.

Export (handler-produced artifact → streaming download):

public async ValueTask<Result<ExportPointer>> HandleAsync(Command command, CancellationToken ct) {
    await using var artifact = await _exporter.BuildAsync(command.List, ct);   // a stream, never buffered
    var blobRef = await _blobs.SaveAsync(new BlobUploadRequest {
        Container = "uploads",                       // the download endpoint's configured container
        Name = $"{_currentUser.UserId}/{Guid.CreateVersion7():N}/{command.List}.xlsx",
        ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        InitialState = BlobLifecycleState.Pending,   // temp: GC reclaims it if never downloaded/committed
        ExpiresAt = _timeProvider.GetUtcNow() + TimeSpan.FromMinutes(30),
        OwnerId = _currentUser.UserId,               // only the requesting user may download it
    }, artifact, ct);

    return new ExportPointer(blobRef.Value);         // the client GETs {prefix}/{id} to stream it
}

The response DTO carries only the blob id — small, cacheable, schema-friendly — and the actual bytes move once, streamed, over the download endpoint.

Not S3 on the wire

Elarion deliberately does not implement the S3 wire protocol (SigV4, aws-chunked streaming signatures, multipart XML). That is the interop-fragile, expensive part of S3, and upload widgets never need it — Uppy treats every upload URL as opaque. The S3 wire protocol is only required for real AWS SDK / CLI interoperability, which is a non-goal. The abstraction stays S3-free — Elarion.Blobs.Azure is exactly such a direct-to-storage backend behind the neutral seams, and a presigned-URL capability could be added the same way without changing the lifecycle.

On this page