-
Notifications
You must be signed in to change notification settings - Fork 0
Driver Oracle Object Storage
Driver for Oracle Cloud Infrastructure (OCI) Object Storage. Ships in the FileHub.OracleObjectStorage package. A hub instance is scoped to a single bucket; an optional root path narrows visibility to a prefix.
using FileHub.OracleObjectStorage;
using var hub = OracleObjectStorageFileHub.Create(
OracleObjectStorageHubOptions.FromConfigFile(bucketName: "reports", rootPath: "archive/2026"));Always using or register as a singleton — the hub owns the SDK HTTP client by default.
Two layers:
-
OracleObjectStorageHubOptions.From*— typed factories, one per valid auth strategy. Captures only the params relevant to that strategy; impossible to mix mutually-exclusive fields. Use these by default. -
new OracleObjectStorageHubOptions { … }— raw object initializer. Escape hatch for unusual combinations.
Both produce an OracleObjectStorageHubOptions passed to OracleObjectStorageFileHub.Create(...) (or CreateAsync(..., ct) under a SynchronizationContext).
| Factory | Required | Optional | Use |
|---|---|---|---|
OracleObjectStorageHubOptions.FromConfigFile(bucketName, profile?, configFilePath?, rootPath?) |
bucketName |
profile (default "DEFAULT"), configFilePath (default ~/.oci/config), rootPath
|
Region from profile, namespace via GetNamespace. Hub creates and owns the SDK client. |
OracleObjectStorageHubOptions.FromProvider(bucketName, IAuthenticationDetailsProvider, regionId, rootPath?) |
bucketName, provider, regionId
|
rootPath |
Instance principals, resource principals, custom. Namespace via GetNamespace. Hub creates and owns the SDK client. |
OracleObjectStorageHubOptions.FromProvider(bucketName, ConfigFileAuthenticationDetailsProvider, rootPath?) |
bucketName, provider
|
rootPath |
Region read from provider.Region.RegionId — no need to repeat. |
OracleObjectStorageHubOptions.FromClient(bucketName, ObjectStorageClient, regionId, namespace, rootPath?) |
bucketName, client, regionId, namespace
|
rootPath |
Reuse an existing client. regionId and namespace both required (no auto-resolution path). Caller keeps ownership; hub disposal is a no-op. |
When rootPath is not empty the factory ensures the prefix marker exists before returning.
// Default ~/.oci/config + DEFAULT profile
var hub = await OracleObjectStorageFileHub.CreateAsync(
OracleObjectStorageHubOptions.FromConfigFile("reports", rootPath: "archive/2026"));
// Custom profile
var hub = OracleObjectStorageFileHub.Create(
OracleObjectStorageHubOptions.FromConfigFile("reports", profile: "prod"));
// Explicit provider
var hub = await OracleObjectStorageFileHub.CreateAsync(
OracleObjectStorageHubOptions.FromProvider(
"reports",
new InstancePrincipalsAuthenticationDetailsProviderBuilder().Build(),
regionId: "sa-saopaulo-1",
rootPath: "archive/2026"));
// Reuse a pre-configured client
var hub = OracleObjectStorageFileHub.Create(
OracleObjectStorageHubOptions.FromClient(
"reports",
existingObjectStorageClient,
regionId: "sa-saopaulo-1",
@namespace: tenancyNamespace));The OCI SDK does not retry by default — every 429/5xx surfaces immediately. When the hub creates its own client (config-file / FromProvider strategies), OracleObjectStorageHubOptions.RetryConfiguration passes a retry strategy through to it; the hub pins nothing when it is null. Pass RetryConfiguration.DefaultRetryConfiguration to enable the SDK's standard backoff. Mutually exclusive with Client — an external client already carries its own configuration.
var hub = await OracleObjectStorageFileHub.CreateAsync(new OracleObjectStorageHubOptions
{
BucketName = "reports",
Profile = "prod",
RetryConfiguration = RetryConfiguration.DefaultRetryConfiguration,
});The legacy positional factories on the hub class —
OracleObjectStorageFileHub.FromConfigFile,FromProvider,FromClient(and their*Asyncsiblings) — were removed in 2.0. UseCreate(OracleObjectStorageHubOptions.FromX(...)); the options builder makes the argument order explicit and avoids the silent-swap footgun of the old positional pairs.
public interface IOracleObjectStorageFileHub : IFileHub { }
public sealed class OracleObjectStorageFileHub
: IOracleObjectStorageFileHub, IDisposable { ... }OCI is a flat key-value store; the driver overlays a tree on it:
| Concept | Mapping |
|---|---|
| Object name | Path under the root (/ separators) |
| Directory | Zero-byte marker with content-type application/x-directory and name ending in /
|
| Listing |
ListObjects with prefix + delimiter = "/" — objects become files, prefixes become subdirectories |
| Sandbox | Everything under rootPath/ is visible; names outside it are rejected |
OCI is flat key/value with directory markers (zero-byte objects whose name ends in /). Asking "does foo exist?" is ambiguous — a key foo and a prefix foo/ can coexist — so the driver exposes two specific probes:
hub.Root.FileExists("foo"); // 1 HEAD on prefix/foo
hub.Root.DirectoryExists("foo"); // 1 LIST(prefix/foo/, limit=1)-
FileExists(name)— singleHeadObject prefix/name. -
DirectoryExists(name)— singleListObjects(prefix/name/, limit=1). Covers both marker-backed and implicit prefixes (no marker, but child keys exist) in one call. -
dir.Exists()andTryOpenDirectory(name)use the same single-LIST probe.
Callers that want either should call both — see API → DirectoryEntry.
OpenFile(name, createIfNotExists: true) and OpenDirectory(name, createIfNotExists: true) defer all server calls. They return a handle synthesised entirely client-side — no HEAD, no LIST, no PUT.
var file = hub.Root.OpenFile("reports/2026/q1.pdf", createIfNotExists: true);
// 0 server calls. file.Length == -1, default timestamps, empty metadata snapshot.
if (file is ILazyLoad lazy)
Console.WriteLine(lazy.IsLoaded); // false-
Length = -1, default timestamps, empty metadata snapshot (GetMetadata()returns empty until loaded). -
((ILazyLoad)file).IsLoaded == false. - Writing materialises the object — 1
PutObject(SetBytes/ stream). On a successful commit,IsLoadedflips totrue. -
Exists()fires 1 HEAD. On hit, the response populatesLength/ timestamps / the metadata snapshot andIsLoadedflips totrue. - Reading bytes from a missing stub silently returns 0 bytes — call
Exists()first if uncertain. -
TryOpenFileAsync/OpenFile(name)(strict) return a loaded file: 1 HEAD up-front,IsLoaded == true.
OpenDirectory(name, createIfNotExists: true) is symmetric — zero server calls. The "directory" is purely a virtual prefix; no marker is written until you explicitly ask:
var dir = hub.Root.OpenDirectory("reports/2026", createIfNotExists: true);
// 0 server calls. The prefix is virtual.
dir.CreateFile("q1.pdf").SetBytes(bytes); // 1 PutObject — writes only the leaf.
hub.Root.CreateDirectory("reports/2026"); // explicit ask → 1 PutObject for the marker.The file APIs accept nested paths (/ and \ are both valid). .. segments are still rejected with FileHubException.
hub.Root.CreateFile("reports/2026/q1.pdf").SetBytes(bytes);
// Exactly 1 PutObject — no markers for "reports/" or "reports/2026/".
var file = hub.Root.OpenFile("reports/2026/q1.pdf", createIfNotExists: true);
// 0 calls — stub returned. SetBytes/SetText/stream then issues the 1 PutObject.
if (hub.Root.TryOpenFile("reports/2026/q1.pdf", out var existing))
Console.WriteLine(existing.Length); // 1 HEAD up-front.The driver never makes a request that isn't strictly needed. The rules:
-
OpenFile(name, true)/OpenDirectory(name, true)/ nested-pathCreateFile("a/b/c.txt")/CreateDirectory("a/b/c")never create intermediate markers. Result: a singlePutObjectfor the leaf; everything else is virtual. -
FileExists(name): 1 HEAD. -
DirectoryExists(name)/dir.Exists()/TryOpenDirectory(name): singleLIST(prefix, limit=1). -
CopyAllObjects(dir.CopyTo/dir.MoveTo/dir.Rename): no post-loop marker enforcement. If the source had a marker it's copied along; if not, the destination prefix stays implicit. -
CreateDirectory(name)still does the markerPutObject— caller explicitly asked for an empty visible directory.
So the canonical workflow
hub.Root.CreateFile("reports/2026/q1.pdf").SetBytes(bytes);costs exactly 1 PutObject end-to-end, regardless of nesting depth.
| Operation | Server calls |
|---|---|
OpenFile(name) / OpenFile(name, false) exists |
1 HEAD |
OpenFile(name) / OpenFile(name, false) missing |
1 HEAD (404) → FileNotFoundException
|
OpenFile(name, createIfNotExists: true) |
0 (lazy stub) |
OpenDirectory(name, createIfNotExists: true) |
0 (lazy handle) |
OpenDirectory(name) / strict |
1 LIST(limit=1) |
TryOpenFile(name) / TryOpenFileAsync(name)
|
1 HEAD |
TryOpenDirectory(name) / TryOpenDirectoryAsync(name)
|
1 LIST(limit=1) |
CreateFile(name) |
1 HEAD + 1 PUT (empty); refuses existing → FileAlreadyExistsException
|
CreateFile(name, overwrite: true) |
1 PUT (empty); clobbers |
CreateFile("a/b/c.txt") |
1 PUT (no markers) |
CreateDirectory(name) |
1 PUT (marker) |
FileExists(name) |
1 HEAD |
DirectoryExists(name) / dir.Exists()
|
1 LIST(limit=1) |
GetFiles() |
LIST pages only; entries unloaded (IsLoaded = false) |
Stub Exists() hit |
1 HEAD; flips IsLoaded = true
|
Stub SetBytes / write |
1 PUT |
Single-arg CreateFile(name) refuses an existing target: it HEADs first and throws FileAlreadyExistsException if the object already exists — it no longer PUTs an empty body over it. Use CreateFile(name, overwrite: true) to clobber.
CreateDirectory and TryOpenDirectory accept nested paths ("a/b/c", "a\b\c") and resolve the whole path in a single request:
| Operation | API cost |
|---|---|
CreateDirectory("a/b/c") |
1 PUT (leaf marker only — no per-segment marker objects) |
TryOpenDirectory("a/b/c") |
1 LIST(limit=1) proving anything exists under the prefix |
// 1 PUT, regardless of depth
hub.Root.CreateDirectory("2026/01/invoices");Path-traversal guards (.., ., absolute paths) always apply.
OCI paginates through a string cursor, not a numeric offset. Index offsets force the driver to walk object-by-object — billed per ListObjects call.
Use a named cursor for anything beyond the first ~1000 entries:
hub.Root.GetFiles(offset: FileListOffset.FromName(lastSeen), limit: 100);Full explanation in Usage → Pagination.
OracleObjectStorageFile implements IUrlAccessible:
var file = hub.Root.OpenFile("january.pdf");
if (file is IUrlAccessible url)
{
var link = url.IsPublic
? url.GetPublicUrl()
: await url.GetSignedUrlAsync(TimeSpan.FromMinutes(15), ct);
return Redirect(link.ToString());
}-
IsPublicis derived from the bucket'sPublicAccessType. -
GetPublicUrl()throwsInvalidOperationExceptionon private buckets. -
GetSignedUrl(TimeSpan)creates a pre-authenticated request (PAR) and returns the full URL.
OracleObjectStorageDirectory implements ISignedUploadable. GetSignedUploadUrl(name, expiresIn, options?) / GetSignedUploadUrlAsync(...) mint a pre-authenticated PUT URL (a PAR) that a remote client can upload straight to — the backend never touches the bytes. The target object does not need to exist beforehand; the first PUT creates it (an existing object is overwritten).
var dir = hub.Root.OpenDirectory("uploads");
if (dir is ISignedUploadable up)
{
// No options — plain upload URL.
var url = await up.GetSignedUploadUrlAsync("user-123/avatar.png", TimeSpan.FromMinutes(15), ct: ct);
// Client PUTs bytes to `url`.
}
⚠️ Cross-provider divergence (security-relevant). OCI pre-authenticated requests cannot bind request headers to the URL the way an S3 pre-signed URL bindsContent-Type/Cache-Control/ user-metadata into its SigV4 signature. So when header-binding options (ContentType,CacheControl, orMetadata) are passed, this driver throwsNotSupportedExceptionrather than silently returning an unconstrained URL the caller would wrongly believe is header-constrained. Passing no options (or empty options) returns a normal upload URL. If you need to enforce those headers, do it server-side after the upload completes. See the Security page.
This driver applies FileWriteOptions (content type, cache-control, user tags) on writes.
-
Write — pass
FileWriteOptionsto any write method. OCI appliesContentType,CacheControl, and theMetadatauser tags (asopc-meta-*) on thePutObjectcommit.OciWriteOptions(the OCI subclass) adds no extra fields yet — it's reserved for OCI-only knobs (e.g. storage tier). Drivers ignore fields they don't support; OCI honours all three base fields. -
Read — call
GetMetadataAsync/GetMetadata(). It returns a baseFileMetadata(ContentType,CacheControl,Tags). There are no OCI-specific typed read fields today, so no downcast is needed.
var file = hub.Root.OpenFile("report.pdf");
await file.SetBytesAsync(pdfBytes, new FileWriteOptions
{
ContentType = "application/pdf",
CacheControl = "public,max-age=86400",
Metadata = new Dictionary<string, string> { ["owner"] = "team-x" },
}, ct);
var meta = await file.GetMetadataAsync(ct);
Console.WriteLine(meta.ContentType); // "application/pdf"
Console.WriteLine(meta.Tags["owner"]); // "team-x"The read snapshot is immutable; replacing metadata means writing the object again with FileWriteOptions (re-uploads the bytes). CopyTo / MoveTo / Rename preserve the source object's metadata. The driver keeps a private _changedAt tag for last-write bookkeeping; it's stripped from the user-facing Tags, so it never surfaces to consumers.
OracleObjectStorageFile and OracleObjectStorageDirectory implement IRefreshable. OracleObjectStorageFile additionally implements ILazyLoad so callers can detect handles whose state was never populated from the bucket (lazy stubs from OpenFile(..., createIfNotExists: true) and entries returned by GetFiles — LIST doesn't return per-object metadata). Property getters (Length, CreationTimeUtc, LastWriteTimeUtc) return cached values and never do hidden I/O — call Refresh() / RefreshAsync() explicitly to re-sync with the bucket. A Refresh() re-fires the HEAD and replaces the metadata snapshot returned by GetMetadataAsync along with the timestamps. Writes through this driver update the cached length as bytes buffer, so the common SetBytes → file.Length flow works without a refresh.
var file = hub.Root.OpenFile("report.pdf");
// Stale — whatever was known at open / last write.
var known = file.Length;
// Round-trip a HEAD to re-sync from OCI.
await ((IRefreshable)file).RefreshAsync(ct);FileEntry.MoveTo(directory, name) picks a strategy based on where the destination lives:
| Scenario | Strategy | Atomicity |
|---|---|---|
Same credentials + same Namespace + same Bucket
|
1 RenameObject call on the full destination key (destPrefix/name) |
Atomic — OCI swaps the key server-side; no data is copied |
| Any other target (different bucket, namespace, region, or credentials) |
CopyObject to the destination + DeleteObject on the source |
Not atomic — brief window where both exist |
// Same bucket, different prefix — one RenameObject call, no bytes moved.
srcDir.OpenFile("report.pdf").MoveTo(dstDir, "report.pdf");
// Cross-bucket — CopyObject + DeleteObject.
hubA.Root.OpenFile("report.pdf").MoveTo(hubB.Root, "report.pdf");Overwrite. CopyTo / MoveTo default to overwrite: false — a HEAD guards the destination first; if the object exists the call throws FileAlreadyExistsException and leaves the source untouched (best-effort, not atomic against a concurrent writer). Pass overwrite: true to skip the guard and let RenameObject / CopyObject replace an existing destination object silently. Rename always guards — it never overwrites an existing name.
The same rule applies at the directory level: dir.CopyTo / dir.MoveTo also default to overwrite: false and throw FileAlreadyExistsException when the destination prefix already exists. Pass overwrite: true to merge into it (per-object server-side copy + delete). dir.Rename always guards.
On the copy+delete path, the copy can succeed and the delete can then fail (permissions revoked mid-operation, transient network error, etc.). When that happens the driver throws FileHub.PartialMoveException so the failure mode is explicit:
try
{
file.MoveTo(otherHub.Root, "name.txt");
}
catch (PartialMoveException ex)
{
// ex.DestinationPath — the copy succeeded and the file is here.
// ex.SourcePath — the original is still here, delete it manually.
// ex.InnerException — the underlying OCI error from DeleteObject.
logger.LogWarning(ex, "Move partial: source still at {Src}, copy at {Dst}",
ex.SourcePath, ex.DestinationPath);
}PartialMoveException : FileHubException : IOException, so generic IOException handlers still catch it.
A FileNotFoundException on the delete step is not treated as a partial move — it means the source is already gone, so the move is effectively complete and no exception is raised.
The atomic rename path (same bucket/namespace) cannot leave a partial state: either the rename succeeds or nothing changes.
file.Delete() and dir.Delete(name) are idempotent: deleting an object (or named child) that is already gone is a no-op, not an error. This driver previously surfaced OCI's 404 on a missing object as FileNotFoundException — it no longer does, matching every other backend. A non-empty child directory still throws DirectoryNotEmptyException unless you pass recursive: true.
dir.Delete(), dir.Delete(name) on a directory, and the cleanup phase of dir.Rename / dir.MoveTo all walk every object under the prefix and issue one DeleteObject per key. OCI Object Storage charges per request, and the driver does not batch — there is no native "delete prefix" primitive on OCI, and the SDK has no multi-key batch delete.
Practical implications:
-
Cost grows linearly with object count. A prefix with 100 000 files becomes 100 001
DeleteObjectcalls + at least 100ListObjectspages. The bill scales with the size of the directory, not with the work you intended. - Latency grows linearly too. Calls are issued sequentially; expect minutes-to-hours for large prefixes.
-
Throttling. OCI may return 429 / 503 when delete velocity is too high. The OCI SDK does not retry by default, so failures surface immediately unless you opt in via
OracleObjectStorageHubOptions.RetryConfiguration(or configure your external client); once retries are exhausted the operation surfaces the failure (see partial-failure section below).
If you need to wipe a large prefix, prefer one of these out-of-band paths instead of dir.Delete():
-
OCI Lifecycle policy with a
DELETEaction targeting the prefix — Object Storage deletes objects in the background at no per-object request cost. Configure via the OCI Console or theObjectLifecyclePolicyAPI. -
Bucket-level recreation when the entire bucket is disposable:
DeleteBucketonly succeeds on an empty bucket, so a lifecycle policy is usually still the path; for one-shot wipes consider deleting and recreating from IaC. - OCI CLI / terraform scripts for synchronous bulk operations when you need to coordinate the cleanup with other workflow steps.
Reach for dir.Delete() only when you know the directory is small (think tens to a few hundred objects) or when the cost is acceptable for the use case (manual ops, occasional cleanup).
When per-object deletes fail mid-walk (granular IAM denial, transient throttle), the driver does not abort on the first error. It collects every failure, finishes deleting what it can, and finally throws an AggregateException carrying every per-object error:
try
{
dir.Delete();
}
catch (AggregateException ex)
{
// ex.InnerExceptions — one entry per failed DeleteObject.
// The directory is partially deleted; remediate the failing keys
// (fix IAM, wait out the throttle) and retry, or fall back to a
// lifecycle policy.
}The same applies to the cleanup phase of dir.Rename and dir.MoveTo — when those wrap a delete failure into PartialMoveException, the underlying AggregateException is exposed via PartialMoveException.InnerException.
GetReadStream* streams the GetObject response with ranged reads (10 MB per range request).
Writes are buffered in memory up to the configured threshold (32 MiB by default). Payloads that stay under it commit as a single PutObject; past it, the stream transparently spills into a multipart upload using configurable parts (64 MiB by default). Configure the hub with OracleObjectStorageHubOptions.Multipart = new MultipartStreamOptions(threshold, partSize), or override one write with OciWriteOptions.Multipart. Implications:
-
Payloads within the threshold (32 MiB by default): single
PutObject;Flushis the commit — callingFlushmid-write issues thePutObjectand the stream stays open for more writes (each subsequentFlushfires anotherPutObjectthat overwrites the object). -
Payloads past the threshold: multipart under the hood with a bounded part buffer (64 MiB by default). After the spill,
Flushis a no-op (OCI cannot append; the object materializes atCommitMultipartUploadon dispose) and an error during writes firesAbortMultipartUploadso no orphan parts are billed. OCI permits at most 10,000 parts; increasePartSizefor objects beyond roughly 625 GiB with the default. - Metadata: open the stream with
GetWriteStream(options)to applyFileWriteOptionson commit — both paths honour them (PutObjectheaders, or bound atCreateMultipartUpload). The options live with the stream — an abandoned write stream never affects a later write. Without options, no metadata headers are sent and bucket defaults apply. - Preference: set
OciWriteOptions.StreamPreference = WriteStreamPreference.Multipartto start multipart on the first written byte (skip the buffering phase — payload known large);Singlenever spills (whole payload buffers for onePutObject— caller owns the memory cost). DefaultAuto= the threshold behaviour above. Because the preference lives in the options object,SetBytes,SetTextandCopyFromStreamhonor it too. See API → WriteStreamPreference.
When multipart is selected explicitly or reached automatically:
-
Bounded memory — the local buffer caps at the configured part size (64 MiB by default); data rolls over to
UploadPartas soon as it fills. -
Flushis a no-op; the auto-rollover insideWriteAsyncuploads complete configured parts.Disposeuploads the trailing smaller part and callsCommitMultipartUpload. - Errors during writes call
AbortMultipartUploadso no orphan parts are billed.
Only one stream open per file at a time — a second GetReadStream / GetWriteStream call before the first is disposed throws InvalidOperationException. Mixed read and write are the same restriction — dispose before opening another.
Use the regular write stream with StreamPreference = Multipart when the backend has the bytes (server-side generation, long-running import). It chunks data using the configured part size, uploads each part, and commits on dispose:
var file = hub.Root.CreateFile("dataset.parquet");
var options = new OciWriteOptions
{
StreamPreference = WriteStreamPreference.Multipart,
};
using var stream = await file.GetWriteStreamAsync(options, ct);
await someLargeSource.CopyToAsync(stream, ct);
// Dispose commits via CommitMultipartUpload. Any exception aborts.Bounded memory usage (64 MiB by default) regardless of total size. If the stream is disposed with an error, AbortMultipartUpload fires to avoid orphan parts being billed. OCI caps uploads at 10,000 parts; choose a larger configured part size for very large objects.
Metadata passed through FileWriteOptions is bound to the object at CreateMultipartUpload and installed on the cached snapshot when the upload commits. Omit the options (or pass null) for bucket defaults.
To write by name without materializing an empty placeholder first, open a lazy file stub and then request its write stream: directory.OpenFile(name, createIfNotExists: true).GetWriteStreamAsync(options, ct).
The signed flow (IMultipartUploadSignable, per-part presigned URLs) is not implemented — OCI pre-authenticated requests don't sign individual multipart parts the way S3 presigned part URLs do.
services.AddFileHub<IOracleObjectStorageFileHub>(sp =>
OracleObjectStorageFileHub.Create(
OracleObjectStorageHubOptions.FromConfigFile("reports", rootPath: "archive/2026")));For multiple buckets, use named hubs — see Dependency Injection.
| Strategy | Owns the SDK client? |
|---|---|
Profile / ConfigFilePath / Provider
|
Yes — hub creates the ObjectStorageClient and disposes it on Dispose(). |
Client |
No — caller owns it; hub disposal is a no-op on it. |
FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.