Skip to content

Spanner go shared core prototype - #9343

Draft
sinhasubham wants to merge 4 commits into
mainfrom
spanner-go-shared-core-prototype
Draft

sinhasubham wants to merge 4 commits into
mainfrom
spanner-go-shared-core-prototype

Conversation

@sinhasubham

@sinhasubham sinhasubham commented Sep 15, 2026

Copy link
Copy Markdown

prototype: DO NOT MERGE

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

@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 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.

Comment on lines +280 to +290
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,
) {

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.

high

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.

Comment on lines +66 to +73
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",
})
}

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.

medium

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]

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.

medium

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.

Suggested change
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]

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.

medium

Use unsafe.Slice to safely and cleanly convert the C string arena pointer to a Go byte slice.

Suggested change
arenaBytes = (*[1 << 28]byte)(unsafe.Pointer(cBatch.string_arena))[:totalStringBytes:totalStringBytes]
arenaBytes = unsafe.Slice((*byte)(unsafe.Pointer(cBatch.string_arena)), totalStringBytes)

Comment on lines +301 to +302
keysSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaKeys))[:count:count]
valsSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaVals))[:count:count]

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.

medium

Use unsafe.Slice to cleanly convert the C metadata keys and values pointers to Go slices.

Suggested change
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)

Comment on lines +127 to +159
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);
}

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.

medium

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);
                }

Comment on lines +344 to +357
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);
}
}

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.

medium

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.

Suggested change
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
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