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:
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
| Behavior | Unavailable | Local | S3-compatible | Connector bridge |
|---|---|---|---|---|
| Upload/Open/Delete | Unsupported | Supported | Supported | Delegated to IFileStore |
| Path protection | N/A | GetFullPath boundary | Bucket/key | Provider-dependent |
| Presigned PUT | No | No | Yes | Provider-dependent |
| Metadata | No | Length/LastModified | HEAD facts | Exists + stream Length |
| Presigned GET | No | No | Yes | Provider-dependent |
| Request TTL | No | No | Honored | Ignored |
| Download name | No | No | Header override | Ignored |
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:
{ "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
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
AddBitzOrcasStoragewith 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.
# 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:
- Endpoint reachability and TLS trust;
- credential access to the target bucket;
- bucket existence or approved initialization rights;
- API PUT/HEAD/GET/DELETE privileges;
- JobHost HEAD/DELETE privileges;
- 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
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:
-- 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 StatusNameFROM [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:
| Reason | Selection | Grace/retention | Deletion order |
|---|---|---|---|
| Expired session | Pending + UploadExpiresAt < now | Short clock-skew grace | Object → metadata |
| Unfinalized upload | Uploaded + no FinalizedAt + age | Configurable | Object → metadata |
| Business deletion | Deleted + DeleteTime + retention | Legal/recycle policy | Object → tombstone/metadata |
| Object without row | Storage inventory - DB keys | Longer safety window | Mark → review → delete |
| Row without object | DB keys - storage inventory | Block download immediately | Evidence → 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
{ "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:
- freeze or dual-write new uploads;
- copy objects from a tenant/key inventory;
- compare byte length and a trusted computed hash;
- update provider/bucket metadata or keep a routing table;
- sample download file names and content types;
- shift reads and monitor NotFound;
- retain the source until the rollback window closes;
- 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
# 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