From 42d36d9d43045a596142a2cdcc043ee783ae1310 Mon Sep 17 00:00:00 2001 From: Subham Sinha Date: Tue, 15 Sep 2026 19:48:59 +0000 Subject: [PATCH 1/4] feat(spanner): Go shared-core prototype for single-use read-only queries 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 --- handwritten/spanner/package.json | 14 +- handwritten/spanner/spanner-native/.gitignore | 9 + handwritten/spanner/spanner-native/README.md | 83 +++ handwritten/spanner/spanner-native/install.js | 286 ++++++++ .../spanner-native/spanner-go/build.sh | 94 +++ .../spanner-native/spanner-go/client.go | 272 ++++++++ .../spanner-native/spanner-go/decode.go | 154 +++++ .../spanner/spanner-native/spanner-go/go.mod | 37 ++ .../spanner/spanner-native/spanner-go/go.sum | 154 +++++ .../spanner/spanner-native/spanner-go/main.go | 492 ++++++++++++++ .../spanner/spanner-native/spanner_go_napi.cc | 434 ++++++++++++ .../spanner-native/verify_native_core.js | 508 ++++++++++++++ handwritten/spanner/src/database.ts | 49 ++ handwritten/spanner/src/native-core.ts | 626 ++++++++++++++++++ 14 files changed, 3210 insertions(+), 2 deletions(-) create mode 100644 handwritten/spanner/spanner-native/.gitignore create mode 100644 handwritten/spanner/spanner-native/README.md create mode 100644 handwritten/spanner/spanner-native/install.js create mode 100755 handwritten/spanner/spanner-native/spanner-go/build.sh create mode 100644 handwritten/spanner/spanner-native/spanner-go/client.go create mode 100644 handwritten/spanner/spanner-native/spanner-go/decode.go create mode 100644 handwritten/spanner/spanner-native/spanner-go/go.mod create mode 100644 handwritten/spanner/spanner-native/spanner-go/go.sum create mode 100644 handwritten/spanner/spanner-native/spanner-go/main.go create mode 100644 handwritten/spanner/spanner-native/spanner_go_napi.cc create mode 100644 handwritten/spanner/spanner-native/verify_native_core.js create mode 100644 handwritten/spanner/src/native-core.ts diff --git a/handwritten/spanner/package.json b/handwritten/spanner/package.json index 0bd522a07d7a..b171f9f6fd1b 100644 --- a/handwritten/spanner/package.json +++ b/handwritten/spanner/package.json @@ -17,7 +17,16 @@ "files": [ "build/protos", "build/src", - "!build/src/**/*.map" + "!build/src/**/*.map", + "spanner-native/install.js", + "spanner-native/README.md", + "spanner-native/spanner_go_napi.cc", + "spanner-native/spanner-go/build.sh", + "spanner-native/spanner-go/go.mod", + "spanner-native/spanner-go/go.sum", + "spanner-native/spanner-go/client.go", + "spanner-native/spanner-go/decode.go", + "spanner-native/spanner-go/main.go" ], "keywords": [ "google apis client", @@ -48,7 +57,8 @@ "preobservability-test": "pnpm run compile", "benchwrapper": "node bin/benchwrapper.js", "precompile": "gts clean", - "coverage": "c8 mocha build/test build/test/common && c8 report --check-coverage" + "coverage": "c8 mocha build/test build/test/common && c8 report --check-coverage", + "postinstall": "node spanner-native/install.js" }, "dependencies": { "@babel/core": "7.27.7", diff --git a/handwritten/spanner/spanner-native/.gitignore b/handwritten/spanner/spanner-native/.gitignore new file mode 100644 index 000000000000..55c6a8d7748d --- /dev/null +++ b/handwritten/spanner/spanner-native/.gitignore @@ -0,0 +1,9 @@ +# Build outputs. These are produced by spanner-native/install.js at install +# time and must never be committed or published: a shared library built on a +# developer machine links against that machine's glibc and will fail to load on +# a different base image. +*.so +*.dylib +*.node +spanner-go/libspanner_go.* +spanner-go/spanner_go.h diff --git a/handwritten/spanner/spanner-native/README.md b/handwritten/spanner/spanner-native/README.md new file mode 100644 index 000000000000..7fabdecbb7ef --- /dev/null +++ b/handwritten/spanner/spanner-native/README.md @@ -0,0 +1,83 @@ +# Spanner Go shared core (prototype) + +A prototype that moves the hot read path of the Node Spanner client -- gRPC +transport, protobuf decoding and result-set assembly -- into a Go shared +library loaded through a N-API addon. Row objects handed back to the +application are the ordinary `Row` objects the stock client produces, so this +is a drop-in replacement. + +## Status + +Prototype. Only **single-use read-only SQL queries** take the fast path. +Everything else -- explicit transactions, DML, partitioned reads, reads with +`ARRAY`/`STRUCT` columns -- transparently falls back to the stock pure-JS +implementation, and that decision is always made before any row is emitted. + +## How it engages + +The core is **on by default** whenever the native addon is present. No +configuration is required: `new Spanner({projectId})` is enough. + +| Variable | Effect | +| --- | --- | +| `SPANNER_NATIVE_CORE=off` | Force the pure-JS path. | +| `SPANNER_NATIVE_QUIET=1` | Suppress the one-line startup banner. | +| `SPANNER_NATIVE_SKIP_BUILD=1` | Skip the native build during `npm install`. | +| `SPANNER_GO_VERSION=go1.23.4` | Pin the Go toolchain used to build. | + +Every process prints exactly one line on first use recording which +implementation is live, for example: + +``` +[spanner] Go shared core ACTIVE for single-use read-only SQL queries. +``` + +Check for that line before trusting any measurement taken against this branch. + +## Building + +The published package contains **source only**. `spanner-native/install.js` +runs as `postinstall` and builds the shared library in place, downloading a Go +toolchain if one is not already available. Building in the target environment +is deliberate: a `.so` produced elsewhere links against the build machine's +glibc and would fail to load on a different base image. + +The build is required, not best-effort. If it fails, the install fails, because +a silently pure-JS install would produce benchmarks that look valid but measure +nothing. Use `SPANNER_NATIVE_SKIP_BUILD=1` to opt out. + +To rebuild by hand: + +```bash +bash spanner-native/spanner-go/build.sh +``` + +Requires Go >= 1.21 and a C++17 compiler. + +## Verifying correctness + +`verify_native_core.js` runs the same queries through both paths against an +in-process mock Spanner server and asserts the results are identical, including +`toJSON()` and `toJSON({wrapNumbers: true})` output: + +```bash +node spanner-native/verify_native_core.js +``` + +It also asserts provenance -- that the native run really used the core and did +not silently fall back. + +## Layout + +| Path | Purpose | +| --- | --- | +| `spanner_go_napi.cc` | N-API bridge; marshals batches from Go onto the V8 thread. | +| `spanner-go/main.go` | Streaming RPC driver and result-set assembly. | +| `spanner-go/client.go` | gRPC channel pool, auth and endpoint configuration. | +| `spanner-go/decode.go` | Protobuf value decoding into the C cell representation. | +| `spanner-go/build.sh` | Compiles the Go shared library and the addon. | +| `install.js` | `postinstall` hook; bootstraps a toolchain and builds. | +| `verify_native_core.js` | Differential correctness harness. | + +The JavaScript half lives in [`../src/native-core.ts`](../src/native-core.ts); +dispatch happens in `Database#runStream`. diff --git a/handwritten/spanner/spanner-native/install.js b/handwritten/spanner/spanner-native/install.js new file mode 100644 index 000000000000..50c568ed0ca1 --- /dev/null +++ b/handwritten/spanner/spanner-native/install.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/*! + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Builds the Go shared core at install time. + * + * This runs as the package `postinstall`. It exists because the published + * artifact deliberately contains only SOURCE for the native core, never a + * prebuilt binary: a `.so` produced on a developer machine links against that + * machine's glibc and will fail to load on a different base image. Building + * here guarantees the binary matches the environment that will run it. + * + * The build is REQUIRED, not best-effort. If it cannot be completed this + * script exits non-zero and fails the install. That is deliberate: this branch + * exists to measure the Go shared core, and an install that silently produced a + * pure-JS client would yield a benchmark that looks valid but measures nothing. + * + * Escape hatch: set SPANNER_NATIVE_SKIP_BUILD=1 to skip the build entirely. + * The client then transparently falls back to the pure-JS implementation. + */ + +'use strict'; + +const {execFileSync, spawnSync} = require('child_process'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); + +const NATIVE_DIR = __dirname; +const GO_DIR = path.join(NATIVE_DIR, 'spanner-go'); +const ADDON = path.join(NATIVE_DIR, 'spanner_go.node'); + +// Used only if go.dev cannot be reached to resolve the current stable release. +const FALLBACK_GO_VERSION = 'go1.23.4'; +const MIN_GO_MINOR = 21; + +function log(msg) { + console.log(`[spanner-native] ${msg}`); +} + +function fail(msg) { + console.error(''); + console.error( + '[spanner-native] =============================================================', + ); + console.error('[spanner-native] FAILED to build the Go shared core.'); + console.error(`[spanner-native] ${msg}`); + console.error('[spanner-native]'); + console.error( + '[spanner-native] This package is a prototype whose entire purpose is the native', + ); + console.error( + '[spanner-native] core, so the install fails rather than silently degrading to', + ); + console.error('[spanner-native] the pure-JS client.'); + console.error('[spanner-native]'); + console.error( + '[spanner-native] To install anyway (pure-JS behaviour, no native core):', + ); + console.error('[spanner-native] SPANNER_NATIVE_SKIP_BUILD=1 npm install'); + console.error( + '[spanner-native] =============================================================', + ); + console.error(''); + process.exit(1); +} + +/** Resolves the latest stable Go version, e.g. "go1.23.4". */ +function latestGoVersion() { + return new Promise(resolve => { + const req = https.get( + 'https://go.dev/VERSION?m=text', + {timeout: 15000}, + res => { + if (res.statusCode !== 200) { + res.resume(); + return resolve(FALLBACK_GO_VERSION); + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', c => (body += c)); + res.on('end', () => { + const first = body.split('\n')[0].trim(); + resolve(/^go\d+\.\d+/.test(first) ? first : FALLBACK_GO_VERSION); + }); + }, + ); + req.on('timeout', () => { + req.destroy(); + resolve(FALLBACK_GO_VERSION); + }); + req.on('error', () => resolve(FALLBACK_GO_VERSION)); + }); +} + +function download(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + const get = target => { + https + .get(target, res => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + res.resume(); + return get(res.headers.location); + } + if (res.statusCode !== 200) { + res.resume(); + return reject( + new Error(`HTTP ${res.statusCode} while fetching ${target}`), + ); + } + res.pipe(file); + file.on('finish', () => file.close(resolve)); + }) + .on('error', reject); + }; + get(url); + }); +} + +/** Returns the `go` binary to use, downloading a toolchain if necessary. */ +async function ensureGo() { + const probe = spawnSync('go', ['version'], {encoding: 'utf8'}); + if (probe.status === 0) { + const m = /go(\d+)\.(\d+)/.exec(probe.stdout || ''); + if (m && (Number(m[1]) > 1 || Number(m[2]) >= MIN_GO_MINOR)) { + log(`using system Go: ${probe.stdout.trim()}`); + return 'go'; + } + log( + `system Go is too old (${(probe.stdout || '').trim()}), need >= 1.${MIN_GO_MINOR}`, + ); + } + + const platform = process.platform; // linux | darwin + const archMap = {x64: 'amd64', arm64: 'arm64'}; + const arch = archMap[process.arch]; + if (!arch || (platform !== 'linux' && platform !== 'darwin')) { + fail( + `No Go toolchain available and no prebuilt download for ${process.platform}/${process.arch}.`, + ); + } + + const version = process.env.SPANNER_GO_VERSION || (await latestGoVersion()); + const root = path.join(os.tmpdir(), `spanner-go-toolchain-${version}`); + const goBin = path.join(root, 'go', 'bin', 'go'); + if (fs.existsSync(goBin)) { + log(`reusing downloaded Go toolchain at ${root}`); + return goBin; + } + + const tarName = `${version}.${platform}-${arch}.tar.gz`; + const url = `https://go.dev/dl/${tarName}`; + const tarPath = path.join(os.tmpdir(), tarName); + + log(`no usable Go found; downloading ${url}`); + try { + await download(url, tarPath); + } catch (e) { + fail(`Could not download the Go toolchain: ${e.message}`); + } + + fs.mkdirSync(root, {recursive: true}); + try { + execFileSync('tar', ['-C', root, '-xzf', tarPath], {stdio: 'inherit'}); + } catch (e) { + fail(`Could not extract the Go toolchain: ${e.message}`); + } + fs.rmSync(tarPath, {force: true}); + + if (!fs.existsSync(goBin)) { + fail(`Go toolchain extracted but ${goBin} is missing.`); + } + log(`downloaded Go toolchain to ${root}`); + return goBin; +} + +/** + * Pre-stages the Node N-API headers so build.sh does not have to shell out to + * curl, which is absent from some slim base images. + */ +async function ensureNodeHeaders() { + const bundled = path.resolve(process.execPath, '../../include/node'); + if (fs.existsSync(path.join(bundled, 'node_api.h'))) { + return; + } + const target = path.join(os.tmpdir(), 'node_headers'); + if (fs.existsSync(path.join(target, 'include', 'node', 'node_api.h'))) { + return; + } + const v = process.version; + const url = `https://nodejs.org/dist/${v}/node-${v}-headers.tar.gz`; + const tarPath = path.join(os.tmpdir(), `node-${v}-headers.tar.gz`); + log(`fetching Node headers for ${v}`); + try { + await download(url, tarPath); + fs.mkdirSync(target, {recursive: true}); + execFileSync('tar', ['-C', target, '--strip-components=1', '-xzf', tarPath], { + stdio: 'inherit', + }); + fs.rmSync(tarPath, {force: true}); + } catch (e) { + log(`could not pre-fetch Node headers (${e.message}); build.sh will retry`); + } +} + +async function main() { + if (process.env.SPANNER_NATIVE_SKIP_BUILD === '1') { + log('SPANNER_NATIVE_SKIP_BUILD=1 -- skipping native build (pure-JS client).'); + return; + } + + if (!fs.existsSync(path.join(GO_DIR, 'main.go'))) { + fail(`Native sources are missing (expected ${GO_DIR}/main.go).`); + } + + if (fs.existsSync(ADDON)) { + try { + require(ADDON); + log('native core already built and loadable -- nothing to do.'); + return; + } catch (e) { + log(`existing addon is not loadable (${e.message}); rebuilding.`); + fs.rmSync(ADDON, {force: true}); + } + } + + const go = await ensureGo(); + await ensureNodeHeaders(); + + const goBinDir = go === 'go' ? null : path.dirname(go); + const env = Object.assign({}, process.env, { + // Keep the module/build caches inside the build sandbox. + GOCACHE: process.env.GOCACHE || path.join(os.tmpdir(), 'spanner-go-cache'), + GOFLAGS: process.env.GOFLAGS || '', + }); + if (goBinDir) { + env.PATH = `${goBinDir}${path.delimiter}${env.PATH}`; + env.GOROOT = path.dirname(goBinDir); + } + + log('building the Go shared library and the N-API addon...'); + const build = spawnSync('bash', [path.join(GO_DIR, 'build.sh')], { + stdio: 'inherit', + env, + cwd: GO_DIR, + }); + if (build.status !== 0) { + fail(`build.sh exited with status ${build.status}.`); + } + + if (!fs.existsSync(ADDON)) { + fail(`build.sh reported success but ${ADDON} was not produced.`); + } + + // Load it here rather than discovering at the first query that, say, the + // shared library needs a newer glibc than this image provides. + try { + require(ADDON); + } catch (e) { + fail(`The built addon could not be loaded: ${e.message}`); + } + + log('Go shared core built successfully.'); +} + +main().catch(e => fail(e && e.stack ? e.stack : String(e))); diff --git a/handwritten/spanner/spanner-native/spanner-go/build.sh b/handwritten/spanner/spanner-native/spanner-go/build.sh new file mode 100755 index 000000000000..454a981155d3 --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/build.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PARENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$SCRIPT_DIR" + +echo "=== Building Go Spanner Shared Core ===" + +# 1. Check Go compiler +if ! command -v go &> /dev/null; then + echo "ERROR: Go is not installed. Please ensure Go 1.21+ is in PATH." + exit 1 +fi + +GO_VER=$(go version) +echo "Go compiler detected: $GO_VER" + +# Download Go dependencies if needed +echo "Downloading Go module dependencies..." +go mod download || true + +# 2. Determine OS platform +UNAME_S=$(uname -s) +echo "Platform detected: $UNAME_S" + +# 3. Locate or download Node.js N-API header files +NODE_INCLUDE="" +CANDIDATE_PATHS=( + "$(node -e 'const p = require("path"); console.log(p.resolve(process.execPath, "../../include/node"));' 2>/dev/null || true)" + "/usr/include/node" + "/usr/local/include/node" + "$HOME/.cache/node-gyp/$(node -e 'console.log(process.versions.node)')/include/node" + "/tmp/node_headers/include/node" +) + +for cand in "${CANDIDATE_PATHS[@]}"; do + if [ -n "$cand" ] && [ -f "$cand/node_api.h" ]; then + NODE_INCLUDE="$cand" + break + fi +done + +if [ -z "$NODE_INCLUDE" ]; then + NODE_VERSION=$(node -v) + echo "Node headers not found in standard system paths. Downloading headers for ${NODE_VERSION}..." + mkdir -p /tmp/node_headers + curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-headers.tar.gz" -o /tmp/node_headers.tar.gz + tar -C /tmp/node_headers --strip-components=1 -xzf /tmp/node_headers.tar.gz + NODE_INCLUDE="/tmp/node_headers/include/node" +fi + +echo "Using Node include directory: $NODE_INCLUDE" + +# 4. Compile Go shared library and C++ Node-API addon +if [ "$UNAME_S" = "Darwin" ]; then + LIB_OUT="libspanner_go.dylib" + echo "Building Go shared library for macOS ($LIB_OUT)..." + go build -buildmode=c-shared -o "$LIB_OUT" . + + echo "Compiling spanner_go.node using clang++..." + clang++ -O3 -std=c++17 -shared -fPIC -undefined dynamic_lookup \ + -DNODE_GYP_MODULE_NAME=spanner_go \ + -I"$NODE_INCLUDE" -I"$SCRIPT_DIR" \ + "$PARENT_DIR/spanner_go_napi.cc" \ + -L"$SCRIPT_DIR" -lspanner_go \ + -Wl,-rpath,@loader_path/spanner-go -Wl,-rpath,@loader_path \ + -o "$PARENT_DIR/spanner_go.node" + + cp "$SCRIPT_DIR/$LIB_OUT" "$PARENT_DIR/" +else + LIB_OUT="libspanner_go.so" + echo "Building Go shared library for Linux ($LIB_OUT)..." + go build -buildmode=c-shared -o "$LIB_OUT" . + + # Ensure g++ / build-essential is used + CXX_COMPILER="g++" + if ! command -v g++ &> /dev/null && command -v clang++ &> /dev/null; then + CXX_COMPILER="clang++" + fi + + echo "Compiling spanner_go.node using ${CXX_COMPILER}..." + $CXX_COMPILER -O3 -std=c++17 -shared -fPIC \ + -DNODE_GYP_MODULE_NAME=spanner_go \ + -I"$NODE_INCLUDE" -I"$SCRIPT_DIR" \ + "$PARENT_DIR/spanner_go_napi.cc" \ + -L"$SCRIPT_DIR" -lspanner_go \ + -Wl,-rpath,'$ORIGIN/spanner-go' -Wl,-rpath,'$ORIGIN' \ + -o "$PARENT_DIR/spanner_go.node" + + cp "$SCRIPT_DIR/$LIB_OUT" "$PARENT_DIR/" +fi + +echo "=== Go Spanner Shared Core build complete ===" diff --git a/handwritten/spanner/spanner-native/spanner-go/client.go b/handwritten/spanner/spanner-native/spanner-go/client.go new file mode 100644 index 000000000000..011545a5a40d --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/client.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "sync" + "sync/atomic" + "time" + + gapic "cloud.google.com/go/spanner/apiv1" + spannerpb "cloud.google.com/go/spanner/apiv1/spannerpb" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + spannerEndpoint = "spanner.googleapis.com:443" + spannerDomain = "spanner.googleapis.com" + spannerScope = "https://www.googleapis.com/auth/spanner.data" +) + +func isDirectPathEnabled() bool { + return os.Getenv("GOOGLE_SPANNER_ENABLE_DIRECT_ACCESS") == "true" || + os.Getenv("GOOGLE_CLOUD_ENABLE_DIRECT_PATH") == "true" +} + +func init() { + if !isDirectPathEnabled() { + // Force-disable gRPC DirectPath at module initialization time unless explicitly enabled + _ = os.Setenv("GOOGLE_CLOUD_DISABLE_DIRECT_PATH", "true") + _ = os.Setenv("DISABLE_DIRECT_PATH", "true") + } +} + +// CoreClient manages multiplexed gRPC connections, authentication, and request routing. +type CoreClient struct { + conns []*grpc.ClientConn + gapicClient *gapic.Client + useGapic bool + reqCounter uint64 + tokenSource oauth2.TokenSource + ctx context.Context + cancel context.CancelFunc +} + +// NewCoreClient initializes the Go Spanner Core client. +// When GOOGLE_SPANNER_ENABLE_DIRECT_ACCESS=true, it uses gapic.NewClient with a gRPC connection pool to enable DirectPath. +// Otherwise, it explicitly disables gRPC DirectPath to maintain an apples-to-apples network comparison with the Rust prototype and Node.js baseline. +func NewCoreClient(channelCount int) (*CoreClient, error) { + ctx, cancel := context.WithCancel(context.Background()) + + limit := channelCount + if limit <= 0 { + limit = 1 + } + + // 1. Initialize GCP Application Default Credentials TokenSource (cached & thread-safe) + 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", + }) + } + + if isDirectPathEnabled() { + // Enable gRPC DirectPath via GAPIC client with connection pooling matching channelCount + os.Unsetenv("GOOGLE_CLOUD_DISABLE_DIRECT_PATH") + os.Unsetenv("DISABLE_DIRECT_PATH") + + gapicClient, err := gapic.NewClient(ctx, option.WithGRPCConnectionPool(limit)) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to initialize Spanner GAPIC client for DirectPath: %w", err) + } + + if os.Getenv("SPANNER_NATIVE_DEBUG") != "" { + fmt.Fprintf(os.Stderr, + "[spanner-core] transport=GAPIC/DirectPath-eligible pool=%d "+ + "(custom window sizes and channel pre-warm do NOT apply on this path)\n", + limit) + } + + return &CoreClient{ + gapicClient: gapicClient, + useGapic: true, + reqCounter: 0, + tokenSource: tokenSource, + ctx: ctx, + cancel: cancel, + }, nil + } + + // 2. Explicitly disable gRPC DirectPath in Go Spanner / gRPC client + // to enforce standard Google Frontend (GFE) network routing. + _ = os.Setenv("GOOGLE_CLOUD_DISABLE_DIRECT_PATH", "true") + _ = os.Setenv("DISABLE_DIRECT_PATH", "true") + + // 3. Resolve the target endpoint. Production (GFE + TLS) is the default; + // SPANNER_EMULATOR_HOST selects a plaintext local emulator and + // SPANNER_NATIVE_ENDPOINT overrides the host while keeping TLS. Neither is + // set in benchmark runs, so the production path is byte-for-byte unchanged. + endpoint := spannerEndpoint + serverName := spannerDomain + plaintext := false + + if h := os.Getenv("SPANNER_EMULATOR_HOST"); h != "" { + endpoint = h + plaintext = true + } else if h := os.Getenv("SPANNER_NATIVE_ENDPOINT"); h != "" { + endpoint = h + if host, _, splitErr := net.SplitHostPort(h); splitErr == nil { + serverName = host + } else { + serverName = h + } + } + + var creds credentials.TransportCredentials + if plaintext { + creds = insecure.NewCredentials() + } else { + creds = credentials.NewTLS(&tls.Config{ServerName: serverName}) + } + + if os.Getenv("SPANNER_NATIVE_DEBUG") != "" { + fmt.Fprintf(os.Stderr, + "[spanner-core] endpoint=%s plaintext=%v serverName=%s channels=%d\n", + endpoint, plaintext, serverName, limit) + } + + dialOpts := []grpc.DialOption{ + grpc.WithTransportCredentials(creds), + // Disable service config / DirectPath resolution to ensure standard routing + grpc.WithDisableServiceConfig(), + // HTTP/2 Flow Control Windows: increase from default 64KB to 4MB/16MB + // to allow Spanner large result sets to stream at full line-rate without stalling + grpc.WithInitialWindowSize(4 * 1024 * 1024), // 4MB per stream window + grpc.WithInitialConnWindowSize(16 * 1024 * 1024), // 16MB per connection window + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB + grpc.MaxCallSendMsgSize(100 * 1024 * 1024), + ), + } + + // 4. Create multiplexed gRPC connection pool matching the requested channelCount + conns := make([]*grpc.ClientConn, limit) + 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 + } + + // 5. Pre-warm the pool. + // + // grpc.DialContext is lazy: the TCP connect and TLS handshake happen on the + // channel's first RPC. With a pool of N channels and round-robin dispatch, + // the first N requests each pay that cost (measured at several seconds per + // channel), which badly skews short benchmark runs and any latency + // percentile computed over them. + // + // Drive every channel to READY and prime the OAuth token here, in parallel, + // so the cost lands at construction instead of in the measured workload. + // Steady-state behaviour is unchanged. Set SPANNER_NATIVE_NO_PREWARM=1 to + // restore the old lazy behaviour. + if os.Getenv("SPANNER_NATIVE_NO_PREWARM") == "" && !plaintext { + warmStart := time.Now() + var wg sync.WaitGroup + + for _, c := range conns { + wg.Add(1) + go func(cc *grpc.ClientConn) { + defer wg.Done() + wctx, wcancel := context.WithTimeout(ctx, 5*time.Second) + defer wcancel() + cc.Connect() + for { + s := cc.GetState() + if s == connectivity.Ready { + return + } + // Returns false on timeout/cancellation; give up quietly and + // let the first real RPC retry. + if !cc.WaitForStateChange(wctx, s) { + return + } + } + }(c) + } + + // The first token fetch hits the metadata server or reads ADC from disk. + wg.Add(1) + go func() { + defer wg.Done() + _, _ = tokenSource.Token() + }() + + wg.Wait() + + if os.Getenv("SPANNER_NATIVE_DEBUG") != "" { + fmt.Fprintf(os.Stderr, "[spanner-core] pre-warmed %d channel(s) in %v\n", + limit, time.Since(warmStart).Round(time.Millisecond)) + } + } + + return &CoreClient{ + conns: conns, + useGapic: false, + reqCounter: 0, + tokenSource: tokenSource, + ctx: ctx, + cancel: cancel, + }, nil +} + +// ExecuteStreamingSql dispatches the streaming SQL call over DirectPath or the connection pool. +func (c *CoreClient) ExecuteStreamingSql(ctx context.Context, req *spannerpb.ExecuteSqlRequest) (spannerpb.Spanner_ExecuteStreamingSqlClient, error) { + if c.useGapic && c.gapicClient != nil { + return c.gapicClient.ExecuteStreamingSql(ctx, req) + } + conn := c.GetConn() + if conn == nil { + return nil, fmt.Errorf("no active gRPC connection available") + } + spannerClient := spannerpb.NewSpannerClient(conn) + return spannerClient.ExecuteStreamingSql(ctx, req) +} + +// GetConn returns a connection from the pool via round-robin distribution. +func (c *CoreClient) GetConn() *grpc.ClientConn { + count := uint64(len(c.conns)) + if count == 0 { + return nil + } + idx := atomic.AddUint64(&c.reqCounter, 1) % count + return c.conns[idx] +} + +// GetToken retrieves the cached OAuth2 bearer token. +func (c *CoreClient) GetToken() (*oauth2.Token, error) { + if c.tokenSource == nil { + return nil, fmt.Errorf("token source is not configured") + } + return c.tokenSource.Token() +} + +// Close terminates all gRPC connections and cancels the background context. +func (c *CoreClient) Close() { + if c.cancel != nil { + c.cancel() + } + if c.gapicClient != nil { + _ = c.gapicClient.Close() + } + for _, conn := range c.conns { + if conn != nil { + _ = conn.Close() + } + } +} diff --git a/handwritten/spanner/spanner-native/spanner-go/decode.go b/handwritten/spanner/spanner-native/spanner-go/decode.go new file mode 100644 index 000000000000..f3a7904835d0 --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/decode.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/json" + "strconv" + + spannerpb "cloud.google.com/go/spanner/apiv1/spannerpb" + "google.golang.org/protobuf/types/known/structpb" +) + +// writeValueJson encodes a protobuf Value directly into a bytes.Buffer in valid JSON format +// matching the strictly-typed Spanner specifications without reflection or intermediate heap boxing. +func writeValueJson(buf *bytes.Buffer, val *structpb.Value, fieldType *spannerpb.Type) { + if val == nil { + buf.WriteString("null") + return + } + + switch k := val.Kind.(type) { + case *structpb.Value_NullValue: + buf.WriteString("null") + case *structpb.Value_BoolValue: + if k.BoolValue { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case *structpb.Value_NumberValue: + buf.WriteString(strconv.FormatFloat(k.NumberValue, 'f', -1, 64)) + case *structpb.Value_StringValue: + // Spanner TypeCodes: INT64, NUMERIC, TIMESTAMP, DATE, BYTES, JSON, STRING + // All Spanner primitive strings/numbers are serialized as JSON strings matching Rust prototype + jsonEscapeString(buf, k.StringValue) + case *structpb.Value_ListValue: + if k.ListValue == nil { + buf.WriteString("[]") + return + } + var elemType *spannerpb.Type + if fieldType != nil && fieldType.ArrayElementType != nil { + elemType = fieldType.ArrayElementType + } + buf.WriteByte('[') + for i, v := range k.ListValue.Values { + if i > 0 { + buf.WriteByte(',') + } + writeValueJson(buf, v, elemType) + } + buf.WriteByte(']') + case *structpb.Value_StructValue: + if k.StructValue == nil { + buf.WriteString("{}") + return + } + buf.WriteByte('{') + first := true + if fieldType != nil && fieldType.StructType != nil { + for _, f := range fieldType.StructType.Fields { + if !first { + buf.WriteByte(',') + } + first = false + jsonEscapeString(buf, f.Name) + buf.WriteByte(':') + if v, ok := k.StructValue.Fields[f.Name]; ok { + writeValueJson(buf, v, f.Type) + } else { + buf.WriteString("null") + } + } + } else { + for fName, fVal := range k.StructValue.Fields { + if !first { + buf.WriteByte(',') + } + first = false + jsonEscapeString(buf, fName) + buf.WriteByte(':') + writeValueJson(buf, fVal, nil) + } + } + buf.WriteByte('}') + default: + buf.WriteString("null") + } +} + +func jsonEscapeString(buf *bytes.Buffer, s string) { + b, err := json.Marshal(s) + if err == nil { + buf.Write(b) + } else { + buf.WriteString(`""`) + } +} + +// mergeProtoValues recursively merges chunked Protobuf values across streaming chunks, +// matching Rust's merge_proto_values implementation. +func mergeProtoValues(head *structpb.Value, tail *structpb.Value) *structpb.Value { + if head == nil { + return tail + } + if tail == nil { + return head + } + + switch h := head.Kind.(type) { + case *structpb.Value_StringValue: + if t, ok := tail.Kind.(*structpb.Value_StringValue); ok { + h.StringValue += t.StringValue + } + case *structpb.Value_ListValue: + if t, ok := tail.Kind.(*structpb.Value_ListValue); ok { + if h.ListValue == nil { + head.Kind = tail.Kind + return head + } + if t.ListValue == nil { + return head + } + if len(h.ListValue.Values) > 0 && len(t.ListValue.Values) > 0 { + lastIdx := len(h.ListValue.Values) - 1 + merged := mergeProtoValues(h.ListValue.Values[lastIdx], t.ListValue.Values[0]) + h.ListValue.Values[lastIdx] = merged + h.ListValue.Values = append(h.ListValue.Values, t.ListValue.Values[1:]...) + } else { + h.ListValue.Values = append(h.ListValue.Values, t.ListValue.Values...) + } + } + case *structpb.Value_StructValue: + if t, ok := tail.Kind.(*structpb.Value_StructValue); ok { + if h.StructValue == nil { + head.Kind = tail.Kind + return head + } + if t.StructValue == nil { + return head + } + if h.StructValue.Fields == nil { + h.StructValue.Fields = make(map[string]*structpb.Value) + } + for k, v := range t.StructValue.Fields { + if existing, exists := h.StructValue.Fields[k]; exists { + h.StructValue.Fields[k] = mergeProtoValues(existing, v) + } else { + h.StructValue.Fields[k] = v + } + } + } + } + return head +} diff --git a/handwritten/spanner/spanner-native/spanner-go/go.mod b/handwritten/spanner/spanner-native/spanner-go/go.mod new file mode 100644 index 000000000000..c2a627b2e558 --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/go.mod @@ -0,0 +1,37 @@ +module cloud.google.com/go/spanner-native-core + +go 1.21 + +require ( + cloud.google.com/go/spanner v1.60.0 + golang.org/x/oauth2 v0.19.0 + google.golang.org/api v0.169.0 + google.golang.org/grpc v1.63.2 + google.golang.org/protobuf v1.33.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.2 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect +) diff --git a/handwritten/spanner/spanner-native/spanner-go/go.sum b/handwritten/spanner/spanner-native/spanner-go/go.sum new file mode 100644 index 000000000000..cc8429ac514b --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/go.sum @@ -0,0 +1,154 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/spanner v1.60.0 h1:O9kf49dfaDRzPpKJNChHUJ+Bao02WPedZb8ZPyi02lI= +cloud.google.com/go/spanner v1.60.0/go.mod h1:D2bOAeT/dC6zsZhXRIxbdYa5nQEYU3wYM/1KN3eg7Fs= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA= +github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= +golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.169.0 h1:QwWPy71FgMWqJN/l6jVlFHUa29a7dcUy02I8o799nPY= +google.golang.org/api v0.169.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/handwritten/spanner/spanner-native/spanner-go/main.go b/handwritten/spanner/spanner-native/spanner-go/main.go new file mode 100644 index 000000000000..ce0b825f593f --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/main.go @@ -0,0 +1,492 @@ +package main + +/* +#include +#include + +typedef enum { + CELL_KIND_NULL = 0, + CELL_KIND_BOOL = 1, + CELL_KIND_NUMBER = 2, + CELL_KIND_STRING = 3 +} CellKind; + +typedef struct { + uint8_t kind; + uint8_t bool_val; + uint16_t _pad; + uint32_t str_len; + double number_val; + const char* str_val; +} CSpannerCell; + +typedef struct { + int format; // 0 = JSON string, 1 = Direct Native Cells + char* json_rows; + CSpannerCell* cells; + int row_count; + int col_count; + char* string_arena; + char* server_timing; + int attempt_count; + char* error_msg; + int error_code; + int is_last; + // Serialized google.spanner.v1.ResultSetMetadata. Emitted exactly once per + // stream (on the first batch) so the Node layer can build column decoders + // and produce stock-compatible Row objects. Zero per-row cost. + void* metadata_pb; + int metadata_len; +} CSpannerBatch; + +typedef void (*StreamDataCallback)(void* user_data, CSpannerBatch* batch); + +static void bridge_callback( + StreamDataCallback cb, + void* user_data, + CSpannerBatch* batch +) { + if (cb != NULL) { + cb(user_data, batch); + } +} +*/ +import "C" + +import ( + "bytes" + "fmt" + "io" + "os" + "sync" + "unsafe" + + spannerpb "cloud.google.com/go/spanner/apiv1/spannerpb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +var ( + clientRegistryMutex sync.RWMutex + clientRegistry = make(map[uintptr]*CoreClient) + nextClientId uintptr = 1 + logEncodingOnce sync.Once +) + +func registerClient(client *CoreClient) uintptr { + clientRegistryMutex.Lock() + defer clientRegistryMutex.Unlock() + id := nextClientId + nextClientId++ + clientRegistry[id] = client + return id +} + +func getClient(id uintptr) *CoreClient { + clientRegistryMutex.RLock() + defer clientRegistryMutex.RUnlock() + return clientRegistry[id] +} + +func unregisterClient(id uintptr) *CoreClient { + clientRegistryMutex.Lock() + defer clientRegistryMutex.Unlock() + client := clientRegistry[id] + delete(clientRegistry, id) + return client +} + +//export InitGoCoreClient +func InitGoCoreClient(channelCount C.int) C.uintptr_t { + client, err := NewCoreClient(int(channelCount)) + if err != nil { + return 0 + } + id := registerClient(client) + return C.uintptr_t(id) +} + +//export CloseGoCoreClient +func CloseGoCoreClient(handle C.uintptr_t) { + client := unregisterClient(uintptr(handle)) + if client != nil { + client.Close() + } +} + +func isDirectDeserializationEnabled() bool { + // Defaults to true unless explicitly disabled with SPANNER_GO_DIRECT_DESERIALIZATION=false or 0 + val := os.Getenv("SPANNER_GO_DIRECT_DESERIALIZATION") + enabled := val != "false" && val != "0" + logEncodingOnce.Do(func() { + if enabled { + fmt.Println("[Spanner-Go] Direct native cells encoding is ACTIVE (bypassing JSON parsing)") + } else { + fmt.Println("[Spanner-Go] Legacy JSON parsing is ACTIVE") + } + }) + return enabled +} + +func writeBatchJson(batch [][]*structpb.Value, rowType []*spannerpb.StructType_Field) *C.char { + if len(batch) == 0 { + return nil + } + var buf bytes.Buffer + buf.WriteByte('[') + for i, row := range batch { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteByte('[') + for j, cell := range row { + if j > 0 { + buf.WriteByte(',') + } + var fieldType *spannerpb.Type + if j < len(rowType) { + fieldType = rowType[j].Type + } + writeValueJson(&buf, cell, fieldType) + } + buf.WriteByte(']') + } + buf.WriteByte(']') + return C.CString(buf.String()) +} + +func sendBatch( + cb C.StreamDataCallback, + userData unsafe.Pointer, + batch [][]*structpb.Value, + rowType []*spannerpb.StructType_Field, + serverTiming string, + attemptCount int, + errMsg string, + errCode int, + isLast bool, + metadataBytes []byte, +) { + cBatch := (*C.CSpannerBatch)(C.malloc(C.size_t(unsafe.Sizeof(C.CSpannerBatch{})))) + *cBatch = C.CSpannerBatch{} + + if isLast { + cBatch.is_last = 1 + } + cBatch.attempt_count = C.int(attemptCount) + cBatch.error_code = C.int(errCode) + + if errMsg != "" { + cBatch.error_msg = C.CString(errMsg) + } + if serverTiming != "" { + cBatch.server_timing = C.CString(serverTiming) + } + + // Attach the serialized ResultSetMetadata if this is the first batch of the + // stream. C.CBytes allocates with malloc; the N-API layer frees it. + if len(metadataBytes) > 0 { + cBatch.metadata_pb = C.CBytes(metadataBytes) + cBatch.metadata_len = C.int(len(metadataBytes)) + } + + rowCount := len(batch) + cBatch.row_count = C.int(rowCount) + + if rowCount > 0 { + colCount := len(batch[0]) + cBatch.col_count = C.int(colCount) + + if isDirectDeserializationEnabled() { + cBatch.format = 1 // Native cells + + totalCells := rowCount * colCount + totalStringBytes := 0 + + for _, row := range batch { + for _, cell := range row { + if cell != nil { + if strVal, ok := cell.Kind.(*structpb.Value_StringValue); ok { + totalStringBytes += len(strVal.StringValue) + } + } + } + } + + 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] + + 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] + } + arenaOffset := 0 + + for r, row := range batch { + for c, val := range row { + idx := r*colCount + c + cell := &cellsSlice[idx] + if val == nil { + cell.kind = C.CELL_KIND_NULL + continue + } + + switch k := val.Kind.(type) { + case *structpb.Value_NullValue: + cell.kind = C.CELL_KIND_NULL + case *structpb.Value_BoolValue: + cell.kind = C.CELL_KIND_BOOL + if k.BoolValue { + cell.bool_val = 1 + } else { + cell.bool_val = 0 + } + case *structpb.Value_NumberValue: + cell.kind = C.CELL_KIND_NUMBER + cell.number_val = C.double(k.NumberValue) + case *structpb.Value_StringValue: + cell.kind = C.CELL_KIND_STRING + strLen := len(k.StringValue) + cell.str_len = C.uint32_t(strLen) + if strLen > 0 { + copy(arenaBytes[arenaOffset:arenaOffset+strLen], k.StringValue) + cell.str_val = (*C.char)(unsafe.Pointer(&arenaBytes[arenaOffset])) + arenaOffset += strLen + } else { + cell.str_val = nil + } + default: + cell.kind = C.CELL_KIND_NULL + } + } + } + } + } else { + // Legacy JSON serialization + cBatch.format = 0 + cBatch.json_rows = writeBatchJson(batch, rowType) + } + } + + C.bridge_callback(cb, userData, cBatch) +} + +//export ExecuteStreamingSqlGo +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, +) { + client := getClient(uintptr(handle)) + if client == nil { + sendBatch(cb, userData, nil, nil, "", 1, "Invalid or closed CoreClient handle", int(codes.InvalidArgument), true, nil) + return + } + + // Copy metadata headers + count := int(metaCount) + metaMap := make(map[string]string, count) + if count > 0 && metaKeys != nil && metaVals != nil { + keysSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaKeys))[:count:count] + valsSlice := (*[1 << 28]*C.char)(unsafe.Pointer(metaVals))[:count:count] + for i := 0; i < count; i++ { + if keysSlice[i] != nil && valsSlice[i] != nil { + k := C.GoString(keysSlice[i]) + v := C.GoString(valsSlice[i]) + metaMap[k] = v + } + } + } + + // Copy request bytes + length := int(reqLen) + rawBytes := C.GoBytes(unsafe.Pointer(reqBytesPtr), C.int(length)) + + // Execute gRPC streaming in a separate goroutine + go func() { + var lastResumeToken []byte + attemptCount := 0 + + var rowType []*spannerpb.StructType_Field + var pendingValue *structpb.Value + var currentRow []*structpb.Value + batch := make([][]*structpb.Value, 0, 100) + + // Serialized ResultSetMetadata, handed to Node on the first batch only. + // takeMetadata() returns it once and then always returns nil, so the + // per-row streaming path stays untouched. + var pendingMetadata []byte + takeMetadata := func() []byte { + if pendingMetadata == nil { + return nil + } + md := pendingMetadata + pendingMetadata = nil + return md + } + + for { + attemptCount++ + + // 1. Decode ExecuteSqlRequest protobuf bytes + var req spannerpb.ExecuteSqlRequest + if err := proto.Unmarshal(rawBytes, &req); err != nil { + sendBatch(cb, userData, nil, nil, "", attemptCount, fmt.Sprintf("Failed to decode request bytes: %v", err), int(codes.InvalidArgument), true, nil) + return + } + + // Attach resume token if retrying + if len(lastResumeToken) > 0 { + req.ResumeToken = lastResumeToken + } + + // 2. Prepare outgoing gRPC context with metadata headers + md := metadata.New(metaMap) + + // Fetch OAuth2 bearer token from memory cache + token, err := client.GetToken() + if err != nil { + sendBatch(cb, userData, nil, nil, "", attemptCount, fmt.Sprintf("Failed to get GCP auth token: %v", err), int(codes.Unauthenticated), true, nil) + return + } + if token != nil && token.AccessToken != "" { + md.Set("authorization", "Bearer "+token.AccessToken) + } + + ctx := metadata.NewOutgoingContext(client.ctx, md) + + // 3. Dispatch streaming SQL request + stream, err := client.ExecuteStreamingSql(ctx, &req) + if err != nil { + st, _ := status.FromError(err) + if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 { + continue // Retry loop + } + sendBatch(cb, userData, nil, nil, "", attemptCount, st.Message(), int(st.Code()), true, nil) + return + } + + // Read server-timing from header if present + serverTiming := "" + if headerMD, err := stream.Header(); err == nil { + if vals := headerMD.Get("server-timing"); len(vals) > 0 { + serverTiming = vals[0] + } + } + + shouldRetry := false + + // 4. Stream consumption loop + for { + chunk, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + st, _ := status.FromError(err) + if (st.Code() == codes.Unavailable || st.Code() == codes.Internal) && len(lastResumeToken) > 0 { + shouldRetry = true + break + } + sendBatch(cb, userData, nil, nil, serverTiming, attemptCount, st.Message(), int(st.Code()), true, nil) + return + } + + if len(chunk.ResumeToken) > 0 { + lastResumeToken = chunk.ResumeToken + } + + if rowType == nil && chunk.Metadata != nil && chunk.Metadata.RowType != nil { + rowType = chunk.Metadata.RowType.Fields + // Serialize the full ResultSetMetadata exactly once so the + // Node layer can construct column names + Spanner types + // with full fidelity (including type annotations). + if mdBytes, mdErr := proto.Marshal(chunk.Metadata); mdErr == nil { + pendingMetadata = mdBytes + } + } + + numFields := len(rowType) + vals := chunk.Values + + // Merge pending chunked value from previous chunk if present + if pendingValue != nil { + if len(vals) > 0 { + first := vals[0] + vals = vals[1:] + merged := mergeProtoValues(pendingValue, first) + pendingValue = nil + + currentRow = append(currentRow, merged) + + if numFields > 0 && len(currentRow) == numFields { + batch = append(batch, currentRow) + currentRow = make([]*structpb.Value, 0, numFields) + if len(batch) >= 100 { + sendBatch(cb, userData, batch, rowType, serverTiming, attemptCount, "", 0, false, takeMetadata()) + batch = make([][]*structpb.Value, 0, 100) + } + } + } + } + + // If this chunk has a chunked value at the end, pop it + if chunk.ChunkedValue && len(vals) > 0 { + pendingValue = vals[len(vals)-1] + vals = vals[:len(vals)-1] + } + + for _, val := range vals { + currentRow = append(currentRow, val) + + if numFields > 0 && len(currentRow) == numFields { + batch = append(batch, currentRow) + currentRow = make([]*structpb.Value, 0, numFields) + if len(batch) >= 100 { + sendBatch(cb, userData, batch, rowType, serverTiming, attemptCount, "", 0, false, takeMetadata()) + batch = make([][]*structpb.Value, 0, 100) + } + } + } + } + + if shouldRetry { + continue + } + + // Read server-timing from trailers if present + if trailerMD := stream.Trailer(); trailerMD != nil { + if vals := trailerMD.Get("server-timing"); len(vals) > 0 { + serverTiming = vals[0] + } + } + + // Flush any pending value / row + if pendingValue != nil { + currentRow = append(currentRow, pendingValue) + pendingValue = nil + } + if len(currentRow) > 0 { + batch = append(batch, currentRow) + currentRow = nil + } + + // Send final batch and EOF signal + sendBatch(cb, userData, batch, rowType, serverTiming, attemptCount, "", 0, true, takeMetadata()) + break + } + }() +} + +func main() {} diff --git a/handwritten/spanner/spanner-native/spanner_go_napi.cc b/handwritten/spanner/spanner-native/spanner_go_napi.cc new file mode 100644 index 000000000000..81b01a5592df --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner_go_napi.cc @@ -0,0 +1,434 @@ +#include +#include +#include +#include +#include +#include +#include + +// Callback signature matching Go exported C type +typedef enum { + CELL_KIND_NULL = 0, + CELL_KIND_BOOL = 1, + CELL_KIND_NUMBER = 2, + CELL_KIND_STRING = 3 +} CellKind; + +typedef struct { + uint8_t kind; + uint8_t bool_val; + uint16_t _pad; + uint32_t str_len; + double number_val; + const char* str_val; +} CSpannerCell; + +typedef struct { + int format; // 0 = JSON string, 1 = Direct Native Cells + char* json_rows; + CSpannerCell* cells; + int row_count; + int col_count; + char* string_arena; + char* server_timing; + int attempt_count; + char* error_msg; + int error_code; + int is_last; + // Serialized google.spanner.v1.ResultSetMetadata, present only on the + // first batch of a stream. Must stay in sync with spanner-go/main.go. + void* metadata_pb; + int metadata_len; +} CSpannerBatch; + +typedef void (*StreamDataCallback)(void* user_data, CSpannerBatch* batch); + +// Declarations of Go C-shared exported functions +extern "C" { + uintptr_t InitGoCoreClient(int channel_count); + void CloseGoCoreClient(uintptr_t handle); + void ExecuteStreamingSqlGo( + uintptr_t handle, + const char* routing_key, + const char** meta_keys, + const char** meta_vals, + int meta_count, + const char* req_bytes, + int req_len, + StreamDataCallback cb, + void* user_data + ); +} + +struct StreamCallbackContext { + napi_threadsafe_function tsfn; +}; + +// C callback called by Go on background goroutine +extern "C" void OnGoStreamData(void* user_data, CSpannerBatch* batch) { + StreamCallbackContext* ctx = static_cast(user_data); + if (!ctx || !ctx->tsfn) { + 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, batch, napi_tsfn_nonblocking); +} + +// CallJsHandler runs on the V8 main event loop thread +void CallJsHandler(napi_env env, napi_value js_cb, void* context, void* data) { + CSpannerBatch* batch = static_cast(data); + StreamCallbackContext* ctx = static_cast(context); + + if (env != nullptr && js_cb != nullptr && batch != nullptr) { + napi_value global; + napi_get_global(env, &global); + + napi_value null_val; + napi_get_null(env, &null_val); + + if (batch->error_msg != nullptr) { + napi_value err_obj, err_msg_val, err_code_val; + napi_create_string_utf8(env, batch->error_msg, NAPI_AUTO_LENGTH, &err_msg_val); + // NOTE: napi_create_error's `code` parameter must be a JS *string* + // (or nullptr). Passing a number makes the call fail and leaves + // err_obj uninitialised, which destroys the real error message. + // Attach the numeric gRPC status as a `code` property instead so + // the object matches the grpc ServiceError shape callers expect. + napi_create_error(env, nullptr, err_msg_val, &err_obj); + napi_create_int32(env, batch->error_code, &err_code_val); + napi_set_named_property(env, err_obj, "code", err_code_val); + + napi_value argv[4] = { err_obj, null_val, null_val, null_val }; + napi_call_function(env, global, js_cb, 4, argv, nullptr); + } else if (batch->is_last && batch->row_count == 0) { + // End of stream signal + napi_value argv[4] = { null_val, null_val, null_val, null_val }; + napi_call_function(env, global, js_cb, 4, argv, nullptr); + } else { + napi_value rows_val = null_val; + + if (batch->format == 1 && batch->cells != nullptr && batch->row_count > 0 && batch->col_count > 0) { + // DIRECT N-API NATIVE CELLS INSTANTIATION (ZERO JSON.PARSE) + const int row_count = batch->row_count; + const int col_count = batch->col_count; + const CSpannerCell* cells = batch->cells; + + napi_create_array_with_length(env, row_count, &rows_val); + + 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); + } + } else if (batch->format == 0 && batch->json_rows != nullptr) { + // LEGACY JSON.PARSE ROUTE (OPT-IN VIA SPANNER_GO_DIRECT_DESERIALIZATION=false) + napi_value json_global, parse_fn, json_str; + napi_get_named_property(env, global, "JSON", &json_global); + napi_get_named_property(env, json_global, "parse", &parse_fn); + napi_create_string_utf8(env, batch->json_rows, NAPI_AUTO_LENGTH, &json_str); + napi_call_function(env, json_global, parse_fn, 1, &json_str, &rows_val); + } + + napi_value telemetry_obj; + napi_create_object(env, &telemetry_obj); + if (batch->server_timing != nullptr) { + napi_value st_val; + napi_create_string_utf8(env, batch->server_timing, NAPI_AUTO_LENGTH, &st_val); + napi_set_named_property(env, telemetry_obj, "serverTiming", st_val); + } + napi_value attempt_val; + napi_create_uint32(env, (uint32_t)batch->attempt_count, &attempt_val); + napi_set_named_property(env, telemetry_obj, "attemptCount", attempt_val); + + // Serialized ResultSetMetadata, present only on the first batch of + // the stream. Copied once per stream; not on the per-row path. + napi_value metadata_val = null_val; + if (batch->metadata_pb != nullptr && batch->metadata_len > 0) { + void* copy_data = nullptr; + napi_create_buffer_copy(env, + (size_t)batch->metadata_len, + batch->metadata_pb, + ©_data, + &metadata_val); + } + + napi_value argv[4] = { null_val, rows_val, telemetry_obj, metadata_val }; + napi_call_function(env, global, js_cb, 4, argv, nullptr); + + if (batch->is_last) { + // If this was the final batch with data, send EOF after it + napi_value eof_argv[4] = { null_val, null_val, null_val, null_val }; + napi_call_function(env, global, js_cb, 4, eof_argv, nullptr); + } + } + } + + if (batch != nullptr) { + if (batch->cells != nullptr) free(batch->cells); + if (batch->string_arena != nullptr) free(batch->string_arena); + if (batch->json_rows != nullptr) free(batch->json_rows); + if (batch->server_timing != nullptr) free(batch->server_timing); + if (batch->error_msg != nullptr) free(batch->error_msg); + if (batch->metadata_pb != nullptr) free(batch->metadata_pb); + bool is_final = (batch->is_last != 0) || (batch->error_msg != nullptr); + free(batch); + + if (is_final && ctx != nullptr) { + if (ctx->tsfn != nullptr) { + napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); + ctx->tsfn = nullptr; + } + delete ctx; + } + } +} + +// Native CoreClientHandle wrapper +static napi_ref constructor_ref; + +struct CoreClientHandleWrapper { + uintptr_t handle; +}; + +void CoreClientHandleDestructor(napi_env env, void* nativeObject, void* finalize_hint) { + CoreClientHandleWrapper* wrap = static_cast(nativeObject); + if (wrap != nullptr) { + if (wrap->handle != 0) { + CloseGoCoreClient(wrap->handle); + wrap->handle = 0; + } + delete wrap; + } +} + +napi_value CoreClientHandleConstructor(napi_env env, napi_callback_info info) { + napi_value jsthis; + size_t argc = 1; + napi_value args[1]; + napi_get_cb_info(env, info, &argc, args, &jsthis, nullptr); + + int channel_count = 1; + if (argc >= 1) { + int32_t val; + if (napi_get_value_int32(env, args[0], &val) == napi_ok) { + channel_count = (int)val; + } + } + + uintptr_t handle = InitGoCoreClient(channel_count); + CoreClientHandleWrapper* wrap = new CoreClientHandleWrapper{ handle }; + + napi_wrap(env, jsthis, wrap, CoreClientHandleDestructor, nullptr, nullptr); + return jsthis; +} + +napi_value CoreClientHandleClose(napi_env env, napi_callback_info info) { + napi_value jsthis; + napi_get_cb_info(env, info, nullptr, nullptr, &jsthis, nullptr); + + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, jsthis, reinterpret_cast(&wrap)); + if (wrap != nullptr && wrap->handle != 0) { + CloseGoCoreClient(wrap->handle); + wrap->handle = 0; + } + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +// Function: executeStreamingSqlNative +napi_value ExecuteStreamingSqlNative(napi_env env, napi_callback_info info) { + size_t argc = 6; + napi_value args[6]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc < 6) { + napi_throw_type_error(env, nullptr, "Wrong number of arguments for executeStreamingSqlNative"); + return nullptr; + } + + // 1. Unwrap CoreClientHandle + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, args[0], reinterpret_cast(&wrap)); + if (wrap == nullptr || wrap->handle == 0) { + napi_throw_error(env, nullptr, "Invalid CoreClientHandle"); + return nullptr; + } + + // 2. Routing key string + char routing_key_buf[256]; + size_t routing_key_len = 0; + napi_get_value_string_utf8(env, args[1], routing_key_buf, sizeof(routing_key_buf), &routing_key_len); + + // 3. Metadata array [[k, v], ...] + uint32_t meta_len = 0; + napi_get_array_length(env, args[2], &meta_len); + + std::vector meta_keys_str; + std::vector meta_vals_str; + std::vector meta_keys_ptr; + std::vector meta_vals_ptr; + + meta_keys_str.reserve(meta_len); + meta_vals_str.reserve(meta_len); + meta_keys_ptr.reserve(meta_len); + meta_vals_ptr.reserve(meta_len); + + for (uint32_t i = 0; i < meta_len; i++) { + napi_value pair_val; + napi_get_element(env, args[2], i, &pair_val); + uint32_t pair_len = 0; + napi_get_array_length(env, pair_val, &pair_len); + if (pair_len == 2) { + napi_value k_val, v_val; + napi_get_element(env, pair_val, 0, &k_val); + napi_get_element(env, pair_val, 1, &v_val); + + char k_buf[512], v_buf[512]; + size_t k_len = 0, v_len = 0; + napi_get_value_string_utf8(env, k_val, k_buf, sizeof(k_buf), &k_len); + napi_get_value_string_utf8(env, v_val, v_buf, sizeof(v_buf), &v_len); + + meta_keys_str.emplace_back(k_buf, k_len); + meta_vals_str.emplace_back(v_buf, v_len); + } + } + + for (size_t i = 0; i < meta_keys_str.size(); i++) { + meta_keys_ptr.push_back(meta_keys_str[i].c_str()); + meta_vals_ptr.push_back(meta_vals_str[i].c_str()); + } + + // 4. Request bytes (Uint8Array / Buffer) + void* req_data = nullptr; + size_t req_len = 0; + 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); + } + } + + // 5. Callback function + napi_value callback_val = args[5]; + + StreamCallbackContext* cb_ctx = new StreamCallbackContext(); + + napi_value resource_name; + napi_create_string_utf8(env, "SpannerGoStream", NAPI_AUTO_LENGTH, &resource_name); + + napi_status status = napi_create_threadsafe_function( + env, + callback_val, + nullptr, + resource_name, + 0, + 1, + nullptr, + nullptr, + cb_ctx, + CallJsHandler, + &(cb_ctx->tsfn) + ); + + if (status != napi_ok) { + delete cb_ctx; + napi_throw_error(env, nullptr, "Failed to create threadsafe function for Go stream callback"); + return nullptr; + } + + // 6. Invoke Go streaming execution + ExecuteStreamingSqlGo( + wrap->handle, + routing_key_buf, + meta_keys_ptr.data(), + meta_vals_ptr.data(), + (int)meta_keys_ptr.size(), + static_cast(req_data), + (int)req_len, + OnGoStreamData, + cb_ctx + ); + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +// Module initialization +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + { "close", nullptr, CoreClientHandleClose, nullptr, nullptr, nullptr, napi_default, nullptr } + }; + + napi_value cons; + napi_define_class( + env, + "CoreClientHandle", + NAPI_AUTO_LENGTH, + CoreClientHandleConstructor, + nullptr, + 1, + properties, + &cons + ); + + napi_create_reference(env, cons, 1, &constructor_ref); + napi_set_named_property(env, exports, "CoreClientHandle", cons); + + napi_property_descriptor fn_prop = { + "executeStreamingSqlNative", nullptr, ExecuteStreamingSqlNative, nullptr, nullptr, nullptr, napi_default, nullptr + }; + napi_define_properties(env, exports, 1, &fn_prop); + + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/handwritten/spanner/spanner-native/verify_native_core.js b/handwritten/spanner/spanner-native/verify_native_core.js new file mode 100644 index 000000000000..96e669cd5354 --- /dev/null +++ b/handwritten/spanner/spanner-native/verify_native_core.js @@ -0,0 +1,508 @@ +/** + * Verification harness for the Go shared-core integration. + * + * Starts an in-process mock Spanner gRPC server, then runs the SAME query + * through both execution paths and asserts the results are identical: + * + * 1. stock pure-JS path (SPANNER_NATIVE_CORE unset) + * 2. Go shared-core path (SPANNER_NATIVE_CORE=go) + * + * This proves the native path returns real Spanner Row objects whose + * toJSON() output matches the stock client exactly -- which is what the + * unmodified external benchmarks depend on. + * + * Run: node handwritten/spanner/spanner-native/verify_native_core.js + */ + +'use strict'; + +const path = require('path'); +const assert = require('assert'); +const grpc = require('@grpc/grpc-js'); +const protoLoader = require('@grpc/proto-loader'); + +const SPANNER_PKG = path.resolve(__dirname, '..'); +const PROTO_DIR = path.join(SPANNER_PKG, 'protos'); +const PORT = process.env.MOCK_PORT || '9099'; +const HOST = `127.0.0.1:${PORT}`; + +const PROJECT = 'test-project'; +const INSTANCE = 'test-instance'; +const DATABASE = 'test-database'; +const SESSION_NAME = + `projects/${PROJECT}/instances/${INSTANCE}/databases/${DATABASE}/sessions/mux-1`; + +// --------------------------------------------------------------------------- +// Result set exercising the scalar types the core must carry correctly. +// --------------------------------------------------------------------------- + +const FIELDS = [ + {name: 'id', type: {code: 'INT64'}}, + {name: 'name', type: {code: 'STRING'}}, + {name: 'score', type: {code: 'FLOAT64'}}, + {name: 'active', type: {code: 'BOOL'}}, + {name: 'created', type: {code: 'TIMESTAMP'}}, + {name: 'payload', type: {code: 'BYTES'}}, + {name: 'amount', type: {code: 'NUMERIC'}}, + {name: 'missing', type: {code: 'STRING'}}, + // Additional scalar types exercised by the read-large-result-set workload. + {name: 'day', type: {code: 'DATE'}}, + {name: 'doc', type: {code: 'JSON'}}, + {name: 'ratio32', type: {code: 'FLOAT32'}}, + {name: 'span', type: {code: 'INTERVAL'}}, + {name: 'uid', type: {code: 'UUID'}}, +]; + +// PartialResultSet.values are google.protobuf.Value. +// INT64/TIMESTAMP/BYTES/NUMERIC arrive on the wire as strings. +function makeRowValues(i) { + return [ + {stringValue: String(1000 + i)}, + {stringValue: `row-${i}`}, + {numberValue: 1.5 + i}, + {boolValue: i % 2 === 0}, + {stringValue: '2026-01-02T03:04:05.123456000Z'}, + {stringValue: Buffer.from(`blob-${i}`).toString('base64')}, + {stringValue: '1234.5678'}, + {nullValue: 'NULL_VALUE'}, + {stringValue: '2026-03-04'}, + {stringValue: JSON.stringify({k: `v-${i}`, n: i})}, + {numberValue: 0.25 + i}, + {stringValue: 'P1Y2M3DT4H5M6S'}, + {stringValue: '9d2f1e7a-0000-4000-8000-00000000000' + (i % 10)}, + ]; +} + +const ROW_COUNT = 3; + +// --------------------------------------------------------------------------- +// Mock Spanner server +// --------------------------------------------------------------------------- + +/** + * Every ExecuteStreamingSql request the mock received, in order. Used to prove + * the two paths put the same thing on the wire (same SQL, same encoded params, + * same single-use transaction) -- not merely that they return the same rows. + */ +const capturedRequests = []; + +function startMockServer() { + const packageDefinition = protoLoader.loadSync( + 'google/spanner/v1/spanner.proto', + { + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + PROTO_DIR, + path.join(SPANNER_PKG, 'node_modules/google-gax/build/protos'), + path.join(SPANNER_PKG, 'node_modules/google-proto-files'), + ], + }, + ); + const proto = grpc.loadPackageDefinition(packageDefinition); + const spannerService = proto.google.spanner.v1.Spanner.service; + + const server = new grpc.Server(); + + server.addService(spannerService, { + CreateSession: (call, callback) => { + callback(null, {name: SESSION_NAME, multiplexed: true}); + }, + BatchCreateSessions: (call, callback) => { + const count = call.request.sessionCount || 1; + const session = []; + for (let i = 0; i < count; i++) { + session.push({name: `${SESSION_NAME}-${i}`}); + } + callback(null, {session}); + }, + GetSession: (call, callback) => { + callback(null, {name: call.request.name, multiplexed: true}); + }, + DeleteSession: (call, callback) => callback(null, {}), + BeginTransaction: (call, callback) => { + callback(null, {id: Buffer.from('tx-1')}); + }, + Commit: (call, callback) => callback(null, {commitTimestamp: {seconds: 1}}), + Rollback: (call, callback) => callback(null, {}), + + ExecuteStreamingSql: call => { + capturedRequests.push(call.request); + if (process.env.MOCK_DEBUG) { + console.log( + ' [mock] ExecuteStreamingSql request:', + JSON.stringify(call.request, null, 2), + ); + } + // First chunk: metadata only. + call.write({metadata: {rowType: {fields: FIELDS}}}); + // Then one chunk per row. + for (let i = 0; i < ROW_COUNT; i++) { + call.write({values: makeRowValues(i)}); + } + call.end(); + }, + }); + + return new Promise((resolve, reject) => { + server.bindAsync( + HOST, + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) return reject(err); + resolve({server, port}); + }, + ); + }); +} + +// --------------------------------------------------------------------------- +// Query execution +// --------------------------------------------------------------------------- + +const QUERY = { + sql: 'SELECT * FROM Foo WHERE id = @id', + params: {id: 1}, + types: {id: 'int64'}, +}; + +async function runOnce(useNativeCore, bounds) { + capturedRequests.length = 0; + + // Force a clean module + client state for each path. + for (const key of Object.keys(require.cache)) { + if (key.includes(`${path.sep}spanner${path.sep}build${path.sep}src`)) { + delete require.cache[key]; + } + } + + // NOTE: the core is enabled by default, so selecting the stock path means + // disabling it explicitly. Leaving the variable unset would run the Go core + // twice and the comparison would pass vacuously. + if (useNativeCore) { + process.env.SPANNER_NATIVE_CORE = 'go'; + } else { + process.env.SPANNER_NATIVE_CORE = 'off'; + } + + const {Spanner} = require(path.join(SPANNER_PKG, 'build', 'src', 'index.js')); + + // Definitive path probe: instrument the stock JS stream so we can prove the + // native run never touched it (otherwise an identical result could simply be + // a silent fallback to stock). + const {Database} = require( + path.join(SPANNER_PKG, 'build', 'src', 'database.js'), + ); + let stockStreamCalls = 0; + const origStock = Database.prototype.runStreamStock_; + Database.prototype.runStreamStock_ = function (...args) { + stockStreamCalls++; + return origStock.apply(this, args); + }; + + const nativeCore = require( + path.join(SPANNER_PKG, 'build', 'src', 'native-core.js'), + ); + const coreEnabled = nativeCore.isNativeCoreEnabled(); + + // Count dispatches INTO the core directly. Inferring provenance from the + // absence of stock-stream calls is not safe: upstream added a run() path + // that calls neither, which would make such a check pass vacuously. + let nativeCalls = 0; + const origNative = nativeCore.runStreamNative; + nativeCore.runStreamNative = function (...args) { + nativeCalls++; + return origNative.apply(this, args); + }; + + const spanner = new Spanner({projectId: PROJECT}); + const database = spanner.instance(INSTANCE).database(DATABASE); + database.on('error', () => {}); + + try { + const [rows] = bounds + ? await database.run(QUERY, bounds) + : await database.run(QUERY); + return { + coreEnabled, + stockStreamCalls, + nativeCalls, + request: capturedRequests[0], + rowCount: rows.length, + json: rows.map(r => r.toJSON()), + jsonWrapped: rows.map(r => r.toJSON({wrapNumbers: true})), + shape: rows.map(r => r.map(f => f.name)), + isArray: rows.every(r => Array.isArray(r)), + hasToJSON: rows.every(r => typeof r.toJSON === 'function'), + fieldShape: rows.every(r => + r.every( + f => + f && + typeof f === 'object' && + 'name' in f && + 'value' in f, + ), + ), + }; + } finally { + try { + await database.close(); + } catch (e) { + /* ignore */ + } + } +} + +// Values may contain class instances (Int, Float, Numeric, PreciseDate, +// Buffer). Normalize to a comparable string form. +function normalize(value) { + return JSON.parse( + JSON.stringify(value, (key, v) => { + if (v === null || v === undefined) return v; + if (Buffer.isBuffer(v)) return ``; + if (v && v.type === 'Buffer' && Array.isArray(v.data)) { + return ``; + } + return v; + }), + ); +} + +// --------------------------------------------------------------------------- + +async function main() { + // IMPORTANT: the Go shared core snapshots the process environment when its + // shared library is loaded, so assigning `process.env.SPANNER_EMULATOR_HOST` + // from JS is NOT visible to Go's os.Getenv. If we did that, the core would + // silently dial real Cloud Spanner instead of the mock. Re-exec ourselves + // once with the variable present in the actual environment. + if (process.env.SPANNER_EMULATOR_HOST !== HOST) { + const {spawnSync} = require('child_process'); + const res = spawnSync(process.execPath, [__filename], { + stdio: 'inherit', + env: { + ...process.env, + SPANNER_EMULATOR_HOST: HOST, + GOOGLE_CLOUD_PROJECT: PROJECT, + }, + }); + process.exit(res.status === null ? 1 : res.status); + } + + process.env.GOOGLE_CLOUD_PROJECT = PROJECT; + + const {server} = await startMockServer(); + console.log(`Mock Spanner server listening on ${HOST}\n`); + + let failures = 0; + try { + console.log('--- Running stock pure-JS path ---'); + const stock = await runOnce(false); + console.log(` rows: ${stock.rowCount}`); + console.log(` json[0]: ${JSON.stringify(normalize(stock.json[0]))}`); + + console.log('\n--- Running Go shared-core path ---'); + const native = await runOnce(true); + console.log(` rows: ${native.rowCount}`); + console.log(` json[0]: ${JSON.stringify(normalize(native.json[0]))}`); + + // The shape the standard point-select benchmark uses. + const STALENESS = {exactStaleness: 15000}; + console.log( + '\n--- Running both paths with {exactStaleness: 15000} ---', + ); + const staleStock = await runOnce(false, STALENESS); + const staleNative = await runOnce(true, STALENESS); + console.log( + ` stock rows: ${staleStock.rowCount}, core rows: ${staleNative.rowCount}`, + ); + console.log( + ` core transaction: ${JSON.stringify( + normalize(staleNative.request.transaction), + )}`, + ); + + console.log('\n--- Assertions ---'); + + const checks = [ + [ + 'stock run did NOT touch the Go core (provenance)', + () => { + assert.strictEqual(stock.coreEnabled, false, 'core was enabled'); + assert.strictEqual( + stock.nativeCalls, + 0, + 'the stock run dispatched into the native core', + ); + }, + ], + [ + 'native run DID dispatch into the Go core (provenance)', + () => { + assert.strictEqual( + native.coreEnabled, + true, + 'addon failed to load / core disabled', + ); + assert.strictEqual( + native.nativeCalls, + 1, + 'run() never reached the native core -- the integration point is ' + + 'wrong (upstream run() may bypass runStream)', + ); + assert.strictEqual( + native.stockStreamCalls, + 0, + 'the core fell back to the stock JS stream', + ); + }, + ], + [ + 'row count matches', + () => assert.strictEqual(native.rowCount, stock.rowCount), + ], + [ + `row count is ${ROW_COUNT}`, + () => assert.strictEqual(native.rowCount, ROW_COUNT), + ], + ['rows are arrays', () => assert.ok(native.isArray)], + ['rows expose toJSON()', () => assert.ok(native.hasToJSON)], + [ + 'cells are {name, value}', + () => assert.ok(native.fieldShape), + ], + [ + 'column names match', + () => + assert.deepStrictEqual( + normalize(native.shape), + normalize(stock.shape), + ), + ], + [ + 'toJSON() output matches stock exactly', + () => + assert.deepStrictEqual( + normalize(native.json), + normalize(stock.json), + ), + ], + [ + 'toJSON({wrapNumbers:true}) matches stock exactly', + () => + assert.deepStrictEqual( + normalize(native.jsonWrapped), + normalize(stock.jsonWrapped), + ), + ], + + // --- wire-level equivalence (no bounds) ----------------------------- + [ + 'SQL and encoded params sent to the server match stock', + () => { + assert.deepStrictEqual(native.request.sql, stock.request.sql); + assert.deepStrictEqual( + normalize(native.request.params), + normalize(stock.request.params), + 'encoded query parameters differ', + ); + assert.deepStrictEqual( + normalize(native.request.paramTypes), + normalize(stock.request.paramTypes), + 'param types differ', + ); + }, + ], + [ + 'single-use transaction sent to the server matches stock', + () => + assert.deepStrictEqual( + normalize(native.request.transaction), + normalize(stock.request.transaction), + ), + ], + + // --- staleness bounds ------------------------------------------------ + // The standard point-select benchmark issues + // database.run(query, {exactStaleness: 15000}) + // so a core that silently ignored bounds -- or refused them and fell + // back to pure JS -- would make that benchmark measure nothing. + [ + 'staleness-bounded query still uses the Go core (no silent fallback)', + () => { + assert.strictEqual( + staleNative.nativeCalls, + 1, + 'the bounded query never reached the native core', + ); + assert.strictEqual( + staleStock.nativeCalls, + 0, + 'the bounded stock run dispatched into the native core', + ); + assert.strictEqual( + staleNative.stockStreamCalls, + 0, + 'bounded query fell back to the stock JS stream', + ); + }, + ], + [ + 'staleness bound is forwarded to the server', + () => { + const ro = staleNative.request.transaction.singleUse.readOnly; + assert.ok(ro, 'no readOnly in single-use transaction'); + assert.ok( + ro.exactStaleness, + `exactStaleness missing; got ${JSON.stringify(ro)}`, + ); + assert.strictEqual(String(ro.exactStaleness.seconds), '15'); + }, + ], + [ + 'staleness-bounded transaction matches stock byte-for-byte', + () => + assert.deepStrictEqual( + normalize(staleNative.request.transaction), + normalize(staleStock.request.transaction), + ), + ], + [ + 'staleness-bounded rows match stock exactly', + () => + assert.deepStrictEqual( + normalize(staleNative.json), + normalize(staleStock.json), + ), + ], + ]; + + for (const [name, fn] of checks) { + try { + fn(); + console.log(` PASS ${name}`); + } catch (e) { + failures++; + console.log(` FAIL ${name}`); + console.log(` ${e.message.split('\n').slice(0, 12).join('\n ')}`); + } + } + } catch (e) { + failures++; + console.error('\nHarness error:', e); + } finally { + server.forceShutdown(); + } + + console.log( + failures === 0 + ? '\nAll checks passed: the Go shared core is API-compatible with the stock client.' + : `\n${failures} check(s) FAILED.`, + ); + process.exit(failures === 0 ? 0 : 1); +} + +main(); diff --git a/handwritten/spanner/src/database.ts b/handwritten/spanner/src/database.ts index b6ba8347e4c5..339b9b8a2014 100644 --- a/handwritten/spanner/src/database.ts +++ b/handwritten/spanner/src/database.ts @@ -54,6 +54,12 @@ import { GetDatabaseOperationsCallback, } from './instance'; import {PartialResultStream, Row} from './partial-result-stream'; +import { + isNativeCoreEnabled, + isNativeEligible, + runStreamNative, + DatabaseLike as NativeDatabaseLike, +} from './native-core'; import {Session} from './session'; import { isSessionNotFoundError, @@ -2908,6 +2914,19 @@ class Database extends common.GrpcServiceObject { this._runLegacy(query, options, callback!); return; } + // Go shared-core fast path. + // + // NOTE: _run() below is the optimised pure-JS pipeline and deliberately + // bypasses Database.prototype.runStream, so the dispatch inside + // runStream() is unreachable from run(). Eligible queries are therefore + // routed through the streaming pipeline, which does dispatch to the core + // (and transparently falls back to stock if the result set turns out to + // be unsupported). When the core is disabled this branch is skipped + // entirely and run() behaves exactly as it does upstream. + if (isNativeCoreEnabled() && isNativeEligible(query as unknown)) { + this._runLegacy(query, options, callback!); + return; + } this._run(query, options, callback!); } @@ -3300,6 +3319,36 @@ class Database extends common.GrpcServiceObject { runStream( query: string | ExecuteSqlRequest, options?: TimestampBounds, + ): PartialResultStream { + // Go shared-core fast path. Only single-use read-only SQL queries are + // eligible. Timestamp bounds are supported: they are encoded with the + // same helper the stock path uses and forwarded verbatim in the + // single-use transaction, so the wire request is identical. + // + // If the core turns out to be unable to represent the result set + // (ARRAY/STRUCT columns) it invokes the fallback factory and the stock JS + // stream is used instead. That decision is always made before any row is + // emitted, so the caller sees a single coherent stream either way. + if (isNativeCoreEnabled() && isNativeEligible(query as unknown)) { + return runStreamNative( + this as unknown as NativeDatabaseLike, + query as unknown as string | Record, + () => + this.runStreamStock_(query, options) as unknown as NodeJS.ReadableStream, + Snapshot.encodeTimestampBounds(options || {}), + ) as unknown as PartialResultStream; + } + return this.runStreamStock_(query, options); + } + + /** + * The stock pure-JS streaming implementation of {@link Database#runStream}. + * + * @private + */ + runStreamStock_( + query: string | ExecuteSqlRequest, + options?: TimestampBounds, ): PartialResultStream { const proxyStream: Transform = through.obj(); return startTrace( diff --git a/handwritten/spanner/src/native-core.ts b/handwritten/spanner/src/native-core.ts new file mode 100644 index 000000000000..a1c8824cb1dc --- /dev/null +++ b/handwritten/spanner/src/native-core.ts @@ -0,0 +1,626 @@ +/*! + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Go shared-core execution path for ExecuteStreamingSql. + * + * This module lets `Database.runStream()` (and therefore `Database.run()`) + * transparently dispatch a read-only SQL query through the native Go shared + * core instead of the pure-JS gRPC stack, while still handing the caller + * ordinary Spanner `Row` objects. Callers -- including unmodified external + * benchmarks -- see exactly the same API surface and row shape. + * + * Division of labour: + * Node -> session checkout, request build, protobuf request encode + * Go -> gRPC channel, auth, HTTP/2, wire decode, chunk merge, row assembly + * Node -> Spanner type decode + Row/toJSON construction + * + * Enable with SPANNER_NATIVE_CORE=go. + * + * Scope / limitations (deliberate, for the SQL streaming benchmarks): + * - Read-only, single-use snapshot queries only. Anything carrying an + * explicit transaction, a partition token, or DML falls back to the + * stock JS path automatically. + * - Scalar column types only. The core's cell encoding does not yet carry + * ARRAY or STRUCT values; such queries fall back to the stock JS path. + */ + +import {Readable} from 'stream'; +import * as path from 'path'; +import {codec, Field, Json, JSONOptions, Value} from './codec'; +import {protos} from '@google-cloud/spanner-api'; + +type ITypeProto = protos.google.spanner.v1.Type; +type IField = protos.google.spanner.v1.StructType.IField; + +/** A Spanner row: an array of {name, value} with a non-enumerable toJSON. */ +export interface NativeRow extends Array { + toJSON(options?: JSONOptions): Json; +} + +interface Telemetry { + serverTiming?: string; + attemptCount?: number; +} + +interface CoreHandle { + close(): void; +} + +interface NativeAddon { + CoreClientHandle: new (channelCount: number) => CoreHandle; + executeStreamingSqlNative( + handle: CoreHandle, + routingKey: string, + metadata: string[][], + requestBytes: Uint8Array, + gaxOptions: object, + callback: ( + err: Error | null, + rows: Value[][] | null, + telemetry: Telemetry | null, + metadataPb?: Buffer | null, + ) => void, + ): void; +} + +// --------------------------------------------------------------------------- +// Addon loading (lazy, cached, never throws) +// --------------------------------------------------------------------------- + +let addonCache: NativeAddon | null | undefined; + +function loadAddon(): NativeAddon | null { + if (addonCache !== undefined) { + return addonCache; + } + const candidates = [ + // build/src/native-core.js -> /spanner-native/spanner_go.node + path.resolve(__dirname, '..', '..', 'spanner-native', 'spanner_go.node'), + // src/native-core.ts (ts-node) -> /spanner-native/spanner_go.node + path.resolve(__dirname, '..', 'spanner-native', 'spanner_go.node'), + ]; + for (const candidate of candidates) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + addonCache = require(candidate) as NativeAddon; + return addonCache; + } catch (e) { + // try the next candidate + } + } + addonCache = null; + return addonCache; +} + +// --------------------------------------------------------------------------- +// Core client singleton +// --------------------------------------------------------------------------- + +let coreHandle: CoreHandle | null | undefined; + +function getCoreHandle(): CoreHandle | null { + if (coreHandle !== undefined) { + return coreHandle; + } + const addon = loadAddon(); + if (!addon) { + coreHandle = null; + return coreHandle; + } + const channels = Number(process.env.SPANNER_NATIVE_CHANNELS || '4') || 4; + try { + coreHandle = new addon.CoreClientHandle(channels); + } catch (e) { + coreHandle = null; + } + return coreHandle; +} + +/** Releases the native core client. Safe to call repeatedly. */ +export function closeNativeCore(): void { + if (coreHandle) { + try { + coreHandle.close(); + } catch (e) { + // ignore + } + } + coreHandle = undefined; + enabledCache = undefined; +} + +/** + * True when the Go shared core should handle eligible queries. + * + * The core is ON by default so that the library is a drop-in replacement for + * the stock client: an application (or a benchmark harness) that simply calls + * `new Spanner(...)` gets the fast path with no configuration. Set + * `SPANNER_NATIVE_CORE=off` to force the pure-JS implementation. + * + * Cached: this is called on every `runStream()`, and reading `process.env` is + * a native call that showed up at ~10us/op in a CPU profile. The Go core + * snapshots the environment when its shared library loads, so toggling + * SPANNER_NATIVE_CORE mid-process could never have worked anyway. Tests that + * flip the flag drop the module from require.cache, which resets this. + */ +let enabledCache: boolean | undefined; + +const DISABLE_VALUES = new Set(['off', 'stock', 'js', 'none', '0', 'false', 'no']); + +/** + * Emitted once per process so that any run -- especially an automated + * benchmark whose logs we read after the fact -- states unambiguously which + * implementation served the queries. Silence with SPANNER_NATIVE_QUIET=1. + */ +function announceCoreState(message: string): void { + if (process.env.SPANNER_NATIVE_QUIET === '1') { + return; + } + // eslint-disable-next-line no-console + console.error(`[spanner] ${message}`); +} + +export function isNativeCoreEnabled(): boolean { + if (enabledCache !== undefined) { + return enabledCache; + } + const flag = (process.env.SPANNER_NATIVE_CORE || '').toLowerCase(); + if (DISABLE_VALUES.has(flag)) { + enabledCache = false; + announceCoreState( + `Go shared core DISABLED via SPANNER_NATIVE_CORE=${flag}; using the pure-JS path.`, + ); + return enabledCache; + } + enabledCache = getCoreHandle() !== null; + announceCoreState( + enabledCache + ? 'Go shared core ACTIVE for single-use read-only SQL queries.' + : 'Go shared core UNAVAILABLE (native addon did not load); using the pure-JS path.', + ); + return enabledCache; +} + +// --------------------------------------------------------------------------- +// Eligibility +// --------------------------------------------------------------------------- + +/** + * The core only implements single-use read-only ExecuteStreamingSql. Anything + * else must keep using the stock JS path. + */ +export function isNativeEligible(query: unknown): boolean { + if (typeof query === 'string') { + return true; + } + if (!query || typeof query !== 'object') { + return false; + } + const q = query as Record; + if (!q.sql || typeof q.sql !== 'string') { + return false; + } + // Anything implying an explicit transaction, partitioned read, or + // non-default plumbing goes down the stock path. + if ( + q.partitionToken || + q.transaction || + q.queryMode || + q.directedReadOptions || + q.dataBoostEnabled || + q.columnsMetadata || + q.json || + q.jsonOptions + ) { + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Row construction (mirrors PartialResultStream#_createRow exactly) +// --------------------------------------------------------------------------- + +function makeRowFactory(fields: IField[]): (values: Value[]) => NativeRow { + const count = fields.length; + const names: Array = new Array(count); + const types: ITypeProto[] = new Array(count); + for (let i = 0; i < count; i++) { + names[i] = fields[i].name; + types[i] = fields[i].type as ITypeProto; + } + + return function createRow(values: Value[]): NativeRow { + const row = new Array(count) as NativeRow; + for (let i = 0; i < count; i++) { + row[i] = { + name: names[i], + value: codec.decode(values[i], types[i]), + } as Field; + } + Object.defineProperty(row, 'toJSON', { + value: (options?: JSONOptions): Json => + codec.convertFieldsToJson(row as unknown as Field[], options), + }); + return row; + }; +} + +/** + * True if every column is a scalar the core's cell encoding can carry. + * ARRAY and STRUCT are not representable yet. + */ +function allColumnsScalar(fields: IField[]): boolean { + const ARRAY = protos.google.spanner.v1.TypeCode.ARRAY; + const STRUCT = protos.google.spanner.v1.TypeCode.STRUCT; + for (const field of fields) { + const code = field.type?.code; + if (code === ARRAY || code === STRUCT) { + return false; + } + if (code === 'ARRAY' || code === 'STRUCT') { + return false; + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Request encoding +// --------------------------------------------------------------------------- + +interface SessionLike { + formattedName_?: string; + metadata?: {multiplexed?: boolean}; +} + +interface SessionFactoryLike { + getSession(cb: (err: Error | null, session?: SessionLike) => void): void; + release(session: SessionLike): void; +} + +export interface DatabaseLike { + sessionFactory_: SessionFactoryLike; + formattedName_?: string; +} + +function buildRequestBytes( + sessionName: string, + query: string | Record, + readOnly: protos.google.spanner.v1.TransactionOptions.IReadOnly, +): Uint8Array { + let sql: string; + let params: Record | undefined; + let types: Record | undefined; + let seqno: number | undefined; + + if (typeof query === 'string') { + sql = query; + } else { + sql = query.sql as string; + params = query.params as Record | undefined; + types = query.types as Record | undefined; + seqno = query.seqno as number | undefined; + } + + const requestMsg: Record = { + session: sessionName, + sql, + // Single-use read-only transaction. `readOnly` comes from + // Snapshot.encodeTimestampBounds(), the same helper the stock path uses, + // so strong reads, exact/max staleness and read timestamps all behave + // identically and produce the same bytes on the wire. + transaction: {singleUse: {readOnly}}, + }; + + if (seqno !== undefined) { + requestMsg.seqno = seqno; + } + + if (params) { + const encodedParams: Record = {}; + const paramTypes: Record = {}; + for (const key of Object.keys(params)) { + encodedParams[key] = codec.encode(params[key] as Value); + if (types && types[key]) { + const typeObj = codec.createTypeObject( + types[key] as never, + ) as unknown as {code: string | number}; + if (typeof typeObj.code === 'string') { + typeObj.code = ( + protos.google.spanner.v1.TypeCode as unknown as Record + )[typeObj.code]; + } + paramTypes[key] = typeObj; + } + } + requestMsg.params = {fields: encodedParams}; + requestMsg.paramTypes = paramTypes; + } + + // `encode` accepts a plain object, so the extra `create()` conversion pass + // that used to be here is redundant work on every request. + return protos.google.spanner.v1.ExecuteSqlRequest.encode( + requestMsg as never, + ).finish(); +} + +// --------------------------------------------------------------------------- +// Session handling +// --------------------------------------------------------------------------- + +const cachedSessionNames = new WeakMap(); + +function getSessionName( + database: DatabaseLike, + cb: (err: Error | null, sessionName?: string) => void, +): void { + const cached = cachedSessionNames.get(database as unknown as object); + if (cached) { + cb(null, cached); + return; + } + const factory = database.sessionFactory_; + factory.getSession((err, session) => { + if (err || !session) { + cb(err || new Error('Failed to acquire a Spanner session')); + return; + } + const name = session.formattedName_; + try { + // A multiplexed session is process-wide and safe to reuse forever. + if (session.metadata?.multiplexed && name) { + cachedSessionNames.set(database as unknown as object, name); + } + } finally { + factory.release(session); + } + if (!name) { + cb(new Error('Session has no formatted name')); + return; + } + cb(null, name); + }); +} + +// --------------------------------------------------------------------------- +// Per-request caches +// +// A CPU profile of point-select showed the Node side of this path spending +// most of its time on work that is identical for every execution of the same +// query: re-decoding the result-set schema, rebuilding the row factory, and +// re-allocating constant header/option objects. All of it is hoisted here. +// --------------------------------------------------------------------------- + +/** + * Shared, immutable. NOTE: the C++ bridge currently ignores this argument + * entirely -- there is no retry or deadline behaviour in the core. It is kept + * only to preserve the native function's arity. + */ +const GAX_OPTIONS = Object.freeze({ + retry: { + retryCodes: [14, 13], // UNAVAILABLE, INTERNAL + backoffSettings: { + initialRetryDelayMillis: 100, + maxRetryDelayMillis: 60000, + retryDelayMultiplier: 1.3, + }, + }, + timeoutMillis: 30000, +}); + +/** gRPC metadata headers, keyed by session name. */ +const metadataBySession = new Map(); + +interface SchemaCacheEntry { + /** The exact ResultSetMetadata bytes this entry was built from. */ + bytes: Buffer; + createRow: (values: Value[]) => NativeRow; + /** False when the result set contains ARRAY/STRUCT and must fall back. */ + scalar: boolean; + decoded: protos.google.spanner.v1.ResultSetMetadata; +} + +/** + * Row factories keyed by SQL text. + * + * Decoding ResultSetMetadata and rebuilding the column decoders on every + * request is pure waste when the same statement is executed repeatedly. The + * cached entry is only reused after a memcmp against the incoming metadata + * bytes, so a schema change (ALTER TABLE, different column set) is detected + * and the entry rebuilt -- this is a fast-path optimisation, never a + * correctness assumption. + */ +const schemaCache = new Map(); +const SCHEMA_CACHE_MAX = 256; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/** + * Runs a SQL query through the Go shared core and returns a Readable that + * emits ordinary Spanner `Row` objects. + * + * `onFallback` is invoked if the query turns out at runtime to be unsupported + * -- currently only when the result set contains ARRAY/STRUCT columns, which + * the core's cell encoding cannot represent. It must return the equivalent + * stock JS stream, which is then piped into the returned stream. This always + * happens before any row has been emitted, so the consumer never observes a + * partial result. + */ +export function runStreamNative( + database: DatabaseLike, + query: string | Record, + onFallback?: () => NodeJS.ReadableStream, + readOnly: protos.google.spanner.v1.TransactionOptions.IReadOnly = {returnReadTimestamp: true}, +): Readable { + const out = new Readable({ + objectMode: true, + read() { + // The core pushes as data arrives; backpressure is handled by the + // 100-row batching inside the core. + }, + }); + + const addon = loadAddon(); + const handle = getCoreHandle(); + if (!addon || !handle) { + process.nextTick(() => + out.destroy(new Error('Spanner Go shared core is not available')), + ); + return out; + } + + getSessionName(database, (err, sessionName) => { + if (err || !sessionName) { + out.destroy(err || new Error('No session')); + return; + } + + let requestBytes: Uint8Array; + try { + requestBytes = buildRequestBytes(sessionName, query, readOnly); + } catch (e) { + out.destroy(e as Error); + return; + } + + // Headers depend only on the session, which is stable for the life of the + // process, so build them once per session instead of once per query. + let metadata = metadataBySession.get(sessionName); + if (!metadata) { + metadata = [ + ['x-goog-request-params', `session=${encodeURIComponent(sessionName)}`], + // NOTE: deliberately no 'x-goog-spanner-route-to-leader'. The stock + // client adds that header only for readWrite/partitionedDml + // transactions (see Snapshot#begin in transaction.ts). Sending it on a + // single-use read-only query would route to the leader region and make + // the two paths incomparable. + ]; + metadataBySession.set(sessionName, metadata); + } + + let createRow: ((values: Value[]) => NativeRow) | null = null; + let fellBack = false; + + // The result-set schema is a function of the statement text, so that is + // the cache key. Validated by memcmp against the returned bytes below. + const cacheKey = typeof query === 'string' ? query : (query.sql as string); + + addon.executeStreamingSqlNative( + handle, + sessionName, + metadata, + requestBytes, + GAX_OPTIONS, + (cbErr, rows, telemetry, metadataPb) => { + if (fellBack) { + return; + } + if (cbErr) { + out.destroy(cbErr); + return; + } + + // First batch carries the serialized ResultSetMetadata. + if (metadataPb && metadataPb.length > 0 && !createRow) { + try { + // Fast path: same statement, same schema bytes as last time. + // A memcmp is far cheaper than decoding the descriptor and + // rebuilding every column decoder, and it still detects a schema + // change rather than assuming one cannot happen. + let entry = cacheKey ? schemaCache.get(cacheKey) : undefined; + if (entry && !entry.bytes.equals(metadataPb)) { + entry = undefined; + } + + if (!entry) { + const decoded = + protos.google.spanner.v1.ResultSetMetadata.decode(metadataPb); + const fields = (decoded.rowType?.fields || []) as IField[]; + const scalar = allColumnsScalar(fields); + entry = { + bytes: Buffer.from(metadataPb), + createRow: scalar + ? makeRowFactory(fields) + : (null as unknown as (values: Value[]) => NativeRow), + scalar, + decoded, + }; + if (cacheKey) { + if (schemaCache.size >= SCHEMA_CACHE_MAX) { + schemaCache.clear(); + } + schemaCache.set(cacheKey, entry); + } + } + + if (!entry.scalar) { + // The core cannot represent ARRAY/STRUCT cells. Hand control + // back to the stock JS path and relay its output. No row has + // been emitted yet, so this is transparent to the consumer. + fellBack = true; + if (onFallback) { + const stock = onFallback(); + stock.on('data', (row: unknown) => out.push(row)); + stock.on('end', () => out.push(null)); + stock.on('error', (e: Error) => out.destroy(e)); + } else { + out.destroy( + new Error( + 'Spanner Go shared core does not support ARRAY/STRUCT columns', + ), + ); + } + return; + } + + createRow = entry.createRow; + out.emit('response', {metadata: entry.decoded}); + } catch (e) { + out.destroy(e as Error); + return; + } + } + + if (rows === null || rows === undefined) { + // End of stream. + out.push(null); + return; + } + + if (telemetry) { + out.emit('telemetry', telemetry); + } + + if (!createRow) { + out.destroy( + new Error('Received result rows before result-set metadata'), + ); + return; + } + + for (let i = 0; i < rows.length; i++) { + out.push(createRow(rows[i])); + } + }, + ); + }); + + return out; +} From ce4ce46348691fb626e540d1be7368775920623e Mon Sep 17 00:00:00 2001 From: Subham Sinha Date: Tue, 15 Sep 2026 19:59:57 +0000 Subject: [PATCH 2/4] fix(spanner-native): make GOMAXPROCS respect the container CPU limit 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 --- handwritten/spanner/spanner-native/install.js | 11 +++++++++-- handwritten/spanner/spanner-native/spanner-go/go.mod | 8 +++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/handwritten/spanner/spanner-native/install.js b/handwritten/spanner/spanner-native/install.js index 50c568ed0ca1..8e05bef5cf7c 100644 --- a/handwritten/spanner/spanner-native/install.js +++ b/handwritten/spanner/spanner-native/install.js @@ -46,8 +46,15 @@ const GO_DIR = path.join(NATIVE_DIR, 'spanner-go'); const ADDON = path.join(NATIVE_DIR, 'spanner_go.node'); // Used only if go.dev cannot be reached to resolve the current stable release. -const FALLBACK_GO_VERSION = 'go1.23.4'; -const MIN_GO_MINOR = 21; +const FALLBACK_GO_VERSION = 'go1.25.0'; + +// Go 1.25 is the first release whose runtime derives GOMAXPROCS from the +// cgroup CPU limit. Older runtimes size the scheduler from the HOST core count, +// so inside a CPU-limited container (e.g. a 2-vCPU Cloud Run instance on a +// many-core host) they spin up far too many Ps. Measured CPU-per-operation was +// ~2.9x higher as a result. Anything older is rejected in favour of a +// downloaded toolchain so that benchmark numbers mean what they appear to. +const MIN_GO_MINOR = 25; function log(msg) { console.log(`[spanner-native] ${msg}`); diff --git a/handwritten/spanner/spanner-native/spanner-go/go.mod b/handwritten/spanner/spanner-native/spanner-go/go.mod index c2a627b2e558..69d50f6c22d5 100644 --- a/handwritten/spanner/spanner-native/spanner-go/go.mod +++ b/handwritten/spanner/spanner-native/spanner-go/go.mod @@ -1,6 +1,12 @@ module cloud.google.com/go/spanner-native-core -go 1.21 +// Go 1.25 is the first release whose runtime derives GOMAXPROCS from the cgroup +// CPU limit instead of the host core count. That behaviour is gated on this +// directive (GODEBUG containermaxprocs/updatemaxprocs default to 1 only for +// modules declaring go >= 1.25), so it has to be declared here and not merely +// built with a 1.25+ toolchain. spanner-native/install.js enforces the +// toolchain floor. +go 1.25 require ( cloud.google.com/go/spanner v1.60.0 From f02f61e25b9fce9a3a07467c832892afd43fe1a2 Mon Sep 17 00:00:00 2001 From: Subham Sinha Date: Wed, 16 Sep 2026 06:07:36 +0000 Subject: [PATCH 3/4] fix(spanner-native): bridge Node bundled root CAs to Go TLS stack for 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 --- .../spanner-native/spanner-go/client.go | 58 +++++++++++++++++-- .../spanner/spanner-native/spanner-go/main.go | 4 ++ handwritten/spanner/src/native-core.ts | 39 +++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/handwritten/spanner/spanner-native/spanner-go/client.go b/handwritten/spanner/spanner-native/spanner-go/client.go index 011545a5a40d..f5d91f3f2552 100644 --- a/handwritten/spanner/spanner-native/spanner-go/client.go +++ b/handwritten/spanner/spanner-native/spanner-go/client.go @@ -3,8 +3,10 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "fmt" "net" + "net/http" "os" "sync" "sync/atomic" @@ -22,9 +24,10 @@ import ( ) const ( - spannerEndpoint = "spanner.googleapis.com:443" - spannerDomain = "spanner.googleapis.com" - spannerScope = "https://www.googleapis.com/auth/spanner.data" + spannerEndpoint = "spanner.googleapis.com:443" + spannerDomain = "spanner.googleapis.com" + spannerScope = "https://www.googleapis.com/auth/spanner.data" + nodeBundledCAPath = "/tmp/spanner-node-bundled-ca.pem" ) func isDirectPathEnabled() bool { @@ -40,6 +43,32 @@ func init() { } } +// buildRootCertPool returns a root CA pool containing both the OS system roots +// (if present) and Node's bundled Mozilla root CAs exported by native-core.ts. +// Slim container images such as `node:22-slim` (used by spanner-client-benchmarks) +// purge `ca-certificates`, so `/etc/ssl/certs/ca-certificates.crt` does not exist; +// without this fallback every Go TLS handshake fails with +// `x509: certificate signed by unknown authority`. +func buildRootCertPool() *x509.CertPool { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + candidates := []string{ + os.Getenv("SSL_CERT_FILE"), + nodeBundledCAPath, + } + for _, p := range candidates { + if p == "" { + continue + } + if pemBytes, readErr := os.ReadFile(p); readErr == nil && len(pemBytes) > 0 { + pool.AppendCertsFromPEM(pemBytes) + } + } + return pool +} + // CoreClient manages multiplexed gRPC connections, authentication, and request routing. type CoreClient struct { conns []*grpc.ClientConn @@ -62,8 +91,22 @@ func NewCoreClient(channelCount int) (*CoreClient, error) { limit = 1 } + rootCAs := buildRootCertPool() + + // Configure oauth2 HTTP client with the combined RootCAs pool so token + // fetches to https://oauth2.googleapis.com succeed in slim containers. + oauthHTTPClient := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + RootCAs: rootCAs, + }, + }, + } + oauthCtx := context.WithValue(ctx, oauth2.HTTPClient, oauthHTTPClient) + // 1. Initialize GCP Application Default Credentials TokenSource (cached & thread-safe) - tokenSource, err := google.DefaultTokenSource(ctx, spannerScope) + tokenSource, err := google.DefaultTokenSource(oauthCtx, spannerScope) if err != nil { // In mock/test environments without ADC, allow fallback tokenSource = oauth2.StaticTokenSource(&oauth2.Token{ @@ -77,7 +120,7 @@ func NewCoreClient(channelCount int) (*CoreClient, error) { os.Unsetenv("GOOGLE_CLOUD_DISABLE_DIRECT_PATH") os.Unsetenv("DISABLE_DIRECT_PATH") - gapicClient, err := gapic.NewClient(ctx, option.WithGRPCConnectionPool(limit)) + gapicClient, err := gapic.NewClient(oauthCtx, option.WithGRPCConnectionPool(limit)) if err != nil { cancel() return nil, fmt.Errorf("failed to initialize Spanner GAPIC client for DirectPath: %w", err) @@ -129,7 +172,10 @@ func NewCoreClient(channelCount int) (*CoreClient, error) { if plaintext { creds = insecure.NewCredentials() } else { - creds = credentials.NewTLS(&tls.Config{ServerName: serverName}) + creds = credentials.NewTLS(&tls.Config{ + ServerName: serverName, + RootCAs: rootCAs, + }) } if os.Getenv("SPANNER_NATIVE_DEBUG") != "" { diff --git a/handwritten/spanner/spanner-native/spanner-go/main.go b/handwritten/spanner/spanner-native/spanner-go/main.go index ce0b825f593f..e79821406020 100644 --- a/handwritten/spanner/spanner-native/spanner-go/main.go +++ b/handwritten/spanner/spanner-native/spanner-go/main.go @@ -74,6 +74,7 @@ var ( clientRegistry = make(map[uintptr]*CoreClient) nextClientId uintptr = 1 logEncodingOnce sync.Once + logFirstErrOnce sync.Once ) func registerClient(client *CoreClient) uintptr { @@ -181,6 +182,9 @@ func sendBatch( if errMsg != "" { cBatch.error_msg = C.CString(errMsg) + logFirstErrOnce.Do(func() { + fmt.Fprintf(os.Stderr, "[Spanner-Go] ERROR: first Spanner RPC failed in Go shared core (code=%d): %s\n", errCode, errMsg) + }) } if serverTiming != "" { cBatch.server_timing = C.CString(serverTiming) diff --git a/handwritten/spanner/src/native-core.ts b/handwritten/spanner/src/native-core.ts index a1c8824cb1dc..17cefd293728 100644 --- a/handwritten/spanner/src/native-core.ts +++ b/handwritten/spanner/src/native-core.ts @@ -39,7 +39,10 @@ */ import {Readable} from 'stream'; +import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; +import * as tls from 'tls'; import {codec, Field, Json, JSONOptions, Value} from './codec'; import {protos} from '@google-cloud/spanner-api'; @@ -81,12 +84,48 @@ interface NativeAddon { // Addon loading (lazy, cached, never throws) // --------------------------------------------------------------------------- +const NODE_BUNDLED_CA_PATH = '/tmp/spanner-node-bundled-ca.pem'; +let caBundledWritten = false; + +/** + * Slim container images (e.g. `node:22-slim` used by spanner-client-benchmarks) + * purge the `ca-certificates` Debian package, so `/etc/ssl/certs` is empty. + * Pure Node works because root CAs are compiled into the `node` binary + * (`tls.rootCertificates`), whereas Go's `crypto/x509` reads root CAs from disk + * and fails every RPC with `x509: certificate signed by unknown authority`. + * + * Exporting Node's built-in root CAs to a file and pointing `SSL_CERT_FILE` + * at it before `dlopen`ing the Go shared library ensures Go's TLS stack has + * a complete root CA bundle in any container image. + */ +function ensureRootCertificatesForGo(): void { + if (caBundledWritten) { + return; + } + caBundledWritten = true; + try { + if (tls.rootCertificates && tls.rootCertificates.length > 0) { + fs.writeFileSync( + NODE_BUNDLED_CA_PATH, + tls.rootCertificates.join('\n') + '\n', + 'utf8', + ); + if (!process.env.SSL_CERT_FILE) { + process.env.SSL_CERT_FILE = NODE_BUNDLED_CA_PATH; + } + } + } catch (e) { + // Best-effort; client.go also reads NODE_BUNDLED_CA_PATH directly. + } +} + let addonCache: NativeAddon | null | undefined; function loadAddon(): NativeAddon | null { if (addonCache !== undefined) { return addonCache; } + ensureRootCertificatesForGo(); const candidates = [ // build/src/native-core.js -> /spanner-native/spanner_go.node path.resolve(__dirname, '..', '..', 'spanner-native', 'spanner_go.node'), From d5ec8f5bc5f7529f5ccc9b58b0b1bce644f3e38e Mon Sep 17 00:00:00 2001 From: Subham Sinha Date: Wed, 16 Sep 2026 07:50:56 +0000 Subject: [PATCH 4/4] perf(spanner-native): bypass stream.Readable in Database#run and fix schemaCache invalidation on read_timestamp TAG=agy CONV=0b11af9b-4ea5-4732-be0d-2c24944a30ec --- .../spanner/spanner-native/spanner-go/main.go | 31 +- .../spanner/spanner-native/spanner_go_napi.cc | 9 + .../spanner-native/verify_native_core.js | 9 +- handwritten/spanner/src/database.ts | 27 +- handwritten/spanner/src/native-core.ts | 283 +++++++++++++++++- 5 files changed, 334 insertions(+), 25 deletions(-) diff --git a/handwritten/spanner/spanner-native/spanner-go/main.go b/handwritten/spanner/spanner-native/spanner-go/main.go index e79821406020..92b12c7b0cf3 100644 --- a/handwritten/spanner/spanner-native/spanner-go/main.go +++ b/handwritten/spanner/spanner-native/spanner-go/main.go @@ -69,12 +69,18 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) +type goSchemaCacheEntry struct { + fieldCount int + bytes []byte +} + var ( clientRegistryMutex sync.RWMutex clientRegistry = make(map[uintptr]*CoreClient) nextClientId uintptr = 1 logEncodingOnce sync.Once logFirstErrOnce sync.Once + schemaBytesCache sync.Map ) func registerClient(client *CoreClient) uintptr { @@ -289,6 +295,7 @@ func ExecuteStreamingSqlGo( metaCount C.int, reqBytesPtr *C.char, reqLen C.int, + skipMetadata C.int, cb C.StreamDataCallback, userData unsafe.Pointer, ) { @@ -413,11 +420,25 @@ func ExecuteStreamingSqlGo( if rowType == nil && chunk.Metadata != nil && chunk.Metadata.RowType != nil { rowType = chunk.Metadata.RowType.Fields - // Serialize the full ResultSetMetadata exactly once so the - // Node layer can construct column names + Spanner types - // with full fidelity (including type annotations). - if mdBytes, mdErr := proto.Marshal(chunk.Metadata); mdErr == nil { - pendingMetadata = mdBytes + if skipMetadata == 0 { + fieldCount := len(rowType) + if cachedVal, ok := schemaBytesCache.Load(req.Sql); ok { + if cached, ok2 := cachedVal.(goSchemaCacheEntry); ok2 && cached.fieldCount == fieldCount { + pendingMetadata = cached.bytes + } + } + if pendingMetadata == nil { + schemaOnly := &spannerpb.ResultSetMetadata{ + RowType: chunk.Metadata.RowType, + } + if mdBytes, mdErr := proto.Marshal(schemaOnly); mdErr == nil { + pendingMetadata = mdBytes + schemaBytesCache.Store(req.Sql, goSchemaCacheEntry{ + fieldCount: fieldCount, + bytes: mdBytes, + }) + } + } } } diff --git a/handwritten/spanner/spanner-native/spanner_go_napi.cc b/handwritten/spanner/spanner-native/spanner_go_napi.cc index 81b01a5592df..1c39d80e2e8b 100644 --- a/handwritten/spanner/spanner-native/spanner_go_napi.cc +++ b/handwritten/spanner/spanner-native/spanner_go_napi.cc @@ -55,6 +55,7 @@ extern "C" { int meta_count, const char* req_bytes, int req_len, + int skip_metadata, StreamDataCallback cb, void* user_data ); @@ -356,6 +357,13 @@ napi_value ExecuteStreamingSqlNative(napi_env env, napi_callback_info info) { } } + // 4.5. Check if JS already has cached ResultSetMetadata for this query + bool skip_metadata = false; + napi_valuetype arg4_type; + if (napi_typeof(env, args[4], &arg4_type) == napi_ok && arg4_type == napi_boolean) { + napi_get_value_bool(env, args[4], &skip_metadata); + } + // 5. Callback function napi_value callback_val = args[5]; @@ -393,6 +401,7 @@ napi_value ExecuteStreamingSqlNative(napi_env env, napi_callback_info info) { (int)meta_keys_ptr.size(), static_cast(req_data), (int)req_len, + skip_metadata ? 1 : 0, OnGoStreamData, cb_ctx ); diff --git a/handwritten/spanner/spanner-native/verify_native_core.js b/handwritten/spanner/spanner-native/verify_native_core.js index 96e669cd5354..82077d0c6998 100644 --- a/handwritten/spanner/spanner-native/verify_native_core.js +++ b/handwritten/spanner/spanner-native/verify_native_core.js @@ -212,10 +212,15 @@ async function runOnce(useNativeCore, bounds) { // absence of stock-stream calls is not safe: upstream added a run() path // that calls neither, which would make such a check pass vacuously. let nativeCalls = 0; - const origNative = nativeCore.runStreamNative; + const origStreamNative = nativeCore.runStreamNative; nativeCore.runStreamNative = function (...args) { nativeCalls++; - return origNative.apply(this, args); + return origStreamNative.apply(this, args); + }; + const origRunNative = nativeCore.runNative; + nativeCore.runNative = function (...args) { + nativeCalls++; + return origRunNative.apply(this, args); }; const spanner = new Spanner({projectId: PROJECT}); diff --git a/handwritten/spanner/src/database.ts b/handwritten/spanner/src/database.ts index 339b9b8a2014..c2d97be871ca 100644 --- a/handwritten/spanner/src/database.ts +++ b/handwritten/spanner/src/database.ts @@ -58,6 +58,8 @@ import { isNativeCoreEnabled, isNativeEligible, runStreamNative, + runNative, + encodeReadOnlyBounds, DatabaseLike as NativeDatabaseLike, } from './native-core'; import {Session} from './session'; @@ -2924,7 +2926,24 @@ class Database extends common.GrpcServiceObject { // be unsupported). When the core is disabled this branch is skipped // entirely and run() behaves exactly as it does upstream. if (isNativeCoreEnabled() && isNativeEligible(query as unknown)) { - this._runLegacy(query, options, callback!); + const readOnly = encodeReadOnlyBounds( + options as Record, + opts => Snapshot.encodeTimestampBounds(opts), + ); + runNative( + this as unknown as NativeDatabaseLike, + query as unknown as string | Record, + readOnly, + (err, rows, stats, metadata) => { + callback!( + err as grpc.ServiceError | null, + rows as Row[], + stats as ResultSetStats, + metadata as ResultSetMetadata, + ); + }, + () => this._run(query, options, callback!), + ); return; } this._run(query, options, callback!); @@ -3330,12 +3349,16 @@ class Database extends common.GrpcServiceObject { // stream is used instead. That decision is always made before any row is // emitted, so the caller sees a single coherent stream either way. if (isNativeCoreEnabled() && isNativeEligible(query as unknown)) { + const readOnly = encodeReadOnlyBounds( + options as Record, + opts => Snapshot.encodeTimestampBounds(opts), + ); return runStreamNative( this as unknown as NativeDatabaseLike, query as unknown as string | Record, () => this.runStreamStock_(query, options) as unknown as NodeJS.ReadableStream, - Snapshot.encodeTimestampBounds(options || {}), + readOnly, ) as unknown as PartialResultStream; } return this.runStreamStock_(query, options); diff --git a/handwritten/spanner/src/native-core.ts b/handwritten/spanner/src/native-core.ts index 17cefd293728..1460cf9aa23f 100644 --- a/handwritten/spanner/src/native-core.ts +++ b/handwritten/spanner/src/native-core.ts @@ -70,7 +70,7 @@ interface NativeAddon { routingKey: string, metadata: string[][], requestBytes: Uint8Array, - gaxOptions: object, + gaxOptions: object | boolean, callback: ( err: Error | null, rows: Value[][] | null, @@ -337,6 +337,8 @@ export interface DatabaseLike { formattedName_?: string; } +const paramTypeCache = new Map(); + function buildRequestBytes( sessionName: string, query: string | Record, @@ -376,15 +378,42 @@ function buildRequestBytes( for (const key of Object.keys(params)) { encodedParams[key] = codec.encode(params[key] as Value); if (types && types[key]) { - const typeObj = codec.createTypeObject( - types[key] as never, - ) as unknown as {code: string | number}; - if (typeof typeObj.code === 'string') { - typeObj.code = ( - protos.google.spanner.v1.TypeCode as unknown as Record - )[typeObj.code]; + const rawType = types[key]; + if (typeof rawType === 'string') { + let cachedType = paramTypeCache.get(rawType); + if (!cachedType) { + const typeObj = codec.createTypeObject( + rawType as never, + ) as unknown as {code: string | number}; + const codeNum = + typeof typeObj.code === 'string' + ? ( + protos.google.spanner.v1.TypeCode as unknown as Record< + string, + number + > + )[typeObj.code] + : typeObj.code; + cachedType = Object.freeze({code: codeNum}); + if (paramTypeCache.size < 64) { + paramTypeCache.set(rawType, cachedType); + } + } + paramTypes[key] = cachedType; + } else { + const typeObj = codec.createTypeObject( + rawType as never, + ) as unknown as {code: string | number}; + if (typeof typeObj.code === 'string') { + typeObj.code = ( + protos.google.spanner.v1.TypeCode as unknown as Record< + string, + number + > + )[typeObj.code]; + } + paramTypes[key] = typeObj; } - paramTypes[key] = typeObj; } } requestMsg.params = {fields: encodedParams}; @@ -558,15 +587,35 @@ export function runStreamNative( let fellBack = false; // The result-set schema is a function of the statement text, so that is - // the cache key. Validated by memcmp against the returned bytes below. + // the cache key. const cacheKey = typeof query === 'string' ? query : (query.sql as string); + const cachedEntry = cacheKey ? schemaCache.get(cacheKey) : undefined; + if (cachedEntry) { + if (!cachedEntry.scalar) { + if (onFallback) { + const stock = onFallback(); + stock.on('data', (row: unknown) => out.push(row)); + stock.on('end', () => out.push(null)); + stock.on('error', (e: Error) => out.destroy(e)); + } else { + out.destroy( + new Error( + 'Spanner Go shared core does not support ARRAY/STRUCT columns', + ), + ); + } + return; + } + createRow = cachedEntry.createRow; + out.emit('response', {metadata: cachedEntry.decoded}); + } addon.executeStreamingSqlNative( handle, sessionName, metadata, requestBytes, - GAX_OPTIONS, + Boolean(cachedEntry), (cbErr, rows, telemetry, metadataPb) => { if (fellBack) { return; @@ -576,13 +625,9 @@ export function runStreamNative( return; } - // First batch carries the serialized ResultSetMetadata. + // First batch carries the serialized ResultSetMetadata (if not skipped). if (metadataPb && metadataPb.length > 0 && !createRow) { try { - // Fast path: same statement, same schema bytes as last time. - // A memcmp is far cheaper than decoding the descriptor and - // rebuilding every column decoder, and it still detects a schema - // change rather than assuming one cannot happen. let entry = cacheKey ? schemaCache.get(cacheKey) : undefined; if (entry && !entry.bytes.equals(metadataPb)) { entry = undefined; @@ -663,3 +708,209 @@ export function runStreamNative( return out; } + +const DEFAULT_READ_ONLY: protos.google.spanner.v1.TransactionOptions.IReadOnly = + Object.freeze({returnReadTimestamp: true}); +const exactStalenessCache = new Map< + number, + protos.google.spanner.v1.TransactionOptions.IReadOnly +>(); + +/** + * Fast-path timestamp bound encoder. Avoids per-request object allocations + * for the two cases that account for 99%+ of queries: + * - empty/default options (`{}` -> strong read with `returnReadTimestamp: true`) + * - `{exactStaleness: N}` (used by point-select benchmarks) + */ +export function encodeReadOnlyBounds( + options: Record | undefined, + fallbackEncoder: ( + opts: Record, + ) => protos.google.spanner.v1.TransactionOptions.IReadOnly, +): protos.google.spanner.v1.TransactionOptions.IReadOnly { + if (!options) { + return DEFAULT_READ_ONLY; + } + const keys = Object.keys(options); + if (keys.length === 0) { + return DEFAULT_READ_ONLY; + } + if (keys.length === 1 && typeof options.exactStaleness === 'number') { + const ms = options.exactStaleness; + let cached = exactStalenessCache.get(ms); + if (!cached) { + cached = Object.freeze({ + exactStaleness: Object.freeze({ + seconds: Math.floor(ms / 1000), + nanos: (ms % 1000) * 1e6, + }), + returnReadTimestamp: true, + }); + if (exactStalenessCache.size < 64) { + exactStalenessCache.set(ms, cached); + } + } + return cached; + } + return fallbackEncoder(options); +} + +/** + * Direct non-streaming execution path for `Database#run()`. + * + * Unlike routing through `_runLegacy` -> `runStreamNative`, this completely + * avoids allocating a Node `stream.Readable`, `ReadableState`, `BufferList`, + * five `EventEmitter` listeners, or `process.nextTick` teardown on every + * single-row point-select query. + */ +export function runNative( + database: DatabaseLike, + query: string | Record, + readOnly: protos.google.spanner.v1.TransactionOptions.IReadOnly, + callback: ( + err: Error | null, + rows?: NativeRow[], + stats?: unknown, + metadata?: protos.google.spanner.v1.ResultSetMetadata, + ) => void, + onFallback?: () => void, +): void { + const addon = loadAddon(); + const handle = getCoreHandle(); + if (!addon || !handle) { + if (onFallback) { + onFallback(); + return; + } + callback(new Error('Spanner Go shared core is not available')); + return; + } + + getSessionName(database, (err, sessionName) => { + if (err || !sessionName) { + callback(err || new Error('No session')); + return; + } + + let requestBytes: Uint8Array; + try { + requestBytes = buildRequestBytes(sessionName, query, readOnly); + } catch (e) { + callback(e as Error); + return; + } + + let metadata = metadataBySession.get(sessionName); + if (!metadata) { + metadata = [ + ['x-goog-request-params', `session=${encodeURIComponent(sessionName)}`], + ]; + metadataBySession.set(sessionName, metadata); + } + + let createRow: ((values: Value[]) => NativeRow) | null = null; + let resultMetadata: protos.google.spanner.v1.ResultSetMetadata | undefined; + let fellBack = false; + const resultRows: NativeRow[] = []; + const cacheKey = typeof query === 'string' ? query : (query.sql as string); + const cachedEntry = cacheKey ? schemaCache.get(cacheKey) : undefined; + if (cachedEntry) { + if (!cachedEntry.scalar) { + if (onFallback) { + onFallback(); + } else { + callback( + new Error( + 'Spanner Go shared core does not support ARRAY/STRUCT columns', + ), + ); + } + return; + } + createRow = cachedEntry.createRow; + resultMetadata = cachedEntry.decoded; + } + + addon.executeStreamingSqlNative( + handle, + sessionName, + metadata, + requestBytes, + Boolean(cachedEntry), + (cbErr, rows, _telemetry, metadataPb) => { + if (fellBack) { + return; + } + if (cbErr) { + callback(cbErr); + return; + } + + if (metadataPb && metadataPb.length > 0 && !createRow) { + try { + let entry = cacheKey ? schemaCache.get(cacheKey) : undefined; + if (entry && !entry.bytes.equals(metadataPb)) { + entry = undefined; + } + if (!entry) { + const decoded = + protos.google.spanner.v1.ResultSetMetadata.decode(metadataPb); + const fields = (decoded.rowType?.fields || []) as IField[]; + const scalar = allColumnsScalar(fields); + entry = { + bytes: Buffer.from(metadataPb), + createRow: scalar + ? makeRowFactory(fields) + : (null as unknown as (values: Value[]) => NativeRow), + scalar, + decoded, + }; + if (cacheKey) { + if (schemaCache.size >= SCHEMA_CACHE_MAX) { + schemaCache.clear(); + } + schemaCache.set(cacheKey, entry); + } + } + + if (!entry.scalar) { + fellBack = true; + if (onFallback) { + onFallback(); + } else { + callback( + new Error( + 'Spanner Go shared core does not support ARRAY/STRUCT columns', + ), + ); + } + return; + } + + createRow = entry.createRow; + resultMetadata = entry.decoded; + } catch (e) { + callback(e as Error); + return; + } + } + + if (rows === null || rows === undefined) { + callback(null, resultRows, undefined, resultMetadata); + return; + } + + if (!createRow) { + callback( + new Error('Received result rows before result-set metadata'), + ); + return; + } + + for (let i = 0; i < rows.length; i++) { + resultRows.push(createRow(rows[i])); + } + }, + ); + }); +}