Skip to content

feat(bigtable): serve grpc.health.v1.Health from the accelerator daemon - #20446

Merged
mutianf merged 2 commits into
googleapis:mainfrom
mutianf:bigtable-accelerator-healthcheck
Sep 2, 2026
Merged

mutianf merged 2 commits into
googleapis:mainfrom
mutianf:bigtable-accelerator-healthcheck

Conversation

@mutianf

@mutianf mutianf commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Why

The daemon offered its caller no way to ask "are you up and responsive?". The only probe available was a real Bigtable RPC, which consumes a session and whose latency is dominated by the backend and the network — so it says very little about the daemon itself.

A locally-served health check has the opposite property: nothing in a Check touches the Channel, a session, or the network, so its latency is almost entirely this process's own scheduling delay. That is what makes it a usable signal for a daemon that has been starved of CPU.

What

  • Register the standard grpc.health.v1.Health service. SERVING while up, NOT_SERVING once Stop begins draining, so a caller on Watch is told the daemon is going away rather than inferring it from a dropped connection.
  • Gate the proxy interceptors with isProxied.

The gate is the load-bearing part. proxyUnaryInterceptor and proxyStreamInterceptor are chained onto the whole server and never invoke the next handler — they answer every RPC out of the Channel. So before this change, registering any locally-served service would have had its RPCs forwarded to Bigtable. Confirmed by neutering the gate and re-running the new tests:

--- FAIL: TestServer_HealthCheck
    Check: rpc error: code = Unimplemented desc = accelerator: method /grpc.health.v1.Health/Check not implemented
--- FAIL: TestServer_HealthWatch
    Recv: rpc error: code = Unimplemented desc = accelerator: streaming method /grpc.health.v1.Health/Watch not implemented

isProxied is written as "is this a Bigtable method?" rather than as a list of local services to skip, so a service we forget to list fails with gRPC's own Unimplemented instead of being silently shipped to a backend that has never heard of it. The trailing slash in the prefix is deliberate and tested — /google.bigtable.v2.BigtableAdmin/... must not match.

What this deliberately does not do

The status is a liveness statement, not a load assessment. The daemon does not declare itself NOT_SERVING when it is slow, for two reasons:

  1. There is no threshold we can pick correctly from inside the daemon. The caller can time its own probes and apply whatever policy suits it.
  2. The client-side fallback breaker is permanent (AcceleratorBreaker.trip() in the Python SDK has no recovery path). A daemon that briefly called itself unhealthy — one GC pause — would be abandoned for the remaining lifetime of the table.

Making the breaker windowed and recoverable is a prerequisite for any automatic latency-based fallback, and belongs on the Python side. This change supplies the mechanism; the policy is a separate decision.

Dependencies

None added. google.golang.org/grpc/health is a subpackage of google.golang.org/grpc, already a direct requirement. go.mod/go.sum are untouched.

Testing

  • TestServer_HealthCheck / TestServer_HealthWatch — end-to-end over the real UDS, covering both the unary and streaming halves of the gate. They use a zero-value Channel on purpose: a health RPC must never reach the Channel, so a regression surfaces as a failure rather than a quietly-forwarded probe.
  • TestServer_HealthNotServingOnStop — a Watch stream observes the NOT_SERVING transition.
  • TestIsProxied — table test including the BigtableAdmin prefix-lookalike and empty/degenerate method names.
  • Full go test -race ./internal/accelerator/... green; darwin cross-build OK.

The daemon gave its caller no way to ask whether it was up and
responsive. The only available probe was a real Bigtable RPC, which
consumes a session and whose latency is dominated by the backend and the
network -- so it says very little about the daemon itself.

Register the standard health service. Because it is served locally, the
latency of a Check is almost entirely this process's own scheduling
delay, which makes it a usable signal for a daemon that has been starved
of CPU. Status is SERVING while the server is up and NOT_SERVING once
Stop begins draining, so a caller watching the stream learns the daemon
is going away instead of inferring it from a dropped connection.

This needs a gate on the proxy interceptors. They are chained onto the
whole server and never invoke the next handler, so before this change
any locally-registered service would have had its RPCs forwarded to
Bigtable. isProxied is written as "is this a Bigtable method?" rather
than as a list of local services to skip: a service we forget to list
then fails with gRPC's own Unimplemented rather than being shipped to a
backend that has never heard of it.

The status is deliberately a liveness statement, not a load assessment.
The daemon does not declare itself NOT_SERVING under load: the threshold
is not one we can pick correctly from in here, and the client-side
fallback breaker is permanent, so a daemon that briefly called itself
unhealthy would be abandoned for the rest of the table's lifetime.
Whether the daemon is fast enough is left to the caller, which can time
its own probes.

No new module dependency: google.golang.org/grpc/health is a subpackage
of a module already required.

Change-Id: I4f9ca3b4c5131db2cf2031590ea996a204ef2f36
@mutianf
mutianf requested review from a team as code owners September 1, 2026 15:03
@product-auto-label product-auto-label Bot added the api: bigtable Issues related to the Bigtable API. label Sep 1, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements and registers a standard gRPC health service for the Bigtable accelerator daemon. It introduces an isProxied check in the unary and stream interceptors to ensure that local RPCs, such as health checks, are handled locally rather than being forwarded to Bigtable. Additionally, it adds comprehensive unit tests to verify the health check behavior, including status transitions during server shutdown. I have no feedback to provide on these changes.

func proxyUnaryInterceptor(channel *Channel) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, _ grpc.UnaryHandler) (interface{}, error) {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !isProxied(info.FullMethod) {

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.

Do we need to do this for auth?

authUnaryInterceptor / authStreamInterceptor to short-circuit on !isProxied(info.FullMethod) the same way the proxy interceptors do — health then works without a token but Bigtable RPCs still auth ?

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.

Good question, but I landed on no — and the ordering means nothing was broken either way.

authUnaryInterceptor is prepended ahead of the proxy interceptor (server.go:171-177) and, unlike the proxy interceptors, it calls handler() on success rather than terminating the RPC. So it needs no exemption to let a locally-served service through. Health works with auth on today; it just has to carry the token — and it does: the client builds its health stub on the same UDS channel as its data RPCs and passes the same x-accelerator-token metadata, so the probe is already authenticated with no extra work.

On whether it should be exempt, the thing that decided it for me is that the two interceptors read isProxied in opposite safety directions:

  • Proxy interceptor: false means "don't forward this to Bigtable." An unrecognised method falls through to gRPC's own Unimplemented — loud and harmless.
  • Auth interceptor: false would mean "serve this without a token." An unrecognised method gets served unauthenticated.

Since isProxied is a Bigtable-prefix test, every service registered later is unrecognised by construction. Today that set is exactly {health}, which is why the two questions look interchangeable — but the next local service someone adds (a stats endpoint, reflection, a drain-now control RPC) would be un-gated by default rather than by decision, and its author would never have touched this file. Gating also means a foreign daemon left on a reused socket path fails our probe closed instead of answering SERVING for a process we aren't actually using.

Your comment did surface a real gap, though — thanks for naming authStreamInterceptor specifically. It had zero coverage: the only auth-enabled servers in the suite made unary calls, and the one streaming test ran with auth disabled. Health/Watch is the only locally-served server-streaming method, so it's what exercises it.

Pushed 54e8449:

  • TestServer_HealthRequiresAuthToken with Check and Watch subtests — no token → Internal, correct token → SERVING. (The streaming rejection lands on Recv, not on Watch; gRPC doesn't round-trip until the stream is read, so asserting on Watch's error would have asserted nothing.)
  • A doc comment on authUnaryInterceptor recording the fail-open/fail-safe asymmetry, so the next person to look at this doesn't have to re-derive it.

… token

Review asked whether the auth interceptors should short-circuit on
!isProxied the way the proxy interceptors do, so health would answer
without a token. They should not. The two interceptors read that
predicate in opposite safety directions: in the proxy interceptor a
false means 'do not forward', and an unrecognised method falls through
to gRPC's Unimplemented; in the auth interceptor it would mean 'serve
without a token'. isProxied is a Bigtable-prefix test, so every service
registered later is unrecognised by construction and would be un-gated
by default rather than by decision.

Nothing is lost by gating health: the client builds its health stub on
the same connection as its data RPCs, so it already attaches the token.
Gating also makes a foreign daemon on a reused socket path fail our
probe closed rather than answer SERVING for a process we are not using.

Cover both halves. authStreamInterceptor previously had no test at all:
the only auth-enabled servers in the suite made unary calls, and the one
streaming test ran with auth disabled. Health/Watch is the only
locally-served server-streaming method, so it is what exercises it.

Change-Id: If1cb4d79c428304cb2fa67e0afc75441fd7dcda3
@mutianf
mutianf merged commit 5fba38a into googleapis:main Sep 2, 2026
19 checks passed
@mutianf
mutianf deleted the bigtable-accelerator-healthcheck branch September 2, 2026 14:12
haphungw pushed a commit that referenced this pull request Sep 2, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.54.0](bigtable/v1.53.0...bigtable/v1.54.0)
(2026-09-02)


### Features

* **bigtable:** Serve grpc.health.v1.Health from the accelerator daemon
([#20446](#20446))
([5fba38a](5fba38a))
* Update API sources and regenerate
([#20459](#20459))
([5426e32](5426e32))


### Bug Fixes

* **bigtable:** Measure real scheduling delay in pacemaker_delays
([#20445](#20445))
([e1304d6](e1304d6))
* **bigtable:** Scope session pool maintenance loops to the pool, not
the client
([#20444](#20444))
([7457e6e](7457e6e))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: bigtable Issues related to the Bigtable API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants