Skip to content

blob/gcsblob: support the gRPC API and Rapid Storage (zonal) buckets - #3772

Open
stanhu wants to merge 6 commits into
google:masterfrom
stanhu:gocloud-rapid-storage-support
Open

blob/gcsblob: support the gRPC API and Rapid Storage (zonal) buckets#3772
stanhu wants to merge 6 commits into
google:masterfrom
stanhu:gocloud-rapid-storage-support

Conversation

@stanhu

@stanhu stanhu commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

This pull request adds support for the Cloud Storage gRPC API to gcsblob, and on top of it, support for Rapid Storage (zonal) buckets.

Two new fields on Options, each with a URL query parameter:

Option URL parameter Effect
UseGRPC grpc=true Builds the client with storage.NewGRPCClient instead of storage.NewClient.
UseZonalAPIs zonal=true Adds experimental.WithZonalBucketAPIs(). Implies UseGRPC.
b, err := blob.OpenBucket(ctx, "gs://my-rapid-bucket?zonal=true")

Why Rapid Storage needs more than a transport switch

Zonal buckets accept only appendable object uploads. Every ordinary write is rejected, on both transports:

gs://bucket?grpc=true
  InvalidArgument: This bucket type only supports appendable objects
gs://bucket
  googleapi: Error 400: This bucket requires appendable objects

An appendable object is uploaded over a bidirectional stream. It becomes visible as soon as the first bytes are flushed and stays open for further writes until something finalizes it. Ordinary uploads, one-shot or resumable, produce an object only once the whole payload has been sent. Zonal buckets support the appendable form and nothing else.

So two things are needed:

  1. experimental.WithZonalBucketAPIs() on the client. It makes ObjectHandle.NewWriter default Writer.Append to true, which selects the appendable upload path, and switches reads to the bidirectional API.

  2. Writer.FinalizeOnClose on the writer. An appendable object stays open by default, so Close leaves an unfinalized object exposing only the bytes that happened to be flushed. For a payload small enough to fit in one buffer that is a zero-length object, even though Close returned no error.

Reads already worked on both transports without any change.

Notes for review

FinalizeOnClose is set unconditionally in NewTypedWriter. This looked risky to me too, so I verified that:

  • The field never appears in http_client.go, so the JSON/HTTP writer ignores it.
  • In grpc_writer.go the only read is gRPCAppendBidiWriteBufferSender.send, and pickBufferSender returns that sender only when Writer.Append is set. It is therefore dead code on every path gcsblob uses today.

Setting it unconditionally rather than gating it on UseZonalAPIs is deliberate: it also fixes a pre-existing issue, where passing experimental.WithZonalBucketAPIs() through Options.ClientOptions already reached the client but produced zero-length objects. Callers who genuinely want an object left open for later appends can set it back to false from WriterOptions.BeforeWrite.

gRPC is not a free speedup. Measured from an e2-standard-4 in us-central1-c, 1 KiB objects, each read exactly once, n=200:

Bucket JSON/HTTP p50 JSON/HTTP p99 gRPC p50 gRPC p99
Rapid Storage (zonal), zonal=true N/A N/A 16.9 ms 33.5 ms
NAM4 dual-region 39.0 ms 55.3 ms 43.3 ms 99.9 ms
US multi-region 52.3 ms 115.7 ms 63.1 ms 136.3 ms

Plain gRPC was slower than JSON/HTTP on both standard buckets, so the UseGRPC docs say to measure before enabling it. Against the zonal bucket it is 2.3x faster than NAM4 and 3.1x faster than multi-region, with a tighter tail.

Testing

go test ./blob/gcsblob/ passes; the existing replay tests are unaffected by the writer change. New unit tests cover the zonal parameter, the invalid value, and the zonal=true plus grpc=false conflict.

Beyond that I exercised the branch against a real Rapid Storage bucket in the same zone as the client. Writes, reads, attributes, range reads and 1 MiB writes all succeed with zero errors, where they fail outright on master. I
also confirmed with ObjectAttrs that objects written through gs://bucket?zonal=true come back finalized (size=23, non-zero Finalized), against size=0 and a zero Finalized without the FinalizeOnClose line.

What I could not test: there is no replay coverage for the gRPC path. gcsblob's harness is httpreplay only. The repository does have grpcreplay wired up in internal/testing/setup.NewGCPgRPCConn, used by gcpkms, gcppubsub and gcpfirestore, so the machinery exists, but plumbing it into the gcsblob conformance tests is a larger piece of work.

What this PR does not do

Sub-millisecond is possible, but under certain conditions. You only get it on later reads of one particular object, from a process that has kept a bidirectional read stream open to that object. The first read of any given object costs 10-20 ms no matter which API you use.

Getting there means keeping state alive between reads, and that requires significantly more changes.

@vangent

vangent commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Can you merge with HEAD? I think that might fix the golangci-lint problem.

Comment thread blob/gcsblob/gcsblob.go Outdated
option.WithEndpoint("http://" + host + "/storage/v1/"),
option.WithHTTPClient(http.DefaultClient),
}
// storage.NewClient and storage.NewGRPCClient share the same signature; the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So, the "client *gcp.HTTPClient" passed in to the constructor here is getting ignored? That seems odd.

Maybe enforce that client is nil to make that more clear?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OpenKeeper for KMS has a constructor that takes a client (so that the caller can do whatever with it); maybe that's a better pattern here?

I.e., the grpc=true URL option is fine, and controls what the URL opener does, but there's no "UseGRPC" Option; instead, there are two separate OpenBucket constructors, one for HTTP and one for gRPC, where the latter takes a storage.Client, and we provide a Dial to create it pre-wrapped similar to KMS ("cloudkms.NewKeyManagementClient(ctx, option.WithTokenSource(ts), useragent.ClientOption("secrets"))").

h.closer()
}

func TestConformance(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't want to merge this without running it through the conformance test.

You should be able to make a new function here, TestConformanceGRPC (and maybe another one, TestConformanceGRPCZonal), that uses a different newHarness-equivalent function (or refactor newHarness) that creates a gRPC client etc.

To generate the golden files locally you'll need to update the constants at the top of the file and run with --record. I'll ask you to revert the constant changes before merging, and I'll re-generate the golden files with our bucket after that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

TestConformanceGRPCZonal currently fails for a number of reasons:

TestCopy/Works
  got unexpected error copying blob: (code=InvalidArgument):
  Rapid storage class objects do not support rewrite.

TestListDelimiters/backslash
  (code=InvalidArgument): Invalid argument.        # non-"/" delimiter on an HNS bucket

TestWrite/write_with_explicit_ContentType_overrides_discovery
  NewWriter or Close got err (code=ResourceExhausted):
  The object <rapid bucket>/blob-for-reading exceeded the rate limit for object
  mutation operations (create, update, and delete).

https://docs.cloud.google.com/storage/docs/rapid/rapid-bucket mentions that object rewrites are not supported (https://docs.cloud.google.com/storage/docs/json_api/v1/objects/rewrite).

Rapid Storage also requires / as the delimeter (https://cloud.google.com/blog/products/storage-data-transfer/understanding-new-cloud-storage-hierarchical-namespace), so those tests fail as well.

For now I'll omit TestConformanceGRPCZonal until there's a better way to selectively disable conformance tests.

stanhu added 3 commits August 31, 2026 22:13
Rapid Storage (zonal) buckets cannot be written to over the JSON/HTTP
API; writes require the Cloud Storage gRPC API and its BidiWriteObject
RPC. The driver only ever built a JSON/HTTP storage.Client, so the
default "gs://" opener could not access these buckets.

Add an Options.UseGRPC field and a "grpc" URL query parameter that
switch the driver to storage.NewGRPCClient. The credentials from the
provided gcp.HTTPClient are reused for the gRPC client, so anonymous
access continues to work.

The transport-specific client options are split into httpClientOptions
and grpcClientOptions, selected in openBucket by a function variable,
since storage.NewClient and storage.NewGRPCClient share a signature.

Emulator handling is left to the storage library on the gRPC path.
Reusing STORAGE_EMULATOR_HOST there would not work: it is the HTTP
endpoint, and a local emulator needs a separate port for gRPC. Passing
it to option.WithEndpoint alongside option.WithoutAuthentication would
also still dial over TLS, because skipping credentials does not make
the transport plaintext, so the handshake would fail against a
plaintext emulator. storage.NewGRPCClient already does this correctly
in defaultGRPCOptions: it reads STORAGE_EMULATOR_HOST_GRPC, strips the
scheme that option.WithEndpoint will not accept for gRPC, dials with
insecure transport credentials and disables client metrics. Those
defaults are merged ahead of caller-supplied options, so nothing we
pass is lost. lazyCredsOpener checks STORAGE_EMULATOR_HOST_GRPC too, so
pointing only the gRPC variable at an emulator does not trigger an
Application Default Credentials lookup and warning.
Selecting the gRPC transport is not enough to use a Rapid Storage
bucket. Those buckets accept only appendable object uploads, so every
write still fails:

  gs://bucket?grpc=true
    InvalidArgument: This bucket type only supports appendable objects
  gs://bucket
    googleapi: Error 400: This bucket requires appendable objects

An appendable object is uploaded over a bidirectional stream. It
becomes visible as soon as the first bytes are flushed and stays open
for further writes until something finalizes it. Ordinary uploads,
one-shot or resumable, instead produce an object only once the whole
payload has been sent. Zonal buckets support the appendable form and
nothing else, which is why both transports reject a normal write.

Two things are needed to get there:

 1. experimental.WithZonalBucketAPIs on the storage client. It makes
    ObjectHandle.NewWriter default Writer.Append to true, which selects
    the appendable upload path, and switches reads to the bidirectional
    API. Without it the client keeps using ordinary uploads and the
    writes above keep failing.

 2. Writer.FinalizeOnClose on the writer. An appendable object stays
    open by default, so Close leaves an unfinalized object exposing
    only the bytes that happened to be flushed. For a payload small
    enough to fit in one buffer that is a zero-length object, even
    though Close reported no error. Finalizing on Close restores the
    blob API's promise that a closed Writer leaves a complete object
    behind.

Add Options.UseZonalAPIs and a "zonal" URL query parameter for it.
The zonal APIs exist only on the gRPC client, so UseZonalAPIs implies
UseGRPC and openBucket builds a gRPC client for either. Combining
zonal=true with an explicit grpc=false is a contradiction, so it is
rejected rather than silently overridden.

FinalizeOnClose is set unconditionally in NewTypedWriter. The storage
library reads it only on the appendable write path, which
pickBufferSender selects solely when Writer.Append is set, so the
JSON/HTTP and plain gRPC paths are unaffected. Setting it always also
fixes the pre-existing escape hatch: passing
experimental.WithZonalBucketAPIs through Options.ClientOptions now
produces finalized objects rather than zero-length ones.

Measured against a Rapid Storage bucket in the same zone as the client,
1 KiB objects, each read exactly once, p50: 16.9ms against 39.0ms for a
NAM4 dual-region bucket and 52.3ms for a US multi-region bucket. Plain
gRPC without the zonal APIs measured slower than JSON/HTTP on both of
those standard buckets, so the UseGRPC docs now say to measure before
enabling it.
Review feedback: the gcp.HTTPClient passed to OpenBucket was only
half-used on the gRPC path. Its OAuth2 token source was extracted and
reused, but its transport, and anything a caller had wrapped around it,
was silently dropped. Worse, when the transport was not exactly an
*oauth2.Transport the code fell back to option.WithoutAuthentication,
so a caller with a wrapped transport got an anonymous client and 403s
at request time rather than an error.

Follow the pattern OpenKeeper uses in secrets/gcpkms instead: give
callers a Dial function that returns a ready-made client, and let them
pass it in.

  - Add DialGRPC, which returns a *storage.Client with the Go CDK user
    agent and a caller-supplied token source. A nil token source means
    unauthenticated, explicitly, rather than inferred. Zonal buckets
    need no special API here; experimental.WithZonalBucketAPIs is just
    another option.ClientOption to pass through.
  - Drop Options.UseGRPC and Options.UseZonalAPIs. Options.Client
    already accepted a *storage.Client and already documented gRPC as
    its use case, so nothing new is needed for programmatic callers.
  - Keep the grpc and zonal URL parameters, which is the case a URL
    cannot express otherwise. URLOpener now builds the client itself
    with DialGRPC, driven by a new TokenSource field that lazyCredsOpener
    populates from the same credentials it already resolves.

A URL that asks for gRPC without a token source and without
anonymous=true is now an error, instead of quietly producing an
unauthenticated client.

Buckets opened this way own the client they created and close it in
Close, which was previously a no-op. A gRPC client owns a connection
pool, so without this every OpenBucketURL leaked one for the process
lifetime. Clients supplied through Options.Client belong to the caller
and are left alone.

Verified against a Rapid Storage bucket: both gs://bucket?zonal=true
and DialGRPC plus Options.Client round-trip an object and leave it
finalized at the right size.
@stanhu
stanhu force-pushed the gocloud-rapid-storage-support branch from d469fc0 to 60f3aab Compare September 1, 2026 05:50
Review feedback: don't merge gRPC support without running it through
the conformance suite.

Add TestConformanceGRPC and TestConformanceGRPCZonal, sharing a
grpcHarness that opens the bucket with a DialGRPC client.

These run against a real bucket named by GCSBLOB_GRPC_TEST_BUCKET and
skip when it is unset, rather than using the record/replay harness that
TestConformance uses. Replay does not work here:
cloud.google.com/go/storage reads objects with a zero-copy codec,
installed unconditionally in NewRangeReaderReadObject as
grpc.ForceCodecV2(bytesCodecReadObject{}), so RecvMsg is handed a
*mem.BufferSlice instead of a proto.Message. grpcreplay assumes every
message is a proto.Message and panics on the unchecked type assertion
in message.set, aborting the test binary on the first gRPC read.
Recording writes works; it is reads that cannot be captured.

    panic: interface conversion: *mem.BufferSlice is not
    protoreflect.ProtoMessage: missing method ProtoReflect
        grpcreplay.(*message).set(...)
        grpcreplay.(*recClientStream).RecvMsg(...)
        storage.(*grpcStorageClient).NewRangeReaderReadObject...

Results so far. Against a multi-region bucket, TestConformanceGRPC
passes in full. Against a Rapid Storage bucket, TestConformanceGRPCZonal
has 60 subtests pass and 27 fail, and every failure is a service
limitation rather than a driver bug:

  - 42 errors of "Rapid storage class objects do not support rewrite",
    from Copy and from the tests that copy.
  - 4 errors of "Invalid argument" from listing with a delimiter other
    than "/", which buckets with a hierarchical namespace do not allow,
    and Rapid Storage requires a hierarchical namespace.

So a Rapid Storage bucket cannot pass the suite as written until
drivertest can express that a driver does not support Copy.

SignedURL is left unexercised: with no GoogleAccessID the driver reports
Unimplemented and drivertest skips those checks, so HTTPClient returns
nil. Signing is client-side and does not depend on the transport.
@stanhu
stanhu force-pushed the gocloud-rapid-storage-support branch from 60f3aab to 953d649 Compare September 1, 2026 05:57
Match the shape of secrets/gcpkms.Dial, which is the pattern this
follows:

	func Dial(ctx context.Context, ts gcp.TokenSource) (*cloudkms.KeyManagementClient, func(), error)

DialGRPC now returns (*storage.Client, func(), error). Callers that
stash the client in Options.Client get a matching clean-up to defer,
rather than having to know that closing a *storage.Client is the right
thing to do.

Unlike gcpkms.Dial, the clean-up is nil on error rather than a closure
over a nil client, so calling it in an error path cannot panic.
A test that cannot pass is not a test. Against a Rapid Storage bucket
the suite failed 33 of 88 checks, and none of the failures were driver
bugs:

  - "Rapid storage class objects do not support rewrite" accounted for
    three of the five failing groups. Only TestCopy is about copying;
    TestKeys and TestAs copy incidentally, and TestKeys alone
    contributed 19 failures because it copies once per weird key.
  - Listing with a delimiter other than "/" fails with "Invalid
    argument". That is a hierarchical namespace restriction, which
    Rapid Storage requires, rather than anything to do with zonal
    buckets or gRPC.
  - TestWrite hit "exceeded the rate limit for object mutation
    operations" on the same object. This one is not even a Rapid
    Storage limit; it is the general GCS per-object mutation cap, and
    it only appeared when running from a VM in the bucket's zone, fast
    enough to trip it. So the failure count varied with where the test
    ran, 27 from a laptop and 33 from in-zone.

drivertest has no way to express any of this. Its only opt-out is the
Unimplemented error code, and all six places that honor it guard
SignedURL; testCopy treats every error from Copy as a failure. Skipping
these would mean changing blob/drivertest for all drivers, which is a
separate discussion.

The useful part of the run was the evidence, which belongs in the pull
request rather than in a permanently red test. What it showed: with the
zonal APIs enabled, everything the service supports passes.

TestConformanceGRPC stays, and newGRPCHarness loses the zonal switch it
no longer needs. Verified against a multi-region bucket from the same
VM: 88 checks pass, three runs in a row.
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