Skip to content

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

Open
stanhu wants to merge 2 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 2 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.

Updated after review. Options.UseGRPC and Options.UseZonalAPIs are gone, replaced by a Dial plus a constructor following secrets/gcpkms. Rebased onto master, which fixes the golangci-lint failure. A gRPC conformance test is added. Details in "What changed since the review" at the bottom.

API

func DialGRPC(ctx context.Context, ts gcp.TokenSource, opts ...option.ClientOption) (*storage.Client, func(), error)
func OpenBucketGRPC(client *storage.Client, bucketName string, opts *Options) (*blob.Bucket, error)
c, cleanup, err := gcsblob.DialGRPC(ctx, ts, experimental.WithZonalBucketAPIs())
if err != nil { ... }
defer cleanup()
b, err := gcsblob.OpenBucketGRPC(c, "my-rapid-bucket", nil)

OpenBucketGRPC takes no context, matching gcpkms.OpenKeeper: once a client exists there is nothing left to cancel. Options.Client, which has accepted a *storage.Client since v0.44.0, keeps working unchanged.

For callers whose entire configuration is a URL string, and who therefore have nowhere to put a client, there are two query parameters:

Parameter Effect
grpc=true Uses the gRPC transport.
zonal=true Additionally enables the zonal bucket APIs. Implies grpc=true.
b, err := blob.OpenBucket(ctx, "gs://my-rapid-bucket?zonal=true")

Combining zonal=true with an explicit grpc=false is rejected rather than silently overridden. URLOpener gains a TokenSource, populated by lazyCredsOpener from the credentials it already resolves, because a gRPC client cannot reuse an HTTP one.

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 traced it. The field never appears in http_client.go, so the JSON/HTTP writer cannot see it. In grpc_writer.go the only read is gRPCAppendBidiWriteBufferSender.send, and pickBufferSender returns that sender only when Writer.Append is set. It is dead code on every path gcsblob uses today.

Buckets now close the client they created. Close was a no-op. A gRPC client owns a connection pool, so every OpenBucketURL with grpc/zonal leaked one for the process lifetime. Clients supplied by the caller, through OpenBucketGRPC or Options.Client, are left alone.

Emulator handling on the gRPC path is left to the storage library. Reusing STORAGE_EMULATOR_HOST 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. defaultGRPCOptions already reads STORAGE_EMULATOR_HOST_GRPC, strips the scheme, dials insecurely and disables client metrics, and those defaults are merged ahead of caller-supplied options. lazyCredsOpener checks that variable too.

gRPC on its own is not a speedup. From an n2-standard-4 in us-central1-c, 1 KiB objects each read exactly once, n=1000:

Bucket JSON/HTTP p50 JSON/HTTP p99 gRPC p50 gRPC p99
Rapid Storage (zonal), zonal=true * * 12.0 ms 34.5 ms
NAM4 dual-region 35.4 ms 69.2 ms 35.5 ms 73.2 ms
US multi-region 61.2 ms 150.9 ms 59.2 ms 124.6 ms

* Not comparable: a zonal bucket rejects every write over JSON/HTTP.

On the standard buckets plain gRPC is a wash, and on a smaller e2-standard-4 it was consistently slower, so the docs say to measure before enabling it. The win is the zonal bucket: 2.9x faster than NAM4 and 5.1x faster than multi-region.

Testing

go test ./blob/gcsblob/ passes and the existing replay tests are unaffected. New unit tests cover the grpc and zonal parameters, their invalid values, the zonal=true plus grpc=false conflict, and OpenBucketGRPC including that the caller's client survives bucket.Close.

TestConformanceGRPC runs the full drivertest suite over gRPC. Against a US multi-region bucket all 88 checks pass, repeatedly.

It runs against a real bucket named by GCSBLOB_GRPC_TEST_BUCKET and skips when unset, so it contributes nothing in CI. That is not a choice I would have made if replay worked. It cannot: 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; reads cannot be captured.

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

Fixing that is a change to google/go-replayers, and I am happy to pursue it as a follow-up if you want these in CI.

I also verified against a real Rapid Storage bucket that writes, reads, attributes, range reads and 1 MiB writes all succeed where they fail outright on master, and that objects written through zonal=true come back finalized, size=23 with a non-zero Finalized, against size=0 without the FinalizeOnClose line.

There is no zonal conformance test

I wrote one and removed it, because it cannot pass. Against a Rapid Storage bucket, 55 checks pass and 33 fail, none of them driver bugs:

  • Rapid storage class objects do not support rewrite accounts for three of the five failing groups. Only TestCopy is about copying; TestKeys and TestAs copy incidentally, and TestKeys alone contributes 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 inherits by requiring HNS, rather than anything to do with zonal buckets or gRPC.
  • TestWrite hits the per-object mutation rate limit. Not a Rapid Storage limit at all, just the general GCS cap, and it only appeared when running from a VM in the bucket's zone, fast enough to trip it. The failure count varied with distance: 27 from a laptop, 33 from in-zone.

drivertest cannot express any of this. Its only opt-out is the Unimplemented error code, and all six places that honor it guard SignedURL; testCopy treats any error from Copy as a failure. Teaching it that a driver does not support Copy would affect every driver, so I have left it alone. Happy to open that separately if you think it is worth doing.

What this PR does not do

It does not deliver the sub-millisecond reads Rapid Storage is advertised for.

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 11 to 12 ms no matter which API you use.

Same 1 KiB object, same client, n=1000:

Read p50 min
Never read before, via NewReader. This is what the driver does. 12.0 ms 6.7 ms
Never read before, by opening a MultiRangeDownloader for it 11.1 ms 6.3 ms
Same object again, on the stream already open 0.95 ms 0.59 ms
Same object again, with a ReadHandle cached from the earlier read 4.3 ms 2.6 ms

Getting there means keeping state alive between reads. Two ways:

  • Cache open MultiRangeDownloaders per object on the driver's bucket. Reaches the third row. Needs idle expiry, cleanup from bucket.Close, and an adapter from Add's callback to io.Reader.
  • Cache ReadHandles per object. Much smaller, but only reaches the fourth row.

Neither has anywhere to live today. driver.Bucket.NewRangeReader hands back a fresh driver.Reader per call, and the portable layer makes a new one for every read and discards it on a Seek.

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

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.

@vangent I've updated this pull request to have:

func OpenBucket(ctx context.Context, client *gcp.HTTPClient, bucketName string, opts *Options) (*blob.Bucket, error)
func OpenBucketGRPC(client *storage.Client, bucketName string, opts *Options) (*blob.Bucket, error) {

I've kept grpc=true and zonal=true for query parameter support because this is the standard way to access a bucket with OpenBucketURL.

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.

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.

Can you go ahead and add the test, but comment it out with some comments explaining the above? When I have time I'll try to make it easier to disable specific conformance tests (with explanation) so that they can be enabled.

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.

Or "skip" rather than comment out.

@stanhu
stanhu force-pushed the gocloud-rapid-storage-support branch 2 times, most recently from 60f3aab to 953d649 Compare September 1, 2026 05:57
Rapid Storage (zonal) buckets cannot be used through this driver at
all. Every 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, which is why both transports reject a normal write.

Two things are needed. experimental.WithZonalBucketAPIs makes
ObjectHandle.NewWriter default Writer.Append to true, selecting the
appendable upload path, and switches reads to the bidirectional API.
Writer.FinalizeOnClose then makes Close finalize the object; without it
Close leaves an unfinalized object exposing only whatever prefix was
flushed, which for a small payload is a zero-length object even though
Close reported no error.

Reads already worked over both transports without any change.

The new API is a Dial plus a constructor, following secrets/gcpkms:

	func DialGRPC(ctx context.Context, ts gcp.TokenSource, opts ...option.ClientOption) (*storage.Client, func(), error)
	func OpenBucketGRPC(client *storage.Client, bucketName string, opts *Options) (*blob.Bucket, error)

	c, cleanup, err := gcsblob.DialGRPC(ctx, ts, experimental.WithZonalBucketAPIs())
	defer cleanup()
	b, err := gcsblob.OpenBucketGRPC(c, "my-rapid-bucket", nil)

OpenBucketGRPC takes no context, matching gcpkms.OpenKeeper: once a
client exists there is nothing left to cancel. Options.Client, which
has accepted a *storage.Client since v0.44.0, keeps working.

For callers whose whole configuration is a URL string and who therefore
cannot supply a client, the URL opener grows two parameters. grpc=true
selects the gRPC transport; zonal=true additionally enables the zonal
APIs and implies grpc=true. Combining zonal=true with an explicit
grpc=false is a contradiction and is rejected rather than silently
overridden. URLOpener gains a TokenSource, populated by lazyCredsOpener
from the credentials it already resolves, because a gRPC client cannot
reuse an HTTP one. 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 that build their own client close it in Close, which was
previously a no-op. A gRPC client owns a connection pool, so otherwise
every OpenBucketURL leaked one for the process lifetime. Clients passed
in by the caller are left alone.

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.

Emulator support on the gRPC path is left to storage.NewGRPCClient.
Reusing STORAGE_EMULATOR_HOST 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, since skipping credentials does not make the
transport plaintext. defaultGRPCOptions already reads
STORAGE_EMULATOR_HOST_GRPC, strips the scheme, dials insecurely and
disables client metrics, and those defaults are merged ahead of
caller-supplied options. lazyCredsOpener checks that variable too, so
pointing only the gRPC one at an emulator does not trigger an
Application Default Credentials lookup.

gRPC is not a speedup on its own. Measured from an n2-standard-4 in
us-central1-c, 1 KiB objects each read exactly once, n=1000, p50: on a
NAM4 dual-region bucket 35.5ms over gRPC against 35.4ms over JSON, and
on a US multi-region bucket 59.2ms against 61.2ms. The win comes from
the zonal bucket, at 12.0ms. The UseGRPC docs say to measure first.
@stanhu
stanhu force-pushed the gocloud-rapid-storage-support branch from 3c0fa39 to 9983ce2 Compare September 1, 2026 19:00
@vangent

vangent commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Let me know when you're ready for another round of review, you'll need to upload the golden files as part of the PR for it to pass I think.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.02439% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.05%. Comparing base (5dbc7eb) to head (9983ce2).

Files with missing lines Patch % Lines
blob/gcsblob/gcsblob.go 89.02% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3772      +/-   ##
==========================================
+ Coverage   79.97%   80.05%   +0.08%     
==========================================
  Files         104      104              
  Lines       12219    12284      +65     
==========================================
+ Hits         9772     9834      +62     
- Misses       2446     2449       +3     
  Partials        1        1              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Add TestConformanceGRPC, which runs the full drivertest suite over a
gRPC client built by DialGRPC, and TestConformanceGRPCZonal, which does
the same with the zonal bucket APIs enabled.

Both run against a real bucket named by GCSBLOB_GRPC_TEST_BUCKET and
skip when it is unset, rather than using the record/replay harness
TestConformance uses. Replay cannot work here: 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...

Against a US multi-region bucket TestConformanceGRPC passes all 88
checks.

TestConformanceGRPCZonal is additionally skipped unconditionally.
Against a Rapid Storage bucket 55 checks pass and 33 fail, and every
failure is a Cloud Storage restriction rather than a driver bug.
drivertest cannot express "this driver does not support X": the only
opt-out is returning gcerrors.Unimplemented, every place that honors it
guards SignedURL, and testCopy treats any error from Copy as a failure.
The test is kept, with a comment naming the specific subtests to
disable and why, so it can be enabled once that is possible:

  - TestCopy, TestKeys and TestAs, because Rapid Storage does not
    support object rewrite. TestKeys accounts for 19 of the 33 failures
    on its own, since it copies once per key it exercises.
  - TestListDelimiters/backslash and TestListDelimiters/abc, because a
    hierarchical namespace, which Rapid Storage requires, only supports
    "/" as a delimiter.
  - Four TestWrite subtests that rewrite one object in a tight loop and
    hit the general per-object mutation rate limit. That is not a Rapid
    Storage restriction and only appears when the client is close
    enough to trip it, so it may not need a permanent skip.

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 transport.
@stanhu
stanhu force-pushed the gocloud-rapid-storage-support branch from 9983ce2 to 7e35981 Compare September 2, 2026 17:35
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