Portal Community
On This Page The Three Operations  ·  StoreAsync  ·  GetStreamAsync  ·  ExistsAsync  ·  StoreOptions  ·  Error Handling  ·  Working with Large Files  ·  Metadata Patterns

The Three Operations

IObjectStorageProvider exposes exactly three methods. Every file workflow in BizFirst AI—whether from a Flow node, an agent callback, or an API controller—uses these three primitives. Understanding them fully is the entire integration surface.

Method Signature When to Use Return Type
StoreAsync StoreAsync(byte[], string, StoreOptions?, CancellationToken) Persisting any file (PDF, image, JSON, binary) to storage. Safe to call multiple times with the same bytes—deduplication is automatic. Task<string> — the CID
GetStreamAsync GetStreamAsync(string, CancellationToken) Retrieving a file by CID. Use to serve downloads, pass bytes to downstream processors, or attach files to responses. Task<Stream> — a MemoryStream
ExistsAsync ExistsAsync(string, CancellationToken) Checking whether a CID is present before retrieval. Use to guard against invalid CIDs, give clean 404s, or verify deduplication results. Task<bool>

StoreAsync — Storing a File

Method Signature

Task<string> StoreAsync(
    byte[] content,
    string contentType,
    StoreOptions? options = null,
    CancellationToken ct = default)

How It Works

StoreAsync returns a Content ID (CID) — a stable string that uniquely identifies the bytes you provided. The CID is deterministic: identical bytes always produce the identical CID, regardless of when or by whom the file was stored. This means:

Internally, the pipeline executes in this order:

  1. Capacity guard — checks current disk usage against WriteStopThresholdPercent. Throws StorageCapacityException if the threshold is exceeded.
  2. SHA-256 hash — computes the CID from the raw bytes using ICidProvider.
  3. ExistsAsync check — looks up the object key in the configured S3 bucket. If it already exists, returns the CID immediately without re-uploading.
  4. PutObject — if the file is new, streams it to the S3 backend with the specified contentType, ACL, and storage class.
  5. Return CID — returns the stable content-addressable identifier to the caller.

Example 1 — Basic Store (PDF)

// Inject IObjectStorageProvider via constructor
string cid = await _storage.StoreAsync(
    pdfBytes,
    "application/pdf",
    ct: ct);

// Persist the CID to your entity
invoice.DocumentCid = cid;

Example 2 — Store with Custom StoreOptions

var opts = new StoreOptions(
    CannedAcl: "private",
    StorageClass: "INTELLIGENT_TIERING");

string cid = await _storage.StoreAsync(
    reportBytes,
    "application/pdf",
    opts,
    ct);

Example 3 — Store an Image

// Store a PNG thumbnail — content type drives browser behaviour on download
string thumbnailCid = await _storage.StoreAsync(
    imageBytes,
    "image/png",
    ct: ct);

product.ThumbnailCid = thumbnailCid;

GetStreamAsync — Retrieving a File

Method Signature

Task<Stream> GetStreamAsync(string cid, CancellationToken ct = default)

GetStreamAsync returns a MemoryStream containing the full file bytes for the given CID. The stream is positioned at the beginning (position 0) and is ready to read. Always dispose the stream when done.

Basic Usage — Dispose Pattern

await using Stream stream = await _storage.GetStreamAsync(cid, ct);
// stream is positioned at 0, ready to read
await stream.CopyToAsync(outputStream, ct);

Convert to byte[]

await using Stream stream = await _storage.GetStreamAsync(cid, ct);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, ct);
byte[] bytes = ms.ToArray();

Serve as HTTP Response (FileStreamResult)

public async Task<IActionResult> DownloadFile(string cid, CancellationToken ct)
{
    if (!await _storage.ExistsAsync(cid, ct))
        return NotFound();

    Stream stream = await _storage.GetStreamAsync(cid, ct);

    // FileStreamResult disposes the stream after the response is sent
    return new FileStreamResult(stream, "application/pdf")
    {
        FileDownloadName = "document.pdf"
    };
}
Large File Warning The stream returned by GetStreamAsync is a MemoryStream backed by the full file bytes. For files over 100 MB, consider using the underlying IS3ObjectService.DownloadStreamAsync directly to avoid buffering the full file in memory. This streams bytes directly from the S3 backend to the HTTP response without loading the entire file into heap memory.

ExistsAsync — Checking Existence

Method Signature

Task<bool> ExistsAsync(string cid, CancellationToken ct = default)

ExistsAsync performs a lightweight HeadObject call against the S3 backend — it does not download the file. Use it in the following situations:

Standard Guard Pattern

if (!await _storage.ExistsAsync(cid, ct))
    return NotFound($"File {cid} not found.");

Stream stream = await _storage.GetStreamAsync(cid, ct);

Because ExistsAsync is a HeadObject, it is extremely fast and does not count toward data transfer costs on any S3-compatible provider. It is safe to call on every download request.

StoreOptions — Per-Request Overrides

Record Definition

public sealed record StoreOptions(
    string? CannedAcl = null,
    string? StorageClass = null)

StoreOptions allows you to override the global defaults from appsettings.json on a per-call basis. When a field is null, the global default is used. This is useful when one category of files (e.g., archival reports) needs different storage behaviour than the rest.

Field Type Null Behaviour Common Values
CannedAcl string? Uses global FileStorage:CannedAcl from configuration private, public-read, authenticated-read
StorageClass string? Uses global FileStorage:StorageClass from configuration STANDARD, REDUCED_REDUNDANCY, INTELLIGENT_TIERING, GLACIER

Example 1 — Force INTELLIGENT_TIERING for Archival Items

// Long-term archival reports — let AWS automatically move to cheaper tiers
var archiveOpts = new StoreOptions(StorageClass: "INTELLIGENT_TIERING");

string cid = await _storage.StoreAsync(
    yearEndReportBytes,
    "application/pdf",
    archiveOpts,
    ct);

Example 2 — Force private ACL for Sensitive Documents

// Payroll documents must never be publicly accessible
var sensitiveOpts = new StoreOptions(
    CannedAcl: "private",
    StorageClass: "STANDARD");

string cid = await _storage.StoreAsync(
    payrollPdfBytes,
    "application/pdf",
    sensitiveOpts,
    ct);
MinIO Storage Class Compatibility On MinIO, only STANDARD and REDUCED_REDUNDANCY storage classes are supported. AWS-specific classes (INTELLIGENT_TIERING, GLACIER, DEEP_ARCHIVE) require AWS S3 as the backend. Passing an unsupported class to MinIO will result in the object being stored with STANDARD class silently. Always test StorageClass overrides against your configured backend.

Error Handling

IObjectStorageProvider surfaces three categories of errors. Each requires a different response strategy at the API layer.

StorageCapacityException

Thrown by StoreAsync when disk usage has reached or exceeded WriteStopThresholdPercent (default: 90%). This is a hard stop — the write is refused before any data is transmitted to the S3 backend. Callers should return HTTP 507 Insufficient Storage.

This exception will not appear during normal operations. It indicates that the MinIO volume is nearly full and an administrator must take action (expand storage or remove obsolete files).

OperationCanceledException

Thrown when the CancellationToken is signalled (e.g., the HTTP client disconnected). This exception always propagates — it is never swallowed internally. Handle it at the API layer and return HTTP 499 (or 400).

S3 / Network Exceptions

Any exception other than the above two indicates an S3 connectivity or configuration issue (wrong bucket name, invalid credentials, network timeout). Log the full exception with context (CID, tenant, operation) and return an appropriate 500-level response. Do not expose S3 internals in the API response body.

Complete Controller-Level Error Handling

public async Task<IActionResult> UploadDocument([FromBody] UploadRequest req, CancellationToken ct)
{
    try
    {
        string cid = await _storage.StoreAsync(req.Bytes, req.ContentType, ct: ct);
        return Ok(new { cid });
    }
    catch (StorageCapacityException)
    {
        return StatusCode(507, "Storage capacity exceeded. Contact your administrator.");
    }
    catch (OperationCanceledException)
    {
        return StatusCode(499, "Request cancelled.");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "StoreAsync failed for tenant {TenantID}", _tenantContext.TenantID);
        return StatusCode(500, "File storage temporarily unavailable.");
    }
}

Working with Streams (Incoming Files)

In ASP.NET Core controllers, incoming files arrive as IFormFile. The pattern below correctly reads the uploaded bytes into a MemoryStream, then calls StoreAsync. The returned CID can be persisted immediately to your entity.

IFormFile Upload Pattern

public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken ct)
{
    using var ms = new MemoryStream();
    await file.CopyToAsync(ms, ct);
    byte[] bytes = ms.ToArray();

    string cid = await _storage.StoreAsync(bytes, file.ContentType, ct: ct);
    return Ok(new { cid, size = bytes.Length });
}

For multipart uploads with multiple files, loop over Request.Form.Files and call StoreAsync for each file. Each call is independent and can be awaited in parallel using Task.WhenAll for throughput:

var storeTasks = Request.Form.Files
    .Select(async f =>
    {
        using var ms = new MemoryStream();
        await f.CopyToAsync(ms, ct);
        string cid = await _storage.StoreAsync(ms.ToArray(), f.ContentType, ct: ct);
        return new { FileName = f.FileName, Cid = cid };
    });

var results = await Task.WhenAll(storeTasks);
Memory Consideration for Parallel Uploads When processing multiple large files in parallel, the combined byte arrays are all in memory simultaneously. For file upload endpoints that accept large files, consider processing sequentially with foreach + await rather than Task.WhenAll to avoid large memory spikes.

CID Storage Patterns

A CID is just a string — a SHA-256 hex hash, typically 64 characters. It is lightweight, stable, and safe to store anywhere you can store a string. Here are the three standard patterns used across the BizFirst AI platform.

Pattern 1 — Single CID Column on an Entity

The simplest pattern: add a nullable string column to your entity for each document slot. The CID functions as a pointer into the object store.

public class InvoiceEntity
{
    public int InvoiceID { get; set; }
    public string? DocumentCid { get; set; }  // points to the PDF in storage
    public string? SignedCopyCid { get; set; }  // separate slot for signed version
}

Pattern 2 — Multiple CIDs (Attachments Array)

When an entity has a variable number of attachments, store the CIDs as a serialized array or a related table:

public class SupportTicketEntity
{
    public int TicketID { get; set; }

    // Option A: JSON column (EF Core 8+ natively supported)
    public List<string> AttachmentCids { get; set; } = new();

    // Option B: Related table (better for querying individual attachments)
    public ICollection<TicketAttachmentEntity> Attachments { get; set; } = new List<TicketAttachmentEntity>();
}

Pattern 3 — CID as a Foreign Key to a Files Table

The most complete pattern. Store a FilesEntity record alongside the CID, capturing the original filename, content type, upload context, and size. This enables file management UIs without round-tripping to the storage backend for metadata.

public class FileMetadataEntity
{
    public int FileID { get; set; }
    public string Cid { get; set; } = "";          // FK into object storage
    public string OriginalName { get; set; } = "";
    public string ContentType { get; set; } = "";
    public long   SizeBytes { get; set; }
    public DateTime UploadedAt { get; set; }
    public int      UploadedBy { get; set; }         // FK to Users table
}

With this pattern, your entity simply holds a FileID foreign key. File management (rename, delete metadata, audit who uploaded what) is handled through the FileMetadataEntity without touching the object store.

CID Uniqueness vs. File Identity Two different users uploading the same byte-for-byte identical file will receive the same CID. This is intentional — it is the deduplication mechanism. If you need to track "which user uploaded this specific copy", store that context in your FileMetadataEntity, not in the CID itself.

Thread Safety

IObjectStorageProvider is registered as Scoped in the DI container — one instance per HTTP request. Do not capture it in a singleton, background service, or static field.

Service DI Lifetime Thread Safety Notes
IObjectStorageProvider Scoped One instance per request — safe within a single request context Do not share across threads or background tasks
ICidProvider Singleton Fully thread-safe — stateless SHA-256 computation Safe to use from any context, including background services
IS3ObjectService Transient Each call gets a fresh instance Wraps the S3 HTTP client; safe to use concurrently via the provider

If you need to store files from a background service (e.g., a BackgroundService or IHostedService), create a new DI scope for each unit of work:

using (var scope = _serviceScopeFactory.CreateScope())
{
    var storage = scope.ServiceProvider.GetRequiredService<IObjectStorageProvider>();
    string cid = await storage.StoreAsync(bytes, contentType, ct: ct);
}
Never Capture Scoped Services in Singletons Injecting IObjectStorageProvider into a singleton-lifetime class is a classic ASP.NET Core anti-pattern that leads to stale state, tenant context bleed, and difficult-to-reproduce bugs. Always inject IServiceScopeFactory into singletons and create scopes on demand.