Skip to content

perf(spanner): create admin stubs lazily instead of at construction#6

Open
fornwall wants to merge 2 commits into
mainfrom
fix/p2-7-lazy-admin-stubs
Open

perf(spanner): create admin stubs lazily instead of at construction#6
fornwall wants to merge 2 commits into
mainfrom
fix/p2-7-lazy-admin-stubs

Conversation

@fornwall

@fornwall fornwall commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Issue (review P2-7)

GapicSpannerRpc builds GrpcInstanceAdminStub and GrpcDatabaseAdminStub in its constructor (spi/v1/GapicSpannerRpc.java ~505–563). With the instantiating channel provider, each admin stub builds its own transport channel (grpc-gcp pool, default 8 channels), interceptor chain, and watchdog. So a data-only client stands up three full channel stacks at startup — data, instance-admin, database-admin — even though the vast majority of applications never call the admin APIs.

What the admin APIs are

  • Instance Admin — create/get/list/update/delete instances, instance configs, instance partitions, instance IAM.
  • Database Admin — create/list/drop databases, schema DDL, backups (create/restore/copy/delete), database roles, database IAM.

These are provisioning/operational surfaces almost always driven out-of-band (Terraform, gcloud, CI), not from the request-serving app process. SpannerImpl already exposes them behind getters, so making the stubs lazy is API-compatible.

Fix

Wrap both admin stubs in a small LazyAdminStub<T extends BackgroundResource> holder that builds the underlying gRPC stub on first use (double-checked locking, volatile field):

  • The stub settings are still built eagerly, so getInstanceAdminStubSettings() / getDatabaseAdminStubSettings() and the emulator auto-config path are unchanged.
  • The holder is close-aware: shutdown() / shutdownNow() call closeAndGet(), which atomically marks the holder closed and returns the stub only if it was actually created, so a data-only client that never touched admin has nothing extra to tear down. A get() racing shutdown either finishes creating the stub before closeAndGet() observes it (so shutdown still closes it), or sees the closed flag and fails with FAILED_PRECONDITION — no window in which a freshly created channel stack can leak.
  • Consequently, calling an admin method after shutdown() now fails cleanly with FAILED_PRECONDITION instead of creating (and leaking) a fresh stub.
  • The auto-throttle GetOperation RESOURCE_EXHAUSTED retry wiring for the database admin stub is moved verbatim into a static createDatabaseAdminStub() helper, invoked from the lazy factory.
  • Stub-creation exceptions (checked and unchecked) are wrapped with the same asSpannerException conversion the constructor used.

Effect

A data-only client now builds one channel stack at construction instead of three. Verified via the two channelz/socket-counting tests, updated to assert the reduced footprint:

before (eager) after (lazy)
grpc-gcp managed channels at construction 6 2
grpc-gcp channel refs 48 16
physical loopback sockets (DirectPath fallback) 48 16

(3 channel-building stubs → 1; the partitioned-DML stub shares the data stub's ClientContext, so it adds none.)

Tests

  • GapicSpannerRpcTest.testDirectPathFallbackCreatesOneGrpcGcpLayerPerPath and GapicSpannerRpcConnectionTest.testDirectPathFallbackCreatesExactlySixteenPhysicalSockets (renamed from the stale ...ExactlyFourPhysicalSockets) updated to the new counts, with comments explaining that admin stubs are now lazy.
  • New GapicSpannerRpcTest.testAdminCallAfterShutdownFailsInsteadOfCreatingStub pins the post-shutdown behavior (FAILED_PRECONDITION, no stub creation) for both admin surfaces.
  • New GapicSpannerRpcTest.testShutdownClosesLazilyCreatedAdminStub exercises the shutdown path when an admin stub was lazily created.
  • The existing admin Gax/ClientImpl tests (DatabaseAdminGaxTest, InstanceAdminGaxTest, DatabaseAdminClientImplTest, InstanceAdminClientImplTest) drive admin RPCs through GapicSpannerRpc and continue to pass — exercising lazy creation on first use.
  • Full module suite green: 9778 tests, 0 failures. google-java-format clean.

Behavioral note

A bad admin stub configuration now surfaces on the first admin call rather than at Spanner construction; data-only clients no longer hit it at all. Calling an admin method after shutdown() fails with FAILED_PRECONDITION ("Cannot use an admin stub after the client has been closed") instead of silently creating a new stub.

Related (P3, not addressed here)

The review's P3 note — that all three stub-settings objects are also built eagerly in SpannerOptions.newBuilder() and rebuilt in the constructor — is a separate classloading-cost cleanup and is left as a follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_016Ci2KFsNgWdHCno4dkXdUU

https://claude.ai/code/session_01B1XiAgYMV1Y2SUx5wdxjfF

fornwall and others added 2 commits July 18, 2026 23:49
GapicSpannerRpc eagerly created GrpcInstanceAdminStub and
GrpcDatabaseAdminStub in its constructor. With the instantiating channel
provider each admin stub builds its own transport channel (grpc-gcp
pool, default 8), interceptor chain and watchdog - so a data-only client
stood up three full channel stacks at startup even though the vast
majority of applications never call the instance/database admin APIs
(provisioning, DDL and backups are typically done out-of-band).

Wrap both admin stubs in a small LazyAdminStub holder that builds the
underlying gRPC stub on first use (double-checked locking). The
(relatively cheap) stub settings are still built eagerly, so
getInstanceAdminStubSettings()/getDatabaseAdminStubSettings() and the
emulator auto-config path are unchanged. shutdown()/shutdownNow() only
close the admin stubs if they were actually created. The auto-throttle
GetOperation retry wiring for the database admin stub is moved verbatim
into a createDatabaseAdminStub() helper invoked lazily.

Effect: a data-only client now builds one channel stack at startup
instead of three. The two channel/socket-counting tests are updated to
assert the reduced footprint (2 grpc-gcp channels / 16 sockets instead
of 6 / 48), with comments explaining the laziness. The existing admin
Gax/ClientImpl tests (which drive admin RPCs through GapicSpannerRpc)
continue to pass, exercising lazy creation on first use.

Note: a bad admin stub configuration now surfaces on the first admin
call rather than at Spanner construction; data-only clients no longer
hit it at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Ci2KFsNgWdHCno4dkXdUU
Address review findings on the lazy admin stub change:

1. Close the shutdown/get race: shutdown()/shutdownNow() now call
   LazyAdminStub.closeAndGet(), which atomically marks the holder closed
   and returns the stub if one was created. A get() racing shutdown either
   completes creation before closeAndGet() (so the stub is still closed by
   shutdown) or observes the closed flag and fails with FAILED_PRECONDITION
   instead of leaking a freshly created channel stack.
2. Catch Exception instead of only IOException in the lazy factory,
   preserving the constructor's previous asSpannerException wrapping for
   runtime exceptions from user-supplied providers.
3. Drop the dead null guard on the lazy holders in shutdownNow(), which
   contradicted the unconditional spannerStub.close() on the next line.
4. Rename testDirectPathFallbackCreatesExactlyFourPhysicalSockets to
   ...SixteenPhysicalSockets to match what it asserts.

Adds tests pinning the new post-shutdown behavior and the shutdown path
for a lazily created admin stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1XiAgYMV1Y2SUx5wdxjfF
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.

1 participant