diff --git a/handwritten/spanner/package.json b/handwritten/spanner/package.json index 0bd522a07d7..b171f9f6fd1 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 00000000000..55c6a8d7748 --- /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 00000000000..7fabdecbb7e --- /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 00000000000..8e05bef5cf7 --- /dev/null +++ b/handwritten/spanner/spanner-native/install.js @@ -0,0 +1,293 @@ +#!/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.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}`); +} + +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 00000000000..6dea4124ac1 --- /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 -pthread -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 00000000000..aa0c5b39473 --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/client.go @@ -0,0 +1,473 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "hash/fnv" + "net" + "net/http" + "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" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" +) + +const ( + 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 { + 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") + } +} + +// 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 + 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, customEndpoint string) (*CoreClient, error) { + ctx, cancel := context.WithCancel(context.Background()) + + limit := channelCount + if limit <= 0 { + 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(oauthCtx, spannerScope) + if err != nil { + // In mock/test environments without ADC, allow fallback + tokenSource = oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: "mock-token", + TokenType: "Bearer", + }) + } + + if isDirectPathEnabled() && customEndpoint == "" { + // 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(oauthCtx, 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; + // customEndpoint or SPANNER_EMULATOR_HOST selects a plaintext local emulator and + // SPANNER_NATIVE_ENDPOINT overrides the host while keeping TLS. + endpoint := spannerEndpoint + serverName := spannerDomain + plaintext := false + + if customEndpoint != "" { + endpoint = customEndpoint + if host, _, splitErr := net.SplitHostPort(customEndpoint); splitErr == nil { + if host == "127.0.0.1" || host == "localhost" || host == "0.0.0.0" { + plaintext = true + } else { + serverName = host + } + } else if customEndpoint == "127.0.0.1" || customEndpoint == "localhost" { + plaintext = true + } + } else 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, + RootCAs: rootCAs, + }) + } + + 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 +} + +// rawProtoCodec allows passing raw pre-encoded protobuf []byte buffers directly to/from +// gRPC streams and unary calls without unmarshaling or re-marshaling in Go, while +// still supporting standard proto.Message structs (e.g., PartialResultSet). +type rawProtoCodec struct{} + +func (rawProtoCodec) Marshal(v interface{}) ([]byte, error) { + if b, ok := v.([]byte); ok { + return b, nil + } + if bp, ok := v.(*[]byte); ok { + return *bp, nil + } + return proto.Marshal(v.(proto.Message)) +} + +func (rawProtoCodec) Unmarshal(data []byte, v interface{}) error { + if bp, ok := v.(*[]byte); ok { + *bp = append((*bp)[:0], data...) + return nil + } + return proto.Unmarshal(data, v.(proto.Message)) +} + +func (rawProtoCodec) Name() string { + return "" +} + +type rawStreamClient struct { + grpc.ClientStream +} + +func (x *rawStreamClient) Recv() (*spannerpb.PartialResultSet, error) { + m := new(spannerpb.PartialResultSet) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +var streamingSqlStreamDesc = &grpc.StreamDesc{ + StreamName: "ExecuteStreamingSql", + ServerStreams: true, +} + +// ExecuteStreamingSqlRaw dispatches ExecuteStreamingSql using raw request bytes from Node.js +// without unmarshaling/re-marshaling the request in Go, while decoding PartialResultSet in Go. +func (c *CoreClient) ExecuteStreamingSqlRaw(ctx context.Context, routingKey string, reqBytes []byte) (spannerpb.Spanner_ExecuteStreamingSqlClient, error) { + if c.useGapic && c.gapicClient != nil { + var req spannerpb.ExecuteSqlRequest + if err := proto.Unmarshal(reqBytes, &req); err != nil { + return nil, err + } + return c.gapicClient.ExecuteStreamingSql(ctx, &req) + } + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, fmt.Errorf("no active gRPC connection available") + } + stream, err := conn.NewStream(ctx, streamingSqlStreamDesc, "/google.spanner.v1.Spanner/ExecuteStreamingSql", grpc.ForceCodec(rawProtoCodec{})) + if err != nil { + return nil, err + } + if err := stream.SendMsg(reqBytes); err != nil { + return nil, err + } + if err := stream.CloseSend(); err != nil { + return nil, err + } + return &rawStreamClient{stream}, nil +} + +// InvokeRaw dispatches a unary gRPC call using raw pre-encoded protobuf request bytes +// and returns raw wire-level protobuf response bytes without unmarshaling/marshaling in Go. +func (c *CoreClient) InvokeRaw(ctx context.Context, routingKey string, method string, reqBytes []byte) ([]byte, metadata.MD, error) { + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, nil, fmt.Errorf("no active gRPC connection available") + } + var respBytes []byte + var trailer metadata.MD + err := conn.Invoke(ctx, method, reqBytes, &respBytes, grpc.ForceCodec(rawProtoCodec{}), grpc.Trailer(&trailer)) + return respBytes, trailer, err +} + +// 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] +} + +// GetConnByKey returns a connection pinned to routingKey if non-empty, or round-robin otherwise. +func (c *CoreClient) GetConnByKey(routingKey string) *grpc.ClientConn { + count := uint64(len(c.conns)) + if count == 0 { + return nil + } + if routingKey == "" { + return c.GetConn() + } + h := fnv.New64a() + _, _ = h.Write([]byte(routingKey)) + return c.conns[h.Sum64()%count] +} + +// BeginTransaction dispatches a unary BeginTransaction call. +func (c *CoreClient) BeginTransaction(ctx context.Context, routingKey string, req *spannerpb.BeginTransactionRequest) (*spannerpb.Transaction, metadata.MD, error) { + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, nil, fmt.Errorf("no active gRPC connection available") + } + var trailer metadata.MD + resp, err := spannerpb.NewSpannerClient(conn).BeginTransaction(ctx, req, grpc.Trailer(&trailer)) + return resp, trailer, err +} + +// Commit dispatches a unary Commit call. +func (c *CoreClient) Commit(ctx context.Context, routingKey string, req *spannerpb.CommitRequest) (*spannerpb.CommitResponse, metadata.MD, error) { + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, nil, fmt.Errorf("no active gRPC connection available") + } + var trailer metadata.MD + resp, err := spannerpb.NewSpannerClient(conn).Commit(ctx, req, grpc.Trailer(&trailer)) + return resp, trailer, err +} + +// ExecuteBatchDml dispatches a unary ExecuteBatchDml call. +func (c *CoreClient) ExecuteBatchDml(ctx context.Context, routingKey string, req *spannerpb.ExecuteBatchDmlRequest) (*spannerpb.ExecuteBatchDmlResponse, metadata.MD, error) { + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, nil, fmt.Errorf("no active gRPC connection available") + } + var trailer metadata.MD + resp, err := spannerpb.NewSpannerClient(conn).ExecuteBatchDml(ctx, req, grpc.Trailer(&trailer)) + return resp, trailer, err +} + +// ExecuteSql dispatches a unary ExecuteSql call (used for DML runUpdate). +func (c *CoreClient) ExecuteSql(ctx context.Context, routingKey string, req *spannerpb.ExecuteSqlRequest) (*spannerpb.ResultSet, metadata.MD, error) { + conn := c.GetConnByKey(routingKey) + if conn == nil { + return nil, nil, fmt.Errorf("no active gRPC connection available") + } + var trailer metadata.MD + resp, err := spannerpb.NewSpannerClient(conn).ExecuteSql(ctx, req, grpc.Trailer(&trailer)) + return resp, trailer, err +} + +// 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 00000000000..f3a7904835d --- /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 00000000000..69d50f6c22d --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/go.mod @@ -0,0 +1,43 @@ +module cloud.google.com/go/spanner-native-core + +// 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 + 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 00000000000..cc8429ac514 --- /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 00000000000..4b4c892ba0a --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner-go/main.go @@ -0,0 +1,1400 @@ +package main + +/* +#include +#include + +typedef enum { + CELL_KIND_NULL = 0, + CELL_KIND_BOOL = 1, + CELL_KIND_NUMBER = 2, + CELL_KIND_STRING = 3, + CELL_KIND_PROTO_VALUE = 4 +} CellKind; + +typedef struct { + uint8_t kind; + uint8_t bool_val; + uint16_t type_code; + 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); + } +} + +typedef struct { + void* resp_pb; + int resp_len; + void* tx_pb; + int tx_len; + int64_t row_count; + int has_row_count; + char* error_msg; + int error_code; + void* retry_info_pb; + int retry_info_len; +} CUnaryResponse; + +typedef void (*UnaryCallback)(void* user_data, CUnaryResponse* resp); + +static void bridge_unary_callback( + UnaryCallback cb, + void* user_data, + CUnaryResponse* resp +) { + if (cb != NULL) { + cb(user_data, resp); + } +} + +typedef struct { + const char* routing_key; + const char** meta_keys; + const char** meta_vals; + int meta_count; + const uint8_t* base_req_pb; + int base_req_len; + int inline_begin; + const uint8_t* begin_req_pb; + int begin_req_len; + int is_mux_rw; +} CSpannerCommitRequest; + +typedef struct { + const char* sql; + int param_count; + const char** param_names; + CSpannerCell* param_cells; + const uint8_t** param_types_pb; + int* param_types_len; +} CSpannerStatement; + +typedef struct { + const char* routing_key; + const char** meta_keys; + const char** meta_vals; + int meta_count; + const char* session; + const uint8_t* tx_id; + int tx_id_len; + int begin_rw; + const uint8_t* prev_tx_id; + int prev_tx_id_len; + int64_t seqno; + const char* transaction_tag; + const char* request_tag; + const uint8_t* base_req_pb; + int base_req_len; + int stmt_count; + CSpannerStatement* statements; +} CSpannerBatchDmlRequest; +*/ +import "C" + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "sync" + "unsafe" + + spannerpb "cloud.google.com/go/spanner/apiv1/spannerpb" + "golang.org/x/oauth2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "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 { + 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, customEndpoint *C.char) C.uintptr_t { + var ep string + if customEndpoint != nil { + ep = C.GoString(customEndpoint) + } + client, err := NewCoreClient(int(channelCount), ep) + 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) + 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) + } + + // 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, + skipMetadata C.int, + cb C.StreamDataCallback, + userData unsafe.Pointer, +) { + // Execute gRPC streaming and setup in a separate goroutine + go func() { + client := getClient(uintptr(handle)) + if client == nil { + sendBatch(cb, userData, nil, nil, "", 1, "Invalid or closed CoreClient handle", int(codes.InvalidArgument), true, nil) + return + } + + var rk string + if routingKey != nil { + rk = C.GoString(routingKey) + } + + // Copy request bytes + length := int(reqLen) + rawBytes := C.GoBytes(unsafe.Pointer(reqBytesPtr), C.int(length)) + + var lastResumeToken []byte + attemptCount := 0 + + var rowType []*spannerpb.StructType_Field + var pendingValue *structpb.Value + var currentRow []*structpb.Value + batch := make([][]*structpb.Value, 0, 100) + + var sqlStr string + if skipMetadata == 0 { + sqlStr = string(extractBytesFieldFromUnknown(rawBytes, 3)) + } + + // 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. Prepare request bytes (attach resume_token field 6 if retrying) + attemptBytes := rawBytes + if len(lastResumeToken) > 0 { + attemptBytes = append([]byte(nil), rawBytes...) + attemptBytes = protowire.AppendTag(attemptBytes, 6, protowire.BytesType) + attemptBytes = protowire.AppendBytes(attemptBytes, lastResumeToken) + } + + // 2. Fetch OAuth2 bearer token and prepare outgoing gRPC context directly + 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 + } + md := extractMetadataMD(metaKeys, metaVals, metaCount, false, token) + ctx := metadata.NewOutgoingContext(client.ctx, md) + + // 3. Dispatch streaming SQL request using raw request bytes (response decoded in Go) + stream, err := client.ExecuteStreamingSqlRaw(ctx, rk, attemptBytes) + 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 + } + + serverTiming := "" + 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 chunk.Metadata != nil { + if rowType == nil && chunk.Metadata.RowType != nil { + rowType = chunk.Metadata.RowType.Fields + } + if chunk.Metadata.Transaction != nil { + copyChunkPrecommitTokenToTransaction(chunk, chunk.Metadata.Transaction) + var mdToMarshal *spannerpb.ResultSetMetadata + if skipMetadata != 0 { + mdToMarshal = &spannerpb.ResultSetMetadata{ + Transaction: chunk.Metadata.Transaction, + } + } else { + mdToMarshal = chunk.Metadata + } + if mdBytes, mdErr := proto.Marshal(mdToMarshal); mdErr == nil { + pendingMetadata = mdBytes + } + if skipMetadata == 0 && chunk.Metadata.RowType != nil { + fieldCount := len(chunk.Metadata.RowType.Fields) + if _, loaded := schemaBytesCache.Load(sqlStr); !loaded { + schemaOnly := &spannerpb.ResultSetMetadata{ + RowType: chunk.Metadata.RowType, + } + if soBytes, soErr := proto.Marshal(schemaOnly); soErr == nil { + schemaBytesCache.Store(sqlStr, goSchemaCacheEntry{ + fieldCount: fieldCount, + bytes: soBytes, + }) + } + } + } + } else if skipMetadata == 0 && chunk.Metadata.RowType != nil && pendingMetadata == nil { + fieldCount := len(rowType) + if cachedVal, ok := schemaBytesCache.Load(sqlStr); 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(sqlStr, goSchemaCacheEntry{ + fieldCount: fieldCount, + bytes: 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 or cached headers if present + if trailerMD := stream.Trailer(); trailerMD != nil { + if vals := trailerMD.Get("server-timing"); len(vals) > 0 { + serverTiming = vals[0] + } + } + if serverTiming == "" { + if headerMD, err := stream.Header(); err == nil && headerMD != nil { + if vals := headerMD.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 + } + }() +} + +// --------------------------------------------------------------------------- +// Native Write / Update / Mutation Core (Commit, ExecuteBatchDml, ExecuteSql) +// --------------------------------------------------------------------------- + +func extractMetadataMap(metaKeys **C.char, metaVals **C.char, metaCount C.int, ensureLeader bool) map[string]string { + count := int(metaCount) + metaMap := make(map[string]string, count+1) + 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 := strings.ToLower(C.GoString(keysSlice[i])) + v := C.GoString(valsSlice[i]) + metaMap[k] = v + } + } + } + if ensureLeader { + metaMap["x-goog-spanner-route-to-leader"] = "true" + } + return metaMap +} + +func extractMetadataMD(metaKeys **C.char, metaVals **C.char, metaCount C.int, ensureLeader bool, token *oauth2.Token) metadata.MD { + count := int(metaCount) + md := make(metadata.MD, count+2) + 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 := strings.ToLower(C.GoString(keysSlice[i])) + v := C.GoString(valsSlice[i]) + md[k] = []string{v} + } + } + } + if ensureLeader { + md["x-goog-spanner-route-to-leader"] = []string{"true"} + } + if token != nil && token.AccessToken != "" { + md["authorization"] = []string{"Bearer " + token.AccessToken} + } + return md +} + +func cellToProtoValue(cell *C.CSpannerCell) *structpb.Value { + switch cell.kind { + case C.CELL_KIND_NULL: + return structpb.NewNullValue() + case C.CELL_KIND_BOOL: + return structpb.NewBoolValue(cell.bool_val != 0) + case C.CELL_KIND_NUMBER: + return structpb.NewNumberValue(float64(cell.number_val)) + case C.CELL_KIND_STRING: + var s string + if cell.str_len > 0 && cell.str_val != nil { + s = C.GoStringN(cell.str_val, C.int(cell.str_len)) + } + return structpb.NewStringValue(s) + case C.CELL_KIND_PROTO_VALUE: + if cell.str_len > 0 && cell.str_val != nil { + raw := C.GoBytes(unsafe.Pointer(cell.str_val), C.int(cell.str_len)) + var v structpb.Value + if err := proto.Unmarshal(raw, &v); err == nil { + return &v + } + } + return structpb.NewNullValue() + default: + return structpb.NewNullValue() + } +} + +func selectMutationKey(mutations []*spannerpb.Mutation) *spannerpb.Mutation { + if len(mutations) == 0 { + return nil + } + var highPriority []*spannerpb.Mutation + var bestInsert *spannerpb.Mutation + maxInsertSize := -1 + + for _, m := range mutations { + switch op := m.Operation.(type) { + case *spannerpb.Mutation_Delete_, *spannerpb.Mutation_Update, *spannerpb.Mutation_Replace, *spannerpb.Mutation_InsertOrUpdate: + highPriority = append(highPriority, m) + case *spannerpb.Mutation_Insert: + size := 0 + if op.Insert != nil { + size = len(op.Insert.Values) + } + if size > maxInsertSize { + maxInsertSize = size + bestInsert = m + } + } + } + if len(highPriority) > 0 { + return highPriority[0] + } + return bestInsert +} + +func attachMutationKeyToBeginReq(beginReq *spannerpb.BeginTransactionRequest, mut *spannerpb.Mutation) { + if mut == nil { + return + } + mutBytes, err := proto.Marshal(mut) + if err != nil || len(mutBytes) == 0 { + return + } + existing := beginReq.ProtoReflect().GetUnknown() + existing = protowire.AppendTag(existing, 4, protowire.BytesType) + existing = protowire.AppendBytes(existing, mutBytes) + beginReq.ProtoReflect().SetUnknown(existing) +} + +func copyPrecommitTokenToCommitReq(txResp *spannerpb.Transaction, commitReq *spannerpb.CommitRequest) { + if txResp == nil || commitReq == nil { + return + } + raw := txResp.ProtoReflect().GetUnknown() + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + raw = raw[n:] + if num == 3 && wtype == protowire.BytesType { + valBytes, vn := protowire.ConsumeBytes(raw) + if vn >= 0 && len(valBytes) > 0 { + setPrecommitTokenOnCommitReq(commitReq, valBytes) + } + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw) + if vn < 0 { + break + } + raw = raw[vn:] + } +} + +func copyChunkPrecommitTokenToTransaction(chunk *spannerpb.PartialResultSet, tx *spannerpb.Transaction) { + if chunk == nil || tx == nil { + return + } + raw := chunk.ProtoReflect().GetUnknown() + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + raw = raw[n:] + if num == 8 && wtype == protowire.BytesType { + valBytes, vn := protowire.ConsumeBytes(raw) + if vn >= 0 && len(valBytes) > 0 { + txRaw := tx.ProtoReflect().GetUnknown() + var filtered []byte + for len(txRaw) > 0 { + tnum, twtype, tn := protowire.ConsumeTag(txRaw) + if tn < 0 { + break + } + tvn := protowire.ConsumeFieldValue(tnum, twtype, txRaw[tn:]) + if tvn < 0 { + break + } + if tnum != 3 { + filtered = append(filtered, txRaw[:tn+tvn]...) + } + txRaw = txRaw[tn+tvn:] + } + filtered = protowire.AppendTag(filtered, 3, protowire.BytesType) + filtered = protowire.AppendBytes(filtered, valBytes) + tx.ProtoReflect().SetUnknown(filtered) + } + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw) + if vn < 0 { + break + } + raw = raw[vn:] + } +} + +func setPrecommitTokenOnCommitReq(commitReq *spannerpb.CommitRequest, tokenBytes []byte) { + if commitReq == nil || len(tokenBytes) == 0 { + return + } + raw := commitReq.ProtoReflect().GetUnknown() + var filtered []byte + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw[n:]) + if vn < 0 { + break + } + if num != 9 { + filtered = append(filtered, raw[:n+vn]...) + } + raw = raw[n+vn:] + } + filtered = protowire.AppendTag(filtered, 9, protowire.BytesType) + filtered = protowire.AppendBytes(filtered, tokenBytes) + commitReq.ProtoReflect().SetUnknown(filtered) +} + +func setRawPrecommitTokenOnCommitBytes(baseBytes []byte, tokenBytes []byte) []byte { + var filtered []byte + raw := baseBytes + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw[n:]) + if vn < 0 { + break + } + if num != 9 { + filtered = append(filtered, raw[:n+vn]...) + } + raw = raw[n+vn:] + } + filtered = protowire.AppendTag(filtered, 9, protowire.BytesType) + filtered = protowire.AppendBytes(filtered, tokenBytes) + return filtered +} + +func checkAndExtractRetryPrecommitToken(commitResp *spannerpb.CommitResponse) []byte { + if commitResp == nil || commitResp.CommitTimestamp != nil { + return nil + } + raw := commitResp.ProtoReflect().GetUnknown() + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + raw = raw[n:] + if num == 4 && wtype == protowire.BytesType { + valBytes, vn := protowire.ConsumeBytes(raw) + if vn >= 0 && len(valBytes) > 0 { + return valBytes + } + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw) + if vn < 0 { + break + } + raw = raw[vn:] + } + return nil +} + +func extractRetryInfo(err error, trailerMD metadata.MD) []byte { + if trailerMD != nil { + if vals := trailerMD.Get("google.rpc.retryinfo-bin"); len(vals) > 0 { + return []byte(vals[0]) + } + } + if st, ok := status.FromError(err); ok && st.Proto() != nil { + for _, det := range st.Proto().Details { + if strings.EqualFold(det.TypeUrl, "type.googleapis.com/google.rpc.RetryInfo") { + return det.Value + } + } + } + return nil +} + +func sendUnaryResponse( + cb C.UnaryCallback, + userData unsafe.Pointer, + respBytes []byte, + txBytes []byte, + err error, + trailerMD metadata.MD, +) { + cResp := (*C.CUnaryResponse)(C.malloc(C.size_t(unsafe.Sizeof(C.CUnaryResponse{})))) + *cResp = C.CUnaryResponse{} + + if err != nil { + st, _ := status.FromError(err) + cResp.error_code = C.int(st.Code()) + cResp.error_msg = C.CString(st.Message()) + retryBytes := extractRetryInfo(err, trailerMD) + if len(retryBytes) > 0 { + cResp.retry_info_len = C.int(len(retryBytes)) + cResp.retry_info_pb = C.CBytes(retryBytes) + } + } else { + if len(respBytes) > 0 { + cResp.resp_len = C.int(len(respBytes)) + cResp.resp_pb = C.CBytes(respBytes) + } + if len(txBytes) > 0 { + cResp.tx_len = C.int(len(txBytes)) + cResp.tx_pb = C.CBytes(txBytes) + } + } + + C.bridge_unary_callback(cb, userData, cResp) +} + +func sendUnaryDmlResponse( + cb C.UnaryCallback, + userData unsafe.Pointer, + precommitBytes []byte, + txBytes []byte, + rowCount int64, +) { + cResp := (*C.CUnaryResponse)(C.malloc(C.size_t(unsafe.Sizeof(C.CUnaryResponse{})))) + *cResp = C.CUnaryResponse{ + has_row_count: 1, + row_count: C.int64_t(rowCount), + } + if len(precommitBytes) > 0 { + cResp.resp_len = C.int(len(precommitBytes)) + cResp.resp_pb = C.CBytes(precommitBytes) + } + if len(txBytes) > 0 { + cResp.tx_len = C.int(len(txBytes)) + cResp.tx_pb = C.CBytes(txBytes) + } + C.bridge_unary_callback(cb, userData, cResp) +} + +var scalarTypes = func() [18]*spannerpb.Type { + var arr [18]*spannerpb.Type + for i := 1; i < 18; i++ { + arr[i] = &spannerpb.Type{Code: spannerpb.TypeCode(i)} + } + return arr +}() + +func buildStatementParams(stmt *C.CSpannerStatement) (*structpb.Struct, map[string]*spannerpb.Type) { + paramCount := int(stmt.param_count) + if paramCount <= 0 || stmt.param_names == nil || stmt.param_cells == nil { + return nil, nil + } + fields := make(map[string]*structpb.Value, paramCount) + paramTypes := make(map[string]*spannerpb.Type, paramCount) + + nameSlice := (*[1 << 28]*C.char)(unsafe.Pointer(stmt.param_names))[:paramCount:paramCount] + cellSlice := (*[1 << 28]C.CSpannerCell)(unsafe.Pointer(stmt.param_cells))[:paramCount:paramCount] + + var typesPbSlice []*C.uint8_t + var typesLenSlice []C.int + if stmt.param_types_pb != nil && stmt.param_types_len != nil { + typesPbSlice = (*[1 << 28]*C.uint8_t)(unsafe.Pointer(stmt.param_types_pb))[:paramCount:paramCount] + typesLenSlice = (*[1 << 28]C.int)(unsafe.Pointer(stmt.param_types_len))[:paramCount:paramCount] + } + + for i := 0; i < paramCount; i++ { + if nameSlice[i] == nil { + continue + } + name := C.GoString(nameSlice[i]) + cell := &cellSlice[i] + fields[name] = cellToProtoValue(cell) + + if typesPbSlice != nil && typesPbSlice[i] != nil && typesLenSlice[i] > 0 { + rawType := C.GoBytes(unsafe.Pointer(typesPbSlice[i]), typesLenSlice[i]) + var t spannerpb.Type + if err := proto.Unmarshal(rawType, &t); err == nil { + paramTypes[name] = &t + continue + } + } + if cell.type_code > 0 { + tc := int(cell.type_code) + if tc > 0 && tc < len(scalarTypes) && scalarTypes[tc] != nil { + paramTypes[name] = scalarTypes[tc] + } else { + paramTypes[name] = &spannerpb.Type{ + Code: spannerpb.TypeCode(cell.type_code), + } + } + } + } + return &structpb.Struct{Fields: fields}, paramTypes +} + +func setPreviousTxIdOnReadWrite(rw *spannerpb.TransactionOptions_ReadWrite, prevTxId []byte) { + if rw == nil || len(prevTxId) == 0 { + return + } + raw := rw.ProtoReflect().GetUnknown() + raw = protowire.AppendTag(raw, 2, protowire.BytesType) + raw = protowire.AppendBytes(raw, prevTxId) + rw.ProtoReflect().SetUnknown(raw) +} + +func extractBytesFieldFromUnknown(raw []byte, fieldNum protowire.Number) []byte { + for len(raw) > 0 { + num, wtype, n := protowire.ConsumeTag(raw) + if n < 0 { + break + } + raw = raw[n:] + if num == fieldNum && wtype == protowire.BytesType { + valBytes, vn := protowire.ConsumeBytes(raw) + if vn >= 0 && len(valBytes) > 0 { + return valBytes + } + break + } + vn := protowire.ConsumeFieldValue(num, wtype, raw) + if vn < 0 { + break + } + raw = raw[vn:] + } + return nil +} + +func extractResultSetPrecommitToken(resp *spannerpb.ResultSet) []byte { + if resp == nil { + return nil + } + if tok := extractBytesFieldFromUnknown(resp.ProtoReflect().GetUnknown(), 8); len(tok) > 0 { + return tok + } + if tx := resp.GetMetadata().GetTransaction(); tx != nil { + if tok := extractBytesFieldFromUnknown(tx.ProtoReflect().GetUnknown(), 3); len(tok) > 0 { + return tok + } + } + return nil +} + +//export CommitNativeGo +func CommitNativeGo( + handle C.uintptr_t, + cReq *C.CSpannerCommitRequest, + cb C.UnaryCallback, + userData unsafe.Pointer, +) { + go func() { + client := getClient(uintptr(handle)) + if client == nil { + sendUnaryResponse(cb, userData, nil, nil, status.Error(codes.InvalidArgument, "Invalid or closed CoreClient handle"), nil) + return + } + + var routingKey string + if cReq.routing_key != nil { + routingKey = C.GoString(cReq.routing_key) + } + + var baseBytes []byte + if cReq.base_req_len > 0 && cReq.base_req_pb != nil { + baseBytes = C.GoBytes(unsafe.Pointer(cReq.base_req_pb), cReq.base_req_len) + } + + inlineBegin := cReq.inline_begin != 0 + isMuxRW := cReq.is_mux_rw != 0 + var beginBytes []byte + if inlineBegin && cReq.begin_req_len > 0 && cReq.begin_req_pb != nil { + beginBytes = C.GoBytes(unsafe.Pointer(cReq.begin_req_pb), cReq.begin_req_len) + } + + token, err := client.GetToken() + if err != nil { + sendUnaryResponse(cb, userData, nil, nil, status.Errorf(codes.Unauthenticated, "Failed to get GCP auth token: %v", err), nil) + return + } + md := extractMetadataMD(cReq.meta_keys, cReq.meta_vals, cReq.meta_count, true, token) + ctx := metadata.NewOutgoingContext(client.ctx, md) + + // Fast path: raw byte buffer transfer for Commit (zero proto.Unmarshal/Marshal in Go) + if !inlineBegin { + respBytes, trailerMD, commitErr := client.InvokeRaw(ctx, routingKey, "/google.spanner.v1.Spanner/Commit", baseBytes) + if commitErr == nil { + if retryToken := extractBytesFieldFromUnknown(respBytes, 4); len(retryToken) > 0 { + retryReq := setRawPrecommitTokenOnCommitBytes(baseBytes, retryToken) + respBytes, trailerMD, commitErr = client.InvokeRaw(ctx, routingKey, "/google.spanner.v1.Spanner/Commit", retryReq) + } + } + if commitErr != nil { + sendUnaryResponse(cb, userData, nil, nil, commitErr, trailerMD) + return + } + sendUnaryResponse(cb, userData, respBytes, nil, nil, nil) + return + } + + // Inline-begin fallback path + var commitReq spannerpb.CommitRequest + _ = proto.Unmarshal(baseBytes, &commitReq) + var beginReq spannerpb.BeginTransactionRequest + _ = proto.Unmarshal(beginBytes, &beginReq) + if isMuxRW && len(commitReq.Mutations) > 0 && len(extractBytesFieldFromUnknown(beginReq.ProtoReflect().GetUnknown(), 4)) == 0 { + attachMutationKeyToBeginReq(&beginReq, selectMutationKey(commitReq.Mutations)) + } + + txResp, trailerMD, beginErr := client.BeginTransaction(ctx, routingKey, &beginReq) + if beginErr != nil { + sendUnaryResponse(cb, userData, nil, nil, beginErr, trailerMD) + return + } + commitReq.Transaction = &spannerpb.CommitRequest_TransactionId{ + TransactionId: txResp.Id, + } + copyPrecommitTokenToCommitReq(txResp, &commitReq) + txBytes, _ := proto.Marshal(txResp) + + commitResp, trailerMD, commitErr := client.Commit(ctx, routingKey, &commitReq) + if commitErr == nil { + if retryToken := checkAndExtractRetryPrecommitToken(commitResp); len(retryToken) > 0 { + setPrecommitTokenOnCommitReq(&commitReq, retryToken) + commitResp, trailerMD, commitErr = client.Commit(ctx, routingKey, &commitReq) + } + } + if commitErr != nil { + sendUnaryResponse(cb, userData, nil, txBytes, commitErr, trailerMD) + return + } + respBytes, marshalErr := proto.Marshal(commitResp) + if marshalErr != nil { + sendUnaryResponse(cb, userData, nil, txBytes, status.Errorf(codes.Internal, "Failed to marshal CommitResponse: %v", marshalErr), nil) + return + } + sendUnaryResponse(cb, userData, respBytes, txBytes, nil, nil) + }() +} + +//export ExecuteBatchDmlNativeGo +func ExecuteBatchDmlNativeGo( + handle C.uintptr_t, + cReq *C.CSpannerBatchDmlRequest, + cb C.UnaryCallback, + userData unsafe.Pointer, +) { + go func() { + client := getClient(uintptr(handle)) + if client == nil { + sendUnaryResponse(cb, userData, nil, nil, status.Error(codes.InvalidArgument, "Invalid or closed CoreClient handle"), nil) + return + } + + var routingKey string + if cReq.routing_key != nil { + routingKey = C.GoString(cReq.routing_key) + } + + var req spannerpb.ExecuteBatchDmlRequest + if cReq.base_req_len > 0 && cReq.base_req_pb != nil { + baseBytes := C.GoBytes(unsafe.Pointer(cReq.base_req_pb), cReq.base_req_len) + _ = proto.Unmarshal(baseBytes, &req) + } else { + if cReq.session != nil { + req.Session = C.GoString(cReq.session) + } + req.Seqno = int64(cReq.seqno) + if cReq.tx_id_len > 0 && cReq.tx_id != nil { + req.Transaction = &spannerpb.TransactionSelector{ + Selector: &spannerpb.TransactionSelector_Id{ + Id: C.GoBytes(unsafe.Pointer(cReq.tx_id), cReq.tx_id_len), + }, + } + } else if cReq.begin_rw != 0 { + rw := &spannerpb.TransactionOptions_ReadWrite{} + if cReq.prev_tx_id_len > 0 && cReq.prev_tx_id != nil { + setPreviousTxIdOnReadWrite(rw, C.GoBytes(unsafe.Pointer(cReq.prev_tx_id), cReq.prev_tx_id_len)) + } + req.Transaction = &spannerpb.TransactionSelector{ + Selector: &spannerpb.TransactionSelector_Begin{ + Begin: &spannerpb.TransactionOptions{ + Mode: &spannerpb.TransactionOptions_ReadWrite_{ + ReadWrite: rw, + }, + }, + }, + } + } + var txTag, reqTag string + if cReq.transaction_tag != nil { + txTag = C.GoString(cReq.transaction_tag) + } + if cReq.request_tag != nil { + reqTag = C.GoString(cReq.request_tag) + } + if txTag != "" || reqTag != "" { + req.RequestOptions = &spannerpb.RequestOptions{ + TransactionTag: txTag, + RequestTag: reqTag, + } + } + } + + stmtCount := int(cReq.stmt_count) + statements := make([]*spannerpb.ExecuteBatchDmlRequest_Statement, stmtCount) + if stmtCount > 0 && cReq.statements != nil { + stmtSlice := (*[1 << 28]C.CSpannerStatement)(unsafe.Pointer(cReq.statements))[:stmtCount:stmtCount] + for i := 0; i < stmtCount; i++ { + cStmt := &stmtSlice[i] + var sqlStr string + if cStmt.sql != nil { + sqlStr = C.GoString(cStmt.sql) + } + params, paramTypes := buildStatementParams(cStmt) + statements[i] = &spannerpb.ExecuteBatchDmlRequest_Statement{ + Sql: sqlStr, + Params: params, + ParamTypes: paramTypes, + } + } + } + req.Statements = statements + + token, err := client.GetToken() + if err != nil { + sendUnaryResponse(cb, userData, nil, nil, status.Errorf(codes.Unauthenticated, "Failed to get GCP auth token: %v", err), nil) + return + } + md := extractMetadataMD(cReq.meta_keys, cReq.meta_vals, cReq.meta_count, true, token) + ctx := metadata.NewOutgoingContext(client.ctx, md) + + resp, trailerMD, rpcErr := client.ExecuteBatchDml(ctx, routingKey, &req) + if rpcErr != nil { + sendUnaryResponse(cb, userData, nil, nil, rpcErr, trailerMD) + return + } + respBytes, marshalErr := proto.Marshal(resp) + if marshalErr != nil { + sendUnaryResponse(cb, userData, nil, nil, status.Errorf(codes.Internal, "Failed to marshal ExecuteBatchDmlResponse: %v", marshalErr), nil) + return + } + sendUnaryResponse(cb, userData, respBytes, nil, nil, nil) + }() +} + +//export ExecuteSqlDmlNativeGo +func ExecuteSqlDmlNativeGo( + handle C.uintptr_t, + cReq *C.CSpannerBatchDmlRequest, + cb C.UnaryCallback, + userData unsafe.Pointer, +) { + go func() { + client := getClient(uintptr(handle)) + if client == nil { + sendUnaryResponse(cb, userData, nil, nil, status.Error(codes.InvalidArgument, "Invalid or closed CoreClient handle"), nil) + return + } + + var routingKey string + if cReq.routing_key != nil { + routingKey = C.GoString(cReq.routing_key) + } + + var req spannerpb.ExecuteSqlRequest + if cReq.base_req_len > 0 && cReq.base_req_pb != nil { + baseBytes := C.GoBytes(unsafe.Pointer(cReq.base_req_pb), cReq.base_req_len) + _ = proto.Unmarshal(baseBytes, &req) + } else { + if cReq.session != nil { + req.Session = C.GoString(cReq.session) + } + req.Seqno = int64(cReq.seqno) + if cReq.tx_id_len > 0 && cReq.tx_id != nil { + req.Transaction = &spannerpb.TransactionSelector{ + Selector: &spannerpb.TransactionSelector_Id{ + Id: C.GoBytes(unsafe.Pointer(cReq.tx_id), cReq.tx_id_len), + }, + } + } else if cReq.begin_rw != 0 { + rw := &spannerpb.TransactionOptions_ReadWrite{} + if cReq.prev_tx_id_len > 0 && cReq.prev_tx_id != nil { + setPreviousTxIdOnReadWrite(rw, C.GoBytes(unsafe.Pointer(cReq.prev_tx_id), cReq.prev_tx_id_len)) + } + req.Transaction = &spannerpb.TransactionSelector{ + Selector: &spannerpb.TransactionSelector_Begin{ + Begin: &spannerpb.TransactionOptions{ + Mode: &spannerpb.TransactionOptions_ReadWrite_{ + ReadWrite: rw, + }, + }, + }, + } + } + var txTag, reqTag string + if cReq.transaction_tag != nil { + txTag = C.GoString(cReq.transaction_tag) + } + if cReq.request_tag != nil { + reqTag = C.GoString(cReq.request_tag) + } + if txTag != "" || reqTag != "" { + req.RequestOptions = &spannerpb.RequestOptions{ + TransactionTag: txTag, + RequestTag: reqTag, + } + } + } + + if int(cReq.stmt_count) > 0 && cReq.statements != nil { + stmtSlice := (*[1 << 28]C.CSpannerStatement)(unsafe.Pointer(cReq.statements))[:1:1] + cStmt := &stmtSlice[0] + if cStmt.sql != nil { + req.Sql = C.GoString(cStmt.sql) + } + params, paramTypes := buildStatementParams(cStmt) + req.Params = params + req.ParamTypes = paramTypes + } + + token, err := client.GetToken() + if err != nil { + sendUnaryResponse(cb, userData, nil, nil, status.Errorf(codes.Unauthenticated, "Failed to get GCP auth token: %v", err), nil) + return + } + md := extractMetadataMD(cReq.meta_keys, cReq.meta_vals, cReq.meta_count, true, token) + ctx := metadata.NewOutgoingContext(client.ctx, md) + + resp, trailerMD, rpcErr := client.ExecuteSql(ctx, routingKey, &req) + if rpcErr != nil { + sendUnaryResponse(cb, userData, nil, nil, rpcErr, trailerMD) + return + } + precommitBytes := extractResultSetPrecommitToken(resp) + var txBytes []byte + if cReq.tx_id_len == 0 { + if tx := resp.GetMetadata().GetTransaction(); tx != nil { + txBytes, _ = proto.Marshal(tx) + } + } + var rowCount int64 + if stats := resp.GetStats(); stats != nil { + if rc, ok := stats.RowCount.(*spannerpb.ResultSetStats_RowCountExact); ok { + rowCount = rc.RowCountExact + } else if rc, ok := stats.RowCount.(*spannerpb.ResultSetStats_RowCountLowerBound); ok { + rowCount = rc.RowCountLowerBound + } + } + sendUnaryDmlResponse(cb, userData, precommitBytes, txBytes, rowCount) + }() +} + +//export BeginTransactionNativeGo +func BeginTransactionNativeGo( + handle C.uintptr_t, + routingKey *C.char, + metaKeys **C.char, + metaVals **C.char, + metaCount C.int, + reqBytesPtr *C.char, + reqLen C.int, + cb C.UnaryCallback, + userData unsafe.Pointer, +) { + go func() { + client := getClient(uintptr(handle)) + if client == nil { + sendUnaryResponse(cb, userData, nil, nil, status.Error(codes.InvalidArgument, "Invalid or closed CoreClient handle"), nil) + return + } + + var rk string + if routingKey != nil { + rk = C.GoString(routingKey) + } + + var rawBytes []byte + if reqLen > 0 && reqBytesPtr != nil { + rawBytes = C.GoBytes(unsafe.Pointer(reqBytesPtr), reqLen) + } + + token, err := client.GetToken() + if err != nil { + sendUnaryResponse(cb, userData, nil, nil, status.Errorf(codes.Unauthenticated, "Failed to get GCP auth token: %v", err), nil) + return + } + md := extractMetadataMD(metaKeys, metaVals, metaCount, true, token) + ctx := metadata.NewOutgoingContext(client.ctx, md) + + respBytes, trailerMD, rpcErr := client.InvokeRaw(ctx, rk, "/google.spanner.v1.Spanner/BeginTransaction", rawBytes) + if rpcErr != nil { + sendUnaryResponse(cb, userData, nil, nil, rpcErr, trailerMD) + return + } + sendUnaryResponse(cb, userData, respBytes, nil, nil, nil) + }() +} + +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 00000000000..c6c754117bd --- /dev/null +++ b/handwritten/spanner/spanner-native/spanner_go_napi.cc @@ -0,0 +1,1412 @@ +#include +#include +#include +#include +#include +#include +#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, + CELL_KIND_PROTO_VALUE = 4 +} CellKind; + +typedef struct { + uint8_t kind; + uint8_t bool_val; + uint16_t type_code; + 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); + +typedef struct { + void* resp_pb; + int resp_len; + void* tx_pb; + int tx_len; + int64_t row_count; + int has_row_count; + char* error_msg; + int error_code; + void* retry_info_pb; + int retry_info_len; +} CUnaryResponse; + +typedef void (*UnaryCallback)(void* user_data, CUnaryResponse* resp); + +typedef struct { + const char* routing_key; + const char** meta_keys; + const char** meta_vals; + int meta_count; + const uint8_t* base_req_pb; + int base_req_len; + int inline_begin; + const uint8_t* begin_req_pb; + int begin_req_len; + int is_mux_rw; +} CSpannerCommitRequest; + +typedef struct { + const char* sql; + int param_count; + const char** param_names; + CSpannerCell* param_cells; + const uint8_t** param_types_pb; + int* param_types_len; +} CSpannerStatement; + +typedef struct { + const char* routing_key; + const char** meta_keys; + const char** meta_vals; + int meta_count; + const char* session; + const uint8_t* tx_id; + int tx_id_len; + int begin_rw; + const uint8_t* prev_tx_id; + int prev_tx_id_len; + int64_t seqno; + const char* transaction_tag; + const char* request_tag; + const uint8_t* base_req_pb; + int base_req_len; + int stmt_count; + CSpannerStatement* statements; +} CSpannerBatchDmlRequest; + +// Declarations of Go C-shared exported functions +extern "C" { + uintptr_t InitGoCoreClient(int channel_count, const char* custom_endpoint); + 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, + int skip_metadata, + StreamDataCallback cb, + void* user_data + ); + void CommitNativeGo( + uintptr_t handle, + CSpannerCommitRequest* req, + UnaryCallback cb, + void* user_data + ); + void ExecuteBatchDmlNativeGo( + uintptr_t handle, + CSpannerBatchDmlRequest* req, + UnaryCallback cb, + void* user_data + ); + void ExecuteSqlDmlNativeGo( + uintptr_t handle, + CSpannerBatchDmlRequest* req, + UnaryCallback cb, + void* user_data + ); + void BeginTransactionNativeGo( + 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, + UnaryCallback cb, + void* user_data + ); +} + +// Fast contiguous memory arena for request parameters and strings +struct Arena { + char inline_buf[4096]; + std::vector blocks; + char* current = nullptr; + size_t offset = 0; + size_t cap = 0; + + Arena() : current(inline_buf), offset(0), cap(sizeof(inline_buf)) {} + + ~Arena() { + for (char* b : blocks) { + free(b); + } + } + + char* alloc(size_t size) { + if (size == 0) return nullptr; + size_t aligned = (size + 7) & ~static_cast(7); + if (offset + aligned > cap) { + size_t new_cap = aligned > 65536 ? aligned : 65536; + current = static_cast(malloc(new_cap)); + blocks.push_back(current); + offset = 0; + cap = new_cap; + } + char* ptr = current + offset; + offset += aligned; + return ptr; + } + + char* copy_str(napi_env env, napi_value str_val, size_t* out_len = nullptr) { + size_t len = 0; + napi_get_value_string_utf8(env, str_val, nullptr, 0, &len); + char* dst = alloc(len + 1); + napi_get_value_string_utf8(env, str_val, dst, len + 1, &len); + dst[len] = '\0'; + if (out_len) *out_len = len; + return dst; + } +}; + +enum RequestType { + REQ_STREAMING_SQL = 1, + REQ_BATCH_DML = 2, + REQ_SQL_DML = 3, + REQ_COMMIT = 4, + REQ_BEGIN_TX = 5 +}; + +struct CoreClientHandleWrapper; + +struct RpcRequestContext { + CoreClientHandleWrapper* wrap; + RequestType type; + napi_ref cb_ref; + Arena arena; + + const char* routing_key = nullptr; + std::vector meta_keys_ptr; + std::vector meta_vals_ptr; + + // For REQ_STREAMING_SQL / REQ_BEGIN_TX + const char* req_bytes = nullptr; + int req_len = 0; + int skip_metadata = 0; + + // For REQ_COMMIT + CSpannerCommitRequest commit_req; + + // For REQ_BATCH_DML / REQ_SQL_DML + CSpannerBatchDmlRequest dml_req; + + RpcRequestContext(CoreClientHandleWrapper* w, RequestType t) + : wrap(w), type(t), cb_ref(nullptr) { + memset(&commit_req, 0, sizeof(commit_req)); + memset(&dml_req, 0, sizeof(dml_req)); + } +}; + +struct CompletionEvent { + RpcRequestContext* ctx; + CSpannerBatch* batch; + CUnaryResponse* unary; +}; + +extern "C" void OnGoStreamData(void* user_data, CSpannerBatch* batch); +extern "C" void OnGoUnaryResponse(void* user_data, CUnaryResponse* resp); + +static napi_ref constructor_ref; + +struct CoreClientHandleWrapper { + uintptr_t handle; + napi_threadsafe_function tsfn = nullptr; + std::mutex tsfn_mu; + std::atomic is_closed{false}; + std::atomic active_rpcs{0}; + + std::mutex queue_mu; + std::condition_variable queue_cv; + std::deque queue; + bool shutdown = false; + std::vector workers; + + CoreClientHandleWrapper(uintptr_t h) : handle(h) { + const int num_workers = 2; + for (int i = 0; i < num_workers; i++) { + workers.emplace_back(&CoreClientHandleWrapper::DispatchLoop, this); + } + } + + void Enqueue(napi_env env, RpcRequestContext* ctx) { + if (active_rpcs.fetch_add(1) == 0 && !is_closed.load() && tsfn != nullptr) { + napi_ref_threadsafe_function(env, tsfn); + } + { + std::lock_guard lock(queue_mu); + queue.push_back(ctx); + } + queue_cv.notify_one(); + } + + void DispatchLoop() { + while (true) { + RpcRequestContext* ctx = nullptr; + { + std::unique_lock lock(queue_mu); + queue_cv.wait(lock, [this] { return shutdown || !queue.empty(); }); + if (shutdown && queue.empty()) { + break; + } + ctx = queue.front(); + queue.pop_front(); + } + if (!ctx) continue; + + switch (ctx->type) { + case REQ_STREAMING_SQL: + ExecuteStreamingSqlGo( + handle, + ctx->routing_key, + ctx->meta_keys_ptr.data(), + ctx->meta_vals_ptr.data(), + static_cast(ctx->meta_keys_ptr.size()), + ctx->req_bytes, + ctx->req_len, + ctx->skip_metadata, + OnGoStreamData, + ctx + ); + break; + case REQ_SQL_DML: + ExecuteSqlDmlNativeGo(handle, &ctx->dml_req, OnGoUnaryResponse, ctx); + break; + case REQ_BATCH_DML: + ExecuteBatchDmlNativeGo(handle, &ctx->dml_req, OnGoUnaryResponse, ctx); + break; + case REQ_COMMIT: + CommitNativeGo(handle, &ctx->commit_req, OnGoUnaryResponse, ctx); + break; + case REQ_BEGIN_TX: + BeginTransactionNativeGo( + handle, + ctx->routing_key, + ctx->meta_keys_ptr.data(), + ctx->meta_vals_ptr.data(), + static_cast(ctx->meta_keys_ptr.size()), + ctx->req_bytes, + ctx->req_len, + OnGoUnaryResponse, + ctx + ); + break; + } + } + } + + void Close(napi_env env) { + bool expected = false; + if (!is_closed.compare_exchange_strong(expected, true)) { + return; + } + { + std::lock_guard lock(queue_mu); + shutdown = true; + } + queue_cv.notify_all(); + for (auto& t : workers) { + if (t.joinable()) { + t.join(); + } + } + workers.clear(); + + while (!queue.empty()) { + RpcRequestContext* ctx = queue.front(); + queue.pop_front(); + if (env && ctx->cb_ref) { + napi_delete_reference(env, ctx->cb_ref); + } + active_rpcs.fetch_sub(1); + delete ctx; + } + + if (handle != 0) { + CloseGoCoreClient(handle); + handle = 0; + } + + { + std::lock_guard lock(tsfn_mu); + if (tsfn != nullptr) { + napi_release_threadsafe_function(tsfn, napi_tsfn_abort); + tsfn = nullptr; + } + } + } + + ~CoreClientHandleWrapper() { + Close(nullptr); + } +}; + +extern "C" void OnGoStreamData(void* user_data, CSpannerBatch* batch) { + RpcRequestContext* ctx = static_cast(user_data); + if (!ctx || !ctx->wrap) { + 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; + } + std::lock_guard lock(ctx->wrap->tsfn_mu); + if (ctx->wrap->is_closed.load() || ctx->wrap->tsfn == nullptr) { + 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; + } + CompletionEvent* ev = new CompletionEvent{ ctx, batch, nullptr }; + napi_call_threadsafe_function(ctx->wrap->tsfn, ev, napi_tsfn_nonblocking); +} + +extern "C" void OnGoUnaryResponse(void* user_data, CUnaryResponse* resp) { + RpcRequestContext* ctx = static_cast(user_data); + if (!ctx || !ctx->wrap) { + if (resp) { + if (resp->resp_pb) free(resp->resp_pb); + if (resp->tx_pb) free(resp->tx_pb); + if (resp->error_msg) free(resp->error_msg); + if (resp->retry_info_pb) free(resp->retry_info_pb); + free(resp); + } + return; + } + std::lock_guard lock(ctx->wrap->tsfn_mu); + if (ctx->wrap->is_closed.load() || ctx->wrap->tsfn == nullptr) { + if (resp) { + if (resp->resp_pb) free(resp->resp_pb); + if (resp->tx_pb) free(resp->tx_pb); + if (resp->error_msg) free(resp->error_msg); + if (resp->retry_info_pb) free(resp->retry_info_pb); + free(resp); + } + return; + } + CompletionEvent* ev = new CompletionEvent{ ctx, nullptr, resp }; + napi_call_threadsafe_function(ctx->wrap->tsfn, ev, napi_tsfn_nonblocking); +} + +// Unified CallJsDispatchHandler runs on the V8 main event loop thread +void CallJsDispatchHandler(napi_env env, napi_value js_cb_unused, void* context, void* data) { + CompletionEvent* ev = static_cast(data); + if (!ev) return; + RpcRequestContext* ctx = ev->ctx; + + if (env != nullptr && ctx != nullptr && ctx->cb_ref != nullptr) { + napi_value global, null_val, js_cb; + napi_get_global(env, &global); + napi_get_null(env, &null_val); + napi_get_reference_value(env, ctx->cb_ref, &js_cb); + + if (ev->unary != nullptr) { + CUnaryResponse* resp = ev->unary; + if (resp->error_msg != nullptr) { + napi_value err_obj, err_msg_val, err_code_val; + napi_create_string_utf8(env, resp->error_msg, NAPI_AUTO_LENGTH, &err_msg_val); + napi_create_error(env, nullptr, err_msg_val, &err_obj); + napi_create_int32(env, resp->error_code, &err_code_val); + napi_set_named_property(env, err_obj, "code", err_code_val); + + if (resp->retry_info_pb != nullptr && resp->retry_info_len > 0) { + napi_value retry_buf; + void* copy_data = nullptr; + napi_create_buffer_copy(env, (size_t)resp->retry_info_len, resp->retry_info_pb, ©_data, &retry_buf); + napi_set_named_property(env, err_obj, "retryInfoPb", retry_buf); + } + + napi_value tx_val = null_val; + if (resp->tx_pb != nullptr && resp->tx_len > 0) { + void* copy_data = nullptr; + napi_create_buffer_copy(env, (size_t)resp->tx_len, resp->tx_pb, ©_data, &tx_val); + } + + napi_value argv[3] = { err_obj, null_val, tx_val }; + napi_call_function(env, global, js_cb, 3, argv, nullptr); + } else { + napi_value resp_val = null_val; + if (resp->resp_pb != nullptr && resp->resp_len > 0) { + void* copy_data = nullptr; + napi_create_buffer_copy(env, (size_t)resp->resp_len, resp->resp_pb, ©_data, &resp_val); + } + + napi_value tx_val = null_val; + if (resp->tx_pb != nullptr && resp->tx_len > 0) { + void* copy_data = nullptr; + napi_create_buffer_copy(env, (size_t)resp->tx_len, resp->tx_pb, ©_data, &tx_val); + } + + napi_value row_count_val = null_val; + if (resp->has_row_count) { + napi_create_int64(env, resp->row_count, &row_count_val); + } + + napi_value argv[4] = { null_val, resp_val, tx_val, row_count_val }; + napi_call_function(env, global, js_cb, 4, argv, nullptr); + } + + if (resp->resp_pb != nullptr) free(resp->resp_pb); + if (resp->tx_pb != nullptr) free(resp->tx_pb); + if (resp->error_msg != nullptr) free(resp->error_msg); + if (resp->retry_info_pb != nullptr) free(resp->retry_info_pb); + free(resp); + + if (ctx->wrap) { + if (ctx->wrap->active_rpcs.fetch_sub(1) == 1 && !ctx->wrap->is_closed.load() && ctx->wrap->tsfn != nullptr) { + napi_unref_threadsafe_function(env, ctx->wrap->tsfn); + } + } + napi_delete_reference(env, ctx->cb_ref); + delete ctx; + } else if (ev->batch != nullptr) { + CSpannerBatch* batch = ev->batch; + bool is_final = (batch->is_last != 0) || (batch->error_msg != nullptr); + + 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); + 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[5] = { err_obj, null_val, null_val, null_val, null_val }; + napi_call_function(env, global, js_cb, 5, argv, nullptr); + } else if (batch->is_last && batch->row_count == 0) { + // End of stream signal (may carry metadata_pb if 0 rows were returned) + 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 true_val; + napi_get_boolean(env, true, &true_val); + napi_value argv[5] = { null_val, null_val, null_val, metadata_val, true_val }; + napi_call_function(env, global, js_cb, 5, argv, nullptr); + } else { + napi_value rows_val = null_val; + + if (batch->format == 1 && batch->cells != nullptr && batch->row_count > 0 && batch->col_count > 0) { + 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) { + 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_val = null_val; + if (batch->server_timing != nullptr && batch->server_timing[0] != '\0') { + napi_create_object(env, &telemetry_val); + napi_value st_val; + napi_create_string_utf8(env, batch->server_timing, NAPI_AUTO_LENGTH, &st_val); + napi_set_named_property(env, telemetry_val, "serverTiming", st_val); + napi_value attempt_val; + napi_create_uint32(env, (uint32_t)batch->attempt_count, &attempt_val); + napi_set_named_property(env, telemetry_val, "attemptCount", attempt_val); + } + + 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 is_last_val; + napi_get_boolean(env, batch->is_last != 0, &is_last_val); + + napi_value argv[5] = { null_val, rows_val, telemetry_val, metadata_val, is_last_val }; + napi_call_function(env, global, js_cb, 5, argv, 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); + free(batch); + + if (is_final) { + if (ctx->wrap) { + if (ctx->wrap->active_rpcs.fetch_sub(1) == 1 && !ctx->wrap->is_closed.load() && ctx->wrap->tsfn != nullptr) { + napi_unref_threadsafe_function(env, ctx->wrap->tsfn); + } + } + napi_delete_reference(env, ctx->cb_ref); + delete ctx; + } + } + } else { + if (ev->unary != nullptr) { + CUnaryResponse* resp = ev->unary; + if (resp->resp_pb) free(resp->resp_pb); + if (resp->tx_pb) free(resp->tx_pb); + if (resp->error_msg) free(resp->error_msg); + if (resp->retry_info_pb) free(resp->retry_info_pb); + free(resp); + if (ctx) { + if (ctx->wrap) ctx->wrap->active_rpcs.fetch_sub(1); + delete ctx; + } + } else if (ev->batch != nullptr) { + CSpannerBatch* batch = ev->batch; + bool is_final = (batch->is_last != 0) || (batch->error_msg != nullptr); + 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); + if (is_final && ctx) { + if (ctx->wrap) ctx->wrap->active_rpcs.fetch_sub(1); + delete ctx; + } + } + } + delete ev; +} + +static napi_value NoopCallback(napi_env env, napi_callback_info info) { + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +void CoreClientHandleDestructor(napi_env env, void* nativeObject, void* finalize_hint) { + CoreClientHandleWrapper* wrap = static_cast(nativeObject); + if (wrap != nullptr) { + wrap->Close(env); + delete wrap; + } +} + +napi_value CoreClientHandleConstructor(napi_env env, napi_callback_info info) { + napi_value jsthis; + size_t argc = 2; + napi_value args[2]; + 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; + } + } + + char endpoint_buf[256] = {0}; + if (argc >= 2) { + size_t ep_len = 0; + napi_get_value_string_utf8(env, args[1], endpoint_buf, sizeof(endpoint_buf), &ep_len); + } + + uintptr_t handle = InitGoCoreClient(channel_count, endpoint_buf); + CoreClientHandleWrapper* wrap = new CoreClientHandleWrapper(handle); + + napi_value noop_fn, resource_name; + napi_create_function(env, "noop", NAPI_AUTO_LENGTH, NoopCallback, nullptr, &noop_fn); + napi_create_string_utf8(env, "SpannerGoCoreDispatch", NAPI_AUTO_LENGTH, &resource_name); + + napi_status status = napi_create_threadsafe_function( + env, + noop_fn, + nullptr, + resource_name, + 0, + 1, + nullptr, + nullptr, + wrap, + CallJsDispatchHandler, + &(wrap->tsfn) + ); + if (status == napi_ok && wrap->tsfn != nullptr) { + napi_unref_threadsafe_function(env, wrap->tsfn); + } + + 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->Close(env); + } + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +static const char B64_TABLE[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +size_t Base64Encode(const uint8_t* src, size_t len, char* dst) { + size_t i = 0, j = 0; + while (i + 2 < len) { + uint32_t v = (static_cast(src[i]) << 16) | + (static_cast(src[i + 1]) << 8) | + static_cast(src[i + 2]); + dst[j++] = B64_TABLE[(v >> 18) & 0x3F]; + dst[j++] = B64_TABLE[(v >> 12) & 0x3F]; + dst[j++] = B64_TABLE[(v >> 6) & 0x3F]; + dst[j++] = B64_TABLE[v & 0x3F]; + i += 3; + } + if (i < len) { + uint32_t v = static_cast(src[i]) << 16; + if (i + 1 < len) v |= static_cast(src[i + 1]) << 8; + dst[j++] = B64_TABLE[(v >> 18) & 0x3F]; + dst[j++] = B64_TABLE[(v >> 12) & 0x3F]; + if (i + 1 < len) { + dst[j++] = B64_TABLE[(v >> 6) & 0x3F]; + } else { + dst[j++] = '='; + } + dst[j++] = '='; + } + return j; +} + +void ExtractBytesInfo(napi_env env, napi_value val, const uint8_t** out_ptr, int* out_len) { + *out_ptr = nullptr; + *out_len = 0; + if (val == nullptr) return; + napi_valuetype vt; + if (napi_typeof(env, val, &vt) != napi_ok || vt == napi_null || vt == napi_undefined) return; + + bool is_typedarray = false; + napi_is_typedarray(env, val, &is_typedarray); + if (is_typedarray) { + napi_typedarray_type type; + napi_value arraybuffer; + size_t byte_offset = 0, byte_len = 0; + void* data = nullptr; + if (napi_get_typedarray_info(env, val, &type, &byte_len, &data, &arraybuffer, &byte_offset) == napi_ok) { + *out_ptr = static_cast(data); + *out_len = static_cast(byte_len); + } + return; + } + bool is_buffer = false; + napi_is_buffer(env, val, &is_buffer); + if (is_buffer) { + void* data = nullptr; + size_t byte_len = 0; + if (napi_get_buffer_info(env, val, &data, &byte_len) == napi_ok) { + *out_ptr = static_cast(data); + *out_len = static_cast(byte_len); + } + } +} + +void CopyBytesToArena(napi_env env, napi_value val, Arena& arena, const uint8_t** out_ptr, int* out_len) { + *out_ptr = nullptr; + *out_len = 0; + const uint8_t* src_ptr = nullptr; + int src_len = 0; + ExtractBytesInfo(env, val, &src_ptr, &src_len); + if (src_len > 0 && src_ptr != nullptr) { + char* dst = arena.alloc(src_len); + memcpy(dst, src_ptr, src_len); + *out_ptr = reinterpret_cast(dst); + *out_len = src_len; + } +} + +void ExtractMetadataArena( + napi_env env, + napi_value meta_arr, + Arena& arena, + std::vector& keys_ptr, + std::vector& vals_ptr +) { + uint32_t meta_len = 0; + if (napi_get_array_length(env, meta_arr, &meta_len) != napi_ok || meta_len == 0) return; + + keys_ptr.reserve(meta_len); + vals_ptr.reserve(meta_len); + + for (uint32_t i = 0; i < meta_len; i++) { + napi_value pair_val; + if (napi_get_element(env, meta_arr, i, &pair_val) != napi_ok) continue; + uint32_t pair_len = 0; + if (napi_get_array_length(env, pair_val, &pair_len) != napi_ok || pair_len != 2) continue; + + napi_value k_val, v_val; + napi_get_element(env, pair_val, 0, &k_val); + napi_get_element(env, pair_val, 1, &v_val); + + keys_ptr.push_back(arena.copy_str(env, k_val)); + vals_ptr.push_back(arena.copy_str(env, v_val)); + } +} + +void ExtractCellFromJsValue( + napi_env env, + napi_value val, + napi_value fallback_fn, + CSpannerCell* cell, + Arena& arena, + bool need_type_code = false, + uint16_t explicit_type_code = 0 +) { + memset(cell, 0, sizeof(CSpannerCell)); + napi_valuetype vtype = napi_undefined; + napi_typeof(env, val, &vtype); + + switch (vtype) { + case napi_null: + case napi_undefined: + cell->kind = CELL_KIND_NULL; + cell->type_code = explicit_type_code; + return; + + case napi_boolean: { + bool b = false; + napi_get_value_bool(env, val, &b); + cell->kind = CELL_KIND_BOOL; + cell->bool_val = b ? 1 : 0; + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 1; // BOOL + return; + } + + case napi_string: { + size_t len = 0; + char* dst = arena.copy_str(env, val, &len); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 6; // STRING + return; + } + + case napi_number: { + double d = 0; + napi_get_value_double(env, val, &d); + if (explicit_type_code == 3 || explicit_type_code == 15) { + if (!std::isfinite(d)) { + const char* s = std::isnan(d) ? "NaN" : (d > 0 ? "Infinity" : "-Infinity"); + size_t len = strlen(s); + char* dst = arena.alloc(len + 1); + memcpy(dst, s, len + 1); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + } else { + cell->kind = CELL_KIND_NUMBER; + cell->number_val = d; + } + cell->type_code = explicit_type_code; + return; + } + if (explicit_type_code == 2 || (explicit_type_code == 0 && std::floor(d) == d && d >= -9007199254740991.0 && d <= 9007199254740991.0)) { + char* dst = arena.alloc(32); + int len = snprintf(dst, 32, "%.0f", d); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len > 0 ? len : 0); + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 2; // INT64 + return; + } + if (!std::isfinite(d)) { + const char* s = std::isnan(d) ? "NaN" : (d > 0 ? "Infinity" : "-Infinity"); + size_t len = strlen(s); + char* dst = arena.alloc(len + 1); + memcpy(dst, s, len + 1); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + } else { + cell->kind = CELL_KIND_NUMBER; + cell->number_val = d; + } + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 3; // FLOAT64 + return; + } + + case napi_bigint: { + int64_t i64 = 0; + bool lossless = false; + napi_get_value_bigint_int64(env, val, &i64, &lossless); + char* dst = arena.alloc(32); + int len = snprintf(dst, 32, "%lld", static_cast(i64)); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len > 0 ? len : 0); + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 2; // INT64 + return; + } + + case napi_object: { + const uint8_t* raw_bytes = nullptr; + int raw_len = 0; + ExtractBytesInfo(env, val, &raw_bytes, &raw_len); + if (raw_bytes != nullptr) { + size_t len = static_cast(raw_len); + size_t b64_len = ((len + 2) / 3) * 4; + char* dst = arena.alloc(b64_len + 1); + size_t actual = Base64Encode(raw_bytes, len, dst); + dst[actual] = '\0'; + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(actual); + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 7; // BYTES + return; + } + + bool has_val = false; + if (napi_has_named_property(env, val, "value", &has_val) == napi_ok && has_val) { + napi_value inner_val; + if (napi_get_named_property(env, val, "value", &inner_val) == napi_ok) { + napi_valuetype iv_type = napi_undefined; + napi_typeof(env, inner_val, &iv_type); + if (iv_type == napi_string) { + size_t len = 0; + char* dst = arena.copy_str(env, inner_val, &len); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + if (explicit_type_code > 0) { + cell->type_code = explicit_type_code; + } else { + cell->type_code = 6; + if (need_type_code) { + napi_value ctor, ctor_name; + if (napi_get_named_property(env, val, "constructor", &ctor) == napi_ok && + napi_get_named_property(env, ctor, "name", &ctor_name) == napi_ok) { + char cname[32]; + size_t clen = 0; + napi_get_value_string_utf8(env, ctor_name, cname, sizeof(cname), &clen); + if (strcmp(cname, "Int") == 0) cell->type_code = 2; + else if (strcmp(cname, "Numeric") == 0 || strcmp(cname, "PGNumeric") == 0) cell->type_code = 10; + else if (strcmp(cname, "SpannerDate") == 0) cell->type_code = 5; + } + } + } + return; + } else if (iv_type == napi_number) { + double d = 0; + napi_get_value_double(env, inner_val, &d); + if (!std::isfinite(d)) { + const char* s = std::isnan(d) ? "NaN" : (d > 0 ? "Infinity" : "-Infinity"); + size_t len = strlen(s); + char* dst = arena.alloc(len + 1); + memcpy(dst, s, len + 1); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + } else { + cell->kind = CELL_KIND_NUMBER; + cell->number_val = d; + } + if (explicit_type_code > 0) { + cell->type_code = explicit_type_code; + } else { + cell->type_code = 3; + if (need_type_code) { + napi_value ctor, ctor_name; + if (napi_get_named_property(env, val, "constructor", &ctor) == napi_ok && + napi_get_named_property(env, ctor, "name", &ctor_name) == napi_ok) { + char cname[32]; + size_t clen = 0; + napi_get_value_string_utf8(env, ctor_name, cname, sizeof(cname), &clen); + if (strcmp(cname, "Float32") == 0) cell->type_code = 15; + } + } + } + return; + } + } + } + + bool is_date = false; + napi_is_date(env, val, &is_date); + if (is_date) { + napi_value global, iso_fn, str_res; + napi_get_global(env, &global); + if (napi_get_named_property(env, val, "toISOString", &iso_fn) == napi_ok && + napi_call_function(env, val, iso_fn, 0, nullptr, &str_res) == napi_ok) { + size_t len = 0; + char* dst = arena.copy_str(env, str_res, &len); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + cell->type_code = explicit_type_code > 0 ? explicit_type_code : 4; // TIMESTAMP + return; + } + } + break; + } + default: + break; + } + + if (fallback_fn != nullptr) { + napi_value global, res; + napi_get_global(env, &global); + napi_value argv[1] = { val }; + if (napi_call_function(env, global, fallback_fn, 1, argv, &res) == napi_ok) { + napi_value kind_val, type_code_val; + int32_t kind = 0, type_code = 0; + if (napi_get_named_property(env, res, "kind", &kind_val) == napi_ok) { + napi_get_value_int32(env, kind_val, &kind); + } + if (napi_get_named_property(env, res, "typeCode", &type_code_val) == napi_ok) { + napi_get_value_int32(env, type_code_val, &type_code); + } + cell->type_code = explicit_type_code > 0 + ? explicit_type_code + : static_cast(type_code); + + if (kind == CELL_KIND_NULL) { + cell->kind = CELL_KIND_NULL; + } else if (kind == CELL_KIND_BOOL) { + napi_value b_val; + int32_t b = 0; + if (napi_get_named_property(env, res, "boolVal", &b_val) == napi_ok) { + napi_get_value_int32(env, b_val, &b); + } + cell->kind = CELL_KIND_BOOL; + cell->bool_val = b ? 1 : 0; + } else if (kind == CELL_KIND_NUMBER) { + napi_value n_val; + double d = 0; + if (napi_get_named_property(env, res, "numVal", &n_val) == napi_ok) { + napi_get_value_double(env, n_val, &d); + } + cell->kind = CELL_KIND_NUMBER; + cell->number_val = d; + } else if (kind == CELL_KIND_STRING) { + napi_value s_val; + if (napi_get_named_property(env, res, "strVal", &s_val) == napi_ok) { + size_t len = 0; + char* dst = arena.copy_str(env, s_val, &len); + cell->kind = CELL_KIND_STRING; + cell->str_val = dst; + cell->str_len = static_cast(len); + } + } else if (kind == CELL_KIND_PROTO_VALUE) { + napi_value pb_val; + if (napi_get_named_property(env, res, "pbBytes", &pb_val) == napi_ok) { + const uint8_t* pb_ptr = nullptr; + int pb_len = 0; + ExtractBytesInfo(env, pb_val, &pb_ptr, &pb_len); + if (pb_ptr != nullptr && pb_len > 0) { + char* dst = arena.alloc(pb_len); + memcpy(dst, pb_ptr, pb_len); + cell->kind = CELL_KIND_PROTO_VALUE; + cell->str_val = dst; + cell->str_len = static_cast(pb_len); + } + } + } + } + } +} + +// 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; + } + + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, args[0], reinterpret_cast(&wrap)); + if (wrap == nullptr || wrap->handle == 0 || wrap->is_closed.load()) { + napi_throw_error(env, nullptr, "Invalid CoreClientHandle"); + return nullptr; + } + + RpcRequestContext* ctx = new RpcRequestContext(wrap, REQ_STREAMING_SQL); + ctx->routing_key = ctx->arena.copy_str(env, args[1]); + ExtractMetadataArena(env, args[2], ctx->arena, ctx->meta_keys_ptr, ctx->meta_vals_ptr); + + const uint8_t* req_ptr = nullptr; + int req_len = 0; + CopyBytesToArena(env, args[3], ctx->arena, &req_ptr, &req_len); + ctx->req_bytes = reinterpret_cast(req_ptr); + ctx->req_len = req_len; + + bool skip_meta = false; + if (napi_get_value_bool(env, args[4], &skip_meta) == napi_ok && skip_meta) { + ctx->skip_metadata = 1; + } + + napi_create_reference(env, args[5], 1, &ctx->cb_ref); + wrap->Enqueue(env, ctx); + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +// Function: commitNative +napi_value CommitNative(napi_env env, napi_callback_info info) { + size_t argc = 8; + napi_value args[8]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc < 8) { + napi_throw_type_error(env, nullptr, "Wrong number of arguments for commitNative"); + return nullptr; + } + + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, args[0], reinterpret_cast(&wrap)); + if (wrap == nullptr || wrap->handle == 0 || wrap->is_closed.load()) { + napi_throw_error(env, nullptr, "Invalid CoreClientHandle"); + return nullptr; + } + + RpcRequestContext* ctx = new RpcRequestContext(wrap, REQ_COMMIT); + ctx->commit_req.routing_key = ctx->arena.copy_str(env, args[1]); + ExtractMetadataArena(env, args[2], ctx->arena, ctx->meta_keys_ptr, ctx->meta_vals_ptr); + ctx->commit_req.meta_keys = ctx->meta_keys_ptr.data(); + ctx->commit_req.meta_vals = ctx->meta_vals_ptr.data(); + ctx->commit_req.meta_count = static_cast(ctx->meta_keys_ptr.size()); + + CopyBytesToArena(env, args[3], ctx->arena, &ctx->commit_req.base_req_pb, &ctx->commit_req.base_req_len); + + bool inline_begin = false; + napi_get_value_bool(env, args[4], &inline_begin); + ctx->commit_req.inline_begin = inline_begin ? 1 : 0; + + CopyBytesToArena(env, args[5], ctx->arena, &ctx->commit_req.begin_req_pb, &ctx->commit_req.begin_req_len); + + bool is_mux_rw = false; + napi_get_value_bool(env, args[6], &is_mux_rw); + ctx->commit_req.is_mux_rw = is_mux_rw ? 1 : 0; + + napi_create_reference(env, args[7], 1, &ctx->cb_ref); + wrap->Enqueue(env, ctx); + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +// Function: executeBatchDmlNative +napi_value ExecuteBatchDmlNative(napi_env env, napi_callback_info info) { + size_t argc = 8; + napi_value args[8]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc < 8) { + napi_throw_type_error(env, nullptr, "Wrong number of arguments for executeBatchDmlNative"); + return nullptr; + } + + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, args[0], reinterpret_cast(&wrap)); + if (wrap == nullptr || wrap->handle == 0 || wrap->is_closed.load()) { + napi_throw_error(env, nullptr, "Invalid CoreClientHandle"); + return nullptr; + } + + bool is_single_sql = false; + napi_get_value_bool(env, args[6], &is_single_sql); + + RpcRequestContext* ctx = new RpcRequestContext(wrap, is_single_sql ? REQ_SQL_DML : REQ_BATCH_DML); + CSpannerBatchDmlRequest& c_req = ctx->dml_req; + + c_req.routing_key = ctx->arena.copy_str(env, args[1]); + ExtractMetadataArena(env, args[2], ctx->arena, ctx->meta_keys_ptr, ctx->meta_vals_ptr); + c_req.meta_keys = ctx->meta_keys_ptr.data(); + c_req.meta_vals = ctx->meta_vals_ptr.data(); + c_req.meta_count = static_cast(ctx->meta_keys_ptr.size()); + + CopyBytesToArena(env, args[3], ctx->arena, &c_req.base_req_pb, &c_req.base_req_len); + if (c_req.base_req_pb == nullptr || c_req.base_req_len == 0) { + napi_valuetype dml_type = napi_undefined; + napi_typeof(env, args[3], &dml_type); + if (dml_type == napi_object) { + napi_value prop_val; + if (napi_get_named_property(env, args[3], "session", &prop_val) == napi_ok) { + c_req.session = ctx->arena.copy_str(env, prop_val); + } + if (napi_get_named_property(env, args[3], "txId", &prop_val) == napi_ok) { + CopyBytesToArena(env, prop_val, ctx->arena, &c_req.tx_id, &c_req.tx_id_len); + } + if (napi_get_named_property(env, args[3], "beginRw", &prop_val) == napi_ok) { + bool b = false; + napi_get_value_bool(env, prop_val, &b); + c_req.begin_rw = b ? 1 : 0; + } + if (napi_get_named_property(env, args[3], "prevTxId", &prop_val) == napi_ok) { + CopyBytesToArena(env, prop_val, ctx->arena, &c_req.prev_tx_id, &c_req.prev_tx_id_len); + } + if (napi_get_named_property(env, args[3], "seqno", &prop_val) == napi_ok) { + napi_get_value_int64(env, prop_val, &c_req.seqno); + } + if (napi_get_named_property(env, args[3], "transactionTag", &prop_val) == napi_ok) { + c_req.transaction_tag = ctx->arena.copy_str(env, prop_val); + } + if (napi_get_named_property(env, args[3], "requestTag", &prop_val) == napi_ok) { + c_req.request_tag = ctx->arena.copy_str(env, prop_val); + } + } + } + + napi_value stmts_arr = args[4]; + napi_value fallback_fn = args[5]; + napi_value callback_val = args[7]; + + uint32_t stmt_count = 0; + napi_get_array_length(env, stmts_arr, &stmt_count); + + CSpannerStatement* c_stmts = nullptr; + if (stmt_count > 0) { + c_stmts = reinterpret_cast(ctx->arena.alloc(sizeof(CSpannerStatement) * stmt_count)); + memset(c_stmts, 0, sizeof(CSpannerStatement) * stmt_count); + } + + for (uint32_t i = 0; i < stmt_count; i++) { + napi_value stmt_obj; + napi_get_element(env, stmts_arr, i, &stmt_obj); + + CSpannerStatement& cs = c_stmts[i]; + + napi_value sql_val; + if (napi_get_named_property(env, stmt_obj, "sql", &sql_val) == napi_ok) { + cs.sql = ctx->arena.copy_str(env, sql_val); + } + + napi_value names_arr, vals_arr, codes_arr, types_arr; + uint32_t param_count = 0; + if (napi_get_named_property(env, stmt_obj, "paramNames", &names_arr) == napi_ok) { + napi_get_array_length(env, names_arr, ¶m_count); + } + cs.param_count = static_cast(param_count); + + if (param_count > 0) { + napi_get_named_property(env, stmt_obj, "paramValues", &vals_arr); + bool has_codes = (napi_get_named_property(env, stmt_obj, "paramTypeCodes", &codes_arr) == napi_ok); + bool has_types = (napi_get_named_property(env, stmt_obj, "paramTypesPb", &types_arr) == napi_ok); + + cs.param_names = reinterpret_cast(ctx->arena.alloc(sizeof(const char*) * param_count)); + cs.param_cells = reinterpret_cast(ctx->arena.alloc(sizeof(CSpannerCell) * param_count)); + cs.param_types_pb = reinterpret_cast(ctx->arena.alloc(sizeof(const uint8_t*) * param_count)); + cs.param_types_len = reinterpret_cast(ctx->arena.alloc(sizeof(int) * param_count)); + + for (uint32_t p = 0; p < param_count; p++) { + napi_value name_val, val_item; + napi_get_element(env, names_arr, p, &name_val); + napi_get_element(env, vals_arr, p, &val_item); + + int32_t explicit_code = 0; + if (has_codes) { + napi_value code_val; + if (napi_get_element(env, codes_arr, p, &code_val) == napi_ok) { + napi_get_value_int32(env, code_val, &explicit_code); + } + } + + cs.param_names[p] = ctx->arena.copy_str(env, name_val); + ExtractCellFromJsValue( + env, + val_item, + fallback_fn, + &cs.param_cells[p], + ctx->arena, + true, + static_cast(explicit_code > 0 ? explicit_code : 0) + ); + + cs.param_types_pb[p] = nullptr; + cs.param_types_len[p] = 0; + if (has_types) { + napi_value type_pb_val; + if (napi_get_element(env, types_arr, p, &type_pb_val) == napi_ok) { + CopyBytesToArena(env, type_pb_val, ctx->arena, &cs.param_types_pb[p], &cs.param_types_len[p]); + } + } + } + } + } + + c_req.stmt_count = static_cast(stmt_count); + c_req.statements = c_stmts; + + napi_create_reference(env, callback_val, 1, &ctx->cb_ref); + wrap->Enqueue(env, ctx); + + napi_value undef; + napi_get_undefined(env, &undef); + return undef; +} + +// Function: beginTransactionNative +napi_value BeginTransactionNative(napi_env env, napi_callback_info info) { + size_t argc = 5; + napi_value args[5]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + if (argc < 5) { + napi_throw_type_error(env, nullptr, "Wrong number of arguments for beginTransactionNative"); + return nullptr; + } + + CoreClientHandleWrapper* wrap = nullptr; + napi_unwrap(env, args[0], reinterpret_cast(&wrap)); + if (wrap == nullptr || wrap->handle == 0 || wrap->is_closed.load()) { + napi_throw_error(env, nullptr, "Invalid CoreClientHandle"); + return nullptr; + } + + RpcRequestContext* ctx = new RpcRequestContext(wrap, REQ_BEGIN_TX); + ctx->routing_key = ctx->arena.copy_str(env, args[1]); + ExtractMetadataArena(env, args[2], ctx->arena, ctx->meta_keys_ptr, ctx->meta_vals_ptr); + + const uint8_t* req_ptr = nullptr; + int req_len = 0; + CopyBytesToArena(env, args[3], ctx->arena, &req_ptr, &req_len); + ctx->req_bytes = reinterpret_cast(req_ptr); + ctx->req_len = req_len; + + napi_create_reference(env, args[4], 1, &ctx->cb_ref); + wrap->Enqueue(env, 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_props[] = { + { "executeStreamingSqlNative", nullptr, ExecuteStreamingSqlNative, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "commitNative", nullptr, CommitNative, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "executeBatchDmlNative", nullptr, ExecuteBatchDmlNative, nullptr, nullptr, nullptr, napi_default, nullptr }, + { "beginTransactionNative", nullptr, BeginTransactionNative, nullptr, nullptr, nullptr, napi_default, nullptr } + }; + napi_define_properties(env, exports, 4, fn_props); + + 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 00000000000..d158ffdb050 --- /dev/null +++ b/handwritten/spanner/spanner-native/verify_native_core.js @@ -0,0 +1,828 @@ +/** + * 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 = []; +const capturedCommits = []; +const capturedBeginTx = []; +const capturedBatchDml = []; +const capturedSqlDml = []; + +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) => { + capturedBeginTx.push(call.request); + callback(null, {id: Buffer.from('tx-1')}); + }, + Commit: (call, callback) => { + capturedCommits.push(call.request); + callback(null, {commitTimestamp: {seconds: 1700000000, nanos: 123456000}}); + }, + Rollback: (call, callback) => callback(null, {}), + ExecuteBatchDml: (call, callback) => { + capturedBatchDml.push(call.request); + const stmts = call.request.statements || []; + callback(null, { + resultSets: stmts.map(() => ({stats: {rowCountExact: 5}})), + status: {code: 0}, + }); + }, + ExecuteSql: (call, callback) => { + capturedSqlDml.push(call.request); + callback(null, { + stats: {rowCountExact: 7}, + metadata: {transaction: {id: Buffer.from('tx-dml-1')}}, + }); + }, + + ExecuteStreamingSql: call => { + capturedRequests.push(call.request); + if (process.env.MOCK_DEBUG) { + console.log( + ' [mock] ExecuteStreamingSql request:', + JSON.stringify(call.request, null, 2), + ); + } + if (call.request.sql && call.request.sql.startsWith('UPDATE')) { + call.write({ + stats: {rowCountExact: 7}, + metadata: { + rowType: {fields: []}, + transaction: {id: Buffer.from('tx-dml-1')}, + }, + }); + call.end(); + return; + } + const txMeta = + call.request.transaction && call.request.transaction.begin + ? {id: Buffer.from('tx-stream-inline')} + : undefined; + + if (call.request.sql && call.request.sql.includes('999999')) { + call.write({ + metadata: { + rowType: {fields: FIELDS}, + transaction: txMeta, + }, + }); + call.end(); + return; + } + + // First chunk: metadata only. + call.write({ + metadata: { + rowType: {fields: FIELDS}, + transaction: txMeta, + }, + }); + // 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 origStreamNative = nativeCore.runStreamNative; + nativeCore.runStreamNative = function (...args) { + nativeCalls++; + 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}); + 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), + ), + ], + ]; + + console.log('\n--- Running Write / Update / Mutation tests (stock vs Go shared core) ---'); + const stockWrite = await runWriteAndUpdateOnce(false); + const nativeWrite = await runWriteAndUpdateOnce(true); + + checks.push( + [ + 'write/update stock run did NOT touch Go shared core (provenance)', + () => { + assert.strictEqual(stockWrite.nativeCommitCalls, 0); + assert.strictEqual(stockWrite.nativeBatchUpdateCalls, 0); + assert.strictEqual(stockWrite.nativeSqlDmlCalls, 0); + assert.strictEqual(stockWrite.nativeTxRunCalls, 0); + }, + ], + [ + 'write/update native run DID dispatch into Go shared core (provenance)', + () => { + assert.strictEqual(nativeWrite.nativeCommitCalls, 8, 'expected 8 commits through native core'); + assert.strictEqual(nativeWrite.nativeBatchUpdateCalls, 1, 'expected 1 batchUpdate through native core'); + assert.strictEqual(nativeWrite.nativeSqlDmlCalls, 3, 'expected 3 runUpdate calls through native core'); + assert.strictEqual(nativeWrite.nativeTxRunCalls, 2, 'expected 2 transaction.run(SELECT) calls through native core'); + }, + ], + [ + 'select-update benchmark flow (SELECT -> runUpdate -> commit) works end-to-end on native core', + () => { + assert.strictEqual(nativeWrite.selectUpdateFoundCount, 3); + assert.strictEqual(nativeWrite.selectUpdateZeroRowsCount, 0); + }, + ], + [ + 'table.insert / upsert / update / deleteRows wire CommitRequest mutations match stock byte-for-byte', + () => { + assert.strictEqual(nativeWrite.commits.length, stockWrite.commits.length); + for (let i = 0; i < stockWrite.commits.length; i++) { + assert.deepStrictEqual( + normalize(nativeWrite.commits[i].mutations), + normalize(stockWrite.commits[i].mutations), + `CommitRequest[${i}].mutations differ between stock and Go shared core`, + ); + } + }, + ], + [ + 'transaction.batchUpdate wire ExecuteBatchDmlRequest statements and params match stock byte-for-byte', + () => { + assert.strictEqual(nativeWrite.batchDml.length, 1); + assert.deepStrictEqual( + normalize(nativeWrite.batchDml[0].statements), + normalize(stockWrite.batchDml[0].statements), + 'ExecuteBatchDmlRequest statements differ between stock and Go shared core', + ); + assert.deepStrictEqual(nativeWrite.batchRowCounts, stockWrite.batchRowCounts); + assert.deepStrictEqual(nativeWrite.batchRowCounts, [5, 5]); + }, + ], + [ + 'transaction.runUpdate wire ExecuteSqlRequest statement and params match stock byte-for-byte', + () => { + assert.strictEqual(nativeWrite.sqlDml.length, 3); + const stockDmls = stockWrite.streamingRequests.filter( + r => r.sql && (r.sql.startsWith('UPDATE') || r.sql.startsWith('INSERT')), + ); + assert.strictEqual(stockDmls.length, 3); + for (let i = 0; i < 3; i++) { + const stockDml = stockDmls[i]; + const nativeDml = nativeWrite.sqlDml[i]; + assert.strictEqual(nativeDml.sql, stockDml.sql); + assert.deepStrictEqual( + normalize(nativeDml.params), + normalize(stockDml.params), + `ExecuteSqlRequest[${i}] params differ between stock and Go shared core`, + ); + assert.deepStrictEqual( + normalize(nativeDml.paramTypes), + normalize(stockDml.paramTypes), + `ExecuteSqlRequest[${i}] paramTypes differ between stock and Go shared core`, + ); + } + assert.strictEqual(nativeWrite.singleRowCount, stockWrite.singleRowCount); + assert.strictEqual(nativeWrite.singleRowCount, 7); + }, + ], + ); + + 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); +} + +async function runWriteAndUpdateOnce(useNativeCore) { + capturedRequests.length = 0; + capturedCommits.length = 0; + capturedBeginTx.length = 0; + capturedBatchDml.length = 0; + capturedSqlDml.length = 0; + + for (const key of Object.keys(require.cache)) { + if (key.includes(`${path.sep}spanner${path.sep}build${path.sep}src`)) { + delete require.cache[key]; + } + } + + process.env.SPANNER_NATIVE_CORE = useNativeCore ? 'go' : 'off'; + + const {Spanner} = require(path.join(SPANNER_PKG, 'build', 'src', 'index.js')); + const nativeCore = require( + path.join(SPANNER_PKG, 'build', 'src', 'native-core.js'), + ); + + let nativeCommitCalls = 0; + let nativeBatchUpdateCalls = 0; + let nativeSqlDmlCalls = 0; + let nativeTxRunCalls = 0; + + const origCommit = nativeCore.executeNativeCommit; + nativeCore.executeNativeCommit = function (...args) { + nativeCommitCalls++; + return origCommit.apply(this, args); + }; + const origBatch = nativeCore.executeNativeBatchUpdate; + nativeCore.executeNativeBatchUpdate = function (...args) { + nativeBatchUpdateCalls++; + return origBatch.apply(this, args); + }; + const origSqlDml = nativeCore.executeNativeSqlDml; + nativeCore.executeNativeSqlDml = function (...args) { + nativeSqlDmlCalls++; + return origSqlDml.apply(this, args); + }; + const origTxRun = nativeCore.executeNativeTransactionRun; + nativeCore.executeNativeTransactionRun = function (...args) { + nativeTxRunCalls++; + return origTxRun.apply(this, args); + }; + + const spanner = new Spanner({projectId: PROJECT}); + const database = spanner.instance(INSTANCE).database(DATABASE); + database.on('error', () => {}); + const table = database.table('Users'); + + const rows = [ + { + id: 101, + name: 'Alice', + score: 98.75, + active: true, + payload: Buffer.from('hello-bytes-1'), + amount: Spanner.numeric('1234.5678'), + day: Spanner.date('2026-03-16'), + created: new Date('2026-03-16T12:34:56.789Z'), + missing: null, + }, + { + id: 102, + name: 'Bob', + score: -42.5, + active: false, + payload: Buffer.from('hello-bytes-2'), + amount: Spanner.numeric('99999.0001'), + day: Spanner.date('2026-03-17'), + created: new Date('2026-03-17T00:00:00.000Z'), + missing: 'not-null', + }, + ]; + + let batchRowCounts = null; + let singleRowCount = null; + + try { + // 1. table.insert (batch insert) + await table.insert(rows); + + // 2. table.upsert (batch upsert) + await table.upsert(rows); + + // 3. table.update (batch update) + await table.update(rows); + + // 4. table.deleteRows (batch delete) + await table.deleteRows([101, 102]); + + // 5. transaction.batchUpdate + transaction.commit + await database.runTransactionAsync(async tx => { + const [counts] = await tx.batchUpdate([ + { + sql: 'INSERT INTO Users (id, name, score, active) VALUES (@id, @name, @score, @active)', + params: {id: 103, name: 'Charlie', score: 77.25, active: true}, + }, + { + sql: 'UPDATE Users SET amount = @amount WHERE id = @id', + params: {id: 103, amount: Spanner.numeric('555.55')}, + }, + ]); + batchRowCounts = counts; + await tx.commit(); + }); + + // 6. transaction.runUpdate + transaction.commit + await database.runTransactionAsync(async tx => { + const [count] = await tx.runUpdate({ + sql: 'UPDATE Users SET score = @score, payload = @payload WHERE id = @id', + params: { + id: 101, + score: 100.0, + payload: Buffer.from('updated-bytes'), + }, + }); + singleRowCount = count; + await tx.commit(); + }); + + // 7. select-update benchmark flow (existing row: SELECT -> UPDATE -> COMMIT) + let selectUpdateFoundCount = 0; + await database.runTransactionAsync(async tx => { + const [foundRows] = await tx.run({ + sql: 'SELECT * FROM Users WHERE id = @id', + params: {id: 101}, + types: {id: 'int64'}, + }); + selectUpdateFoundCount = foundRows.length; + await tx.runUpdate({ + sql: 'UPDATE Users SET name = @name WHERE id = @id', + params: {id: 101, name: 'UpdatedAlice'}, + }); + await tx.commit(); + }); + + // 8. select-update benchmark flow (0 rows found: SELECT -> INSERT -> COMMIT) + let selectUpdateZeroRowsCount = -1; + await database.runTransactionAsync(async tx => { + const [zeroRows] = await tx.run({ + sql: 'SELECT * FROM Users WHERE id = 999999', + }); + selectUpdateZeroRowsCount = zeroRows.length; + await tx.runUpdate({ + sql: 'INSERT INTO Users (id, name) VALUES (@id, @name)', + params: {id: 999999, name: 'NewUser'}, + }); + await tx.commit(); + }); + + return { + nativeCommitCalls, + nativeBatchUpdateCalls, + nativeSqlDmlCalls, + nativeTxRunCalls, + selectUpdateFoundCount, + selectUpdateZeroRowsCount, + commits: capturedCommits.slice(), + batchDml: capturedBatchDml.slice(), + sqlDml: capturedSqlDml.slice(), + streamingRequests: capturedRequests.slice(), + batchRowCounts, + singleRowCount, + }; + } finally { + try { + await database.close(); + } catch (e) { + /* ignore */ + } + } +} + +main(); diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index be713e7e130..9adea54882c 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -44,6 +44,28 @@ const DIGITS_REGEX = /^\d+$/; const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +const CHAR_CODE_ZERO = 48; // '0' + +/** + * Parses the fixed-width decimal run `[start, end)` of `str` without + * allocating a substring. + * + * @returns the parsed value, or -1 if any character is not a digit. Callers + * treat -1 as "not a fast-path timestamp" and fall back to the general + * `PreciseDate` string constructor. + */ +function readDigits(str: string, start: number, end: number): number { + let value = 0; + for (let i = start; i < end; i++) { + const digit = str.charCodeAt(i) - CHAR_CODE_ZERO; + if (digit < 0 || digit > 9) { + return -1; + } + value = value * 10 + digit; + } + return value; +} + function isValidDate(year: number, month: number, date: number): boolean { if (month < 0 || month > 11) { return false; @@ -1228,21 +1250,20 @@ function parsePreciseDate(isoString: string): PreciseDate { isoString[13] === ':' && isoString[16] === ':' ) { - const year = Number(isoString.substring(0, 4)); - const month = Number(isoString.substring(5, 7)) - 1; - const day = Number(isoString.substring(8, 10)); - const hours = Number(isoString.substring(11, 13)); - const minutes = Number(isoString.substring(14, 16)); - const seconds = Number(isoString.substring(17, 19)); + const year = readDigits(isoString, 0, 4); + const month = readDigits(isoString, 5, 7) - 1; + const day = readDigits(isoString, 8, 10); + const hours = readDigits(isoString, 11, 13); + const minutes = readDigits(isoString, 14, 16); + const seconds = readDigits(isoString, 17, 19); if ( - Number.isNaN(year) || year < 1970 || - Number.isNaN(month) || - Number.isNaN(day) || - Number.isNaN(hours) || - Number.isNaN(minutes) || - Number.isNaN(seconds) + month < 0 || + day < 0 || + hours < 0 || + minutes < 0 || + seconds < 0 ) { return new PreciseDate(isoString); } @@ -1256,17 +1277,32 @@ function parsePreciseDate(isoString: string): PreciseDate { if (dotIndex !== 19) { return new PreciseDate(isoString); } - const subSecondsStr = isoString.substring( - dotIndex + 1, - isoString.length - 1, - ); - if (!DIGITS_REGEX.test(subSecondsStr)) { + // Accumulate the fractional seconds directly as a 9-digit (nanosecond) + // integer. Digits beyond the 9th are validated but discarded, matching + // the previous padEnd(9)/substring behaviour. + const subStart = dotIndex + 1; + const subEnd = isoString.length - 1; + let frac = 0; + let taken = 0; + for (let i = subStart; i < subEnd; i++) { + const digit = isoString.charCodeAt(i) - CHAR_CODE_ZERO; + if (digit < 0 || digit > 9) { + return new PreciseDate(isoString); + } + if (taken < 9) { + frac = frac * 10 + digit; + taken++; + } + } + if (taken === 0) { return new PreciseDate(isoString); } - const padded = subSecondsStr.padEnd(9, '0'); - milliseconds = Number(padded.substring(0, 3)); - microseconds = Number(padded.substring(3, 6)); - nanoseconds = Number(padded.substring(6, 9)); + for (let i = taken; i < 9; i++) { + frac *= 10; + } + milliseconds = Math.floor(frac / 1e6); + microseconds = Math.floor(frac / 1e3) % 1000; + nanoseconds = frac % 1000; } else if (isoString.length !== 20) { return new PreciseDate(isoString); } diff --git a/handwritten/spanner/src/database.ts b/handwritten/spanner/src/database.ts index b6ba8347e4c..cda7466bddd 100644 --- a/handwritten/spanner/src/database.ts +++ b/handwritten/spanner/src/database.ts @@ -54,6 +54,14 @@ import { GetDatabaseOperationsCallback, } from './instance'; import {PartialResultStream, Row} from './partial-result-stream'; +import { + isNativeCoreEnabled, + isNativeEligible, + runStreamNative, + runNative, + encodeReadOnlyBounds, + DatabaseLike as NativeDatabaseLike, +} from './native-core'; import {Session} from './session'; import { isSessionNotFoundError, @@ -2908,6 +2916,36 @@ 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)) { + 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!); } @@ -3300,6 +3338,40 @@ 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)) { + 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, + readOnly, + ) 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( @@ -3666,9 +3738,8 @@ class Database extends common.GrpcServiceObject { : {}; let sessionId = ''; - const getSession = this.sessionFactory_.getSessionForReadWrite.bind( - this.sessionFactory_, - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sf = this.sessionFactory_ as any; return startTrace( 'Database.runTransactionAsync', @@ -3682,11 +3753,27 @@ class Database extends common.GrpcServiceObject { // eslint-disable-next-line no-constant-condition while (true) { try { - const [session, transaction] = await promisify(getSession)(); - transaction.requestOptions = Object.assign( - transaction.requestOptions || {}, - options.requestOptions, - ); + let session: Session; + let transaction: Transaction; + if ( + sf.isMultiplexedRW && + sf.multiplexedSession_?._multiplexedSession + ) { + session = sf.multiplexedSession_._multiplexedSession; + transaction = session.transaction(this.queryOptions_); + } else { + const getSession = + this.sessionFactory_.getSessionForReadWrite.bind( + this.sessionFactory_, + ); + [session, transaction] = await promisify(getSession)(); + } + if (options?.requestOptions) { + transaction.requestOptions = Object.assign( + transaction.requestOptions || {}, + options.requestOptions, + ); + } transaction!.setReadWriteTransactionOptions( options as RunTransactionOptions, ); diff --git a/handwritten/spanner/src/native-core.ts b/handwritten/spanner/src/native-core.ts new file mode 100644 index 00000000000..ec726c029d3 --- /dev/null +++ b/handwritten/spanner/src/native-core.ts @@ -0,0 +1,2263 @@ +/*! + * 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 fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as tls from 'tls'; +import {grpc} from 'google-gax'; +import {PreciseDate, DateStruct} from '@google-cloud/precise-date'; +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, endpoint?: string) => CoreHandle; + executeStreamingSqlNative( + handle: CoreHandle, + routingKey: string, + metadata: string[][], + requestBytes: Uint8Array, + gaxOptions: object | boolean, + callback: ( + err: Error | null, + rows: Value[][] | null, + telemetry: Telemetry | null, + metadataPb?: Buffer | null, + isLast?: boolean, + ) => void, + ): void; + commitNative( + handle: CoreHandle, + routingKey: string, + metadata: string[][], + reqBytes: Uint8Array, + inlineBegin: boolean, + beginReqBytes: Uint8Array | null, + isMuxRw: boolean, + callback: ( + err: (Error & {retryInfoPb?: Buffer; code?: number; metadata?: grpc.Metadata}) | null, + respPb?: Buffer | null, + txPb?: Buffer | null, + ) => void, + ): void; + executeBatchDmlNative( + handle: CoreHandle, + routingKey: string, + metadata: string[][], + dmlReqInput: Uint8Array | object, + statements: unknown[], + fallbackEncoder: (val: unknown) => unknown, + isSingleSql: boolean, + callback: ( + err: (Error & {retryInfoPb?: Buffer; code?: number; metadata?: grpc.Metadata}) | null, + respPb?: Buffer | null, + txPb?: Buffer | null, + directRowCount?: number | null, + ) => void, + ): void; + beginTransactionNative( + handle: CoreHandle, + routingKey: string, + metadata: string[][], + reqBytes: Uint8Array, + callback: ( + err: (Error & {retryInfoPb?: Buffer; code?: number; metadata?: grpc.Metadata}) | null, + respPb?: Buffer | null, + txPb?: Buffer | null, + ) => void, + ): void; +} + +// --------------------------------------------------------------------------- +// 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'), + // 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; +let coreHandleEndpoint: string | undefined; + +/** + * Memoised endpoint per long-lived owner (a Database or Spanner instance). + * + * `getCoreHandle()` runs on every RPC, i.e. three times per read/write + * transaction. Re-walking the `_getSpanner()` chain and rebuilding the endpoint + * string each time showed up as ~1.2% of client CPU in the select-update + * profile, so the resolved value is cached against the owning object. Sessions + * and transactions are short-lived, so the cache is deliberately keyed on their + * stable parent rather than on the target itself. + */ +const endpointByOwner = new WeakMap(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveCustomEndpoint(target?: any): string { + // Read every call: tests and embedders may point the client at an emulator + // part-way through a process, and this lookup is cheap relative to the walk. + if (process.env.SPANNER_EMULATOR_HOST) { + return process.env.SPANNER_EMULATOR_HOST; + } + + // Resolve to the longest-lived object we can safely memoise against. + let owner: any; // eslint-disable-line @typescript-eslint/no-explicit-any + if (target?._getSpanner) { + owner = target; + } else if (target?.session?.parent?._getSpanner) { + owner = target.session.parent; + } else if (target?.parent?._getSpanner) { + owner = target.parent; + } else { + owner = target; + } + + const memoisable = Boolean(owner) && typeof owner === 'object'; + if (memoisable) { + const hit = endpointByOwner.get(owner); + if (hit !== undefined) { + return hit; + } + } + + const spanner = owner?._getSpanner ? owner._getSpanner() : owner; + const opts = spanner?.options; + let endpoint = ''; + if (opts && opts.apiEndpoint) { + endpoint = String(opts.apiEndpoint); + if (opts.port && !endpoint.includes(':')) { + endpoint = `${endpoint}:${opts.port}`; + } + } + + if (memoisable) { + endpointByOwner.set(owner, endpoint); + } + return endpoint; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getCoreHandle(target?: any): CoreHandle | null { + const endpoint = resolveCustomEndpoint(target); + if ( + coreHandle !== undefined && + (coreHandleEndpoint === endpoint || (!endpoint && coreHandleEndpoint === '')) + ) { + return coreHandle; + } + const addon = loadAddon(); + if (!addon) { + coreHandle = null; + return coreHandle; + } + if (coreHandle && coreHandleEndpoint !== endpoint) { + try { + coreHandle.close(); + } catch (e) { + // ignore + } + } + const channels = Number(process.env.SPANNER_NATIVE_CHANNELS || '4') || 4; + try { + coreHandle = new addon.CoreClientHandle(channels, endpoint); + coreHandleEndpoint = endpoint; + } 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; + coreHandleEndpoint = 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 and Write/Update/Mutation APIs.' + : '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; +} + +const paramTypeCache = new Map(); + +/** + * Lookup table mapping protobuf `TypeCode` enum *names* (e.g. 'INT64') to their + * numeric wire values. `codec.createTypeObject()` yields string codes, but the + * protobuf encoder requires numbers, so hot paths use this map to convert + * without repeatedly re-casting the generated enum object. + */ +const TYPE_CODE_STR_TO_NUM = + protos.google.spanner.v1.TypeCode as unknown as Record; + +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 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; + } + } + } + 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(database); + 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. + 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, + Boolean(cachedEntry), + (cbErr, rows, telemetry, metadataPb, isLast) => { + if (fellBack) { + return; + } + if (cbErr) { + out.destroy(cbErr); + return; + } + + // First batch carries the serialized ResultSetMetadata (if not skipped). + 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) { + // 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])); + } + if (isLast) { + out.push(null); + } + }, + ); + }); + + 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(database); + 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, isLast) => { + 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])); + } + if (isLast) { + callback(null, resultRows, undefined, resultMetadata); + } + }, + ); + }); +} + +// --------------------------------------------------------------------------- +// Write / Mutation / DML execution path (Commit, ExecuteBatchDml, ExecuteSql) +// --------------------------------------------------------------------------- + +const TYPE_NAME_TO_CODE: Record = { + unspecified: 0, + bool: 1, + boolean: 1, + int64: 2, + pgOid: 2, + float64: 3, + timestamp: 4, + date: 5, + string: 6, + bytes: 7, + array: 8, + struct: 9, + numeric: 10, + pgNumeric: 10, + json: 11, + pgJsonb: 11, + proto: 13, + enum: 14, + float32: 15, + interval: 16, + uuid: 17, +}; + +const SCALAR_TYPE_TO_CODE: Record = { + bool: 1, + boolean: 1, + BOOL: 1, + BOOLEAN: 1, + int64: 2, + INT64: 2, + float64: 3, + FLOAT64: 3, + timestamp: 4, + TIMESTAMP: 4, + date: 5, + DATE: 5, + string: 6, + STRING: 6, + bytes: 7, + BYTES: 7, + numeric: 10, + NUMERIC: 10, + json: 11, + JSON: 11, + float32: 15, + FLOAT32: 15, + interval: 16, + INTERVAL: 16, + uuid: 17, + UUID: 17, +}; + +function resolveScalarTypeCode(typeSpec: unknown): number { + if (!typeSpec) return 0; + if (typeof typeSpec === 'string') { + return SCALAR_TYPE_TO_CODE[typeSpec] || 0; + } + if (typeof typeSpec === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = typeSpec as any; + if ( + obj.child || + obj.fields || + obj.arrayElementType || + obj.structType || + obj.protoTypeFqn || + obj.typeAnnotation + ) { + return 0; + } + if (typeof obj.type === 'string') { + return SCALAR_TYPE_TO_CODE[obj.type] || 0; + } + if (typeof obj.code === 'number') { + if ( + (obj.code >= 1 && obj.code <= 7) || + obj.code === 10 || + obj.code === 11 || + (obj.code >= 15 && obj.code <= 17) + ) { + return obj.code; + } + } + } + return 0; +} + +/** + * Fallback encoder invoked from C++ only when a cell value is an Object wrapper + * (Int, Float, Float32, Numeric, Date, PreciseDate, SpannerDate, Interval, Array, Struct). + * Primitive types (null, boolean, number, string, Buffer) are encoded directly in C++. + */ +export function fallbackEncodeCell(val: unknown): { + kind: number; + typeCode: number; + boolVal?: number; + numVal?: number; + strVal?: string; + pbBytes?: Uint8Array; +} { + if (val === null || val === undefined) { + return {kind: 0, typeCode: 0}; + } + const t = codec.getType(val as Value); + const typeCode = TYPE_NAME_TO_CODE[t.type] || 0; + const encoded = codec.encode(val as Value); + + if (encoded.nullValue !== undefined && encoded.nullValue !== null) { + return {kind: 0, typeCode}; + } + if (encoded.boolValue !== undefined && encoded.boolValue !== null) { + return {kind: 1, typeCode, boolVal: encoded.boolValue ? 1 : 0}; + } + if (encoded.numberValue !== undefined && encoded.numberValue !== null) { + return {kind: 2, typeCode, numVal: Number(encoded.numberValue)}; + } + if (encoded.stringValue !== undefined && encoded.stringValue !== null) { + return {kind: 3, typeCode, strVal: String(encoded.stringValue)}; + } + const pbBytes = protos.google.protobuf.Value.encode(encoded).finish(); + return {kind: 4, typeCode, pbBytes}; +} + +const sessionParamPairCache = new Map(); + +function headersToMetadataArray( + sessionName: string, + headersObj?: Record, +): string[][] { + let sessionPair = sessionParamPairCache.get(sessionName); + if (!sessionPair) { + sessionPair = [ + 'x-goog-request-params', + `session=${encodeURIComponent(sessionName)}`, + ]; + if (sessionParamPairCache.size > 1000) { + sessionParamPairCache.clear(); + } + sessionParamPairCache.set(sessionName, sessionPair); + } + const meta: string[][] = [sessionPair]; + if (headersObj) { + const keys = Object.keys(headersObj); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const v = headersObj[k]; + if (v !== undefined && v !== null) { + meta.push([k.toLowerCase(), String(v)]); + } + } + } + return meta; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function attachRetryMetadata(err: any) { + if (err && err.retryInfoPb && err.retryInfoPb.length > 0) { + if (!err.metadata) { + err.metadata = new grpc.Metadata(); + } + err.metadata.add('google.rpc.retryinfo-bin', err.retryInfoPb); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function normalizeTypeProto(typeObj: any): any { + if (!typeObj || typeof typeObj !== 'object') { + return typeObj; + } + const copy = Object.assign({}, typeObj); + if (typeof copy.code === 'string') { + copy.code = + (protos.google.spanner.v1.TypeCode as unknown as Record)[ + copy.code + ] || 0; + } + if (typeof copy.typeAnnotation === 'string') { + copy.typeAnnotation = + ( + protos.google.spanner.v1.TypeAnnotationCode as unknown as Record< + string, + number + > + )[copy.typeAnnotation] || 0; + } + if (copy.arrayElementType) { + copy.arrayElementType = normalizeTypeProto(copy.arrayElementType); + } + if (copy.structType && Array.isArray(copy.structType.fields)) { + copy.structType = { + fields: copy.structType.fields.map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (f: any) => ({ + name: f.name, + type: normalizeTypeProto(f.type), + }), + ), + }; + } + return copy; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function prepareNativeStatements(queries: Array): Array<{ + sql: string; + paramNames: string[]; + paramValues: unknown[]; + paramTypeCodes: number[]; + paramTypesPb: Array; +}> { + const result = new Array(queries.length); + for (let qIdx = 0; qIdx < queries.length; qIdx++) { + const q = queries[qIdx]; + if (typeof q === 'string') { + result[qIdx] = { + sql: q, + paramNames: [], + paramValues: [], + paramTypeCodes: [], + paramTypesPb: [], + }; + continue; + } + const sql = q.sql || ''; + const params = q.params; + if (!params || typeof params !== 'object') { + result[qIdx] = { + sql, + paramNames: [], + paramValues: [], + paramTypeCodes: [], + paramTypesPb: [], + }; + continue; + } + const paramNames = Object.keys(params); + const paramValues = new Array(paramNames.length); + const paramTypeCodes = new Array(paramNames.length); + const paramTypesPb = new Array(paramNames.length); + const explicitTypes = q.types; + + for (let i = 0; i < paramNames.length; i++) { + const name = paramNames[i]; + const val = params[name]; + paramValues[i] = val; + + if (explicitTypes && explicitTypes[name]) { + const scalarCode = resolveScalarTypeCode(explicitTypes[name]); + if (scalarCode > 0) { + paramTypeCodes[i] = scalarCode; + paramTypesPb[i] = null; + } else { + paramTypeCodes[i] = 0; + const typeObj = normalizeTypeProto( + codec.createTypeObject(explicitTypes[name]), + ); + paramTypesPb[i] = + protos.google.spanner.v1.Type.encode(typeObj).finish(); + } + } else if (val === null || val === undefined) { + paramTypeCodes[i] = 0; + paramTypesPb[i] = null; + } else if ( + Array.isArray(val) || + (typeof val === 'object' && + !Buffer.isBuffer(val) && + !(val instanceof Uint8Array) && + !(val instanceof codec.Int) && + !(val instanceof codec.Float) && + !(val instanceof codec.Float32) && + !(val instanceof codec.Numeric) && + !(val instanceof codec.SpannerDate) && + !(val instanceof Date) && + !(val instanceof PreciseDate) && + !(val instanceof codec.Interval)) + ) { + paramTypeCodes[i] = 0; + const t = codec.getType(val as Value); + const typeObj = normalizeTypeProto(codec.createTypeObject(t)); + paramTypesPb[i] = protos.google.spanner.v1.Type.encode(typeObj).finish(); + } else { + paramTypeCodes[i] = 0; + paramTypesPb[i] = null; + } + } + + result[qIdx] = { + sql, + paramNames, + paramValues, + paramTypeCodes, + paramTypesPb, + }; + } + return result; +} + +/** + * Dispatches `Transaction#commit` through the Go shared core. + * Request encoding and response decoding happen in Node.js; only raw byte buffers cross FFI. + */ +const sessionBytesCache = new Map(); + +function getSessionBytes(sessionName: string): Buffer { + let buf = sessionBytesCache.get(sessionName); + if (!buf) { + buf = Buffer.from(sessionName, 'utf8'); + if (sessionBytesCache.size < 64) { + sessionBytesCache.set(sessionName, buf); + } + } + return buf; +} + +function writeVarint(buf: Buffer, offset: number, val: number): number { + while (val > 0x7f) { + buf[offset++] = (val & 0x7f) | 0x80; + val >>>= 7; + } + buf[offset++] = val & 0x7f; + return offset; +} + +function varintLen(val: number): number { + let l = 1; + while (val > 0x7f) { + l++; + val >>>= 7; + } + return l; +} + +function encodeSimpleCommitRequestFast( + sessionName: string, + txId: Uint8Array, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + precommitToken?: any, +): Uint8Array { + const sessionBuf = getSessionBytes(sessionName); + let tokLen = 0; + let tokBodyLen = 0; + const tokBytes = precommitToken && precommitToken.precommitToken; + const seqNum = (precommitToken && precommitToken.seqNum) || 0; + if (tokBytes && tokBytes.length > 0) { + tokBodyLen += 1 + varintLen(tokBytes.length) + tokBytes.length; + } + if (seqNum > 0) { + tokBodyLen += 1 + varintLen(seqNum); + } + if (tokBodyLen > 0) { + tokLen = 1 + varintLen(tokBodyLen) + tokBodyLen; + } + + const totalLen = + 1 + + varintLen(sessionBuf.length) + + sessionBuf.length + + 1 + + varintLen(txId.length) + + txId.length + + tokLen; + + const out = Buffer.allocUnsafe(totalLen); + let pos = 0; + out[pos++] = 0x0a; + pos = writeVarint(out, pos, sessionBuf.length); + sessionBuf.copy(out, pos); + pos += sessionBuf.length; + + out[pos++] = 0x12; + pos = writeVarint(out, pos, txId.length); + out.set(txId, pos); + pos += txId.length; + + if (tokBodyLen > 0) { + out[pos++] = 0x4a; + pos = writeVarint(out, pos, tokBodyLen); + if (tokBytes && tokBytes.length > 0) { + out[pos++] = 0x0a; + pos = writeVarint(out, pos, tokBytes.length); + out.set(tokBytes, pos); + pos += tokBytes.length; + } + if (seqNum > 0) { + out[pos++] = 0x10; + pos = writeVarint(out, pos, seqNum); + } + } + return out; +} + +function decodePrecommitTokenFast( + buf: Uint8Array, +): {precommitToken: Uint8Array; seqNum: number} | null { + let pos = 0; + const len = buf.length; + let precommitToken: Uint8Array | null = null; + let seqNum = 0; + while (pos < len) { + const tag = buf[pos++]; + if (tag === 0x0a) { + let bLen = 0; + let shift = 0; + while (pos < len) { + const b = buf[pos++]; + bLen |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + precommitToken = buf.subarray(pos, pos + bLen); + pos += bLen; + } else if (tag === 0x10) { + let val = 0; + let shift = 0; + while (pos < len) { + const b = buf[pos++]; + val |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + seqNum = val; + } else { + return protos.google.spanner.v1.MultiplexedSessionPrecommitToken.decode( + buf, + ) as unknown as {precommitToken: Uint8Array; seqNum: number}; + } + } + if (!precommitToken) return null; + return {precommitToken, seqNum}; +} + +function decodeTxMetadataFast(buf: Uint8Array): { + id?: Uint8Array; + precommitToken?: {precommitToken: Uint8Array; seqNum: number} | null; +} | null { + let pos = 0; + const len = buf.length; + while (pos < len) { + const tag = buf[pos++]; + if (tag === 0x12) { + let txLen = 0; + let shift = 0; + while (pos < len) { + const b = buf[pos++]; + txLen |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const txEnd = pos + txLen; + let id: Uint8Array | undefined; + let precommitToken: {precommitToken: Uint8Array; seqNum: number} | null = + null; + while (pos < txEnd) { + const txTag = buf[pos++]; + if (txTag === 0x0a) { + let idLen = 0; + let s = 0; + while (pos < txEnd) { + const b = buf[pos++]; + idLen |= (b & 0x7f) << s; + if ((b & 0x80) === 0) break; + s += 7; + } + id = buf.subarray(pos, pos + idLen); + pos += idLen; + } else if (txTag === 0x1a) { + let tokLen = 0; + let s = 0; + while (pos < txEnd) { + const b = buf[pos++]; + tokLen |= (b & 0x7f) << s; + if ((b & 0x80) === 0) break; + s += 7; + } + precommitToken = decodePrecommitTokenFast( + buf.subarray(pos, pos + tokLen), + ); + pos += tokLen; + } else { + return null; + } + } + return {id, precommitToken}; + } else { + return null; + } + } + return null; +} + +function decodeCommitResponseFast( + buf: Uint8Array, +): protos.google.spanner.v1.ICommitResponse { + let pos = 0; + const len = buf.length; + while (pos < len) { + const tag = buf[pos++]; + if (tag === 0x0a) { + let tsLen = 0; + let shift = 0; + while (pos < len) { + const b = buf[pos++]; + tsLen |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const tsEnd = pos + tsLen; + let seconds = 0; + let nanos = 0; + while (pos < tsEnd) { + const tsTag = buf[pos++]; + if (tsTag === 0x08) { + let val = 0n; + let s = 0n; + while (pos < tsEnd) { + const b = buf[pos++]; + val |= BigInt(b & 0x7f) << s; + if ((b & 0x80) === 0) break; + s += 7n; + } + seconds = Number(val); + } else if (tsTag === 0x10) { + let val = 0; + let s = 0; + while (pos < tsEnd) { + const b = buf[pos++]; + val |= (b & 0x7f) << s; + if ((b & 0x80) === 0) break; + s += 7; + } + nanos = val; + } else { + return protos.google.spanner.v1.CommitResponse.decode(buf); + } + } + return {commitTimestamp: {seconds, nanos}}; + } else { + return protos.google.spanner.v1.CommitResponse.decode(buf); + } + } + return {}; +} + +export function executeNativeCommit( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any, + headersObj: Record, + callback: ( + err: Error | null, + resp?: protos.google.spanner.v1.ICommitResponse | null, + ) => void, +): boolean { + const addon = loadAddon(); + const handle = getCoreHandle(transaction); + if (!addon || !handle) { + return false; + } + + const mutations = transaction._queuedMutations || []; + const sessionName: string = transaction.session.formattedName_!; + const routingKey: string = transaction._affinityKey || sessionName; + const database = transaction.session.parent; + const isMuxRw = Boolean(database && database.isMuxEnabledForRW_); + const inlineBegin = !transaction.id && Boolean(transaction._useInRunner); + + const requestOptions = options?.requestOptions; + const txTag = + requestOptions?.transactionTag || transaction.requestOptions?.transactionTag; + const hasStatsOrDelay = Boolean( + options && + (('returnCommitStats' in options && options.returnCommitStats) || + ('maxCommitDelay' in options && options.maxCommitDelay) || + requestOptions?.priority), + ); + + let reqBytes: Uint8Array; + if ( + mutations.length === 0 && + !inlineBegin && + transaction.id && + !txTag && + !hasStatsOrDelay + ) { + reqBytes = encodeSimpleCommitRequestFast( + sessionName, + transaction.id, + transaction._latestPreCommitToken, + ); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseReq: any = { + session: sessionName, + mutations, + requestOptions: Object.assign( + requestOptions || {}, + transaction.requestOptions, + ), + precommitToken: transaction._latestPreCommitToken, + }; + if (transaction.id) { + baseReq.transactionId = transaction.id; + } else if (!transaction._useInRunner) { + baseReq.singleUseTransaction = transaction._options; + } + if (options && 'returnCommitStats' in options && options.returnCommitStats) { + baseReq.returnCommitStats = options.returnCommitStats; + } + if (options && 'maxCommitDelay' in options && options.maxCommitDelay) { + baseReq.maxCommitDelay = options.maxCommitDelay; + } + reqBytes = + protos.google.spanner.v1.CommitRequest.encode(baseReq).finish(); + } + + let beginReqBytes: Uint8Array | null = null; + if (inlineBegin) { + if ( + isMuxRw && + mutations.length > 0 && + typeof transaction._setMutationKey === 'function' + ) { + transaction._setMutationKey(mutations); + } + const beginOptions = Object.assign({}, transaction._options); + if ( + transaction.multiplexedSessionPreviousTransactionId && + isMuxRw && + beginOptions.readWrite + ) { + beginOptions.readWrite = Object.assign({}, beginOptions.readWrite, { + multiplexedSessionPreviousTransactionId: + transaction.multiplexedSessionPreviousTransactionId, + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const beginReq: any = { + session: sessionName, + options: beginOptions, + }; + if (transaction._mutationKey) { + beginReq.mutationKey = transaction._mutationKey; + } + if (transaction.requestOptions) { + beginReq.requestOptions = transaction.requestOptions; + } + beginReqBytes = + protos.google.spanner.v1.BeginTransactionRequest.encode(beginReq).finish(); + } + + const metadata = headersToMetadataArray(sessionName, headersObj); + + addon.commitNative( + handle, + routingKey, + metadata, + reqBytes, + inlineBegin, + beginReqBytes, + isMuxRw, + (err, respPb, txPb) => { + if (txPb && txPb.length > 0) { + try { + const txResp = protos.google.spanner.v1.Transaction.decode(txPb); + transaction._updatePrecommitToken(txResp); + transaction._update(txResp); + } catch (e) { + // ignore + } + } + if (err) { + attachRetryMetadata(err); + callback(err, null); + return; + } + let resp: protos.google.spanner.v1.ICommitResponse = {}; + if (respPb && respPb.length > 0) { + resp = decodeCommitResponseFast(respPb); + } + callback(null, resp); + }, + ); + return true; +} + +/** + * Dispatches `Transaction#batchUpdate` through the Go shared core. + */ +export function executeNativeBatchUpdate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queries: Array, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reqOptsOrBytes: Uint8Array | any, + headersObj: Record, + callback: ( + err: Error | null, + resp?: protos.google.spanner.v1.ExecuteBatchDmlResponse | null, + ) => void, +): boolean { + const addon = loadAddon(); + const handle = getCoreHandle(transaction); + if (!addon || !handle) { + return false; + } + + const sessionName: string = transaction.session.formattedName_!; + const routingKey: string = transaction._affinityKey || sessionName; + const metadata = headersToMetadataArray(sessionName, headersObj); + const nativeStatements = prepareNativeStatements(queries); + + let dmlReqInput: Uint8Array | object; + if ( + reqOptsOrBytes instanceof Uint8Array || + Buffer.isBuffer(reqOptsOrBytes) + ) { + dmlReqInput = reqOptsOrBytes; + } else if (reqOptsOrBytes?.requestOptions?.priority) { + dmlReqInput = + protos.google.spanner.v1.ExecuteBatchDmlRequest.encode( + reqOptsOrBytes, + ).finish(); + } else { + const database = transaction.session.parent; + dmlReqInput = { + session: sessionName, + txId: transaction.id || null, + beginRw: !transaction.id && Boolean(transaction._options?.readWrite), + prevTxId: + (!transaction.id && + database && + database.isMuxEnabledForRW_ && + transaction.multiplexedSessionPreviousTransactionId) || + null, + seqno: reqOptsOrBytes?.seqno ?? 0, + transactionTag: + reqOptsOrBytes?.requestOptions?.transactionTag || + transaction.requestOptions?.transactionTag || + '', + requestTag: reqOptsOrBytes?.requestOptions?.requestTag || '', + }; + } + + addon.executeBatchDmlNative( + handle, + routingKey, + metadata, + dmlReqInput, + nativeStatements, + fallbackEncodeCell, + false, + (err, respPb) => { + if (err) { + attachRetryMetadata(err); + callback(err, null); + return; + } + let resp: protos.google.spanner.v1.ExecuteBatchDmlResponse; + if (respPb && respPb.length > 0) { + resp = protos.google.spanner.v1.ExecuteBatchDmlResponse.decode(respPb); + } else { + resp = new protos.google.spanner.v1.ExecuteBatchDmlResponse(); + } + callback(null, resp); + }, + ); + return true; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function hasNonEmptyQueryOptions(opts: any): boolean { + if (!opts) return false; + return Boolean(opts.optimizerVersion || opts.optimizerStatisticsPackage); +} + +/** + * Dispatches `Transaction#runUpdate` / `Dml#runUpdate` through the Go shared core via unary `ExecuteSql`. + * Request encoding happens natively in Go; steady-state responses return rowCount directly. + */ +export function executeNativeSqlDml( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: any, + headersObj: Record, + callback: (err: Error | null, rowCount: number) => void, +): boolean { + const addon = loadAddon(); + const handle = getCoreHandle(transaction); + if (!addon || !handle) { + return false; + } + + const sessionName: string = transaction.session.formattedName_!; + const routingKey: string = transaction._affinityKey || sessionName; + const database = transaction.session.parent; + + let dmlReqInput: Uint8Array | object; + const hasQueryOptions = + hasNonEmptyQueryOptions(query.queryOptions) || + hasNonEmptyQueryOptions(transaction.queryOptions) || + Boolean(query.requestOptions?.priority); + const isSingleUse = !transaction.id && !transaction._options?.readWrite; + + if (hasQueryOptions || isSingleUse) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const txSelector: any = {}; + if (transaction.id) { + txSelector.id = transaction.id; + } else if (transaction._options?.readWrite) { + txSelector.begin = transaction._options; + if (database && database.isMuxEnabledForRW_) { + transaction._setPreviousTransactionId(txSelector); + } + } else { + txSelector.singleUse = transaction._options; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const baseReq: any = { + session: sessionName, + transaction: txSelector, + sql: typeof query === 'string' ? query : query.sql, + seqno: transaction._seqno++, + requestOptions: transaction.configureTagOptions( + typeof txSelector.singleUse !== 'undefined', + transaction.requestOptions?.transactionTag ?? undefined, + query.requestOptions, + ), + }; + if (hasQueryOptions) { + baseReq.queryOptions = Object.assign( + {}, + transaction.queryOptions, + query.queryOptions, + ); + } + + dmlReqInput = + protos.google.spanner.v1.ExecuteSqlRequest.encode(baseReq).finish(); + } else { + dmlReqInput = { + session: sessionName, + txId: transaction.id || null, + beginRw: !transaction.id && Boolean(transaction._options?.readWrite), + prevTxId: + (!transaction.id && + database && + database.isMuxEnabledForRW_ && + transaction.multiplexedSessionPreviousTransactionId) || + null, + seqno: transaction._seqno++, + transactionTag: transaction.requestOptions?.transactionTag || '', + requestTag: query.requestOptions?.requestTag || '', + }; + } + + const metadata = headersToMetadataArray(sessionName, headersObj); + const nativeStatements = prepareNativeStatements([query]); + + addon.executeBatchDmlNative( + handle, + routingKey, + metadata, + dmlReqInput, + nativeStatements, + fallbackEncodeCell, + true, + (err, respPb, txPb, directRowCount) => { + if (err) { + attachRetryMetadata(err); + callback(err, 0); + return; + } + if (txPb && txPb.length > 0) { + try { + const txResp = protos.google.spanner.v1.Transaction.decode(txPb); + transaction._updatePrecommitToken(txResp); + if (!transaction.id) { + transaction._update(txResp); + } + } catch (e) { + // ignore + } + } + if (respPb && respPb.length > 0) { + try { + const precommitToken = decodePrecommitTokenFast(respPb); + if (precommitToken) { + transaction._updatePrecommitToken({precommitToken}); + } + } catch (e) { + // ignore + } + } + callback(null, typeof directRowCount === 'number' ? directRowCount : 0); + }, + ); + return true; +} + +/** + * Dispatches `Snapshot#begin` through the Go shared core. + */ +export function executeNativeBeginTransaction( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reqOpts: any, + headersObj: Record, + callback: ( + err: Error | null, + resp?: protos.google.spanner.v1.ITransaction | null, + ) => void, +): boolean { + const addon = loadAddon(); + const handle = getCoreHandle(transaction); + if (!addon || !handle) { + return false; + } + + const sessionName: string = transaction.session.formattedName_!; + const routingKey: string = transaction._affinityKey || sessionName; + const reqBytes = + protos.google.spanner.v1.BeginTransactionRequest.encode(reqOpts).finish(); + const metadata = headersToMetadataArray(sessionName, headersObj); + + addon.beginTransactionNative( + handle, + routingKey, + metadata, + reqBytes, + (err, respPb) => { + if (err) { + attachRetryMetadata(err); + callback(err, null); + return; + } + let resp: protos.google.spanner.v1.ITransaction = {}; + if (respPb && respPb.length > 0) { + resp = protos.google.spanner.v1.Transaction.decode(respPb); + } + callback(null, resp); + }, + ); + return true; +} + +/** + * Dispatches `Snapshot#_run` / `Transaction#run` through the Go shared core via `executeStreamingSqlNative`. + * Preserves transaction channel affinity by routing on `transaction._affinityKey || sessionName` + * and updates `transaction.id` and `precommitToken` when inline `begin` metadata is returned. + */ +export function executeNativeTransactionRun( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + seqnoOrFormattedReq: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: any, + headersObj: Record, + callback: ( + err: Error | null, + rows?: NativeRow[], + stats?: unknown, + metadata?: protos.google.spanner.v1.ResultSetMetadata, + ) => void, + onFallback: () => void, +): boolean { + const addon = loadAddon(); + const handle = getCoreHandle(transaction); + if (!addon || !handle) { + return false; + } + + const sessionName: string = transaction.session.formattedName_!; + const routingKey: string = transaction._affinityKey || sessionName; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let formattedRequest: any; + if (typeof seqnoOrFormattedReq === 'number') { + const database = transaction.session.parent; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const txSelector: any = {}; + if (transaction.id) { + txSelector.id = transaction.id; + } else if (transaction._options?.readWrite) { + txSelector.begin = transaction._options; + if (database && database.isMuxEnabledForRW_) { + transaction._setPreviousTransactionId(txSelector); + } + } else { + txSelector.singleUse = transaction._options; + } + + formattedRequest = { + session: sessionName, + transaction: txSelector, + sql: typeof query === 'string' ? query : query.sql, + seqno: seqnoOrFormattedReq, + }; + + const reqTag = query?.requestOptions?.requestTag; + const txTag = transaction.requestOptions?.transactionTag; + if (reqTag || txTag || query?.requestOptions?.priority) { + formattedRequest.requestOptions = transaction.configureTagOptions( + typeof txSelector.singleUse !== 'undefined', + txTag ?? undefined, + query?.requestOptions, + ); + } + + if ( + hasNonEmptyQueryOptions(query?.queryOptions) || + hasNonEmptyQueryOptions(transaction.queryOptions) + ) { + formattedRequest.queryOptions = Object.assign( + {}, + transaction.queryOptions, + query?.queryOptions, + ); + } + + const params = query?.params; + const types = query?.types; + if (params) { + const encodedParams: Record = {}; + let paramTypes: Record | undefined; + const keys = Object.keys(params); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + encodedParams[key] = codec.encode(params[key] as Value); + if (types && types[key]) { + if (!paramTypes) paramTypes = {}; + 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' + ? (TYPE_CODE_STR_TO_NUM[typeObj.code] ?? 0) + : typeObj.code; + cachedType = {code: codeNum}; + paramTypeCache.set(rawType, cachedType); + } + paramTypes[key] = cachedType; + } else { + paramTypes[key] = normalizeTypeProto(rawType); + } + } + } + formattedRequest.params = {fields: encodedParams}; + if (paramTypes) { + formattedRequest.paramTypes = paramTypes; + } + } + } else { + formattedRequest = seqnoOrFormattedReq; + if (formattedRequest.paramTypes) { + const normalizedParamTypes: Record = {}; + for (const k of Object.keys(formattedRequest.paramTypes)) { + normalizedParamTypes[k] = normalizeTypeProto( + formattedRequest.paramTypes[k], + ); + } + formattedRequest = Object.assign({}, formattedRequest, { + paramTypes: normalizedParamTypes, + }); + } + } + + const requestBytes = + protos.google.spanner.v1.ExecuteSqlRequest.encode(formattedRequest).finish(); + const metadata = headersToMetadataArray(sessionName, headersObj); + + 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) { + onFallback(); + return true; + } + createRow = cachedEntry.createRow; + resultMetadata = cachedEntry.decoded; + } + + const skipMetadata = Boolean(cachedEntry); + + addon.executeStreamingSqlNative( + handle, + routingKey, + metadata, + requestBytes, + skipMetadata, + (cbErr, rows, _telemetry, metadataPb, isLast) => { + if (fellBack) { + return; + } + if (cbErr) { + attachRetryMetadata( + cbErr as Error & {retryInfoPb?: Buffer; metadata?: grpc.Metadata}, + ); + callback(cbErr); + return; + } + + if (metadataPb && metadataPb.length > 0) { + if (skipMetadata) { + const fastTx = decodeTxMetadataFast(metadataPb); + if (fastTx) { + if (fastTx.precommitToken) { + transaction._updatePrecommitToken({ + precommitToken: fastTx.precommitToken, + }); + } + if (!transaction.id && fastTx.id) { + transaction._update({id: fastTx.id}); + } + } else { + try { + const decoded = + protos.google.spanner.v1.ResultSetMetadata.decode(metadataPb); + if (decoded.transaction) { + transaction._updatePrecommitToken(decoded.transaction); + if (!transaction.id && decoded.transaction.id) { + transaction._update(decoded.transaction); + } + } + } catch (e) { + // ignore + } + } + } else { + try { + const decoded = + protos.google.spanner.v1.ResultSetMetadata.decode(metadataPb); + if (decoded.transaction) { + transaction._updatePrecommitToken(decoded.transaction); + if (!transaction.id && decoded.transaction.id) { + transaction._update(decoded.transaction); + } + } + if (!createRow && decoded.rowType?.fields) { + const fields = (decoded.rowType.fields || []) as IField[]; + const scalar = allColumnsScalar(fields); + if (!scalar) { + fellBack = true; + onFallback(); + return; + } + const schemaOnly = new protos.google.spanner.v1.ResultSetMetadata( + { + rowType: decoded.rowType, + }, + ); + const entry: SchemaCacheEntry = { + bytes: Buffer.from( + protos.google.spanner.v1.ResultSetMetadata.encode( + schemaOnly, + ).finish(), + ), + createRow: makeRowFactory(fields), + scalar: true, + decoded: schemaOnly, + }; + if (cacheKey) { + if (schemaCache.size >= SCHEMA_CACHE_MAX) { + schemaCache.clear(); + } + schemaCache.set(cacheKey, entry); + } + createRow = entry.createRow; + } + resultMetadata = 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])); + } + if (isLast) { + callback(null, resultRows, undefined, resultMetadata); + } + }, + ); + return true; +} + + diff --git a/handwritten/spanner/src/transaction.ts b/handwritten/spanner/src/transaction.ts index f84b73362eb..8ab792cec64 100644 --- a/handwritten/spanner/src/transaction.ts +++ b/handwritten/spanner/src/transaction.ts @@ -42,6 +42,15 @@ import { traceConfig, } from './instrument'; import {NormalCallback, addLeaderAwareRoutingHeader} from './common'; +import { + isNativeCoreEnabled, + isNativeEligible, + executeNativeCommit, + executeNativeBatchUpdate, + executeNativeSqlDml, + executeNativeBeginTransaction, + executeNativeTransactionRun, +} from './native-core'; import {protos} from '@google-cloud/spanner-api'; import spannerClient = protos.google; import google = protos.google; @@ -343,6 +352,12 @@ export class Snapshot extends EventEmitter { | null; id?: Uint8Array | string; protected _affinityKey?: string; + /** + * True once a request has actually gone out over the JS gRPC channel and + * bound it to `_affinityKey`. Requests served by the Go shared core never + * touch that channel, so `end()` uses this to skip the unbind teardown. + */ + protected _affinityBound = false; protected _bindGaxOpts?: CallOptions; protected _unbindGaxOpts?: CallOptions; multiplexedSessionPreviousTransactionId?: Uint8Array | string; @@ -450,6 +465,9 @@ export class Snapshot extends EventEmitter { ); } config = Object.assign({}, config, {gaxOpts}); + // This request binds the channel to our affinity key, so end() must + // release it. + this._affinityBound = true; return session.request(config, callback); }; @@ -465,6 +483,7 @@ export class Snapshot extends EventEmitter { ); } config = Object.assign({}, config, {gaxOpts}); + this._affinityBound = true; return session.requestStream(config); }; } else { @@ -715,13 +734,35 @@ export class Snapshot extends EventEmitter { span => { span.addEvent('Begin Transaction'); + const reqHeaders = injectRequestIDIntoHeaders(headers, this.session); + if (isNativeCoreEnabled()) { + const handled = executeNativeBeginTransaction( + this, + reqOpts, + reqHeaders, + (err, resp) => { + if (err) { + setSpanError(span, err); + } else if (resp) { + this._updatePrecommitToken(resp); + this._update(resp, span); + } + span.end(); + callback!(err as grpc.ServiceError | null, resp || undefined); + }, + ); + if (handled) { + return; + } + } + this.request( { client: 'SpannerClient', method: 'beginTransaction', reqOpts, gaxOpts, - headers: injectRequestIDIntoHeaders(headers, this.session), + headers: reqHeaders, }, ( err: null | grpc.ServiceError, @@ -1134,7 +1175,12 @@ export class Snapshot extends EventEmitter { this._releaseWaitingRequests(new Error('Transaction has ended.')); process.nextTick(() => this.emit('end')); - if (this._affinityKey) { + // Only tear down channel affinity if a request actually established it. + // On the Go shared core path every RPC is routed natively (the core keys on + // `_affinityKey` itself), so nothing is ever bound to the JS gRPC channel + // and this block would otherwise allocate a promise chain -- and possibly + // force lazy construction of the GAPIC stub -- on every transaction. + if (this._affinityKey && this._affinityBound) { const database = this.session?.parent as Database; const spanner = database?.parent?.parent as Spanner; const client = spanner?.clients_?.get('SpannerClient') as any; @@ -1592,6 +1638,40 @@ export class Snapshot extends EventEmitter { ); }; + if (isNativeCoreEnabled() && isNativeEligible(query)) { + const reqHeaders = injectRequestIDIntoHeaders( + headers, + this.session, + nthRequest, + 1, + ); + let fallbackTriggered = false; + const handled = executeNativeTransactionRun( + this, + seqno, + query, + reqHeaders, + (err, rows, stats, metadata) => { + if (fallbackTriggered) { + return; + } + complete( + err, + (rows || []) as Rows, + stats as ResultStats, + metadata as ResultMetadata, + ); + }, + () => { + fallbackTriggered = true; + formattedRequest = undefined; + }, + ); + if (handled && !fallbackTriggered) { + return; + } + } + const makeRequest = (resumeToken?: ResumeToken): Readable => { attempt++; if (!resumeToken) { @@ -2280,14 +2360,16 @@ export class Snapshot extends EventEmitter { */ protected _update( resp: spannerClient.spanner.v1.ITransaction, - span: Span, + span?: Span, ): void { const {id, readTimestamp} = resp; this.id = id!; this.metadata = resp; - span.addEvent('Transaction Creation Done', {id: this.id.toString()}); + if (span) { + span.addEvent('Transaction Creation Done', {id: this.id.toString()}); + } if (readTimestamp) { this.readTimestampProto = readTimestamp; @@ -2420,6 +2502,34 @@ export class Dml extends Snapshot { requestTag: query.requestOptions?.requestTag, }, span => { + if (isNativeCoreEnabled()) { + const headers = {...this.commonHeaders_}; + if (this._getSpanner().routeToLeaderEnabled) { + addLeaderAwareRoutingHeader(headers); + } + const reqHeaders = injectRequestIDIntoHeaders( + headers, + this.session, + nextNthRequest(this.session.parent as Database), + 1, + ); + const handled = executeNativeSqlDml( + this, + query, + reqHeaders, + (err, rowCount) => { + if (err) { + setSpanError(span, err); + } + span.end(); + callback!(err as grpc.ServiceError | null, rowCount); + }, + ); + if (handled) { + return; + } + } + this.run( query, ( @@ -2666,15 +2776,18 @@ export class Transaction extends Dml { return; } + const nativeEnabled = isNativeCoreEnabled(); const statements: spannerClient.spanner.v1.ExecuteBatchDmlRequest.IStatement[] = - queries.map(query => { - if (typeof query === 'string') { - return {sql: query}; - } - const {sql} = query; - const {params, paramTypes} = Snapshot.encodeParams(query); - return {sql, params, paramTypes}; - }); + nativeEnabled + ? [] + : queries.map(query => { + if (typeof query === 'string') { + return {sql: query}; + } + const {sql} = query; + const {params, paramTypes} = Snapshot.encodeParams(query); + return {sql, params, paramTypes}; + }); const transaction: spannerClient.spanner.v1.ITransactionSelector = {}; if (this.id) { @@ -2721,6 +2834,68 @@ export class Transaction extends Dml { requestTag: (options as BatchUpdateOptions)?.requestOptions?.requestTag, }; return startTrace('Transaction.batchUpdate', traceConfig, span => { + const handleResponse = ( + err: null | grpc.ServiceError, + resp?: spannerClient.spanner.v1.ExecuteBatchDmlResponse | null, + ) => { + let batchUpdateError: BatchUpdateError; + + if (err) { + const rowCounts: number[] = []; + batchUpdateError = Object.assign(err, {rowCounts}); + setSpanError(span, batchUpdateError); + span.end(); + callback!(batchUpdateError, rowCounts, resp || undefined); + return; + } + + this._updatePrecommitToken(resp!); + + const {resultSets, status} = resp!; + for (const resultSet of resultSets) { + if (!this.id && resultSet.metadata?.transaction) { + this._update(resultSet.metadata.transaction, span); + } + } + const rowCounts: number[] = resultSets.map(({stats}) => { + return ( + (stats && + Number( + stats[ + (stats as spannerClient.spanner.v1.ResultSetStats).rowCount! + ], + )) || + 0 + ); + }); + + if (status && status.code !== 0) { + const error = new Error(status.message!); + batchUpdateError = Object.assign(error, { + code: status.code, + metadata: Transaction.extractKnownMetadata(status.details!), + rowCounts, + }) as BatchUpdateError; + setSpanError(span, batchUpdateError); + } + + span.end(); + callback!(batchUpdateError!, rowCounts, resp!); + }; + + if (nativeEnabled) { + const handled = executeNativeBatchUpdate( + this, + queries, + reqOpts, + headers, + (err, resp) => handleResponse(err as grpc.ServiceError | null, resp), + ); + if (handled) { + return; + } + } + this.request( { client: 'SpannerClient', @@ -2729,54 +2904,7 @@ export class Transaction extends Dml { gaxOpts, headers: headers, }, - ( - err: null | grpc.ServiceError, - resp: spannerClient.spanner.v1.ExecuteBatchDmlResponse, - ) => { - let batchUpdateError: BatchUpdateError; - - if (err) { - const rowCounts: number[] = []; - batchUpdateError = Object.assign(err, {rowCounts}); - setSpanError(span, batchUpdateError); - span.end(); - callback!(batchUpdateError, rowCounts, resp); - return; - } - - this._updatePrecommitToken(resp); - - const {resultSets, status} = resp; - for (const resultSet of resultSets) { - if (!this.id && resultSet.metadata?.transaction) { - this._update(resultSet.metadata.transaction, span); - } - } - const rowCounts: number[] = resultSets.map(({stats}) => { - return ( - (stats && - Number( - stats[ - (stats as spannerClient.spanner.v1.ResultSetStats).rowCount! - ], - )) || - 0 - ); - }); - - if (status && status.code !== 0) { - const error = new Error(status.message!); - batchUpdateError = Object.assign(error, { - code: status.code, - metadata: Transaction.extractKnownMetadata(status.details!), - rowCounts, - }) as BatchUpdateError; - setSpanError(span, batchUpdateError); - } - - span.end(); - callback!(batchUpdateError!, rowCounts, resp); - }, + handleResponse, ); }); } @@ -2904,6 +3032,49 @@ export class Transaction extends Dml { ...this._traceConfig, }, span => { + if (isNativeCoreEnabled()) { + const headers = {...this.commonHeaders_}; + if (this._getSpanner().routeToLeaderEnabled) { + addLeaderAwareRoutingHeader(headers); + } + const reqHeaders = injectRequestIDIntoHeaders( + headers, + this.session, + nextNthRequest(this.session.parent as Database), + 1, + ); + span.addEvent('Starting Commit'); + const handled = executeNativeCommit( + this, + options, + reqHeaders, + (err, resp) => { + this.end(); + if (err) { + span.addEvent('Commit failed'); + setSpanError(span, err); + } else { + span.addEvent('Commit Done'); + } + if (resp && resp.commitTimestamp) { + this.commitTimestampProto = resp.commitTimestamp; + this.commitTimestamp = new PreciseDate( + resp.commitTimestamp as DateStruct, + ); + } + err = Transaction.decorateCommitError( + err as ServiceError, + mutations, + ); + span.end(); + callback!(err as ServiceError | null, resp || undefined); + }, + ); + if (handled) { + return; + } + } + if (this.id) { reqOpts.transactionId = this.id as Uint8Array; } else if (!this._useInRunner) { @@ -3573,25 +3744,47 @@ function buildMutation( const rows: object[] = toArray(keyVals); const columns = Transaction.getUniqueKeys(rows); - const values = rows.map((row, index) => { + for (let index = 0; index < rows.length; index++) { + const row = rows[index]; const keys = Object.keys(row); - const missingColumns = columns.filter(column => !keys.includes(column)); - - if (missingColumns.length > 0) { - throw new GoogleError( - [ - `Row at index ${index} does not contain the correct number of columns.`, - `Missing columns: ${JSON.stringify(missingColumns)}`, - ].join('\n\n'), - ); + if (keys.length < columns.length) { + const missingColumns = columns.filter(column => !keys.includes(column)); + if (missingColumns.length > 0) { + throw new GoogleError( + [ + `Row at index ${index} does not contain the correct number of columns.`, + `Missing columns: ${JSON.stringify(missingColumns)}`, + ].join('\n\n'), + ); + } } + } - const values = columns.map(column => row[column]); - return codec.convertToListValue(values); + let cachedValues: spannerClient.protobuf.IListValue[] | undefined; + const writeObj: spannerClient.spanner.v1.Mutation.IWrite = { + table, + columns, + }; + Object.defineProperty(writeObj, 'values', { + enumerable: true, + configurable: true, + get() { + if (!cachedValues) { + cachedValues = rows.map(row => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vals = columns.map(column => (row as any)[column]); + return codec.convertToListValue(vals); + }); + } + return cachedValues; + }, + set(v) { + cachedValues = v; + }, }); const mutation: spannerClient.spanner.v1.IMutation = { - [method]: {table, columns, values}, + [method]: writeObj, }; return mutation as spannerClient.spanner.v1.Mutation; } @@ -3607,9 +3800,22 @@ function buildDeleteMutation( table: string, keys: Key[], ): spannerClient.spanner.v1.Mutation { - const keySet: spannerClient.spanner.v1.IKeySet = { - keys: toArray(keys).map(codec.convertToListValue), - }; + const keysArr = toArray(keys); + let cachedKeys: spannerClient.protobuf.IListValue[] | undefined; + const keySet: spannerClient.spanner.v1.IKeySet = {}; + Object.defineProperty(keySet, 'keys', { + enumerable: true, + configurable: true, + get() { + if (!cachedKeys) { + cachedKeys = keysArr.map(codec.convertToListValue); + } + return cachedKeys; + }, + set(v) { + cachedKeys = v; + }, + }); const mutation: spannerClient.spanner.v1.IMutation = { delete: {table, keySet}, };