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:
Pending blob with a time-to-live.Committed inside the same transaction that creates the referencing entity — atomic with the entity insert.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
| Package | Use it in | Contains |
|---|---|---|
Elarion.Blobs | Application | BlobLifecycleState, 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.PostgreSql | Infrastructure / host | The 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.Azure | Infrastructure / host | The same two roles on Azure Blob Storage: blob store + lifecycle over blob metadata, and native staging over append blobs (AddElarionAzureBlobLifecycle, AddElarionAzureStagedUploads). |
Elarion.Blobs.Tus | Host | The tus 1.0 resumable transport (MapElarionResumableBlobUploads, AddElarionResumableBlobUploads) — a pure protocol adapter over the staging seam. |
Elarion.Blobs.AspNetCore | Host | The 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:
| Method | Purpose |
|---|---|
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 OnModelCreatingAddElarionPostgreSqlStagedUploads 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/tusagainstMapElarionResumableBlobUploads(recommended), or@uppy/aws-s3'sgetUploadParameters/@uppy/xhr-uploadagainst the direct endpoint. - FilePond —
server.process→POSTthe direct endpoint (the response body is the file id);server.revert→DELETE /{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):
- The client uploads via tus (or the direct endpoint) into the staging area → gets a pending blob reference.
- It calls the processing handler (any transport) with that reference as a plain string field.
- 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
ExpiresAtpasses. 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.
Blob storage
Store binary content behind provider-neutral contracts, with an optional PostgreSQL-backed implementation.
Telemetry & observability
Elarion emits OpenTelemetry-compatible traces and metrics through System.Diagnostics — the host chooses exporters, the runtime forces no SDK dependency — and enriches every handler span and log scope with user context by default.