Spanner go shared core prototype - #9343
sinhasubham wants to merge 4 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
There was a problem hiding this comment.
Code Review
This pull request introduces a prototype that offloads the hot read path of the Node Spanner client (gRPC transport, protobuf decoding, and result-set assembly) to a Go shared library loaded via a Node-API (N-API) addon. Key additions include a post-install build script, a multiplexed gRPC connection pool in Go, a C++ N-API bridge, and integration hooks in the Node.js client. The review feedback highlights several critical improvement opportunities: implementing a query cancellation mechanism to prevent resource leaks, refining authentication fallback logic to avoid silent mock token usage in production, leveraging Go's unsafe.Slice for safer pointer conversions, preventing Node-API local handle leaks using escapable handle scopes, and adding robust type validation for request arguments in the native bridge.
| func ExecuteStreamingSqlGo( | ||
| handle C.uintptr_t, | ||
| routingKey *C.char, | ||
| metaKeys **C.char, | ||
| metaVals **C.char, | ||
| metaCount C.int, | ||
| reqBytesPtr *C.char, | ||
| reqLen C.int, | ||
| cb C.StreamDataCallback, | ||
| userData unsafe.Pointer, | ||
| ) { |
There was a problem hiding this comment.
There is currently no mechanism to propagate query cancellation from Node.js to Go. If a Node.js client destroys or cancels a stream early (e.g., due to a timeout or only reading a subset of rows), the background Go goroutine will continue running and downloading the entire result set from Spanner. This can cause significant resource leaks (CPU, memory, and network bandwidth). Exposing a cancellation mechanism or passing a query-specific context/channel to abort the gRPC stream is highly recommended.
| tokenSource, err := google.DefaultTokenSource(ctx, spannerScope) | ||
| if err != nil { | ||
| // In mock/test environments without ADC, allow fallback | ||
| tokenSource = oauth2.StaticTokenSource(&oauth2.Token{ | ||
| AccessToken: "mock-token", | ||
| TokenType: "Bearer", | ||
| }) | ||
| } |
There was a problem hiding this comment.
If google.DefaultTokenSource fails to initialize, silently falling back to a mock token in non-emulator/production environments can make debugging authentication issues extremely difficult. We should only fall back to the mock token if SPANNER_EMULATOR_HOST is set, and otherwise return the initialization error.
tokenSource, err := google.DefaultTokenSource(ctx, spannerScope)
if err != nil {
if os.Getenv("SPANNER_EMULATOR_HOST") == "" {
cancel()
return nil, fmt.Errorf("failed to initialize Application Default Credentials: %w", err)
}
// In mock/test environments without ADC, allow fallback
tokenSource = oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: "mock-token",
TokenType: "Bearer",
})
}|
|
||
| if totalCells > 0 { | ||
| cBatch.cells = (*C.CSpannerCell)(C.malloc(C.size_t(totalCells) * C.size_t(unsafe.Sizeof(C.CSpannerCell{})))) | ||
| cellsSlice := (*[1 << 28]C.CSpannerCell)(unsafe.Pointer(cBatch.cells))[:totalCells:totalCells] |
There was a problem hiding this comment.
Using unsafe.Slice (introduced in Go 1.17) is much cleaner, safer, and more idiomatic than slicing a pointer cast to a very large array (e.g., *[1 << 28]C.CSpannerCell). Since this module declares go 1.25, we should leverage unsafe.Slice directly.
| cellsSlice := (*[1 << 28]C.CSpannerCell)(unsafe.Pointer(cBatch.cells))[:totalCells:totalCells] | |
| cellsSlice := unsafe.Slice(cBatch.cells, totalCells) |
| var arenaBytes []byte | ||
| if totalStringBytes > 0 { | ||
| cBatch.string_arena = (*C.char)(C.malloc(C.size_t(totalStringBytes))) | ||
| arenaBytes = (*[1 << 28]byte)(unsafe.Pointer(cBatch.string_arena))[:totalStringBytes:totalStringBytes] |
There was a problem hiding this comment.
Use unsafe.Slice to safely and cleanly convert the C string arena pointer to a Go byte slice.
| arenaBytes = (*[1 << 28]byte)(unsafe.Pointer(cBatch.string_arena))[:totalStringBytes:totalStringBytes] | |
| arenaBytes = unsafe.Slice((*byte)(unsafe.Pointer(cBatch.string_arena)), totalStringBytes) |
| keysSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaKeys))[:count:count] | ||
| valsSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaVals))[:count:count] |
There was a problem hiding this comment.
Use unsafe.Slice to cleanly convert the C metadata keys and values pointers to Go slices.
| keysSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaKeys))[:count:count] | |
| valsSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaVals))[:count:count] | |
| keysSlice := unsafe.Slice(metaKeys, count) | |
| valsSlice := unsafe.Slice(metaVals, count) |
| for (int r = 0; r < row_count; ++r) { | ||
| napi_value row_arr; | ||
| napi_create_array_with_length(env, col_count, &row_arr); | ||
|
|
||
| for (int c = 0; c < col_count; ++c) { | ||
| const CSpannerCell& cell = cells[r * col_count + c]; | ||
| napi_value js_cell = nullptr; | ||
|
|
||
| switch (cell.kind) { | ||
| case CELL_KIND_NULL: | ||
| napi_get_null(env, &js_cell); | ||
| break; | ||
| case CELL_KIND_BOOL: | ||
| napi_get_boolean(env, cell.bool_val != 0, &js_cell); | ||
| break; | ||
| case CELL_KIND_NUMBER: | ||
| napi_create_double(env, cell.number_val, &js_cell); | ||
| break; | ||
| case CELL_KIND_STRING: | ||
| if (cell.str_len > 0 && cell.str_val != nullptr) { | ||
| napi_create_string_utf8(env, cell.str_val, cell.str_len, &js_cell); | ||
| } else { | ||
| napi_create_string_utf8(env, "", 0, &js_cell); | ||
| } | ||
| break; | ||
| default: | ||
| napi_get_null(env, &js_cell); | ||
| break; | ||
| } | ||
| napi_set_element(env, row_arr, c, js_cell); | ||
| } | ||
| napi_set_element(env, rows_val, r, row_arr); | ||
| } |
There was a problem hiding this comment.
When creating a large number of JavaScript objects in a loop (such as rows and cells in a query result), Node-API local handles can accumulate rapidly and exceed V8's local handle limit or cause unnecessary memory overhead. To prevent handle leaks, we should use napi_escapable_handle_scope for each row, and escape the row array before closing the scope.
for (int r = 0; r < row_count; ++r) {
napi_escapable_handle_scope scope;
napi_open_escapable_handle_scope(env, &scope);
napi_value row_arr;
napi_create_array_with_length(env, col_count, &row_arr);
for (int c = 0; c < col_count; ++c) {
const CSpannerCell& cell = cells[r * col_count + c];
napi_value js_cell = nullptr;
switch (cell.kind) {
case CELL_KIND_NULL:
napi_get_null(env, &js_cell);
break;
case CELL_KIND_BOOL:
napi_get_boolean(env, cell.bool_val != 0, &js_cell);
break;
case CELL_KIND_NUMBER:
napi_create_double(env, cell.number_val, &js_cell);
break;
case CELL_KIND_STRING:
if (cell.str_len > 0 && cell.str_val != nullptr) {
napi_create_string_utf8(env, cell.str_val, cell.str_len, &js_cell);
} else {
napi_create_string_utf8(env, "", 0, &js_cell);
}
break;
default:
napi_get_null(env, &js_cell);
break;
}
napi_set_element(env, row_arr, c, js_cell);
}
napi_value escaped_row_arr;
napi_escape_handle(env, scope, row_arr, &escaped_row_arr);
napi_close_escapable_handle_scope(env, scope);
napi_set_element(env, rows_val, r, escaped_row_arr);
}| bool is_typedarray = false; | ||
| napi_is_typedarray(env, args[3], &is_typedarray); | ||
| if (is_typedarray) { | ||
| napi_typedarray_type type; | ||
| napi_value arraybuffer; | ||
| size_t byte_offset; | ||
| napi_get_typedarray_info(env, args[3], &type, &req_len, &req_data, &arraybuffer, &byte_offset); | ||
| } else { | ||
| bool is_buffer = false; | ||
| napi_is_buffer(env, args[3], &is_buffer); | ||
| if (is_buffer) { | ||
| napi_get_buffer_info(env, args[3], &req_data, &req_len); | ||
| } | ||
| } |
There was a problem hiding this comment.
If args[3] is neither a TypedArray nor a Buffer, we should throw a clear type error on the JavaScript side rather than silently proceeding with req_data = nullptr and req_len = 0, which would eventually cause a decoding failure or potential panic in Go.
| bool is_typedarray = false; | |
| napi_is_typedarray(env, args[3], &is_typedarray); | |
| if (is_typedarray) { | |
| napi_typedarray_type type; | |
| napi_value arraybuffer; | |
| size_t byte_offset; | |
| napi_get_typedarray_info(env, args[3], &type, &req_len, &req_data, &arraybuffer, &byte_offset); | |
| } else { | |
| bool is_buffer = false; | |
| napi_is_buffer(env, args[3], &is_buffer); | |
| if (is_buffer) { | |
| napi_get_buffer_info(env, args[3], &req_data, &req_len); | |
| } | |
| } | |
| bool is_typedarray = false; | |
| napi_is_typedarray(env, args[3], &is_typedarray); | |
| if (is_typedarray) { | |
| napi_typedarray_type type; | |
| napi_value arraybuffer; | |
| size_t byte_offset; | |
| napi_get_typedarray_info(env, args[3], &type, &req_len, &req_data, &arraybuffer, &byte_offset); | |
| } else { | |
| bool is_buffer = false; | |
| napi_is_buffer(env, args[3], &is_buffer); | |
| if (is_buffer) { | |
| napi_get_buffer_info(env, args[3], &req_data, &req_len); | |
| } else { | |
| napi_throw_type_error(env, nullptr, "Argument 3 must be a TypedArray or Buffer"); | |
| return nullptr; | |
| } | |
| } |
… 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
prototype: DO NOT MERGE