Skip to content

feat(storage): add client feature tracking header - #16591

Merged
cpriti-os merged 7 commits into
googleapis:mainfrom
cpriti-os:feature-tracking-header
Nov 3, 2026
Merged

cpriti-os merged 7 commits into
googleapis:mainfrom
cpriti-os:feature-tracking-header

Conversation

@cpriti-os

@cpriti-os cpriti-os commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

The service has no way to tell when a client read used read hedging or the multi-stream optimization for bidi reads (ObjectDescriptor), so the server-side impact of these features cannot be measured. google-cloud-go already reports this with x-goog-storage-go-features (see googleapis/google-cloud-go#20684 for the encoding); this PR adds the C++ equivalent.

What

Adds the x-goog-storage-cpp-features header (REST header / gRPC metadata key), carrying a bitmask of the client-side features in effect for the request.

Bits

Single source of truth: google/cloud/storage/internal/tracked_features.h (machine-parseable kName = N, lines; kAllTrackedFeatures lists them and a single static_assert(TrackedFeaturesAreValid()) enforces distinct positions < 32).

Bit Enumerator Set when
0 kMultistreamInMrd An async ObjectDescriptor (AsyncClient::Open / BidiReadObject) runs with storage::EnableMultiStreamOptimizationOption true. Attached to every BidiReadObject stream the descriptor opens (initial, resumes, and additional streams), since all go through the same stream factory.
1 kReadHedging The sync storage::Client issues ReadObject while read hedging is in effect (storage_experimental::EnableReadHedgingOption true, MaxReadHedgesOption > 0, and the hedging thread pools exist). Semantics: the bit means hedging is in effect for the read, not that a hedge was actually dispatched. Attached on both REST and gRPC transports.

Encoding contract

  • Value = 4-byte big-endian uint32 bitmask → URL-safe base64 (RFC 4648 §5, -/_ alphabet) with = padding.
  • The service decodes only the URL-safe alphabet; a value in the standard alphabet (+//) is rejected. fix(storage): use URL-safe base64 for the feature tracking header google-cloud-go#20684 (merged 2026-10-28) switched Go to the same URL-safe encoding, so both libraries now follow the contract the service enforces.
  • Bit positions are per library: the service keeps a separate bit→feature table per client header, populated per release by an internal tracker that parses tracked_features.h. Positions need not match other SDKs (e.g. Go uses bit 1 for a different feature); bits are never renumbered or reused once released.
  • The header is omitted when the mask is 0.
  • Examples: 1 → "AAAAAQ==", 2 → "AAAAAg==", 0xF8 → "AAAA-A==".

Implementation notes

  • New storage/internal/tracked_features.{h,cc}: TrackedFeature (with static_asserts that enumerators are distinct bit positions < 32), constexpr FeatureBit(), EncodeFeatureMask() (returns "" for mask 0), kFeaturesHeader, and AddFeaturesHeader(rest_internal::RestContext&, mask) (no-op for mask 0). Uses the existing internal::UrlsafeBase64EncodeWithPadding().
  • AddFeaturesHeader(grpc::ClientContext&, mask) lives in storage/internal/grpc/configure_client_context.{h,cc} because google_cloud_cpp_storage does not depend on gRPC.
  • Sync: StorageConnectionImpl::ReadObject() computes the mask from whether hedging is in effect and calls AddFeaturesHeader() on the RestContext next to the idempotency token. ApplyRestContextHeaders(grpc::ClientContext&, RestContext const&) (renamed from the RestContext overload of AddIdempotencyToken) forwards an explicit allowlist (kForwardedHeaders: idempotency token, feature mask) into gRPC metadata, so one code path covers both transports. The AddIdempotencyToken(ClientContext&, std::string const&) overload is unchanged.
  • Async: MakeOpenStreamFactory() in storage/internal/async/connection_impl.cc calls AddFeaturesHeader() on the per-stream grpc::ClientContext.

Tests

Coverage

Path Test Checks
Encoding internal_tracked_features_test exact encodings for 1, 2, 3, 4, 0xF8 (AAAA-A==), 0xFC (AAAA_A==), 0xFFFFFFFF (_____w==); big-endian byte order; mask 0 → empty (header omitted); sextets 62/63 in every position never produce + or /; FeatureBit() per enumerator (also static_asserted, it is constexpr); AddFeaturesHeader(RestContext&) sets the header and is a no-op for mask 0.
Sync, REST (GenericStub) internal_connection_impl_test ReadObject carries AAAAAg== with hedging in effect; header absent when hedging is off, when MaxReadHedgesOption is 0, and when the connection has no hedging thread pools (option set per-call only); never sent on GetObjectMetadata / DeleteObject even with hedging in effect. The REST stub forwards all RestContext headers to curl (CurlRestClient → SetHeaders(context.headers())), which rest/stub_test already covers generically.
Sync, gRPC (GrpcStub → StorageStub mock) internal_grpc_stub_test, internal_grpc_configure_client_context_test GrpcStub::ReadObject() end-to-end: RestContext header appears in grpc::ClientContext metadata next to the idempotency token; no metadata entry at all when the RestContext has none. ApplyRestContextHeaders() forwards only the allowlisted headers (a REST-only header is not copied); AddFeaturesHeader(grpc::ClientContext&) sets the header and is a no-op for mask 0.
Async, descriptor internal_async_connection_impl_open_test header on the first BidiReadObject stream with multi-stream enabled; absent when EnableMultiStreamOptimizationOption is false; with multi-stream enabled, every stream the same descriptor opens (first + the background/subsequent streams, incl. retry attempts) carries it.
Async, non-descriptor internal_async_connection_impl_read_test ReadObject() and ReadObjectRange() never send it, even with multi-stream enabled (the default).

A mutation check (removing the AddMetadata call in the async factory) turns the async tests red.

Validation run (latest revision)

  • bazel test //google/cloud/storage:all (every storage unit test; internal/ has no BUILD of its own) — 179/179 PASSED. Earlier full //google/cloud/storage/... run: 209 unit tests PASSED; the only failures were storage/tests:*_integration_test* / storage/examples:*_samples, which need GOOGLE_CLOUD_PROJECT/ADC and are not runnable here.
  • Targeted: internal_tracked_features_test, internal_connection_impl_test, internal_grpc_configure_client_context_test, internal_grpc_stub_test, internal_grpc_stub_acl_test, internal_async_connection_impl_open_test, internal_async_connection_impl_read_test, internal_async_connection_impl_test, internal_async_rewriter_connection_impl_test — 9/9 PASSED.
  • ci/cloudbuild/build.sh -t checkers-pr (docker) — PASSED (exit 0, no diff).
  • GOOGLE_CLOUD_CPP_FAST_CHECKERS=1 ci/cloudbuild/builds/checkers.sh — SKIPPED locally (typos not installed); covered by the docker run.
  • ci/cloudbuild/build.sh -t clang-tidy-pr — SKIPPED (the fedora-latest-cmake image builds every dependency from source). Instead clang-tidy 22.1.8 with the repo's .clang-tidy and Bazel's compile flags was run on every .cc this PR touches: no findings on any PR-touched line or file (the newer clang-tidy flags pre-existing sites in untouched headers via checks CI's pinned version does not have).
  • CMake build — not run (the .cmake source lists were updated alongside the .bzl lists).

@product-auto-label product-auto-label Bot added the api: storage Issues related to the Cloud Storage API. label Oct 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces client-side feature tracking to report active features, such as multi-stream optimization and read hedging, to the storage service via a new x-goog-storage-cpp-features header. The features are encoded as a 4-byte big-endian bitmask using URL-safe base64. The review feedback highlights several violations of the repository style guide regarding the use of auto, specifically requesting explicit types for primitives and when auto obscures function return types or loop variables.

Comment on lines +412 to +418
auto const enable_hedging =
current->get<storage_experimental::EnableReadHedgingOption>();
auto const delay = current->get<storage_experimental::ReadHedgeDelayOption>();
auto const max_hedges =
current->get<storage_experimental::MaxReadHedgesOption>();
auto const max_buffer =
current->get<storage_experimental::MaximumHedgeBufferOption>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

According to the repository style guide, auto should not be used for primitive or scalar types (such as bool, int, std::size_t). Please use explicit types instead.

  bool const enable_hedging =
      current->get<storage_experimental::EnableReadHedgingOption>();
  auto const delay = current->get<storage_experimental::ReadHedgeDelayOption>();
  int const max_hedges =
      current->get<storage_experimental::MaxReadHedgesOption>();
  std::size_t const max_buffer =
      current->get<storage_experimental::MaximumHedgeBufferOption>();
References
  1. Disallow auto for Primitives: Require explicit numeric and scalar types (std::size_t, std::int64_t, bool, etc.) rather than deducing them from literals. (link)

features |= storage_internal::FeatureBit(
storage_internal::TrackedFeature::kReadHedging);
}
auto const features_value = storage_internal::EncodeFeatureMask(features);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

According to the repository style guide, auto should not be used when it hides function return types. Please use std::string explicitly.

  std::string const features_value = storage_internal::EncodeFeatureMask(features);
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr<T>, domain objects, protobuf messages/fields, or function return types. (link)

auto const& headers = context.headers();
auto const l = headers.find(name);
if (l == headers.end()) return;
for (auto const& v : l->second.values()) ctx.AddMetadata(name, v);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

According to the repository style guide, auto should not be used for simple types like std::string in range-based loops when it hides the type. Please use std::string const& explicitly.

  for (std::string const& v : l->second.values()) ctx.AddMetadata(name, v);
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr<T>, domain objects, protobuf messages/fields, or function return types. (link)

Comment on lines +451 to +452
auto const expected =
EncodeFeatureMask(FeatureBit(TrackedFeature::kMultistreamInMrd));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

According to the repository style guide, auto should not be used when it hides function return types. Please use std::string explicitly.

  std::string const expected =
      EncodeFeatureMask(FeatureBit(TrackedFeature::kMultistreamInMrd));
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr<T>, domain objects, protobuf messages/fields, or function return types. (link)

Comment on lines +862 to +864
auto const expected =
storage_internal::EncodeFeatureMask(storage_internal::FeatureBit(
storage_internal::TrackedFeature::kReadHedging));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

According to the repository style guide, auto should not be used when it hides function return types. Please use std::string explicitly.

  std::string const expected =
      storage_internal::EncodeFeatureMask(storage_internal::FeatureBit(
          storage_internal::TrackedFeature::kReadHedging));
References
  1. Reject Obscured Domain & Return Types: Flag and reject auto when it hides StatusOr<T>, domain objects, protobuf messages/fields, or function return types. (link)

@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.47%. Comparing base (b6333d3) to head (c2629b1).

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #16591      +/-   ##
==========================================
+ Coverage   92.44%   92.47%   +0.03%     
==========================================
  Files        2266     2269       +3     
  Lines      221667   222037     +370     
==========================================
+ Hits       204918   205337     +419     
+ Misses      16749    16700      -49     

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

@cpriti-os
cpriti-os marked this pull request as ready for review October 26, 2026 06:49
@cpriti-os
cpriti-os requested review from a team as code owners October 26, 2026 06:49
@cpriti-os
cpriti-os requested a review from kalragauri October 26, 2026 06:49
@cpriti-os cpriti-os changed the title feat(storage): add client feature tracking header chore(storage): add client feature tracking header Oct 26, 2026

@kalragauri kalragauri left a comment

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.

Changes look good but I see that googleapis/google-cloud-go#20684 is still open, and Go main currently encodes this header with standard base64 (encodeUint32). Should we call out explicitly that the URL-safe alphabet is what the service decodes, and note the Go PR is pending?

Alternately, let's wait until the Go PR is merged so a reader does not assume the two libraries are already aligned.

Also, consider using feat(storage): rather than chore(storage):. This changes what the client sends on the wire for every descriptor open and hedged read, which seems worth a release-note line.

* is parsed by tooling outside this repository: keep each enumerator on one
* line, in the form `kName = N,`, and never reuse or renumber a bit.
*/
enum class TrackedFeature : std::uint32_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.

Bit 1 here is kReadHedging, while in Go bit 1 is featurePCU. Since the comment says this file is parsed by tooling outside the repo, could you name that tooling (or link the registration) so it is clear the service keeps a C++-specific bit table and that these two bits are already registered there?

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.

yes the service keeps feature bit map per sdk version (so different for C++ and Go)


// The mask is 32 bits wide, so every enumerator must be a position below 32,
// and no two features may share a position.
static_assert(static_cast<std::uint32_t>(TrackedFeature::kMultistreamInMrd) <

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.

These asserts grow quadratically as features are added, and tracked_features_test.cc keeps its own copy of the list in kAllFeatures. One constexpr array of all features in this header, checked by a single constexpr function, would cover both and give the test one list to iterate.

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.

Done

@cpriti-os cpriti-os changed the title chore(storage): add client feature tracking header feat(storage): add client feature tracking header Oct 27, 2026
@cpriti-os
cpriti-os requested a review from kalragauri October 29, 2026 04:46
@cpriti-os

Copy link
Copy Markdown
Contributor Author

Changes look good but I see that googleapis/google-cloud-go#20684 is still open, and Go main currently encodes this header with standard base64 (encodeUint32). Should we call out explicitly that the URL-safe alphabet is what the service decodes, and note the Go PR is pending?

Alternately, let's wait until the Go PR is merged so a reader does not assume the two libraries are already aligned.

Also, consider using feat(storage): rather than chore(storage):. This changes what the client sends on the wire for every descriptor open and hedged read, which seems worth a release-note line.

Go PR is merged, the encoding/decoding will be the same in Go for standard and URL-safe since the number of bits used are 2, no impact for our readers and dashboard. Go PR is merged to avoid inconsistency in the future when we use more bits.

@kalragauri kalragauri left a comment

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.

This PR currently has merge conflicts with main in configure_client_context.h, configure_client_context_test.cc, and grpc/stub.cc following #16600. Please rebase onto main so CreateResumableUpload, QueryResumableUpload, DeleteResumableUpload, and SignBlob keep both #16600's ApplyQueryParameters changes and ApplyRestContextHeaders.

The service cannot currently tell when a client used read hedging or the
multi-stream optimization for bidi reads, which makes it impossible to
measure the impact of these features from the server side. Other client
libraries (e.g. google-cloud-go, `x-goog-storage-go-features`) already
report this via a small bitmask header.

This change adds `x-goog-storage-cpp-features`, sent as a REST header and
as gRPC metadata. The value is a 4-byte big-endian uint32 bitmask encoded
as URL-safe base64 (RFC 4648 section 5, `-`/`_` alphabet, with `=`
padding). The header is omitted when no tracked feature is in use.

Bits (see `storage/internal/tracked_features.h`, the single source of
truth):

* bit 0, `kMultistreamInMrd`: set on every `BidiReadObject` stream an
  async `ObjectDescriptor` opens while
  `storage::EnableMultiStreamOptimizationOption` is true.
* bit 1, `kReadHedging`: set on `ReadObject` requests (REST and gRPC)
  issued by the sync client while read hedging is in effect.

The sync path sets the header on the `RestContext`, and
`AddIdempotencyToken()` now forwards it (alongside the idempotency token)
into the `grpc::ClientContext`, so both transports are covered by a single
code path.
Fold the header-copy helper and the idempotency token constant into the
file's existing unnamed namespace instead of opening a second one. No
behavior change.
Fill the gaps in the test coverage for `x-goog-storage-cpp-features`:

* `tracked_features_test`: pin the encodings for masks 3, 0xFC, and a
  high-byte value (big-endian), place sextets 62 and 63 in every
  position to prove the URL-safe alphabet, and `static_assert` that the
  enumerators are distinct bit positions below 32.
* `connection_impl_test` (sync, REST `GenericStub`): the header is
  absent when hedging is enabled but `MaxReadHedgesOption` is 0, when
  the connection has no hedging thread pools, and on non-`ReadObject`
  RPCs even with hedging in effect.
* `grpc/stub_test` (sync, gRPC): `GrpcStub::ReadObject()` forwards the
  header from the `RestContext` into the `grpc::ClientContext`, and
  sends no metadata entry at all when the `RestContext` has none.
* `async/connection_impl_open_test`: with the multi-stream optimization
  on, every `BidiReadObject` stream a descriptor opens carries the
  header, not just the first one.
* `async/connection_impl_read_test`: the plain `ReadObject()` and
  `ReadObjectRange()` paths never send it.
Rename `AddIdempotencyToken(grpc::ClientContext&, RestContext const&)` to
`ApplyRestContextHeaders()`, since it now forwards more than the token.
The headers it copies are an explicit allowlist (`kForwardedHeaders`:
idempotency token and client feature mask), so REST-only headers on the
`RestContext` never leak into gRPC metadata. The string overload of
`AddIdempotencyToken()` is unchanged.

Also adds `AddFeaturesHeader(grpc::ClientContext&, std::uint32_t)`; it
lives here rather than in `tracked_features.h` because
`google_cloud_cpp_storage` does not depend on gRPC.
* `FeatureBit()` is now `constexpr` in the header, and the header
  `static_assert`s that the enumerators are distinct bit positions
  below 32 (the test-side duplicates are gone).
* `AddFeaturesHeader(RestContext&, mask)` and the gRPC overload are
  no-ops for a zero mask, so callers no longer re-check for the empty
  encoding. The sync `ReadObject()` and the async open-stream factory
  use them instead of hand-rolled blocks.
* Drop the redundant `& 0xFF` after `static_cast<std::uint8_t>` in
  `EncodeFeatureMask()`, and the duplicated encoding literal assertion
  in `connection_impl_test`.
Spell out primitive and function-return types instead of `auto` in the
lines this change added, per .gemini/styleguide.md.
* `kAllTrackedFeatures` lists every feature in the header, and one
  `constexpr` validator (`TrackedFeaturesAreValid()`) behind a single
  `static_assert` replaces the pairwise asserts: every position is below
  32 and the OR of all bits has one bit per feature. The test iterates
  the same list instead of its own copy.
* Document that bit positions are per library (the service keeps a
  separate bit-to-feature table per client header, populated per release
  by a tracker that parses this file), and that the service decodes only
  the URL-safe base64 alphabet.
@cpriti-os

Copy link
Copy Markdown
Contributor Author

/gcbrun

@cpriti-os
cpriti-os merged commit 66b9dd7 into googleapis:main Nov 3, 2026
71 checks passed

This branch was successfully deployed

1 active deployment
false — c2629b15 Deployed Nov 3, 2026 by cpriti-os via Save PR ref #12783
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: storage Issues related to the Cloud Storage API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants