Spanner go shared core write apis - #9364
sinhasubham wants to merge 10 commits into
Conversation
Moves the hot read path -- gRPC transport, protobuf decoding and result-set
assembly -- into a Go shared library loaded through a N-API addon. Rows handed
back to the application are the ordinary Row objects the stock client produces,
so the library remains a drop-in replacement and needs no configuration.
Scope is deliberately narrow: only single-use read-only SQL queries take the
fast path. Explicit transactions, DML, partitioned reads and result sets with
ARRAY/STRUCT columns transparently fall back to the stock implementation, and
that decision is always made before any row is emitted.
Notes on the integration:
* Dispatch lives in both Database#run and Database#runStream. run() needs its
own hook because _run() bypasses Database.prototype.runStream entirely when
multiplexed sessions are enabled, so a hook in runStream alone is
unreachable from run().
* Timestamp bounds are supported. They are encoded with
Snapshot.encodeTimestampBounds(), the same helper the stock path uses, and
forwarded verbatim in the single-use transaction, so the request is
identical on the wire. This matters for staleness-bounded reads.
* The native path no longer sends x-goog-spanner-route-to-leader. The stock
client sends it only for readWrite/partitionedDml; sending it on a
single-use read changes replica routing.
* The package ships SOURCE only and builds during postinstall. A shared
library built elsewhere links against the build machine's glibc and fails
to load on a different base image. The build is required rather than
best-effort so that a failure is visible instead of silently yielding a
pure-JS client. Set SPANNER_NATIVE_SKIP_BUILD=1 to opt out.
The core is enabled by default when the addon is present; SPANNER_NATIVE_CORE=off
forces the pure-JS path. Each process logs one line stating which
implementation is live.
verify_native_core.js runs both paths against an in-process mock and asserts
identical rows, identical toJSON() output, and identical wire requests, plus
positive provenance -- that the core was actually reached and did not silently
fall back.
TAG=agy
CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
Before Go 1.25 the runtime sized the scheduler from the host core count and
ignored the cgroup CPU limit. In a CPU-limited container (the benchmark
harness runs on a 2-vCPU instance that may sit on a many-core host) that
spins up far too many Ps; measured CPU-per-operation was ~2.9x higher purely
as a result, which would make the shared core look much worse than it is.
The cgroup-aware behaviour is gated on the module's go directive, not just on
the toolchain (GODEBUG containermaxprocs/updatemaxprocs default to 1 only for
modules declaring go >= 1.25), so both have to move:
- go.mod: go 1.21 -> go 1.25
- install.js: MIN_GO_MINOR 21 -> 25, FALLBACK_GO_VERSION go1.23.4 ->
go1.25.0, so an older system toolchain is rejected in favour of a
downloaded one rather than silently producing a mis-tuned build.
Verified empirically with an equivalent binary built from this module:
under `systemd-run -p CPUQuota=200%`, the go 1.21 directive yields
GOMAXPROCS=24 on a 24-core host while the go 1.25 directive yields
GOMAXPROCS=2.
Rebuilt the shared library and re-ran spanner-native/verify_native_core.js:
all 16 API-compatibility checks pass.
TAG=agy
CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
… slim containers The spanner-client-benchmarks runner uses `node:22-slim` (`debian:bookworm-slim`) as its production runtime image. That image purges the `ca-certificates` package (`apt-get purge -y --auto-remove`), leaving `/etc/ssl/certs` empty. - Pure Node.js (`main` branch) works because Mozilla's root CA bundle is compiled directly into the `node` binary (`tls.rootCertificates`). - Go's `crypto/x509` does not embed root CAs; it reads `/etc/ssl/certs/ca-certificates.crt` from disk. In `node:22-slim`, every Go RPC failed immediately with: `x509: certificate signed by unknown authority`. - Because `abstract-benchmark.ts` only records `latencyHistogram` when `msg.success === true`, 100% RPC failure resulted in 0 data points exported for `spanner_client_benchmarks/latency` on the custom branch. Fix: 1. `native-core.ts`: before loading `spanner_go.node`, export `tls.rootCertificates` to `/tmp/spanner-node-bundled-ca.pem` and set `SSL_CERT_FILE` if unset. 2. `client.go`: `buildRootCertPool()` loads `/tmp/spanner-node-bundled-ca.pem` (and `SSL_CERT_FILE`) into `x509.CertPool` and wires it into both the `oauth2.HTTPClient` transport and gRPC `credentials.NewTLS`. 3. `main.go`: log the first Go RPC error once to stderr (`[Spanner-Go] ERROR: ...`) so any future transport/auth error is immediately visible in container logs. Verified against real Cloud Spanner (`benchmark_db_async`) with `SSL_CERT_FILE=/nonexistent SSL_CERT_DIR=/nonexistent` and `verify_native_core.js` (16/16 checks passing). TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…schemaCache invalidation on read_timestamp TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…update, DML, and commit APIs - Add Commit, ExecuteBatchDml, ExecuteSql (unary DML), and BeginTransaction native paths in Go shared core and C++ N-API bridge - Bypass JS protobuf encoding on write mutations via lazy property getters and direct C++ stack Arena marshalling - Connect Snapshot._run in transaction.ts to executeNativeTransactionRun so select-update and TPC-C transactions execute 100% on the Go shared core with channel affinity - Fix precommit_token protobuf field number on Transaction (field 3) and PartialResultSet (field 8), and pass ResultSetMetadata on 0-row queries - Support custom apiEndpoint in CoreClientHandle for mock/emulator benchmark environments TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
There was a problem hiding this comment.
Code Review
This pull request introduces a prototype native Go shared core for the Node Spanner client, routing single-use read-only SQL queries and write/mutation APIs through a Go shared library via an N-API addon to optimize performance. Feedback on the implementation highlights several critical issues, including a type mismatch risk in C++ where safe integers are incorrectly encoded as INT64 instead of FLOAT64, an infinite retry loop on gRPC stream failures, potential alignment panics on 32-bit platforms with 64-bit atomic operations, memory leaks in the N-API thread-safe function calls, and connection leaks during gRPC pool initialization.
| if (std::isfinite(d) && std::fmod(d, 1.0) == 0.0 && | ||
| d >= -9007199254740991.0 && d <= 9007199254740991.0) { | ||
| char buf[32]; | ||
| int len = snprintf(buf, sizeof(buf), "%.0f", d); | ||
| char* dst = arena.alloc(len + 1); | ||
| memcpy(dst, buf, len + 1); | ||
| cell->kind = CELL_KIND_STRING; | ||
| cell->str_val = dst; | ||
| cell->str_len = static_cast<uint32_t>(len); | ||
| cell->type_code = 2; // INT64 | ||
| } else if (!std::isfinite(d)) { | ||
| const char* s = std::isnan(d) ? "NaN" : (d > 0 ? "Infinity" : "-Infinity"); | ||
| size_t len = strlen(s); | ||
| char* dst = arena.alloc(len + 1); | ||
| memcpy(dst, s, len + 1); | ||
| cell->kind = CELL_KIND_STRING; | ||
| cell->str_val = dst; | ||
| cell->str_len = static_cast<uint32_t>(len); | ||
| cell->type_code = 3; // FLOAT64 | ||
| } else { | ||
| cell->kind = CELL_KIND_NUMBER; | ||
| cell->number_val = d; | ||
| cell->type_code = 3; // FLOAT64 | ||
| } |
There was a problem hiding this comment.
Plain JS numbers are always encoded as FLOAT64 (numberValue) in @google-cloud/spanner, and only Spanner.Int wrappers are encoded as INT64. Encoding safe integers as INT64 strings by default will cause type mismatch errors on real Spanner databases when writing integer values to FLOAT64 columns.
if (!std::isfinite(d)) {
const char* s = std::isnan(d) ? "NaN" : (d > 0 ? "Infinity" : "-Infinity");
size_t len = strlen(s);
char* dst = arena.alloc(len + 1);
memcpy(dst, s, len + 1);
cell->kind = CELL_KIND_STRING;
cell->str_val = dst;
cell->str_len = static_cast<uint32_t>(len);
cell->type_code = 3; // FLOAT64
} else {
cell->kind = CELL_KIND_NUMBER;
cell->number_val = d;
cell->type_code = 3; // FLOAT64
}
return;| if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 { | ||
| continue // Retry loop | ||
| } |
There was a problem hiding this comment.
If the gRPC stream fails with Unavailable or Internal errors, this loop retries infinitely without any maximum attempt limit. This can cause tight infinite loops and CPU exhaustion if the database or network is down. Limit the number of retries.
| if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 { | |
| continue // Retry loop | |
| } | |
| if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 && attemptCount < 5 { | |
| continue // Retry loop | |
| } |
| conns []*grpc.ClientConn | ||
| gapicClient *gapic.Client | ||
| useGapic bool | ||
| reqCounter uint64 |
| if count == 0 { | ||
| return nil | ||
| } | ||
| idx := atomic.AddUint64(&c.reqCounter, 1) % count |
| return; | ||
| } | ||
|
|
||
| napi_call_threadsafe_function(ctx->tsfn, batch, napi_tsfn_nonblocking); |
There was a problem hiding this comment.
If napi_call_threadsafe_function fails (e.g., if the thread-safe function is closing or closed), the allocated batch is leaked. Check the return value and free the batch on failure.
if (napi_call_threadsafe_function(ctx->tsfn, batch, napi_tsfn_nonblocking) != napi_ok) {
if (batch) {
if (batch->cells) free(batch->cells);
if (batch->string_arena) free(batch->string_arena);
if (batch->json_rows) free(batch->json_rows);
if (batch->server_timing) free(batch->server_timing);
if (batch->error_msg) free(batch->error_msg);
if (batch->metadata_pb) free(batch->metadata_pb);
free(batch);
}
}| } | ||
| return; | ||
| } | ||
| napi_call_threadsafe_function(ctx->tsfn, resp, napi_tsfn_nonblocking); |
There was a problem hiding this comment.
If napi_call_threadsafe_function fails, the allocated resp is leaked. Check the return value and free the response on failure.
if (napi_call_threadsafe_function(ctx->tsfn, resp, napi_tsfn_nonblocking) != napi_ok) {
if (resp) {
if (resp->resp_pb) free(resp->resp_pb);
if (resp->tx_pb) free(resp->tx_pb);
if (resp->error_msg) free(resp->error_msg);
if (resp->retry_info_pb) free(resp->retry_info_pb);
free(resp);
}
}| for i := 0; i < limit; i++ { | ||
| conn, err := grpc.DialContext(ctx, endpoint, dialOpts...) | ||
| if err != nil { | ||
| cancel() | ||
| return nil, fmt.Errorf("failed to connect to Spanner endpoint %s: %w", endpoint, err) | ||
| } | ||
| conns[i] = conn |
There was a problem hiding this comment.
If any connection dial fails during the pool initialization loop, the successfully opened connections before the failure are leaked. Ensure they are closed before returning the error.
| for i := 0; i < limit; i++ { | |
| conn, err := grpc.DialContext(ctx, endpoint, dialOpts...) | |
| if err != nil { | |
| cancel() | |
| return nil, fmt.Errorf("failed to connect to Spanner endpoint %s: %w", endpoint, err) | |
| } | |
| conns[i] = conn | |
| conns := make([]*grpc.ClientConn, limit) | |
| for i := 0; i < limit; i++ { | |
| conn, err := grpc.DialContext(ctx, endpoint, dialOpts...) | |
| if err != nil { | |
| cancel() | |
| for j := 0; j < i; j++ { | |
| _ = conns[j].Close() | |
| } | |
| return nil, fmt.Errorf("failed to connect to Spanner endpoint %s: %w", endpoint, err) | |
| } | |
| conns[i] = conn | |
| } |
…Spanner protobuf encoding TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…e buffers for begin/commit - Shift DML request and parameter encoding (runUpdate / batchUpdate) to Go/C++ using native cells and static scalar type tables, eliminating protobufjs request encoding in Node.js - Keep BeginTransaction and Commit encoding/decoding in pure Node.js and transfer raw byte buffers across N-API/CGO using rawProtoCodec in Go without redundant protobuf unmarshaling/marshaling - Add 4KB stack buffer to C++ Arena in spanner_go_napi.cc to eliminate per-RPC heap allocations - Pass DML rowCount (int64) directly across C++/N-API callback for steady-state runUpdate calls - Pass pre-encoded ExecuteSqlRequest bytes directly to ExecuteStreamingSqlRaw while keeping heavy PartialResultSet response decoding in Go TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…it retry protobuf bug in Go shared core - Reuse persistent napi_threadsafe_function per CoreClientHandleWrapper instead of creating/destroying a tsfn per RPC (eliminates uv_async_init/uv_close handle churn on libuv event loop). - Offload CGO transitions and Go setup work off the single V8 event loop thread into a C++ background dispatcher thread pool with arena-allocated RpcRequestContext. - Fix protobuf field number bug in CommitNativeGo where retry precommit_token was appended as field 7 (requestOptions) instead of replacing field 9 (precommit_token). - Skip full RowType schema marshaling in Go when skipMetadata=true and inline transaction metadata is present, marshaling only the 20-byte Transaction ID/precommit token. - Coalesce final row batch and is_last EOF signal into a single napi_call_function callback invocation. TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…hared core
Round 2 of select-update optimizations. Five bottlenecks were still forcing
protobufjs reflection and extra event-loop turns onto every transaction:
1. hasQueryOptions was always true in executeNativeSqlDml. Snapshot's
constructor sets `this.queryOptions = Object.assign({}, queryOptions)`, and
`Boolean({})` is true, so every runUpdate silently took the slow path:
encode ExecuteSqlRequest in JS, Unmarshal in Go, then re-Marshal. Replaced
with hasNonEmptyQueryOptions() so the native C-struct fast path is actually
reached.
2. Database#runTransactionAsync allocated a bind(), a promisify() wrapper, a
Promise and a process.nextTick on every transaction just to fetch an
already-cached multiplexed session. Now acquired synchronously when the
multiplexed RW session is present.
3. Snapshot#_run called sanitizeRequest() before the native dispatch, running
encodeParams() and normalizeTypeProto() on every SELECT. The native path now
receives seqno and builds the request directly off paramTypeCache.
4. Four protobufjs reflection calls per transaction replaced with zero-alloc
binary helpers: encodeSimpleCommitRequestFast, decodeCommitResponseFast,
decodePrecommitTokenFast, decodeTxMetadataFast. Each falls back to protobufjs
on any unexpected tag, and wire output is verified byte-for-byte identical.
5. ExecuteStreamingSqlGo called stream.Header() before the first Recv(),
forcing gRPC-Go to synchronize on HTTP/2 headers separately from the data
frame in the same TCP packet. Header inspection is now deferred to EOF.
Isolated mock-server benchmark (SELECT + UPDATE + COMMIT), client CPU per
transaction, core off -> on: c=1 11.71 -> 5.66 ms, c=4 6.93 -> 3.12 ms,
c=10 4.41 -> 2.42 ms, c=20 3.85 -> 1.86 ms, c=40 2.84 -> 1.64 ms. Throughput at
c=20 rises from 324 to 1174 TPS.
verify_native_core.js passes all 22 assertions, including byte-for-byte wire
equality for runUpdate, batchUpdate, commit mutations and staleness bounds.
TAG=agy
CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
…nd, and make timestamp decoding allocation-free Round 3, found by CPU-profiling the select-update flow against the isolated mock server and attributing self time. 1. resolveCustomEndpoint() ran on every RPC (three times per read/write transaction) via getCoreHandle(), re-walking the _getSpanner() chain and rebuilding the endpoint string each time -- ~1.2% of client CPU. The result is now memoised in a WeakMap keyed on the long-lived Database/Spanner owner rather than on the short-lived session or transaction. SPANNER_EMULATOR_HOST is still consulted per call so mid-process emulator overrides keep working. 2. Snapshot#end() unconditionally tore down gRPC channel affinity for multiplexed sessions, allocating a promise chain -- and potentially forcing lazy construction of the GAPIC stub -- on every transaction. On the Go shared core path no request ever touches that channel, since routing is done natively off _affinityKey, so there was nothing to unbind. The JS request wrappers now set _affinityBound when they actually bind, and end() only unbinds when that happened. 3. codec parsePreciseDate() allocated roughly eleven strings per decoded TIMESTAMP (six substrings for the date parts, plus substring/padEnd/substring for sub-seconds) and ran a regex. It now parses via charCodeAt arithmetic with zero allocations, keeping the existing fallback semantics including truncation of sub-second precision beyond nine digits. Isolated mock-server benchmark, client CPU per transaction with the core on, round 2 -> round 3: c=1 5.66 -> 4.91 ms, c=4 3.12 -> 3.00 ms, c=10 2.42 -> 2.25 ms, c=40 1.64 -> 1.50 ms. Throughput at c=40 rises from 1703 to 1812 TPS. Verification: tsc clean; verify_native_core.js passes all 22 wire-level assertions; the 184 codec unit tests pass; a differential fuzz harness replays the previous timestamp parser against the new one over 40115 handcrafted and randomized inputs with zero mismatches. The wider unit suite reports 462 passing / 10 failing, identical to baseline 8f2422a, so no new regressions. TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec
prototype: do not merge