Skip to content

Spanner go shared core write apis - #9364

Draft
sinhasubham wants to merge 10 commits into
mainfrom
spanner-go-shared-core-write-apis
Draft

sinhasubham wants to merge 10 commits into
mainfrom
spanner-go-shared-core-write-apis

Conversation

@sinhasubham

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
… 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

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

Comment on lines +801 to +824
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
}

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.

critical

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;

Comment on lines +469 to +471
if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 {
continue // Retry loop
}

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.

critical

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.

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

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

On 32-bit architectures, 64-bit atomic operations will panic if the variable is not 8-byte aligned. Using atomic.Uint64 instead of a raw uint64 guarantees proper alignment on all platforms.

Suggested change
reqCounter uint64
reqCounter atomic.Uint64

if count == 0 {
return nil
}
idx := atomic.AddUint64(&c.reqCounter, 1) % 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.

high

Update the atomic increment to use the atomic.Uint64 API to prevent alignment panics on 32-bit platforms.

Suggested change
idx := atomic.AddUint64(&c.reqCounter, 1) % count
idx := c.reqCounter.Add(1) % count

return;
}

napi_call_threadsafe_function(ctx->tsfn, batch, napi_tsfn_nonblocking);

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

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

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

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

Comment on lines +215 to +221
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

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

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