Skip to content

Driver Oracle Object Storage

Gustavo Viana edited this page Aug 7, 2026 · 18 revisions

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.

Construction

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).

Auth strategies

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));

Retry configuration (RetryConfiguration)

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 *Async siblings) — were removed in 2.0. Use Create(OracleObjectStorageHubOptions.FromX(...)); the options builder makes the argument order explicit and avoids the silent-swap footgun of the old positional pairs.

Interface

public interface IOracleObjectStorageFileHub : IFileHub { }
public sealed class OracleObjectStorageFileHub
    : IOracleObjectStorageFileHub, IDisposable { ... }

Bucket → filesystem mapping

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

Existence semantics

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) — single HeadObject prefix/name.
  • DirectoryExists(name) — single ListObjects(prefix/name/, limit=1). Covers both marker-backed and implicit prefixes (no marker, but child keys exist) in one call.
  • dir.Exists() and TryOpenDirectory(name) use the same single-LIST probe.

Callers that want either should call both — see API → DirectoryEntry.

Lazy stubs (zero-call open-or-create)

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.

File stub

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, IsLoaded flips to true.
  • Exists() fires 1 HEAD. On hit, the response populates Length / timestamps / the metadata snapshot and IsLoaded flips to true.
  • 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.

Directory handle

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.

Nested paths in CreateFile / OpenFile / TryOpenFile

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.

Cost minimization

The driver never makes a request that isn't strictly needed. The rules:

  • OpenFile(name, true) / OpenDirectory(name, true) / nested-path CreateFile("a/b/c.txt") / CreateDirectory("a/b/c") never create intermediate markers. Result: a single PutObject for the leaf; everything else is virtual.
  • FileExists(name): 1 HEAD.
  • DirectoryExists(name) / dir.Exists() / TryOpenDirectory(name): single LIST(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 marker PutObject — 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.

Behavior matrix

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.

Nested directory paths

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.

⚠️ Pagination

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.

URLs

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());
}
  • IsPublic is derived from the bucket's PublicAccessType.
  • GetPublicUrl() throws InvalidOperationException on private buckets.
  • GetSignedUrl(TimeSpan) creates a pre-authenticated request (PAR) and returns the full URL.

Signed upload URLs — ISignedUploadable

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 binds Content-Type / Cache-Control / user-metadata into its SigV4 signature. So when header-binding options (ContentType, CacheControl, or Metadata) are passed, this driver throws NotSupportedException rather 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.

Metadata

This driver applies FileWriteOptions (content type, cache-control, user tags) on writes.

  • Write — pass FileWriteOptions to any write method. OCI applies ContentType, CacheControl, and the Metadata user tags (as opc-meta-*) on the PutObject commit. 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 base FileMetadata (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.

Metadata refresh

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);

Moving files

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.

Partial move failures

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.

Deleting

Idempotent-silent on a missing target

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.

Recursive delete cost — avoid for large prefixes

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 DeleteObject calls + at least 100 ListObjects pages. 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 DELETE action targeting the prefix — Object Storage deletes objects in the background at no per-object request cost. Configure via the OCI Console or the ObjectLifecyclePolicy API.
  • Bucket-level recreation when the entire bucket is disposable: DeleteBucket only 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).

Partial-failure reporting on directory delete

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.

Streams

Read stream

GetReadStream* streams the GetObject response with ranged reads (10 MB per range request).

Write stream (GetWriteStream / GetWriteStreamAsync) — single PUT, spills to multipart

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; Flush is the commit — calling Flush mid-write issues the PutObject and the stream stays open for more writes (each subsequent Flush fires another PutObject that overwrites the object).
  • Payloads past the threshold: multipart under the hood with a bounded part buffer (64 MiB by default). After the spill, Flush is a no-op (OCI cannot append; the object materializes at CommitMultipartUpload on dispose) and an error during writes fires AbortMultipartUpload so no orphan parts are billed. OCI permits at most 10,000 parts; increase PartSize for objects beyond roughly 625 GiB with the default.
  • Metadata: open the stream with GetWriteStream(options) to apply FileWriteOptions on commit — both paths honour them (PutObject headers, or bound at CreateMultipartUpload). 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.Multipart to start multipart on the first written byte (skip the buffering phase — payload known large); Single never spills (whole payload buffers for one PutObject — caller owns the memory cost). Default Auto = the threshold behaviour above. Because the preference lives in the options object, SetBytes, SetText and CopyFromStream honor 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 UploadPart as soon as it fills.
  • Flush is a no-op; the auto-rollover inside WriteAsync uploads complete configured parts. Dispose uploads the trailing smaller part and calls CommitMultipartUpload.
  • Errors during writes call AbortMultipartUpload so no orphan parts are billed.

One stream at a time

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.

Multipart upload

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.

DI

services.AddFileHub<IOracleObjectStorageFileHub>(sp =>
    OracleObjectStorageFileHub.Create(
        OracleObjectStorageHubOptions.FromConfigFile("reports", rootPath: "archive/2026")));

For multiple buckets, use named hubs — see Dependency Injection.

Disposal

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.

Clone this wiki locally