blob/gcsblob: support the gRPC API and Rapid Storage (zonal) buckets - #3772
blob/gcsblob: support the gRPC API and Rapid Storage (zonal) buckets#3772stanhu wants to merge 6 commits into
Conversation
|
Can you merge with HEAD? I think that might fix the golangci-lint problem. |
| option.WithEndpoint("http://" + host + "/storage/v1/"), | ||
| option.WithHTTPClient(http.DefaultClient), | ||
| } | ||
| // storage.NewClient and storage.NewGRPCClient share the same signature; the |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
d469fc0 to
60f3aab
Compare
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.
60f3aab to
953d649
Compare
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.
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:UseGRPCgrpc=truestorage.NewGRPCClientinstead ofstorage.NewClient.UseZonalAPIszonal=trueexperimental.WithZonalBucketAPIs(). ImpliesUseGRPC.Why Rapid Storage needs more than a transport switch
Zonal buckets accept only appendable object uploads. Every ordinary write is rejected, on both transports:
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:
experimental.WithZonalBucketAPIs()on the client. It makesObjectHandle.NewWriterdefaultWriter.Appendto true, which selects the appendable upload path, and switches reads to the bidirectional API.Writer.FinalizeOnCloseon the writer. An appendable object stays open by default, soCloseleaves 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 thoughClosereturned no error.Reads already worked on both transports without any change.
Notes for review
FinalizeOnCloseis set unconditionally inNewTypedWriter. This looked risky to me too, so I verified that:http_client.go, so the JSON/HTTP writer ignores it.grpc_writer.gothe only read isgRPCAppendBidiWriteBufferSender.send, andpickBufferSenderreturns that sender only whenWriter.Appendis set. It is therefore dead code on every pathgcsblobuses today.Setting it unconditionally rather than gating it on
UseZonalAPIsis deliberate: it also fixes a pre-existing issue, where passingexperimental.WithZonalBucketAPIs()throughOptions.ClientOptionsalready 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 fromWriterOptions.BeforeWrite.gRPC is not a free speedup. Measured from an
e2-standard-4inus-central1-c, 1 KiB objects, each read exactly once, n=200:zonal=truePlain gRPC was slower than JSON/HTTP on both standard buckets, so the
UseGRPCdocs 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 thezonalparameter, the invalid value, and thezonal=trueplusgrpc=falseconflict.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. Ialso confirmed with
ObjectAttrsthat objects written throughgs://bucket?zonal=truecome back finalized (size=23, non-zeroFinalized), againstsize=0and a zeroFinalizedwithout theFinalizeOnCloseline.What I could not test: there is no replay coverage for the gRPC path.
gcsblob's harness ishttpreplayonly. The repository does havegrpcreplaywired up ininternal/testing/setup.NewGCPgRPCConn, used bygcpkms,gcppubsubandgcpfirestore, so the machinery exists, but plumbing it into thegcsblobconformance 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.