Skip to content

Storage: one interface for every store, context everywhere, S3 semantics - #52

Open
theomonnom wants to merge 14 commits into
mainfrom
theo/object-storage
Open

theomonnom wants to merge 14 commits into
mainfrom
theo/object-storage

Conversation

@theomonnom

@theomonnom theomonnom commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

One interface over S3 (and S3-compatible stores), GCS, Azure Blob, OCI, AliOSS and a local directory, for services that keep and manage their own objects. Breaking: every call takes a context, and the API is cleaned up (callers updated, below).

API

objects.Bucket PutObject, UploadObject (stream), GetObject, GetObjectRange, DownloadObject (to an io.Writer), ObjectExists, ListObjects, DeleteObjects, PresignedURL
objects.Storage Bucket + UploadFile(ctx, storagePath, filepath, PutOptions), DownloadFile(ctx, storagePath, filepath)
PutOptions ContentType, IfAbsent (refuse to replace an object) on every write
errors ErrNotFound, ErrExists (errors.Is), ErrorWithStatusCode (errors.As)
storage/objects no provider SDK imports: a fake (e.g. the gosim one) or a library can implement or accept the interfaces
removed / renamed UploadData (use PutObject with a content type), DownloadData (GetObject), DeleteObject (DeleteObjects), GeneratePresignedUrl → PresignedURL, ErrObjectNotFound/ErrObjectExists → ErrNotFound/ErrExists, ProxyConfig.Url → URL, AssumeRoleArn/AssumeRoleExternalId → AssumeRoleARN/AssumeRoleExternalID (YAML tags unchanged), S3Logger unexported

Semantics

S3's, wherever stores differ:

  • DeleteObjects of a missing path succeeds; any path still holding an object fails the call, so nothing is reported deleted that is still there, and a retry after a partial failure is safe.
  • ListObjects is recursive, in byte order, on every backend.
  • A range past the end returns what the object holds; one starting at or past it, no bytes.
  • IfAbsent is atomic where the store honors the condition; ErrExists only ever comes from such a write.

Improvements over main

area change
correctness S3 DeleteObjects ignored per-key errors (AccessDenied reported as deleted); GCS/Azure/local failed a delete on a missing key, so a retry after a partial failure never finished
S3 + ProxyConfig dropped AWS_CA_BUNDLE; GCS timeouts never applied; GCS presign off GCE had no signer
GCS swapped the process-wide http.DefaultTransport, racing with every other request and sending them through its proxy; now its own transport
GCS token refresh and S3 proxying ignored ProxyConfig/NO_PROXY; proxy credentials only went on CONNECT; EC2/ECS/GKE metadata endpoints went through the proxy
S3 retries had no backoff when max_retry_delay was unset; MinRetryDelay was unused
S3 Object Lock buckets behind an AWS VPC or FIPS Endpoint failed (checksums dropped for every endpoint, now only non-AWS hosts)
the S3 upload failure log carried the session token
OCI presign and location hard-coded https; OCI's 400 for a range at the end was an error
Azure sovereign clouds were unreachable (hard-coded blob.core.windows.net): AzureConfig.Endpoint; a token-only Azure config now signs requests
local keys could escape StorageDir (../)
robustness every client cuts a connection idle for 5 minutes (either direction), never a slow but live transfer; OCI's 60s whole-request cap is gone (OCI_CUSTOM_CLIENT_TIMEOUT still honored)
AliOSS's SDK never retries: calls safe to resend retry on 5xx, 429 and network errors
GCS chunk retry deadline 10 min (32s default failed slow uploads); GCS deletes retried
failed S3 and OCI multipart uploads are aborted even when the caller's context ended
DownloadFile writes through a temp file: a failed download leaves the destination as it was
an upload whose body fails partway stores nothing
scale one S3 client per Storage (connections reused, no shared retry quota); 32 idle connections per host; GCS, Azure and OCI deletes run 16 at a time; S3 files in 16 MiB parts (5 MiB capped files at 48 GiB)
tests every backend but AliOSS runs end to end in CI against floci emulators (pinned by digest) and MinIO, one check each; write-only and delete-refusing proxies, conditional commits checked on the wire, failure injection, -race

Behavior changes callers can see

change before
S3 DeleteObjects fails on per-key errors other than NoSuchKey ignored
DeleteObjects of a missing path succeeds on every backend error on GCS, Azure, local
MaxRetries counts retries (5 → 6 attempts) counted attempts
GCS DownloadFile returns bytes written, also for gzip-transcoded objects -1 for those
DownloadFile creates the destination's directory, and replaces the file only on success truncated it first (non-local)
local: keys outside StorageDir and directory keys are refused / not found allowed / raw errors
GCS with GOOGLE_API_USE_CLIENT_CERTIFICATE=true keeps the SDK's transport (mTLS), without this package's proxy and timeouts one shared path

Tested

backend IfAbsent CI
S3 / compatible If-None-Match on put and complete floci, MinIO (atomic race)
GCS ifGenerationMatch=0 floci-gcp
Azure If-None-Match on put and commit floci-az, flat and hierarchical namespace
OCI native on create and commit floci-oci
local link into place yes
AliOSS x-oss-forbid-overwrite (not on versioned buckets) no emulator: env-gated, plus fake-server tests for retries and errors

Not supported, as on main: SSE-KMS/ACL/Requester Pays headers, GCS workload identity federation JSON (ADC works), an Azure proxy setting, AliOSS STS tokens.

Callers, merged in this order

  1. this PR
  2. https://github.com/livekit/cloud-protocol/pull/1399 (replay uploads, test mock; cloud imports it)
  3. https://github.com/livekit/cloud/pull/4825
  4. Bump livekit/storage: Storage takes a context egress#1458
  5. https://github.com/livekit/cloud-sip/pull/501
  • cloud-egress, cloud-config, private-cloud: no change needed.

NewObjectStorage returns an ObjectStorage for S3 (and S3-compatible
endpoints) and local storage. Every call takes a context; PutObject and
UploadObject take PutOptions (ContentType, IfAbsent); GetObjectRange,
DownloadObject and ObjectExists read; DeleteObjects reports per-key failures
inside an accepted batch. Missing objects and refused writes come back as
ErrObjectNotFound and ErrObjectExists, still wrapping the status code.
Storage is unchanged.
NewObjectStorage now covers every Config. DeleteObjects succeeds for paths
that hold nothing, and a range starting at or past the end returns no bytes,
on every backend. The S3 uploader's checksum workaround for S3-compatible
stores is shared with UploadObject.
…temp files unlisted

UploadObject sends If-None-Match on CompleteMultipartUpload, which the
uploader drops; MinIO and OCI's S3 API both refuse with it. The per-backend
constructors are unexported behind NewObjectStorage, GetObject is shared over
DownloadObject, and local ListObjects leaves out UploadObject's temporary files.
…nedUrl take a context

ObjectStorage folds into Storage: every backend type implements the new
calls, and ListObjects, DeleteObjects and GeneratePresignedUrl take a context
with the new semantics (DeleteObjects succeeds for missing paths and fails on
any key it could not delete). The interface lives in storage/objects;
storage.Storage aliases it. Each provider test runs both suites; TestS3 takes
an optional S3_ENDPOINT.

Breaking: callers of the three calls pass a context, and implementations of
Storage add the new methods.
@theomonnom theomonnom changed the title ObjectStorage: context, create-only writes, ranged and streaming reads Storage: context, create-only writes, ranged and streaming reads Oct 17, 2026
objects.Bucket holds the context calls, so a fake or a consumer that only
manages objects takes the small interface; Storage is Bucket plus the
file calls. DownloadData and DownloadFile report ErrObjectNotFound too,
ListObjects is in byte order on every backend (local sorts), and S3
GetObjectRange refuses an answer without Content-Range. The doc comments
state IfAbsent's limits, UploadObject's memory per backend, the range
arguments, and when DeleteObjects is safe to retry.
@theomonnom
theomonnom requested a review from paulwe October 17, 2026 07:47
…moved

UploadData, UploadFile and DownloadFile take a context. DownloadData and
DeleteObject duplicated GetObject and DeleteObjects and are gone. The file
calls go through the object calls, so S3's config metadata, tagging and
content disposition apply to every write. OCI has one client: the SDK's 60s
whole-request timeout gives way to dial, TLS and response header timeouts,
which S3 and GCS clients get too.
floci_test.go starts floci's S3, Azure Blob, GCS and OCI emulators with
dockertest and runs the Storage and Bucket suites on each; without Docker
they skip, except under CI. AzureConfig and GCPConfig gain an Endpoint for
emulators.

Found by the emulators:
- GCS presign signs with CredentialsJSON's service account: passed only as a
  token source, the SDK had no signer off GCE. URLs are V4 (7 days at most).
- OCI presign and locations keep an http:// endpoint's scheme.
- OCI answers a range starting at the end with 400 on floci; the object's
  size decides. UploadObject with IfAbsent checks first, as S3 and Azure do,
  since floci-oci ignores if-none-match on multipart uploads.
Comment thread s3.go Outdated
Comment thread gcp.go Outdated
Comment thread s3.go Outdated
Comment thread local.go Outdated
Comment thread object_test.go Outdated
Comment thread s3.go Outdated
Comment thread gcp.go Outdated
…dule

API:
- UploadData removed: PutObject with a content type does the same
- GeneratePresignedUrl is PresignedURL; ErrObjectNotFound/ErrObjectExists
  are ErrNotFound/ErrExists, as in objects
- ProxyConfig.URL, S3Config.AssumeRoleARN/AssumeRoleExternalID (YAML unchanged)
- S3Logger unexported

Behavior:
- DeleteObjects follows S3: a missing key succeeds, any key still present
  fails the call (S3 per-key errors were ignored on main)
- Connections are cut after 5 minutes without bytes either way, replacing
  the response header timeout; S3 pinned to legacy defaults mode so the
  SDK keeps the dialer
- OCI: UploadData/UploadFile one PutObject as on main, Content-Disposition
  inline, SDK TLS wrapper kept, OCI_CUSTOM_CLIENT_TIMEOUT honored
- S3 GetObject uses the ranged downloader again
- GCS: V2 signing as on main, V4 only for an http:// emulator
- Local: keys outside StorageDir refused, byte-prefix listing, a directory
  is no object, fsync before publish, os.Create's file mode
- A wrapped http.DefaultTransport no longer panics

Tests:
- Suites move to the e2e module, so users don't inherit dockertest's deps
- New: refused deletes, conditional commit on the wire, idle timeout,
  proxy and GCS endpoint credentials, local keys, CI matrix completeness
- floci images pinned by digest
…t per Storage; legacy defaults mode explained
…es, enterprise)

API:
- UploadFile(ctx, storagePath, filepath, PutOptions) and
  DownloadFile(ctx, storagePath, filepath): key first, IfAbsent on files
- ErrorWithStatusCode lives in objects, aliased in root
- A precondition failure is ErrExists only on IfAbsent writes: S3's and
  Azure's readers pin the ETag, so a 412 on a read is no conflict
- Listings sorted on every backend, as documented

Robustness:
- S3: no client-side retry quota (one client serves every call), backoff
  between MinRetryDelay and MaxRetryDelay, MaxRetries counts retries,
  multipart aborted on a context of its own, idle timeout kept under any
  AWS_DEFAULTS_MODE, checksums dropped only for non-AWS hosts (Object Lock
  behind VPC/FIPS endpoints), 16 MiB parts for files, bare 416, no session
  token in failure logs, bounded region lookup
- GCS: own transport through the SDK hook (no swap of http.DefaultTransport,
  which raced with the process and leaked the proxy), tokens fetched
  through it, 10-minute chunk retry deadline, retried deletes, bounded
  presign retries, public host on presign behind a private endpoint
- Azure: this package's transport (idle timeout, 32 idle connections),
  token-only credentials sign requests, SAS clock-skew allowance
- AliOSS: retries on 5xx, 429 and network errors, which its SDK never does
- OCI: abort in the background, bounded namespace lookup
- Proxy: credentials for http targets too, NO_PROXY, metadata services
  never proxied
- idleConn never extends a deadline set from above
- DownloadFile writes through a temporary file, so a failed download
  leaves the destination as it was
- Local: plain writes in place as on main (mounted volumes, file
  watchers), IfAbsent by link; listing survives concurrent deletes and
  prunes directories; directory keys read as not found; parent fsync

Tests:
- failed bodies store nothing, race winner read back, content type read
  back, DownloadObject not found, DownloadFile keeps the destination,
  multipart abort after a deadline, AliOSS retries, proxy credentials and
  NO_PROXY, idle deadlines from above, listing during deletes, no write
  to http.DefaultTransport under -race
- CI caches through setup-go keyed on both modules
@theomonnom theomonnom changed the title Storage: context, create-only writes, ranged and streaming reads Storage: one interface for every store, context everywhere, S3 semantics Oct 17, 2026
Comment thread alioss.go
// downloadFile does for the other stores.
func (s *aliOSSStorage) DownloadFile(ctx context.Context, storagePath, filepath string) (int64, error) {
if err := aliOSSRetry(ctx, func() error {
return s.bucket.GetObjectToFile(storagePath, filepath, oss.WithContext(ctx))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does AliOSS keep the DownloadFile contract? The interface says it makes the destination's directory, but GetObjectToFile opens <filepath>.temp without creating it, so a download into a directory that doesn't exist yet fails with "no such file or directory". It also doesn't remove the .temp file when the copy fails partway, so a failed download leaves it behind. I reproduced both with a fake server, like TestAliOSSRetries. Would going through downloadFile with GetObject, as the other backends do, cover both?

Comment thread gcp.go
return wrapGCPError(err)
}

// gcsTransportMu serializes newGCSClient's swap of http.DefaultTransport.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is the gcsTransportMu line left over? There's no mutex or http.DefaultTransport swap anymore, so it now describes code that doesn't exist.

Comment thread oci.go
func (s *ociStorage) UploadData(data []byte, storagePath, contentType string) (string, int64, error) {
size := int64(len(data))
body := common.NewOCIReadSeekCloser(io.NopCloser(bytes.NewReader(data)))
// UploadFile sends the file in one PutObject, up to OCI's 50 GiB, which the SDK

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this doc comment appears twice in a row, in two slightly different versions. Should the second one (with "as on main" and the 100 MiB note) be the only one kept?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants