Repository navigation
feat(bigtable): serve grpc.health.v1.Health from the accelerator daemon - #20446
Conversation
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
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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:
falsemeans "don't forward this to Bigtable." An unrecognised method falls through to gRPC's ownUnimplemented— loud and harmless. - Auth interceptor:
falsewould 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_HealthRequiresAuthTokenwithCheckandWatchsubtests — no token →Internal, correct token →SERVING. (The streaming rejection lands onRecv, not onWatch; gRPC doesn't round-trip until the stream is read, so asserting onWatch's error would have asserted nothing.)- A doc comment on
authUnaryInterceptorrecording 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
🤖 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>
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
Checktouches 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
grpc.health.v1.Healthservice.SERVINGwhile up,NOT_SERVINGonceStopbegins draining, so a caller onWatchis told the daemon is going away rather than inferring it from a dropped connection.isProxied.The gate is the load-bearing part.
proxyUnaryInterceptorandproxyStreamInterceptorare 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:isProxiedis 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 ownUnimplementedinstead 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_SERVINGwhen it is slow, for two reasons: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/healthis a subpackage ofgoogle.golang.org/grpc, already a direct requirement.go.mod/go.sumare 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-valueChannelon purpose: a health RPC must never reach the Channel, so a regression surfaces as a failure rather than a quietly-forwarded probe.TestServer_HealthNotServingOnStop— aWatchstream observes theNOT_SERVINGtransition.TestIsProxied— table test including theBigtableAdminprefix-lookalike and empty/degenerate method names.go test -race ./internal/accelerator/...green; darwin cross-build OK.