Skip to content
bitzorcas
中EN

Guide

Files storage composition, deletion, and cleanup

IFileStorage, Local/S3/connector composition, configuration, health, deletion semantics, orphan cleanup, and lifecycle risks.

Last updated

The Files lifecycle spans SysFileAsset and object storage. Resolving an IFileStorage is only the first step. Production must prove that API and JobHost use the same provider, bucket, and key rules and that create failures, unfinished uploads, business deletion, and retention expiry eventually converge.

1. The IFileStorage port

The platform port has seven capabilities:

IFileStorage capability surface
public interface IFileStorage
{
// Byte I/O and delete are basic capabilities; callers still define missing/repeat semantics.
Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken ct);
Task<Stream> OpenReadAsync(string objectKey, CancellationToken ct);
Task DeleteAsync(string objectKey, CancellationToken ct);
// Presign and metadata are optional provider capabilities required by the HTTP roundtrip.
Task<string> GeneratePresignedUploadUrlAsync(string objectKey, TimeSpan ttl, CancellationToken ct);
Task<bool> ExistsAsync(string objectKey, CancellationToken ct);
Task<FileObjectMetadata> GetMetadataAsync(string objectKey, CancellationToken ct);
Task<string> GeneratePresignedDownloadUrlAsync(
string objectKey, string fileName, string contentType, TimeSpan ttl, CancellationToken ct);
}

Application depends only on this narrow port. It does not reference AWS, MinIO, or connector types. A provider that cannot honor an operation should throw NotSupportedException so the use case fails closed.

2. Provider capability and limits

BehaviorUnavailableLocalS3-compatibleConnector bridge
Upload/Open/DeleteUnsupportedSupportedSupportedDelegated to IFileStore
Path protectionN/AGetFullPath boundaryBucket/keyProvider-dependent
Presigned PUTNoNoYesProvider-dependent
MetadataNoLength/LastModifiedHEAD factsExists + stream Length
Presigned GETNoNoYesProvider-dependent
Request TTLNoNoHonoredIgnored
Download nameNoNoHeader overrideIgnored

FileStoreAdapter.OpenReadAsync converts a connector null stream into Stream.Null. That can turn absence into an empty object. Any consumer using OpenRead needs a preceding Exists check or a corrected contract.

3. API production configuration

The API production guard currently accepts only FileStorage:DefaultProvider=Minio:

S3-compatible configuration skeleton
{
"FileStorage": {
"DefaultProvider": "Minio",
"S3": {
"Endpoint": "https://minio.internal.example",
"AccessKey": "${FILE_STORAGE_ACCESS_KEY}",
"SecretKey": "${FILE_STORAGE_SECRET_KEY}",
"BucketName": "bitzorcas-files",
"Region": "cn-east-1",
"UseSsl": true,
"ForcePathStyle": true,
"CreateBucketIfNotExists": true
}
}
}

${...} communicates deployment-time secret injection; .NET configuration does not inherently expand that string. Override with environment variables, User Secrets, Vault, or an approved secret reference. Never commit real credentials.

Container options, per-container TTLs, and MaxSinglePutBytes exist in option types, but the four Files use cases do not consume them. They are not active policy today.

4. API registration order

Minio / S3Local / Unavailable

AddBitzOrcasGeneratedServices
TryAdd Unavailable

AddBitzOrcasCoreRuntime

AddBitzOrcasPersistenceAdapters

DefaultProvider

AddBitzOrcasS3CompatibleStorage
Add IFileStorage

AddBitzOrcasFilePlatform
TryAdd Local

S3 uses a normal AddSingleton, so its later registration wins. Local uses TryAddSingleton after generated Unavailable already exists, and the API does not bind LocalFileStorageOptions. The comment claiming that DefaultProvider=Local automatically enables development storage is not proven runtime behavior.

The production guard rejects Local, reducing production exposure. Development still needs a composition integration test that asserts the concrete resolved type.

5. Verify JobHost separately

JobHost’s AddJobHostFileStorage path is explicit:

  • Minio/S3 registers S3-compatible;
  • Local calls AddBitzOrcasStorage with BaseDirectory;
  • every other value registers Unavailable.

API and JobHost are different processes. They need the same bucket, endpoint, credential permissions, and key interpretation, or cleanup cannot see API-created objects.

Terminal window
# Compare effective non-secret environment wiring in both deployments.
kubectl -n bitzorcas get deploy bitzorcas-api bitzorcas-jobhost \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.template.spec.containers[0].env}{"\n"}{end}'
# Inspect secret references only; do not print secret values.
kubectl -n bitzorcas get deploy bitzorcas-api bitzorcas-jobhost -o yaml \
| rg "secretKeyRef|FileStorage__DefaultProvider|FileStorage__S3__BucketName"

6. S3 initialization and health

AddBitzOrcasS3CompatibleStorage registers the client, options, store, and BucketManager. The API also adds an s3-storage readiness check. Operational evidence should cover:

  1. Endpoint reachability and TLS trust;
  2. credential access to the target bucket;
  3. bucket existence or approved initialization rights;
  4. API PUT/HEAD/GET/DELETE privileges;
  5. JobHost HEAD/DELETE privileges;
  6. acceptable clock skew for signatures.

A green health check does not prove that finalize can retrieve SHA-256 or that lifecycle cleanup is correct.

7. Current delete semantics

Object storagePublisherRepositoryFileAssetDelete handlerClientObject storagePublisherRepositoryFileAssetDelete handlerClientno storage DeleteAsync callDELETE /api/files/{id}FindAsyncDelete()Status=Deleted, IsDeleted=trueDeleteAsync (soft delete)Publish FileAssetSummary

A repeat DELETE may fail at the normal soft-delete-filtered repository lookup before aggregate File.AlreadyDeleted can run. A real-repository HTTP test needs to freeze the external contract.

8. Actual orphan-cleanup SQL

JobHost uses a fixed 24-hour threshold, but the SQL is:

Current orphan selection
-- Pending has no age condition, so enabling the job selects every open session immediately.
-- Only Uploaded uses the 24-hour cutoff; Deleted and UploadExpiresAt never participate.
SELECT [Id] AS FileId, [StorageKey], [Status] AS StatusName
FROM [SysFileAsset]
WHERE ([Status] = 'PendingUpload')
OR ([Status] = 'Uploaded'
AND [FinalizedAt] IS NULL
AND [CreateTime] < @cutoff);

Consequences:

  • every PendingUpload is selected immediately, without age or UploadExpiresAt;
  • Uploaded is selected only after 24 hours;
  • Deleted is never selected;
  • UploadExpiresAt is ignored;
  • query and metadata delete do not receive the cancellation token;
  • database metadata is physically deleted only after object deletion succeeds;
  • storage failure retains metadata for retry.

The job is disabled by default, with a default 05:00 daily cron. Enabling the current implementation can remove an object still inside its 30-minute upload window. Fix the predicate first.

9. Target cleanup model

Separate lifecycle reasons instead of overloading one orphan query:

ReasonSelectionGrace/retentionDeletion order
Expired sessionPending + UploadExpiresAt < nowShort clock-skew graceObject → metadata
Unfinalized uploadUploaded + no FinalizedAt + ageConfigurableObject → metadata
Business deletionDeleted + DeleteTime + retentionLegal/recycle policyObject → tombstone/metadata
Object without rowStorage inventory - DB keysLonger safety windowMark → review → delete
Row without objectDB keys - storage inventoryBlock download immediatelyEvidence → metadata repair

Business deletion typically needs an Outbox/Saga: persist DeleteRequested, delete the object idempotently in a worker, then persist Purged. An HTTP transaction cannot be atomic with S3.

10. Cleanup configuration

Enable only after fixing the predicate
{
"DataLifecycle": {
"OrphanedFileCleanup": {
"Enabled": true,
"CronExpression": "0 0 5 * * ?"
}
}
}

The 24-hour threshold is hard-coded in OrphanedFileCleanupJobExecutor; there is no threshold option. Changing cron does not change orphan age.

11. Connector bridge boundary

AddBitzOrcasFileStorageConnectors recognizes FileStorage:ConnectorProvider=Minio, but neither current API nor JobHost composition calls that extension. The bridge does not make ten providers turnkey.

Explicit wiring still leaves these contract gaps:

  • request TTL cannot pass through;
  • metadata has no ContentType, ETag, SHA, or LastModified;
  • download FileName cannot pass through;
  • null streams become Stream.Null;
  • separate ConnectorProvider and DefaultProvider selectors can drift.

Every provider needs the same behavior contract, not just a DI resolution test.

12. Migration and rollback

Moving from Local/S3-A to S3-B requires more than a config switch:

  1. freeze or dual-write new uploads;
  2. copy objects from a tenant/key inventory;
  3. compare byte length and a trusted computed hash;
  4. update provider/bucket metadata or keep a routing table;
  5. sample download file names and content types;
  6. shift reads and monitor NotFound;
  7. retain the source until the rollback window closes;
  8. destroy the old objects only under an approved plan.

Handlers use one global IFileStorage; they do not resolve historical assets by FileAsset.StorageProvider/BucketName. A direct global provider switch makes old keys point at a new bucket where they may not exist.

13. Local verification

Terminal window
# Local tests prove direct I/O and traversal protection, not the HTTP flow.
dotnet test tests/BitzOrcas.Integration.Tests \
--filter FullyQualifiedName~LocalFileStorageTests
# Compare API, JobHost, and connector provider registration.
rg -n "RegisterFileStorageProvider|AddJobHostFileStorage|AddBitzOrcasFileStorageConnectors" \
src/Hosts src/Platform/Files -g '*.cs'
# Expose the lifecycle gap around Pending, Deleted, and UploadExpiresAt.
rg -n "PendingUpload|Uploaded|Deleted|UploadExpiresAt" \
src/Framework/BitzOrcas.Infrastructure.SqlSugar/DataLifecycle \
src/Platform/Files -g '*.cs'

Previous: Download authorization · Next: Testing and GA gates

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%