From 037aaebe5e7536afd94cf22c6e2f7b2fd6259051 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 13:07:53 -0700 Subject: [PATCH 1/7] feat(experimental): add async DatabasePool subpath Expose a fixed-size pool of warm SQLite connections at @photostructure/sqlite/experimental so SQL executes on libuv workers instead of the event loop; the stable root surface is unchanged. Warm connections meet the multi-op/ms target that per-operation opens miss, and the default strict authorizer rejects connection-affine SQL so calls stay correct on any leased connection. See the archived TPP in doc/done/ for the contract and validation evidence. --- .valgrind.supp | 29 +- README.md | 29 +- benchmark/README.md | 118 + benchmark/async-pool.ts | 1097 ++++++++ benchmark/package.json | 5 +- benchmark/results/async-pool-default.json | 2347 +++++++++++++++++ benchmark/results/async-pool-uv8.json | 2347 +++++++++++++++++ binding.gyp | 7 +- doc/build-flags.md | 54 +- ...08-P10-experimental-async-database-pool.md | 1057 ++++++++ doc/experimental-async-pool.md | 271 ++ eslint.config.mjs | 10 + package.json | 16 +- scripts/clang-tidy.ts | 1 + scripts/lsan-pool-test.cjs | 53 + scripts/post-build.mjs | 9 +- scripts/sanitizers-test.sh | 58 +- scripts/test-docker-alpine.sh | 4 +- scripts/valgrind-test.sh | 16 +- scripts/valgrind-test.ts | 34 + src/async_pool_impl.cpp | 1913 ++++++++++++++ src/async_pool_impl.h | 22 + src/binding.cpp | 15 + src/experimental.ts | 568 ++++ src/sqlite_impl.h | 7 + test/async-pool-api.test.ts | 139 + test/async-pool-batch.test.ts | 232 ++ test/async-pool-concurrency.test.ts | 130 + test/async-pool-errors.test.ts | 124 + test/async-pool-lifecycle.test.ts | 333 +++ test/async-pool-policy.test.ts | 113 + test/async-pool-setup.test.ts | 173 ++ test/async-pool-values.test.ts | 167 ++ .../package-exports/experimental-import.cts | 42 + .../package-exports/experimental-import.mts | 31 + test/fixtures/package-exports/tsconfig.json | 13 + test/package-exports.test.mjs | 78 + tsup.config.ts | 2 +- typedoc.json | 3 +- 39 files changed, 11633 insertions(+), 34 deletions(-) create mode 100644 benchmark/async-pool.ts create mode 100644 benchmark/results/async-pool-default.json create mode 100644 benchmark/results/async-pool-uv8.json create mode 100644 doc/done/20260808-P10-experimental-async-database-pool.md create mode 100644 doc/experimental-async-pool.md create mode 100644 scripts/lsan-pool-test.cjs create mode 100644 src/async_pool_impl.cpp create mode 100644 src/async_pool_impl.h create mode 100644 src/experimental.ts create mode 100644 test/async-pool-api.test.ts create mode 100644 test/async-pool-batch.test.ts create mode 100644 test/async-pool-concurrency.test.ts create mode 100644 test/async-pool-errors.test.ts create mode 100644 test/async-pool-lifecycle.test.ts create mode 100644 test/async-pool-policy.test.ts create mode 100644 test/async-pool-setup.test.ts create mode 100644 test/async-pool-values.test.ts create mode 100644 test/fixtures/package-exports/experimental-import.cts create mode 100644 test/fixtures/package-exports/experimental-import.mts create mode 100644 test/fixtures/package-exports/tsconfig.json create mode 100644 test/package-exports.test.mjs diff --git a/.valgrind.supp b/.valgrind.supp index 59e5cb5..adad037 100644 --- a/.valgrind.supp +++ b/.valgrind.supp @@ -68,6 +68,18 @@ obj:*/libcrypto.so* } +# OpenSSL state linked into the Node executable on Node 26. +{ + Node_OpenSSL_Builtin_Compressions + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + fun:CRYPTO_malloc + fun:ossl_load_builtin_compressions + fun:context_init + fun:default_context_do_init_ossl_ +} + # pthread thread-local storage { pthread_TLS @@ -77,6 +89,21 @@ fun:pthread_* } +# The main Node environment starts one inspector agent thread even when no +# inspector client connects. Suppress only that TLS allocation stack. +{ + Node_Inspector_TLS + Memcheck:Leak + match-leak-kinds: possible + fun:calloc + fun:calloc + fun:allocate_dtv + fun:_dl_allocate_tls + fun:allocate_stack + fun:pthread_create* + fun:_ZN4node9inspector5Agent5Start* +} + # dlopen and dynamic loading { dlopen_Reachable @@ -145,4 +172,4 @@ # Napi::Reference -- one of the most likely real defects in an addon of this # shape. If Node's own internals prove noisy here, suppress the specific # node:: frame, never the napi_ entry point. -# See the matching note in .lsan-suppressions.txt. \ No newline at end of file +# See the matching note in .lsan-suppressions.txt. diff --git a/README.md b/README.md index 7b56b16..51ade61 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,38 @@ console.log(users); // [{ id: 1, name: 'Alice' }] db.close(); ``` +## Experimental async pool + +Server workloads that must keep SQLite execution off the event loop can use the +experimental fixed-size connection pool: + +```typescript +import { DatabasePool } from "@photostructure/sqlite/experimental"; + +await using pool = await DatabasePool.open("app.db", { + connections: 2, + connectionSetup: [ + { sql: "PRAGMA journal_mode=WAL" }, + { sql: "PRAGMA busy_timeout=5000" }, + ], +}); + +const user = await pool.get("SELECT * FROM users WHERE id = ?", [1]); +``` + +The experimental entry point deliberately offers only connection-independent +`run`, `get`, `all`, and `batch` operations. Review its authorizer, setup, +ordering, memory, and libuv tradeoffs in the +[async pool guide](./doc/experimental-async-pool.md) before using it in +production. + ## Features - API-compatible with Node.js v26.7.0 built-in `node:sqlite` module\* - Zero dependencies - native SQLite implementation -- Synchronous API - no async overhead +- Stable synchronous API with no async overhead on the root entry point - Native SQLite performance ([benchmarks and tradeoffs](./benchmark/README.md)) +- Experimental async connection pool for off-event-loop SQLite execution - Full SQLite feature set ([details](./doc/features.md)) - TypeScript support with complete type definitions - Cross-platform prebuilt binaries (Windows/macOS/Linux, x64/ARM64) @@ -75,6 +101,7 @@ in your application, [review the results and run the benchmark](./benchmark/READ - [Working with Data](./doc/working-with-data.md) - [Extending SQLite](./doc/extending-sqlite.md) - [Advanced Patterns](./doc/advanced-patterns.md) +- [Experimental Async Pool](./doc/experimental-async-pool.md) **Reference** diff --git a/benchmark/README.md b/benchmark/README.md index cf6d85a..2e3c987 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -82,6 +82,124 @@ Transaction` and `DELETE Bulk` each write roughly 1,000 rows per operation. +## Experimental async pool + +The async pool has a separate focused benchmark because its promise and +concurrency semantics do not fit the synchronous driver harness above. The +benchmark runs every case against a fresh, identically seeded database and +reports repeated raw samples plus a median and distribution-free relative +margin of error. It compares: + +- a warm synchronous connection with a reused statement; +- a fresh synchronous connection per operation; +- a `worker_threads` control that owns a `DatabaseSync` and receives one message + per operation; +- `strict` and `none` pool authorizers with one, two, three, and four connections; +- individual concurrent calls and explicit batches of 10 and 100 operations; +- `all()` results containing 1, 100, and 1,000 rows; +- 100/0, 90/10, and 0/100 read/write mixes; +- repeated identical SQL text versus 32 equivalent rotating texts, exposing + prepare and cache effects; +- pool reads while representative PBKDF2 or filesystem reads compete for + libuv workers; and +- operations and materialized rows per millisecond, with event-loop heartbeat + delay as a diagnostic. + +Run it from the repository root: + +```bash +npm run bench:async +``` + +For a reproducible comparison, run inside the benchmark directory and record +both Node's default libuv pool and a larger process-startup pool. Keep the +commands and all CLI values the same between runs: + +```bash +cd benchmark +npm install + +unset UV_THREADPOOL_SIZE +npm run bench:async -- \ + --iterations=10000 \ + --write-iterations=2000 \ + --connections=1,2,3,4 \ + --samples=6 \ + --warmup=1 \ + --output=results/async-pool-default.json + +UV_THREADPOOL_SIZE=8 npm run bench:async -- \ + --iterations=10000 \ + --write-iterations=2000 \ + --connections=1,2,3,4 \ + --samples=6 \ + --warmup=1 \ + --output=results/async-pool-uv8.json +``` + +The checked-in reference run used Node 26.6.0 on Linux x64 with an AMD Ryzen 9 +5950X (32 logical CPUs). Each figure below is the median of six measured +samples after one warmup; the raw reports retain every sample. These reports +predate the current addition of a three-connection scaling case and remain +valid historical results for their recorded one/two/four-connection matrix. + +| Reference scenario | Median ops/ms | +| -------------------------------------------------------- | ------------------------: | +| Warm sync, reused statement | 244.7 | +| Fresh sync connection per operation | 4.1 | +| `worker_threads` sync control | 123.9 | +| Pool `none`, one connection | 37.9 | +| Pool `strict`, one connection | 39.9 | +| Pool `none`, four connections | 132.7 | +| Pool `none`, batches of 100 | 131.6 | +| Pool `all()` with 1,000 rows | 0.9 ops/ms; 883.0 rows/ms | +| Four-connection pool plus PBKDF2, default libuv pool | 11.3 | +| Four-connection pool plus PBKDF2, `UV_THREADPOOL_SIZE=8` | 123.6 | + +These numbers meet the goal of multiple simple operations per millisecond and +also make the global-libuv tradeoff concrete: increasing the startup-time pool +size mostly mattered when four SQLite jobs competed with four crypto jobs. It +did not materially improve the uncontended four-connection point-read case. + +The canonical raw result locations are +`benchmark/results/async-pool-default.json` and +`benchmark/results/async-pool-uv8.json`. The versioned JSON schema records the +package and SQLite versions, git revision and dirty state, Node/V8/N-API/libuv +versions, CPU and platform, effective `UV_THREADPOOL_SIZE`, all CLI options, each +scenario's settings, every raw sample, and the computed summaries. Do not +publish only the console table: keep both raw files and the exact commands with +any comparison. Absolute throughput is machine-specific, so compare repeated +samples on the same otherwise-idle machine rather than treating one run as a +performance guarantee. + +Runtime and scope are fully controllable. List scenario IDs and groups with +`npm run bench:async -- --list`, or see all options with +`npm run bench:async -- --help`. For example, this quick sanity run exercises one +control and the result-size group without writing a report: + +```bash +npm run bench:async -- \ + --scenarios=worker-thread-sync-control,result-size \ + --iterations=10 \ + --result-sizes=1,10 \ + --seed-rows=10 \ + --samples=2 \ + --warmup=0 +``` + +Idle SQLite connections consume no libuv worker, but each active pool operation +competes for Node's process-global worker threads with filesystem, DNS, crypto, +and zlib work. More busy connections than `UV_THREADPOOL_SIZE` do not create +more simultaneous SQLite execution. Set the environment variable before Node +starts, only after measuring the complete application; the library never +changes it. + +The heartbeat is diagnostic benchmark evidence, not a functional timing test. +Deterministic tests separately prove that long SQLite work leaves the event loop +responsive. See the [experimental async pool guide](../doc/experimental-async-pool.md) +for operational limits, authorizer policy, setup replay, result memory, and +shutdown behavior. + ## Run the benchmarks ```bash diff --git a/benchmark/async-pool.ts b/benchmark/async-pool.ts new file mode 100644 index 0000000..7e14249 --- /dev/null +++ b/benchmark/async-pool.ts @@ -0,0 +1,1097 @@ +#!/usr/bin/env tsx + +import { execFileSync } from "node:child_process"; +import { pbkdf2 } from "node:crypto"; +import { once } from "node:events"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cpus, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; +import { Worker } from "node:worker_threads"; +import { DatabasePool, type PoolAuthorizer } from "../src/experimental.js"; +import { DatabaseSync } from "../src/index.js"; + +type ScenarioGroup = + | "controls" + | "pool-scale" + | "batch" + | "result-size" + | "read-write" + | "repeated-sql" + | "contention"; +type ContentionKind = "crypto" | "fs"; + +interface Options { + iterations: number; + writeIterations: number; + samples: number; + warmup: number; + seedRows: number; + connections: number[]; + batchSizes: number[]; + resultSizes: number[]; + scenarios: string[] | null; + contentionWorkers: number; + cryptoIterations: number; + heartbeatIntervalMs: number; + output?: string; +} + +interface EventLoopObservation { + heartbeats: number; + intervalMs: number; + maxDelayMs: number | null; + meanDelayMs: number | null; +} + +interface CompetingWorkObservation { + kind: ContentionKind; + workers: number; + completed: number; + elapsedMs: number; +} + +interface Sample { + sample: number; + logicalOperations: number; + materializedRows: number; + elapsedMs: number; + operationsPerMs: number; + rowsPerMs: number; + eventLoop: EventLoopObservation; + competingWork?: CompetingWorkObservation; +} + +interface Summary { + medianOperationsPerMs: number; + medianRowsPerMs: number; + medianElapsedMs: number; + relativeMarginOfErrorPct: number; + minOperationsPerMs: number; + maxOperationsPerMs: number; + medianEventLoopHeartbeats: number; + maxEventLoopDelayMs: number | null; + competingWorkCompleted?: number; +} + +interface TrialContext { + dbPath: string; + competingFile: string; +} + +interface TrialResult { + sample: Omit; +} + +interface Scenario { + id: string; + group: ScenarioGroup; + description: string; + settings: Record; + run(context: TrialContext): Promise; +} + +interface ScenarioResult { + id: string; + group: ScenarioGroup; + description: string; + settings: Record; + samples: Sample[]; + summary: Summary; +} + +interface CompetitorController { + stop(): Promise; +} + +const DEFAULT_OPTIONS: Options = { + iterations: 1_000, + writeIterations: 200, + samples: 6, + warmup: 1, + seedRows: 2_000, + connections: [1, 2, 3, 4], + batchSizes: [10, 100], + resultSizes: [1, 100, 1_000], + scenarios: null, + contentionWorkers: 4, + cryptoIterations: 10_000, + heartbeatIntervalMs: 10, +}; + +const WORKER_SOURCE = String.raw` + "use strict"; + const { parentPort, workerData } = require("node:worker_threads"); + const { DatabaseSync } = require(workerData.modulePath); + const db = new DatabaseSync(workerData.dbPath); + const statement = db.prepare("SELECT value FROM item WHERE id = ?"); + parentPort.on("message", (message) => { + if (message.type === "get") { + parentPort.postMessage({ + type: "result", + id: message.id, + row: statement.get(message.key), + }); + return; + } + if (message.type === "close") { + db.close(); + parentPort.postMessage({ type: "closed" }); + parentPort.close(); + } + }); + parentPort.postMessage({ type: "ready" }); +`; + +function readArgument(args: string[], name: string): string | undefined { + const prefix = `--${name}=`; + return args + .find((argument) => argument.startsWith(prefix)) + ?.slice(prefix.length); +} + +function positiveInteger( + value: string | undefined, + name: string, + fallback: number, +): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`--${name} must be a positive safe integer`); + } + return parsed; +} + +function nonNegativeInteger( + value: string | undefined, + name: string, + fallback: number, +): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`--${name} must be a non-negative safe integer`); + } + return parsed; +} + +function integerList( + value: string | undefined, + name: string, + fallback: number[], +): number[] { + if (value === undefined) return fallback; + const parsed = value + .split(",") + .map((entry) => positiveInteger(entry, name, 0)); + if (parsed.length === 0) throw new Error(`--${name} must not be empty`); + return [...new Set(parsed)]; +} + +function parseOptions(args: string[]): Options { + const output = readArgument(args, "output"); + const scenarios = readArgument(args, "scenarios"); + const options: Options = { + iterations: positiveInteger( + readArgument(args, "iterations"), + "iterations", + DEFAULT_OPTIONS.iterations, + ), + writeIterations: positiveInteger( + readArgument(args, "write-iterations"), + "write-iterations", + DEFAULT_OPTIONS.writeIterations, + ), + samples: positiveInteger( + readArgument(args, "samples"), + "samples", + DEFAULT_OPTIONS.samples, + ), + warmup: nonNegativeInteger( + readArgument(args, "warmup"), + "warmup", + DEFAULT_OPTIONS.warmup, + ), + seedRows: positiveInteger( + readArgument(args, "seed-rows"), + "seed-rows", + DEFAULT_OPTIONS.seedRows, + ), + connections: integerList( + readArgument(args, "connections"), + "connections", + DEFAULT_OPTIONS.connections, + ), + batchSizes: integerList( + readArgument(args, "batch-sizes"), + "batch-sizes", + DEFAULT_OPTIONS.batchSizes, + ), + resultSizes: integerList( + readArgument(args, "result-sizes"), + "result-sizes", + DEFAULT_OPTIONS.resultSizes, + ), + scenarios: + scenarios === undefined + ? null + : scenarios + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + contentionWorkers: positiveInteger( + readArgument(args, "contention-workers"), + "contention-workers", + DEFAULT_OPTIONS.contentionWorkers, + ), + cryptoIterations: positiveInteger( + readArgument(args, "crypto-iterations"), + "crypto-iterations", + DEFAULT_OPTIONS.cryptoIterations, + ), + heartbeatIntervalMs: positiveInteger( + readArgument(args, "heartbeat-ms"), + "heartbeat-ms", + DEFAULT_OPTIONS.heartbeatIntervalMs, + ), + }; + if (output !== undefined) options.output = output; + if (options.seedRows < Math.max(...options.resultSizes)) { + throw new Error( + "--seed-rows must be at least the largest --result-sizes value", + ); + } + return options; +} + +function showHelp(): void { + console.log(` +Usage: tsx benchmark/async-pool.ts [options] + +Options: + --iterations=N Read operations per ordinary sample (default 1000) + --write-iterations=N Operations per read/write sample (default 200) + --samples=N Measured samples per scenario (default 6) + --warmup=N Discarded warmup rounds (default 1) + --seed-rows=N Rows in each fresh trial database (default 2000) + --connections=1,2,3,4 Pool sizes used by scale scenarios + --batch-sizes=10,100 Explicit batch sizes + --result-sizes=1,100,1000 Rows materialized by all() scenarios + --scenarios=LIST Scenario groups or exact IDs (default all) + --contention-workers=N Concurrent crypto/fs jobs (default 4) + --crypto-iterations=N PBKDF2 iterations per competing job (default 10000) + --heartbeat-ms=N Event-loop heartbeat interval (default 10) + --output=PATH Write the versioned raw JSON report + --list List generated scenarios and exit + --help Show this help + +Scenario groups: controls, pool-scale, batch, result-size, read-write, + repeated-sql, contention + +Quick sanity check: + npm run bench:async -- --iterations=10 --write-iterations=10 \\ + --samples=2 --warmup=0 --result-sizes=1,10 +`); +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const midpoint = sorted.length >> 1; + return sorted.length % 2 === 1 + ? sorted[midpoint] + : (sorted[midpoint - 1] + sorted[midpoint]) / 2; +} + +function medianRelativeMarginOfError(values: number[]): number { + const count = values.length; + if (count < 2) return 0; + const sorted = [...values].sort((left, right) => left - right); + const center = median(sorted); + if (center === 0) return 0; + + let cumulative = 0; + let combination = 1; + let lowerIndex = 0; + for (let index = 0; index <= Math.floor((count - 1) / 2); index++) { + if (index > 0) combination = (combination * (count - index + 1)) / index; + cumulative += combination / 2 ** count; + if (1 - 2 * cumulative >= 0.95) lowerIndex = index + 1; + } + + const low = sorted[Math.max(0, lowerIndex - 1)]; + const high = sorted[Math.min(count - 1, count - lowerIndex)]; + return ( + (Math.max(Math.abs(center - low), Math.abs(high - center)) / + Math.abs(center)) * + 100 + ); +} + +function summarize(samples: Sample[]): Summary { + const operations = samples.map((sample) => sample.operationsPerMs); + const delays = samples + .map((sample) => sample.eventLoop.maxDelayMs) + .filter((value): value is number => value !== null); + const summary: Summary = { + medianOperationsPerMs: median(operations), + medianRowsPerMs: median(samples.map((sample) => sample.rowsPerMs)), + medianElapsedMs: median(samples.map((sample) => sample.elapsedMs)), + relativeMarginOfErrorPct: medianRelativeMarginOfError(operations), + minOperationsPerMs: Math.min(...operations), + maxOperationsPerMs: Math.max(...operations), + medianEventLoopHeartbeats: median( + samples.map((sample) => sample.eventLoop.heartbeats), + ), + maxEventLoopDelayMs: delays.length === 0 ? null : Math.max(...delays), + }; + const competing = samples.map( + (sample) => sample.competingWork?.completed ?? 0, + ); + if (competing.some((completed) => completed > 0)) { + summary.competingWorkCompleted = competing.reduce( + (total, completed) => total + completed, + 0, + ); + } + return summary; +} + +async function measure( + logicalOperations: number, + materializedRows: number, + heartbeatIntervalMs: number, + operation: () => void | Promise, +): Promise> { + let heartbeats = 0; + let totalDelayMs = 0; + let maxDelayMs = 0; + let expectedAt = performance.now() + heartbeatIntervalMs; + const timer = setInterval(() => { + const now = performance.now(); + const delay = Math.max(0, now - expectedAt); + heartbeats++; + totalDelayMs += delay; + maxDelayMs = Math.max(maxDelayMs, delay); + expectedAt = now + heartbeatIntervalMs; + }, heartbeatIntervalMs); + timer.unref(); + + const started = performance.now(); + try { + await operation(); + } finally { + clearInterval(timer); + } + const elapsedMs = performance.now() - started; + return { + logicalOperations, + materializedRows, + elapsedMs, + operationsPerMs: logicalOperations / elapsedMs, + rowsPerMs: materializedRows / elapsedMs, + eventLoop: { + heartbeats, + intervalMs: heartbeatIntervalMs, + maxDelayMs: heartbeats === 0 ? null : maxDelayMs, + meanDelayMs: heartbeats === 0 ? null : totalDelayMs / heartbeats, + }, + }; +} + +function openPool( + dbPath: string, + connections: number, + authorizer: PoolAuthorizer, +): Promise { + return DatabasePool.open(dbPath, { + connections, + authorizer, + connectionSetup: [ + { sql: "PRAGMA journal_mode=WAL" }, + { sql: "PRAGMA busy_timeout=5000" }, + ], + }); +} + +function pointReadKey(index: number, seedRows: number): number { + return (index % seedRows) + 1; +} + +async function runConcurrentPointReads( + pool: DatabasePool, + operations: number, + seedRows: number, + sqlForIndex: (index: number) => string = () => + "SELECT value FROM item WHERE id = ?", +): Promise { + await Promise.all( + Array.from({ length: operations }, (_, index) => + pool.get(sqlForIndex(index), [pointReadKey(index, seedRows)]), + ), + ); +} + +async function startCompetitors( + kind: ContentionKind, + file: string, + workers: number, + cryptoIterations: number, +): Promise { + let stopping = false; + let completed = 0; + let failure: unknown; + let stopPromise: Promise | undefined; + const started = performance.now(); + + const perform = + kind === "crypto" + ? () => + new Promise((resolvePromise, rejectPromise) => { + pbkdf2( + "sqlite-pool-benchmark", + "photostructure", + cryptoIterations, + 32, + "sha256", + (error) => (error ? rejectPromise(error) : resolvePromise()), + ); + }) + : async () => { + await readFile(file); + }; + + const pumps = Array.from({ length: workers }, async () => { + while (!stopping) { + try { + await perform(); + completed++; + } catch (error) { + failure = error; + stopping = true; + } + } + }); + + return { + async stop(): Promise { + stopPromise ??= (async () => { + stopping = true; + await Promise.all(pumps); + if (failure !== undefined) throw failure; + return { + kind, + workers, + completed, + elapsedMs: performance.now() - started, + }; + })(); + return stopPromise; + }, + }; +} + +async function seedTrial(options: Options): Promise<{ + directory: string; + context: TrialContext; +}> { + const directory = await mkdtemp(join(tmpdir(), "sqlite-async-pool-bench-")); + const dbPath = join(directory, "benchmark.db"); + const competingFile = join(directory, "contention.bin"); + const database = new DatabaseSync(dbPath); + try { + database.exec( + "PRAGMA journal_mode=WAL; CREATE TABLE item(" + + "id INTEGER PRIMARY KEY, value TEXT, payload TEXT, counter INTEGER DEFAULT 0)", + ); + database.exec("BEGIN"); + const insert = database.prepare( + "INSERT INTO item(value, payload) VALUES (?, ?)", + ); + const payload = "x".repeat(128); + for (let index = 0; index < options.seedRows; index++) { + insert.run(`value-${index}`, payload); + } + database.exec("COMMIT"); + } finally { + database.close(); + } + await writeFile(competingFile, Buffer.alloc(1024 * 1024, 0x5a)); + return { directory, context: { dbPath, competingFile } }; +} + +function createSyncControls(options: Options): Scenario[] { + const controls: Scenario[] = [ + { + id: "warm-sync-reused-statement", + group: "controls", + description: "Warm DatabaseSync connection with one reused statement", + settings: { implementation: "DatabaseSync", connection: "warm" }, + async run({ dbPath }): Promise { + const database = new DatabaseSync(dbPath); + const statement = database.prepare( + "SELECT value FROM item WHERE id = ?", + ); + try { + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + () => { + for (let index = 0; index < options.iterations; index++) { + statement.get(pointReadKey(index, options.seedRows)); + } + }, + ), + }; + } finally { + database.close(); + } + }, + }, + { + id: "fresh-sync-connection", + group: "controls", + description: "Open, query, and close DatabaseSync for every operation", + settings: { implementation: "DatabaseSync", connection: "fresh" }, + async run({ dbPath }): Promise { + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + () => { + for (let index = 0; index < options.iterations; index++) { + const database = new DatabaseSync(dbPath); + database + .prepare("SELECT value FROM item WHERE id = ?") + .get(pointReadKey(index, options.seedRows)); + database.close(); + } + }, + ), + }; + }, + }, + { + id: "worker-thread-sync-control", + group: "controls", + description: "One DatabaseSync worker thread with per-operation messages", + settings: { implementation: "worker_threads + DatabaseSync", workers: 1 }, + async run({ dbPath }): Promise { + const modulePath = resolve(import.meta.dirname, "../dist/index.cjs"); + const worker = new Worker(WORKER_SOURCE, { + eval: true, + workerData: { dbPath, modulePath }, + }); + try { + await new Promise((resolveReady, rejectReady) => { + const onMessage = (message: { type?: string }) => { + if (message.type !== "ready") return; + worker.off("error", rejectReady); + worker.off("message", onMessage); + resolveReady(); + }; + worker.on("message", onMessage); + worker.once("error", rejectReady); + }); + + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + async () => { + let remaining = options.iterations; + const completed = new Promise((resolveCompleted) => { + const onMessage = (message: { type?: string }) => { + if (message.type !== "result") return; + remaining--; + if (remaining === 0) { + worker.off("message", onMessage); + resolveCompleted(); + } + }; + worker.on("message", onMessage); + }); + for (let index = 0; index < options.iterations; index++) { + worker.postMessage({ + type: "get", + id: index, + key: pointReadKey(index, options.seedRows), + }); + } + await completed; + }, + ), + }; + } finally { + worker.postMessage({ type: "close" }); + await once(worker, "exit"); + } + }, + }, + ]; + return controls; +} + +function createPoolScaleScenarios(options: Options): Scenario[] { + const scenarios: Scenario[] = []; + for (const authorizer of ["none", "strict"] as const) { + for (const connections of options.connections) { + scenarios.push({ + id: `pool-${authorizer}-${connections}c-point-read`, + group: "pool-scale", + description: `${connections}-connection ${authorizer} pool, concurrent point reads`, + settings: { authorizer, connections, operation: "get" }, + async run({ dbPath }): Promise { + const pool = await openPool(dbPath, connections, authorizer); + try { + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + () => + runConcurrentPointReads( + pool, + options.iterations, + options.seedRows, + ), + ), + }; + } finally { + await pool.close(); + } + }, + }); + } + } + return scenarios; +} + +function createBatchScenarios(options: Options): Scenario[] { + return options.batchSizes.map((batchSize) => ({ + id: `pool-none-1c-batch-${batchSize}`, + group: "batch" as const, + description: `One-connection none pool, explicit get batches of ${batchSize}`, + settings: { authorizer: "none", connections: 1, batchSize }, + async run({ dbPath }): Promise { + const pool = await openPool(dbPath, 1, "none"); + try { + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + async () => { + for ( + let offset = 0; + offset < options.iterations; + offset += batchSize + ) { + const count = Math.min(batchSize, options.iterations - offset); + await pool.batch( + Array.from({ length: count }, (_, index) => ({ + kind: "get" as const, + sql: "SELECT value FROM item WHERE id = ?", + params: [pointReadKey(offset + index, options.seedRows)], + })), + ); + } + }, + ), + }; + } finally { + await pool.close(); + } + }, + })); +} + +function createResultSizeScenarios(options: Options): Scenario[] { + return options.resultSizes.map((resultSize) => { + const operations = Math.max( + 1, + Math.floor(options.iterations / Math.max(1, resultSize / 10)), + ); + return { + id: `pool-none-all-${resultSize}-rows`, + group: "result-size" as const, + description: `One-connection none pool, all() materializing ${resultSize} rows`, + settings: { + authorizer: "none", + connections: 1, + resultSize, + operations, + }, + async run({ dbPath }): Promise { + const pool = await openPool(dbPath, 1, "none"); + try { + return { + sample: await measure( + operations, + operations * resultSize, + options.heartbeatIntervalMs, + async () => { + for (let index = 0; index < operations; index++) { + await pool.all( + "SELECT id, value, payload FROM item ORDER BY id LIMIT ?", + [resultSize], + ); + } + }, + ), + }; + } finally { + await pool.close(); + } + }, + }; + }); +} + +function createReadWriteScenarios(options: Options): Scenario[] { + return [ + { id: "100r-0w", writesEvery: 0, readsPct: 100, writesPct: 0 }, + { id: "90r-10w", writesEvery: 10, readsPct: 90, writesPct: 10 }, + { id: "0r-100w", writesEvery: 1, readsPct: 0, writesPct: 100 }, + ].map(({ id, writesEvery, readsPct, writesPct }) => ({ + id: `pool-strict-2c-mix-${id}`, + group: "read-write" as const, + description: `Two-connection strict pool, ${readsPct}% reads and ${writesPct}% writes`, + settings: { + authorizer: "strict", + connections: 2, + readsPct, + writesPct, + }, + async run({ dbPath }): Promise { + const pool = await openPool(dbPath, 2, "strict"); + try { + return { + sample: await measure( + options.writeIterations, + options.writeIterations - + (writesEvery === 0 + ? 0 + : writesEvery === 1 + ? options.writeIterations + : Math.ceil(options.writeIterations / writesEvery)), + options.heartbeatIntervalMs, + async () => { + const pending = Array.from( + { length: options.writeIterations }, + (_, index) => { + const isWrite = + writesEvery > 0 && + (writesEvery === 1 || index % writesEvery === 0); + return isWrite + ? pool.run( + "UPDATE item SET counter = counter + 1 WHERE id = ?", + [pointReadKey(index, options.seedRows)], + ) + : pool.get("SELECT value FROM item WHERE id = ?", [ + pointReadKey(index, options.seedRows), + ]); + }, + ); + await Promise.all(pending); + }, + ), + }; + } finally { + await pool.close(); + } + }, + })); +} + +function createRepeatedSqlScenarios(options: Options): Scenario[] { + return [ + { + id: "pool-none-repeated-identical-sql", + description: "Repeated identical SQL text (prepare cost/cache baseline)", + sqlForIndex: () => "SELECT value FROM item WHERE id = ?", + variants: 1, + }, + { + id: "pool-none-rotating-sql-32", + description: "Equivalent SQL rotated across 32 distinct texts", + sqlForIndex: (index: number) => + `SELECT value FROM item WHERE id = ? /* variant ${index % 32} */`, + variants: 32, + }, + ].map(({ id, description, sqlForIndex, variants }) => ({ + id, + group: "repeated-sql" as const, + description, + settings: { authorizer: "none", connections: 1, variants }, + async run({ dbPath }): Promise { + const pool = await openPool(dbPath, 1, "none"); + try { + return { + sample: await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + () => + runConcurrentPointReads( + pool, + options.iterations, + options.seedRows, + sqlForIndex, + ), + ), + }; + } finally { + await pool.close(); + } + }, + })); +} + +function createContentionScenarios(options: Options): Scenario[] { + return (["crypto", "fs"] as const).map((kind) => ({ + id: `pool-none-4c-point-read-with-${kind}`, + group: "contention" as const, + description: `Four-connection pool competing with ${kind} libuv work`, + settings: { + authorizer: "none", + connections: 4, + contention: kind, + contentionWorkers: options.contentionWorkers, + ...(kind === "crypto" + ? { cryptoIterations: options.cryptoIterations } + : { competingFileBytes: 1024 * 1024 }), + }, + async run({ dbPath, competingFile }): Promise { + const pool = await openPool(dbPath, 4, "none"); + const competitors = await startCompetitors( + kind, + competingFile, + options.contentionWorkers, + options.cryptoIterations, + ); + try { + const sample = await measure( + options.iterations, + options.iterations, + options.heartbeatIntervalMs, + () => + runConcurrentPointReads(pool, options.iterations, options.seedRows), + ); + sample.competingWork = await competitors.stop(); + return { sample }; + } finally { + try { + await competitors.stop(); + } finally { + await pool.close(); + } + } + }, + })); +} + +function createScenarios(options: Options): Scenario[] { + return [ + ...createSyncControls(options), + ...createPoolScaleScenarios(options), + ...createBatchScenarios(options), + ...createResultSizeScenarios(options), + ...createReadWriteScenarios(options), + ...createRepeatedSqlScenarios(options), + ...createContentionScenarios(options), + ]; +} + +function selectScenarios( + scenarios: Scenario[], + filters: string[] | null, +): Scenario[] { + if (filters === null) return scenarios; + const selected = scenarios.filter( + (scenario) => + filters.includes(scenario.group) || filters.includes(scenario.id), + ); + const known = new Set( + scenarios.flatMap((scenario) => [scenario.group, scenario.id]), + ); + const unknown = filters.filter( + (filter) => !known.has(filter as ScenarioGroup), + ); + if (unknown.length > 0) { + throw new Error(`Unknown --scenarios value(s): ${unknown.join(", ")}`); + } + if (selected.length === 0) + throw new Error("--scenarios selected no scenarios"); + return selected; +} + +async function runOneTrial( + scenario: Scenario, + options: Options, + sample: number, +): Promise { + const { directory, context } = await seedTrial(options); + try { + const result = await scenario.run(context); + return { sample, ...result.sample }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function gitValue(args: string[]): string | null { + try { + return execFileSync("git", args, { encoding: "utf8" }).trim(); + } catch { + return null; + } +} + +async function packageMetadata(): Promise<{ + name: string; + version: string; + sqlite: string; +}> { + const content = await readFile( + resolve(import.meta.dirname, "../package.json"), + "utf8", + ); + const parsed = JSON.parse(content) as { + name: string; + version: string; + versions: { sqlite: string }; + }; + return { + name: parsed.name, + version: parsed.version, + sqlite: parsed.versions.sqlite, + }; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.includes("--help") || args.includes("-h")) { + showHelp(); + return; + } + + const options = parseOptions(args); + const allScenarios = createScenarios(options); + if (args.includes("--list")) { + for (const scenario of allScenarios) { + console.log(`${scenario.id}\t${scenario.group}\t${scenario.description}`); + } + return; + } + const scenarios = selectScenarios(allScenarios, options.scenarios); + const measured = new Map(); + + console.log("Experimental DatabasePool benchmark"); + console.log( + `Scenarios: ${scenarios.length}; samples: ${options.samples}; warmup: ${options.warmup}`, + ); + console.log( + `Read iterations: ${options.iterations}; write iterations: ${options.writeIterations}`, + ); + + for (let warmup = 0; warmup < options.warmup; warmup++) { + console.log(`Warmup ${warmup + 1}/${options.warmup}`); + for (const scenario of scenarios) { + await runOneTrial(scenario, options, -(warmup + 1)); + } + } + + for (let sample = 0; sample < options.samples; sample++) { + console.log(`Measured round ${sample + 1}/${options.samples}`); + const order = scenarios.map( + (_, index) => scenarios[(index + sample) % scenarios.length], + ); + for (const scenario of order) { + const result = await runOneTrial(scenario, options, sample + 1); + const samples = measured.get(scenario.id) ?? []; + samples.push(result); + measured.set(scenario.id, samples); + console.log( + ` ${scenario.id}: ${result.operationsPerMs.toFixed(3)} ops/ms` + + ` (${result.elapsedMs.toFixed(1)} ms)`, + ); + } + } + + const results: ScenarioResult[] = scenarios.map((scenario) => { + const samples = measured.get(scenario.id) ?? []; + return { + id: scenario.id, + group: scenario.group, + description: scenario.description, + settings: scenario.settings, + samples, + summary: summarize(samples), + }; + }); + + console.table( + results.map((result) => ({ + scenario: result.id, + "median ops/ms": result.summary.medianOperationsPerMs.toFixed(3), + "median rows/ms": result.summary.medianRowsPerMs.toFixed(3), + "RME %": result.summary.relativeMarginOfErrorPct.toFixed(1), + "heartbeat count": result.summary.medianEventLoopHeartbeats.toFixed(1), + })), + ); + + const metadata = await packageMetadata(); + const dirty = gitValue(["status", "--short"]); + const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + package: metadata, + git: { + commit: gitValue(["rev-parse", "HEAD"]), + dirty: dirty === null ? null : dirty.length > 0, + }, + environment: { + node: process.version, + v8: process.versions.v8, + napi: process.versions.napi, + uv: process.versions.uv, + platform: process.platform, + arch: process.arch, + cpuModel: cpus()[0]?.model ?? "unknown", + cpuCount: cpus().length, + uvThreadpoolSize: process.env["UV_THREADPOOL_SIZE"] ?? "default (4)", + }, + config: { + iterations: options.iterations, + writeIterations: options.writeIterations, + samples: options.samples, + warmup: options.warmup, + seedRows: options.seedRows, + connections: options.connections, + batchSizes: options.batchSizes, + resultSizes: options.resultSizes, + scenarioFilters: options.scenarios, + contentionWorkers: options.contentionWorkers, + cryptoIterations: options.cryptoIterations, + heartbeatIntervalMs: options.heartbeatIntervalMs, + }, + results, + }; + + if (options.output !== undefined) { + const output = resolve(options.output); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Raw JSON: ${output}`); + } +} + +void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/benchmark/package.json b/benchmark/package.json index 39637bf..789e1b0 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -12,7 +12,10 @@ "bench:insert": "tsx index.ts insert", "bench:transaction": "tsx index.ts transaction", "bench:memory": "tsx --expose-gc memory-benchmark.ts", - "bench:tagstore": "tsx tagstore-benchmark.ts" + "prebench:async": "npm run typecheck:async && cd .. && npm run build:dist", + "bench:async": "tsx async-pool.ts", + "bench:tagstore": "tsx tagstore-benchmark.ts", + "typecheck:async": "tsc --noEmit --ignoreConfig --target es2022 --module esnext --moduleResolution bundler --esModuleInterop --skipLibCheck --types node --strict async-pool.ts ../src/types/node-gyp-build.d.ts" }, "dependencies": { "@photostructure/sqlite": "file:..", diff --git a/benchmark/results/async-pool-default.json b/benchmark/results/async-pool-default.json new file mode 100644 index 0000000..0ff4c83 --- /dev/null +++ b/benchmark/results/async-pool-default.json @@ -0,0 +1,2347 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-08-08T08:35:27.062Z", + "package": { + "name": "@photostructure/sqlite", + "version": "2.2.0", + "sqlite": "3.53.4" + }, + "git": { + "commit": "9ac2e43995ae039488590ea5999576884c5990fb", + "dirty": true + }, + "environment": { + "node": "v26.6.0", + "v8": "14.6.202.34-node.26", + "napi": "10", + "uv": "1.52.1", + "platform": "linux", + "arch": "x64", + "cpuModel": "AMD Ryzen 9 5950X 16-Core Processor", + "cpuCount": 32, + "uvThreadpoolSize": "default (4)" + }, + "config": { + "iterations": 10000, + "writeIterations": 2000, + "samples": 6, + "warmup": 1, + "seedRows": 2000, + "connections": [1, 2, 4], + "batchSizes": [10, 100], + "resultSizes": [1, 100, 1000], + "scenarioFilters": null, + "contentionWorkers": 4, + "cryptoIterations": 10000, + "heartbeatIntervalMs": 10 + }, + "results": [ + { + "id": "warm-sync-reused-statement", + "group": "controls", + "description": "Warm DatabaseSync connection with one reused statement", + "settings": { + "implementation": "DatabaseSync", + "connection": "warm" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 39.89086800000041, + "operationsPerMs": 250.68394099621742, + "rowsPerMs": 250.68394099621742, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.301274999997986, + "operationsPerMs": 248.13110751460096, + "rowsPerMs": 248.13110751460096, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.97745399999985, + "operationsPerMs": 244.03663536538986, + "rowsPerMs": 244.03663536538986, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 41.0031650000019, + "operationsPerMs": 243.88361239917788, + "rowsPerMs": 243.88361239917788, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.76803099999961, + "operationsPerMs": 245.2902373430813, + "rowsPerMs": 245.2902373430813, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 41.41780099999596, + "operationsPerMs": 241.44207945759783, + "rowsPerMs": 241.44207945759783, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + } + ], + "summary": { + "medianOperationsPerMs": 244.66343635423556, + "medianRowsPerMs": 244.66343635423556, + "medianElapsedMs": 40.87274249999973, + "relativeMarginOfErrorPct": 2.46072920894689, + "minOperationsPerMs": 241.44207945759783, + "maxOperationsPerMs": 250.68394099621742, + "medianEventLoopHeartbeats": 0, + "maxEventLoopDelayMs": null + } + }, + { + "id": "fresh-sync-connection", + "group": "controls", + "description": "Open, query, and close DatabaseSync for every operation", + "settings": { + "implementation": "DatabaseSync", + "connection": "fresh" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2386.6517239999994, + "operationsPerMs": 4.189970367037936, + "rowsPerMs": 4.189970367037936, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2394.9195359999994, + "operationsPerMs": 4.175505627509317, + "rowsPerMs": 4.175505627509317, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2419.2674449999977, + "operationsPerMs": 4.133482646024698, + "rowsPerMs": 4.133482646024698, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2425.2475830000003, + "operationsPerMs": 4.1232903684126665, + "rowsPerMs": 4.1232903684126665, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2401.127956999997, + "operationsPerMs": 4.164709327900267, + "rowsPerMs": 4.164709327900267, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2425.4228460000013, + "operationsPerMs": 4.122992416143834, + "rowsPerMs": 4.122992416143834, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + } + ], + "summary": { + "medianOperationsPerMs": 4.149095986962482, + "medianRowsPerMs": 4.149095986962482, + "medianElapsedMs": 2410.1977009999973, + "relativeMarginOfErrorPct": 0.9851394184152676, + "minOperationsPerMs": 4.122992416143834, + "maxOperationsPerMs": 4.189970367037936, + "medianEventLoopHeartbeats": 0, + "maxEventLoopDelayMs": null + } + }, + { + "id": "worker-thread-sync-control", + "group": "controls", + "description": "One DatabaseSync worker thread with per-operation messages", + "settings": { + "implementation": "worker_threads + DatabaseSync", + "workers": 1 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 81.47510200000033, + "operationsPerMs": 122.73688224409905, + "rowsPerMs": 122.73688224409905, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.01117799999883573, + "meanDelayMs": 0.003837624999732725 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 80.01745099999971, + "operationsPerMs": 124.97273875919937, + "rowsPerMs": 124.97273875919937, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.01195700000062061, + "meanDelayMs": 0.0018865000001824228 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 84.85943100000077, + "operationsPerMs": 117.84194027885844, + "rowsPerMs": 117.84194027885844, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.0108879999970668, + "meanDelayMs": 0.003023875000053522 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 82.30318499999703, + "operationsPerMs": 121.50198075567016, + "rowsPerMs": 121.50198075567016, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.007267000000865664, + "meanDelayMs": 0.0025897500004248286 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 79.74285700000473, + "operationsPerMs": 125.40308155750435, + "rowsPerMs": 125.40308155750435, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.011797999999544118, + "meanDelayMs": 0.002886374999434338 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 79.5606140000018, + "operationsPerMs": 125.69033215354237, + "rowsPerMs": 125.69033215354237, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.010108000002219342, + "meanDelayMs": 0.0034938750004585017 + } + } + ], + "summary": { + "medianOperationsPerMs": 123.85481050164921, + "medianRowsPerMs": 123.85481050164921, + "medianElapsedMs": 80.74627650000002, + "relativeMarginOfErrorPct": 4.854773261076283, + "minOperationsPerMs": 117.84194027885844, + "maxOperationsPerMs": 125.69033215354237, + "medianEventLoopHeartbeats": 8, + "maxEventLoopDelayMs": 0.01195700000062061 + } + }, + { + "id": "pool-none-1c-point-read", + "group": "pool-scale", + "description": "1-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 1, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 271.02584899999965, + "operationsPerMs": 36.89684964329736, + "rowsPerMs": 36.89684964329736, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 7.335345000001325, + "meanDelayMs": 0.2869932692308514 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 261.37703599999986, + "operationsPerMs": 38.258908100863174, + "rowsPerMs": 38.258908100863174, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 5.170264000000316, + "meanDelayMs": 0.21071608000005654 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 234.13464499999827, + "operationsPerMs": 42.71046687686939, + "rowsPerMs": 42.71046687686939, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.02646699999968405, + "meanDelayMs": 0.004386652174023608 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 259.38880699999936, + "operationsPerMs": 38.552164666072215, + "rowsPerMs": 38.552164666072215, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 10.189727999997558, + "meanDelayMs": 0.4113015199995425 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 280.94486500000494, + "operationsPerMs": 35.59417254342707, + "rowsPerMs": 35.59417254342707, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 8.49209300000075, + "meanDelayMs": 0.3205250740742216 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 266.7048949999953, + "operationsPerMs": 37.494624911178235, + "rowsPerMs": 37.494624911178235, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 13.120420999999624, + "meanDelayMs": 0.5315571200000704 + } + } + ], + "summary": { + "medianOperationsPerMs": 37.8767665060207, + "medianRowsPerMs": 37.8767665060207, + "medianElapsedMs": 264.0409654999976, + "relativeMarginOfErrorPct": 12.761649994807103, + "minOperationsPerMs": 35.59417254342707, + "maxOperationsPerMs": 42.71046687686939, + "medianEventLoopHeartbeats": 25, + "maxEventLoopDelayMs": 13.120420999999624 + } + }, + { + "id": "pool-none-2c-point-read", + "group": "pool-scale", + "description": "2-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 2, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 114.44737799999893, + "operationsPerMs": 87.37640105656324, + "rowsPerMs": 87.37640105656324, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.01839800000016112, + "meanDelayMs": 0.004044636363522097 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 132.91185099999893, + "operationsPerMs": 75.23783563890086, + "rowsPerMs": 75.23783563890086, + "eventLoop": { + "heartbeats": 13, + "intervalMs": 10, + "maxDelayMs": 0.019148999999742955, + "meanDelayMs": 0.003566846153821993 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 117.99337100000048, + "operationsPerMs": 84.75052382391854, + "rowsPerMs": 84.75052382391854, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.020908000002236804, + "meanDelayMs": 0.004493090909156969 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 140.7281460000013, + "operationsPerMs": 71.05899057321417, + "rowsPerMs": 71.05899057321417, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 1.7491440000012517, + "meanDelayMs": 0.12700785714280624 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 128.5818769999969, + "operationsPerMs": 77.77145763706841, + "rowsPerMs": 77.77145763706841, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 5.4007369999999355, + "meanDelayMs": 0.4539042499997474 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 111.37105300000258, + "operationsPerMs": 89.78993850403631, + "rowsPerMs": 89.78993850403631, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.016376999999920372, + "meanDelayMs": 0.0029496363634031945 + } + } + ], + "summary": { + "medianOperationsPerMs": 81.26099073049348, + "medianRowsPerMs": 81.26099073049348, + "medianElapsedMs": 123.28762399999869, + "relativeMarginOfErrorPct": 12.554609617196022, + "minOperationsPerMs": 71.05899057321417, + "maxOperationsPerMs": 89.78993850403631, + "medianEventLoopHeartbeats": 11.5, + "maxEventLoopDelayMs": 5.4007369999999355 + } + }, + { + "id": "pool-none-4c-point-read", + "group": "pool-scale", + "description": "4-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 4, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.26489500000025, + "operationsPerMs": 131.1219270674924, + "rowsPerMs": 131.1219270674924, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.4946749999999156, + "meanDelayMs": 0.0740668571428874 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 74.57006000000001, + "operationsPerMs": 134.10207796533888, + "rowsPerMs": 134.10207796533888, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.006978000001254259, + "meanDelayMs": 0.0020472857140703127 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 73.04440800000157, + "operationsPerMs": 136.9030193248987, + "rowsPerMs": 136.9030193248987, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.011137999998027226, + "meanDelayMs": 0.002937714285508264 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.17312400000083, + "operationsPerMs": 131.27989866872062, + "rowsPerMs": 131.27989866872062, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.015956999999616528, + "meanDelayMs": 0.0024320000000963254 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 73.48715400000219, + "operationsPerMs": 136.0782049063936, + "rowsPerMs": 136.0782049063936, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.009028000000398606, + "meanDelayMs": 0.001933285714455581 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 83.54730199999904, + "operationsPerMs": 119.69267421705749, + "rowsPerMs": 119.69267421705749, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.03473800000210758, + "meanDelayMs": 0.007624250000844768 + } + } + ], + "summary": { + "medianOperationsPerMs": 132.69098831702973, + "medianRowsPerMs": 132.69098831702973, + "medianElapsedMs": 75.37159200000042, + "relativeMarginOfErrorPct": 9.79592831799266, + "minOperationsPerMs": 119.69267421705749, + "maxOperationsPerMs": 136.9030193248987, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 0.4946749999999156 + } + }, + { + "id": "pool-strict-1c-point-read", + "group": "pool-scale", + "description": "1-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 1, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 229.5558170000004, + "operationsPerMs": 43.56238988271851, + "rowsPerMs": 43.56238988271851, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.021917000000030384, + "meanDelayMs": 0.003915347826161609 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 246.2147530000002, + "operationsPerMs": 40.61495047780501, + "rowsPerMs": 40.61495047780501, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.039579000000230735, + "meanDelayMs": 0.005832249999987956 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 237.67262699999992, + "operationsPerMs": 42.07468115375357, + "rowsPerMs": 42.07468115375357, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.04747900000074878, + "meanDelayMs": 0.006448347826137805 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 277.8643799999991, + "operationsPerMs": 35.988779850083816, + "rowsPerMs": 35.988779850083816, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 0.01817799999844283, + "meanDelayMs": 0.004078666666626102 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 254.8640009999981, + "operationsPerMs": 39.23661231387509, + "rowsPerMs": 39.23661231387509, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.01589800000147079, + "meanDelayMs": 0.005199000000138767 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 265.22155300000304, + "operationsPerMs": 37.70432639009502, + "rowsPerMs": 37.70432639009502, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 0.04338799999823095, + "meanDelayMs": 0.005498961538810713 + } + } + ], + "summary": { + "medianOperationsPerMs": 39.92578139584005, + "medianRowsPerMs": 39.92578139584005, + "medianElapsedMs": 250.53937699999915, + "relativeMarginOfErrorPct": 9.860800235124357, + "minOperationsPerMs": 35.988779850083816, + "maxOperationsPerMs": 43.56238988271851, + "medianEventLoopHeartbeats": 24.5, + "maxEventLoopDelayMs": 0.04747900000074878 + } + }, + { + "id": "pool-strict-2c-point-read", + "group": "pool-scale", + "description": "2-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 2, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 117.3946020000003, + "operationsPerMs": 85.18279230590154, + "rowsPerMs": 85.18279230590154, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.06333900000026915, + "meanDelayMs": 0.009333454545412678 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 119.03946700000051, + "operationsPerMs": 84.00575247871328, + "rowsPerMs": 84.00575247871328, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.008748000000196043, + "meanDelayMs": 0.0017004545454917454 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 119.96544999999969, + "operationsPerMs": 83.35733329887918, + "rowsPerMs": 83.35733329887918, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.02072799999950803, + "meanDelayMs": 0.005629666666512397 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 144.8033859999996, + "operationsPerMs": 69.05915860282458, + "rowsPerMs": 69.05915860282458, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.04496800000197254, + "meanDelayMs": 0.008438928571714703 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 147.0498090000001, + "operationsPerMs": 68.00416857392854, + "rowsPerMs": 68.00416857392854, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.0673979999992298, + "meanDelayMs": 0.011706357142819408 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 121.74475600000005, + "operationsPerMs": 82.13906149682533, + "rowsPerMs": 82.13906149682533, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.025988000001234468, + "meanDelayMs": 0.004957250000491816 + } + } + ], + "summary": { + "medianOperationsPerMs": 82.74819739785227, + "medianRowsPerMs": 82.74819739785227, + "medianElapsedMs": 120.85510299999987, + "relativeMarginOfErrorPct": 17.817945632138215, + "minOperationsPerMs": 68.00416857392854, + "maxOperationsPerMs": 85.18279230590154, + "medianEventLoopHeartbeats": 12, + "maxEventLoopDelayMs": 0.0673979999992298 + } + }, + { + "id": "pool-strict-4c-point-read", + "group": "pool-scale", + "description": "4-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 4, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 73.4352429999999, + "operationsPerMs": 136.17439789775074, + "rowsPerMs": 136.17439789775074, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.8465699999997014, + "meanDelayMs": 0.12437414285705017 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.18273899999986, + "operationsPerMs": 133.0092536266871, + "rowsPerMs": 133.0092536266871, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.021457999999256572, + "meanDelayMs": 0.0037279999999425073 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.4475429999984, + "operationsPerMs": 132.54242089765881, + "rowsPerMs": 132.54242089765881, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.009357999999338062, + "meanDelayMs": 0.002467428571565376 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 74.26627500000177, + "operationsPerMs": 134.65062035223607, + "rowsPerMs": 134.65062035223607, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.02097700000012992, + "meanDelayMs": 0.004445857142335237 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 77.19305900000109, + "operationsPerMs": 129.54532608948506, + "rowsPerMs": 129.54532608948506, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.013398000002780464, + "meanDelayMs": 0.0029804285716506585 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 71.43470399999933, + "operationsPerMs": 139.9879811918881, + "rowsPerMs": 139.9879811918881, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.04663799999980256, + "meanDelayMs": 0.009260428570477026 + } + } + ], + "summary": { + "medianOperationsPerMs": 133.8299369894616, + "medianRowsPerMs": 133.8299369894616, + "medianElapsedMs": 74.72450700000081, + "relativeMarginOfErrorPct": 4.601395129485432, + "minOperationsPerMs": 129.54532608948506, + "maxOperationsPerMs": 139.9879811918881, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 0.8465699999997014 + } + }, + { + "id": "pool-none-1c-batch-10", + "group": "batch", + "description": "One-connection none pool, explicit get batches of 10", + "settings": { + "authorizer": "none", + "connections": 1, + "batchSize": 10 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 90.43914500000028, + "operationsPerMs": 110.57158932672317, + "rowsPerMs": 110.57158932672317, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.05556799999976647, + "meanDelayMs": 0.01128344444441609 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 95.79387299999871, + "operationsPerMs": 104.39081004690284, + "rowsPerMs": 104.39081004690284, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.07578800000010233, + "meanDelayMs": 0.01799622222218507 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 98.02843700000085, + "operationsPerMs": 102.01121537824696, + "rowsPerMs": 102.01121537824696, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.05361799999809591, + "meanDelayMs": 0.01534144444465508 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 91.43435899999895, + "operationsPerMs": 109.36807682985031, + "rowsPerMs": 109.36807682985031, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.061219000002893154, + "meanDelayMs": 0.010767000000163939 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 94.84660900000017, + "operationsPerMs": 105.43339509375588, + "rowsPerMs": 105.43339509375588, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.0735290000011446, + "meanDelayMs": 0.022648888889509382 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 91.95921600000293, + "operationsPerMs": 108.74385879931471, + "rowsPerMs": 108.74385879931471, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.05652900000131922, + "meanDelayMs": 0.013001555555901077 + } + } + ], + "summary": { + "medianOperationsPerMs": 107.0886269465353, + "medianRowsPerMs": 107.0886269465353, + "medianElapsedMs": 93.40291250000155, + "relativeMarginOfErrorPct": 4.741317274357502, + "minOperationsPerMs": 102.01121537824696, + "maxOperationsPerMs": 110.57158932672317, + "medianEventLoopHeartbeats": 9, + "maxEventLoopDelayMs": 0.07578800000010233 + } + }, + { + "id": "pool-none-1c-batch-100", + "group": "batch", + "description": "One-connection none pool, explicit get batches of 100", + "settings": { + "authorizer": "none", + "connections": 1, + "batchSize": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.63658100000066, + "operationsPerMs": 130.48598814709536, + "rowsPerMs": 130.48598814709536, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.463434999999663, + "meanDelayMs": 0.11668257142862005 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.66998600000079, + "operationsPerMs": 132.15279305060128, + "rowsPerMs": 132.15279305060128, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.6201769999988755, + "meanDelayMs": 0.09955214285686712 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 74.39640800000052, + "operationsPerMs": 134.41509165334878, + "rowsPerMs": 134.41509165334878, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.40053400000033434, + "meanDelayMs": 0.10578228571414781 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.85759399999733, + "operationsPerMs": 130.11076042792007, + "rowsPerMs": 130.11076042792007, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.2888219999986177, + "meanDelayMs": 0.09183171428542534 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.9785609999999, + "operationsPerMs": 131.61607522416767, + "rowsPerMs": 131.61607522416767, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.6835870000031719, + "meanDelayMs": 0.160616000000508 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.96198100000038, + "operationsPerMs": 131.64480268096156, + "rowsPerMs": 131.64480268096156, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.5098050000015064, + "meanDelayMs": 0.14099128571459524 + } + } + ], + "summary": { + "medianOperationsPerMs": 131.63043895256462, + "medianRowsPerMs": 131.63043895256462, + "medianElapsedMs": 75.97027100000014, + "relativeMarginOfErrorPct": 2.115508178004074, + "minOperationsPerMs": 130.11076042792007, + "maxOperationsPerMs": 134.41509165334878, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 0.6835870000031719 + } + }, + { + "id": "pool-none-all-1-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 1 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 1, + "operations": 10000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 305.196903, + "operationsPerMs": 32.76573222631948, + "rowsPerMs": 32.76573222631948, + "eventLoop": { + "heartbeats": 30, + "intervalMs": 10, + "maxDelayMs": 0.05655800000022282, + "meanDelayMs": 0.009397733333450257 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 315.00035800000114, + "operationsPerMs": 31.745995666455602, + "rowsPerMs": 31.745995666455602, + "eventLoop": { + "heartbeats": 31, + "intervalMs": 10, + "maxDelayMs": 0.06419799999821407, + "meanDelayMs": 0.009284516128959254 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 313.5787470000032, + "operationsPerMs": 31.88991631502341, + "rowsPerMs": 31.88991631502341, + "eventLoop": { + "heartbeats": 31, + "intervalMs": 10, + "maxDelayMs": 0.022257999997236766, + "meanDelayMs": 0.005710580645339383 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 301.54837000000043, + "operationsPerMs": 33.162175607183634, + "rowsPerMs": 33.162175607183634, + "eventLoop": { + "heartbeats": 30, + "intervalMs": 10, + "maxDelayMs": 0.04451799999878858, + "meanDelayMs": 0.007417700000102437 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 291.61203300000125, + "operationsPerMs": 34.29213773219008, + "rowsPerMs": 34.29213773219008, + "eventLoop": { + "heartbeats": 29, + "intervalMs": 10, + "maxDelayMs": 0.5656960000014806, + "meanDelayMs": 0.025075103448185036 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 362.0278410000028, + "operationsPerMs": 27.62218500206431, + "rowsPerMs": 27.62218500206431, + "eventLoop": { + "heartbeats": 36, + "intervalMs": 10, + "maxDelayMs": 0.03493700000399258, + "meanDelayMs": 0.006498166666966022 + } + } + ], + "summary": { + "medianOperationsPerMs": 32.32782427067144, + "medianRowsPerMs": 32.32782427067144, + "medianElapsedMs": 309.3878250000016, + "relativeMarginOfErrorPct": 14.556003612269691, + "minOperationsPerMs": 27.62218500206431, + "maxOperationsPerMs": 34.29213773219008, + "medianEventLoopHeartbeats": 30.5, + "maxEventLoopDelayMs": 0.5656960000014806 + } + }, + { + "id": "pool-none-all-100-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 100 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 100, + "operations": 1000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 147.23000200000024, + "operationsPerMs": 6.792093910315904, + "rowsPerMs": 679.2093910315904, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.9585110000007262, + "meanDelayMs": 0.1243367857141493 + } + }, + { + "sample": 2, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 148.6136630000001, + "operationsPerMs": 6.728856417461424, + "rowsPerMs": 672.8856417461424, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 2.8122090000033495, + "meanDelayMs": 0.22726735714318888 + } + }, + { + "sample": 3, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 146.65315399999963, + "operationsPerMs": 6.818810047549353, + "rowsPerMs": 681.8810047549352, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 2.8612390000016603, + "meanDelayMs": 0.22714114285736287 + } + }, + { + "sample": 4, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 144.09150600000066, + "operationsPerMs": 6.940034341788303, + "rowsPerMs": 694.0034341788304, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.12848900000244612, + "meanDelayMs": 0.026480499999836735 + } + }, + { + "sample": 5, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 140.82045799999833, + "operationsPerMs": 7.101240929070205, + "rowsPerMs": 710.1240929070206, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.09440900000117836, + "meanDelayMs": 0.022187428571019803 + } + }, + { + "sample": 6, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 149.70001900000352, + "operationsPerMs": 6.680025872274448, + "rowsPerMs": 668.0025872274448, + "eventLoop": { + "heartbeats": 15, + "intervalMs": 10, + "maxDelayMs": 0.09124899999733316, + "meanDelayMs": 0.01689159999950789 + } + } + ], + "summary": { + "medianOperationsPerMs": 6.805451978932629, + "medianRowsPerMs": 680.5451978932629, + "medianElapsedMs": 146.94157799999994, + "relativeMarginOfErrorPct": 4.346352763243923, + "minOperationsPerMs": 6.680025872274448, + "maxOperationsPerMs": 7.101240929070205, + "medianEventLoopHeartbeats": 14, + "maxEventLoopDelayMs": 2.8612390000016603 + } + }, + { + "id": "pool-none-all-1000-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 1000 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 1000, + "operations": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 113.87814000000071, + "operationsPerMs": 0.8781316589821311, + "rowsPerMs": 878.1316589821311, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.8754100000005565, + "meanDelayMs": 0.2584219090908432 + } + }, + { + "sample": 2, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 116.0306210000017, + "operationsPerMs": 0.8618414616603537, + "rowsPerMs": 861.8414616603537, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.8194100000000617, + "meanDelayMs": 0.2345876363641847 + } + }, + { + "sample": 3, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 116.74758199999997, + "operationsPerMs": 0.8565487891646444, + "rowsPerMs": 856.5487891646444, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.8269999999974971, + "meanDelayMs": 0.31447300000019657 + } + }, + { + "sample": 4, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 109.84334099999978, + "operationsPerMs": 0.9103874580799595, + "rowsPerMs": 910.3874580799595, + "eventLoop": { + "heartbeats": 10, + "intervalMs": 10, + "maxDelayMs": 0.8320000000021537, + "meanDelayMs": 0.23501549999964483 + } + }, + { + "sample": 5, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 110.75461399999767, + "operationsPerMs": 0.9028969212966793, + "rowsPerMs": 902.8969212966794, + "eventLoop": { + "heartbeats": 10, + "intervalMs": 10, + "maxDelayMs": 2.739688000001479, + "meanDelayMs": 0.5377443000001222 + } + }, + { + "sample": 6, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 112.61935100000119, + "operationsPerMs": 0.8879468680298019, + "rowsPerMs": 887.946868029802, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.864610000004177, + "meanDelayMs": 0.2676293636369254 + } + } + ], + "summary": { + "medianOperationsPerMs": 0.8830392635059665, + "medianRowsPerMs": 883.0392635059666, + "medianElapsedMs": 113.24874550000095, + "relativeMarginOfErrorPct": 3.097053064821978, + "minOperationsPerMs": 0.8565487891646444, + "maxOperationsPerMs": 0.9103874580799595, + "medianEventLoopHeartbeats": 11, + "maxEventLoopDelayMs": 2.739688000001479 + } + }, + { + "id": "pool-strict-2c-mix-100r-0w", + "group": "read-write", + "description": "Two-connection strict pool, 100% reads and 0% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 100, + "writesPct": 0 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 28.72264300000097, + "operationsPerMs": 69.63147507003211, + "rowsPerMs": 69.63147507003211, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 29.4094640000003, + "operationsPerMs": 68.00531964812346, + "rowsPerMs": 68.00531964812346, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 23.602717999998276, + "operationsPerMs": 84.73600370940949, + "rowsPerMs": 84.73600370940949, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.00824799999827519, + "meanDelayMs": 0.004123999999137595 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 27.546125999997457, + "operationsPerMs": 72.60549087738089, + "rowsPerMs": 72.60549087738089, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.05385799999930896, + "meanDelayMs": 0.02692899999965448 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 31.16189900000245, + "operationsPerMs": 64.18094096254669, + "rowsPerMs": 64.18094096254669, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0.005386999997426756, + "meanDelayMs": 0.0017956666658089186 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 26.927566999998817, + "operationsPerMs": 74.27332740459202, + "rowsPerMs": 74.27332740459202, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.010307999997166917, + "meanDelayMs": 0.005153999998583458 + } + } + ], + "summary": { + "medianOperationsPerMs": 71.1184829737065, + "medianRowsPerMs": 71.1184829737065, + "medianElapsedMs": 28.134384499999214, + "relativeMarginOfErrorPct": 19.147653558270598, + "minOperationsPerMs": 64.18094096254669, + "maxOperationsPerMs": 84.73600370940949, + "medianEventLoopHeartbeats": 2, + "maxEventLoopDelayMs": 0.05385799999930896 + } + }, + { + "id": "pool-strict-2c-mix-90r-10w", + "group": "read-write", + "description": "Two-connection strict pool, 90% reads and 10% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 90, + "writesPct": 10 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 26.965908000000127, + "operationsPerMs": 74.1677231858831, + "rowsPerMs": 66.75095086729479, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.020617999998648884, + "meanDelayMs": 0.010308999999324442 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 29.946211999998923, + "operationsPerMs": 66.78641024781605, + "rowsPerMs": 60.107769223034445, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 28.30192800000077, + "operationsPerMs": 70.66656377614788, + "rowsPerMs": 63.5999073985331, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.013216999999713153, + "meanDelayMs": 0.006608499999856576 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 29.964131999997335, + "operationsPerMs": 66.74646874470376, + "rowsPerMs": 60.071821870233386, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0.03139800000280957, + "meanDelayMs": 0.012451666666796276 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 28.382559000001493, + "operationsPerMs": 70.46580965443937, + "rowsPerMs": 63.419228688995425, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.004198000002361368, + "meanDelayMs": 0.002099000001180684 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 25.487796000001254, + "operationsPerMs": 78.46892685424434, + "rowsPerMs": 70.62203416881991, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.004617999999027234, + "meanDelayMs": 0.002308999999513617 + } + } + ], + "summary": { + "medianOperationsPerMs": 70.56618671529363, + "medianRowsPerMs": 63.50956804376426, + "medianElapsedMs": 28.342243500001132, + "relativeMarginOfErrorPct": 11.199046606889953, + "minOperationsPerMs": 66.74646874470376, + "maxOperationsPerMs": 78.46892685424434, + "medianEventLoopHeartbeats": 2, + "maxEventLoopDelayMs": 0.03139800000280957 + } + }, + { + "id": "pool-strict-2c-mix-0r-100w", + "group": "read-write", + "description": "Two-connection strict pool, 0% reads and 100% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 0, + "writesPct": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 73.92749100000037, + "operationsPerMs": 27.053535470316312, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.030968000000939355, + "meanDelayMs": 0.008867285714066904 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 83.06493599999885, + "operationsPerMs": 24.077548196750886, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.9219509999966249, + "meanDelayMs": 0.12009424999996554 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 79.05309599999964, + "operationsPerMs": 25.299451902554317, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.010586999997030944, + "meanDelayMs": 0.0030617142848703744 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 81.68555599999672, + "operationsPerMs": 24.484132788421984, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.049738000001525506, + "meanDelayMs": 0.01080912499992337 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 88.17844100000002, + "operationsPerMs": 22.681281017431456, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.7250480000002426, + "meanDelayMs": 0.0963053749997016 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 74.26834600000439, + "operationsPerMs": 26.92937311408392, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.7746289999995497, + "meanDelayMs": 0.1838812857147007 + } + } + ], + "summary": { + "medianOperationsPerMs": 24.89179234548815, + "medianRowsPerMs": 0, + "medianElapsedMs": 80.36932599999818, + "relativeMarginOfErrorPct": 8.880482760645267, + "minOperationsPerMs": 22.681281017431456, + "maxOperationsPerMs": 27.053535470316312, + "medianEventLoopHeartbeats": 7.5, + "maxEventLoopDelayMs": 0.9219509999966249 + } + }, + { + "id": "pool-none-repeated-identical-sql", + "group": "repeated-sql", + "description": "Repeated identical SQL text (prepare cost/cache baseline)", + "settings": { + "authorizer": "none", + "connections": 1, + "variants": 1 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 236.07812300000114, + "operationsPerMs": 42.358859317091195, + "rowsPerMs": 42.358859317091195, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.022916999998415122, + "meanDelayMs": 0.004516347826052958 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 275.79601999999795, + "operationsPerMs": 36.25868132542331, + "rowsPerMs": 36.25868132542331, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 0.026458000000275206, + "meanDelayMs": 0.005265999999850195 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 235.78777799999807, + "operationsPerMs": 42.41101928531716, + "rowsPerMs": 42.41101928531716, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.03669799999988754, + "meanDelayMs": 0.006068391304364448 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 238.10461300000316, + "operationsPerMs": 41.99834633191196, + "rowsPerMs": 41.99834633191196, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.027728999997634673, + "meanDelayMs": 0.006695478260751216 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 249.44867100000192, + "operationsPerMs": 40.08840760670929, + "rowsPerMs": 40.08840760670929, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.03462800000124844, + "meanDelayMs": 0.006612599999934901 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 233.59345600000233, + "operationsPerMs": 42.80941842822814, + "rowsPerMs": 42.80941842822814, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 0.6995380000007572, + "meanDelayMs": 0.036415434782532466 + } + } + ], + "summary": { + "medianOperationsPerMs": 42.17860282450158, + "medianRowsPerMs": 42.17860282450158, + "medianElapsedMs": 237.09136800000215, + "relativeMarginOfErrorPct": 14.035366519156922, + "minOperationsPerMs": 36.25868132542331, + "maxOperationsPerMs": 42.80941842822814, + "medianEventLoopHeartbeats": 23, + "maxEventLoopDelayMs": 0.6995380000007572 + } + }, + { + "id": "pool-none-rotating-sql-32", + "group": "repeated-sql", + "description": "Equivalent SQL rotated across 32 distinct texts", + "settings": { + "authorizer": "none", + "connections": 1, + "variants": 32 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 240.61484000000019, + "operationsPerMs": 41.56019637026541, + "rowsPerMs": 41.56019637026541, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.0361880000000383, + "meanDelayMs": 0.006066166666641948 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 264.21473900000274, + "operationsPerMs": 37.84800211315954, + "rowsPerMs": 37.84800211315954, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 0.02372799999648123, + "meanDelayMs": 0.005754192307414576 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 273.4476140000006, + "operationsPerMs": 36.570075904922604, + "rowsPerMs": 36.570075904922604, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 0.05230899999878602, + "meanDelayMs": 0.008179259258920664 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 250.49144499999966, + "operationsPerMs": 39.92152306838269, + "rowsPerMs": 39.92152306838269, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.03387799999836716, + "meanDelayMs": 0.004606279999861727 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 275.80725000000166, + "operationsPerMs": 36.25720498645318, + "rowsPerMs": 36.25720498645318, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 0.03032800000073621, + "meanDelayMs": 0.006997074074105412 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 240.13446299999487, + "operationsPerMs": 41.643335467430234, + "rowsPerMs": 41.643335467430234, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.0500979999997071, + "meanDelayMs": 0.0072168749999643 + } + } + ], + "summary": { + "medianOperationsPerMs": 38.88476259077112, + "medianRowsPerMs": 38.88476259077112, + "medianElapsedMs": 257.3530920000012, + "relativeMarginOfErrorPct": 7.0942258428853915, + "minOperationsPerMs": 36.25720498645318, + "maxOperationsPerMs": 41.643335467430234, + "medianEventLoopHeartbeats": 25.5, + "maxEventLoopDelayMs": 0.05230899999878602 + } + }, + { + "id": "pool-none-4c-point-read-with-crypto", + "group": "contention", + "description": "Four-connection pool competing with crypto libuv work", + "settings": { + "authorizer": "none", + "connections": 4, + "contention": "crypto", + "contentionWorkers": 4, + "cryptoIterations": 10000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 886.2084159999995, + "operationsPerMs": 11.284027345549386, + "rowsPerMs": 11.284027345549386, + "eventLoop": { + "heartbeats": 88, + "intervalMs": 10, + "maxDelayMs": 0.7559990000008838, + "meanDelayMs": 0.1120148749999275 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2671, + "elapsedMs": 887.5312259999992 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 881.668608, + "operationsPerMs": 11.342130035325019, + "rowsPerMs": 11.342130035325019, + "eventLoop": { + "heartbeats": 88, + "intervalMs": 10, + "maxDelayMs": 0.7239480000025651, + "meanDelayMs": 0.14702252272739066 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2656, + "elapsedMs": 883.0418989999998 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 893.7825470000025, + "operationsPerMs": 11.188403749396521, + "rowsPerMs": 11.188403749396521, + "eventLoop": { + "heartbeats": 89, + "intervalMs": 10, + "maxDelayMs": 0.723308000000543, + "meanDelayMs": 0.13688935955049988 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2673, + "elapsedMs": 895.1825179999978 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 889.2503799999977, + "operationsPerMs": 11.245426737967799, + "rowsPerMs": 11.245426737967799, + "eventLoop": { + "heartbeats": 88, + "intervalMs": 10, + "maxDelayMs": 0.8550299999988056, + "meanDelayMs": 0.13210646590906353 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2653, + "elapsedMs": 890.607799999998 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 881.7975609999994, + "operationsPerMs": 11.340471376059982, + "rowsPerMs": 11.340471376059982, + "eventLoop": { + "heartbeats": 88, + "intervalMs": 10, + "maxDelayMs": 1.6911929999987478, + "meanDelayMs": 0.12364790909090186 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2660, + "elapsedMs": 883.1098700000002 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 874.8997779999991, + "operationsPerMs": 11.429880600564069, + "rowsPerMs": 11.429880600564069, + "eventLoop": { + "heartbeats": 87, + "intervalMs": 10, + "maxDelayMs": 0.9678410000051372, + "meanDelayMs": 0.11863824137931103 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 2644, + "elapsedMs": 876.2240979999988 + } + } + ], + "summary": { + "medianOperationsPerMs": 11.312249360804685, + "medianRowsPerMs": 11.312249360804685, + "medianElapsedMs": 884.0029884999994, + "relativeMarginOfErrorPct": 1.0947920918121865, + "minOperationsPerMs": 11.188403749396521, + "maxOperationsPerMs": 11.429880600564069, + "medianEventLoopHeartbeats": 88, + "maxEventLoopDelayMs": 1.6911929999987478, + "competingWorkCompleted": 15957 + } + }, + { + "id": "pool-none-4c-point-read-with-fs", + "group": "contention", + "description": "Four-connection pool competing with fs libuv work", + "settings": { + "authorizer": "none", + "connections": 4, + "contention": "fs", + "contentionWorkers": 4, + "competingFileBytes": 1048576 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 204.43516700000146, + "operationsPerMs": 48.91526319441864, + "rowsPerMs": 48.91526319441864, + "eventLoop": { + "heartbeats": 20, + "intervalMs": 10, + "maxDelayMs": 0.7697389999993902, + "meanDelayMs": 0.06979479999999967 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1505, + "elapsedMs": 204.78272199999992 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 223.03371100000004, + "operationsPerMs": 44.83627141010983, + "rowsPerMs": 44.83627141010983, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 0.292741000001115, + "meanDelayMs": 0.02984372727289817 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1524, + "elapsedMs": 224.106026999998 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 215.79303400000208, + "operationsPerMs": 46.34069883831331, + "rowsPerMs": 46.34069883831331, + "eventLoop": { + "heartbeats": 21, + "intervalMs": 10, + "maxDelayMs": 0.8562090000013995, + "meanDelayMs": 0.11661842857124395 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1493, + "elapsedMs": 215.99518700000044 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 216.09976900000038, + "operationsPerMs": 46.274922209657625, + "rowsPerMs": 46.274922209657625, + "eventLoop": { + "heartbeats": 21, + "intervalMs": 10, + "maxDelayMs": 1.4112789999999222, + "meanDelayMs": 0.13819904761909302 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1509, + "elapsedMs": 216.34005199999956 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 235.63410699999804, + "operationsPerMs": 42.43867803059632, + "rowsPerMs": 42.43867803059632, + "eventLoop": { + "heartbeats": 23, + "intervalMs": 10, + "maxDelayMs": 1.378328999999212, + "meanDelayMs": 0.16631900000008856 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1570, + "elapsedMs": 235.87956000000122 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 227.007408999998, + "operationsPerMs": 44.05142565192702, + "rowsPerMs": 44.05142565192702, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 1.0763839999999618, + "meanDelayMs": 0.10758809090938568 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1529, + "elapsedMs": 227.0916309999957 + } + } + ], + "summary": { + "medianOperationsPerMs": 45.555596809883724, + "medianRowsPerMs": 45.555596809883724, + "medianElapsedMs": 219.5667400000002, + "relativeMarginOfErrorPct": 7.3748707509106906, + "minOperationsPerMs": 42.43867803059632, + "maxOperationsPerMs": 48.91526319441864, + "medianEventLoopHeartbeats": 21.5, + "maxEventLoopDelayMs": 1.4112789999999222, + "competingWorkCompleted": 9130 + } + } + ] +} diff --git a/benchmark/results/async-pool-uv8.json b/benchmark/results/async-pool-uv8.json new file mode 100644 index 0000000..0c2cc41 --- /dev/null +++ b/benchmark/results/async-pool-uv8.json @@ -0,0 +1,2347 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-08-08T08:36:15.628Z", + "package": { + "name": "@photostructure/sqlite", + "version": "2.2.0", + "sqlite": "3.53.4" + }, + "git": { + "commit": "9ac2e43995ae039488590ea5999576884c5990fb", + "dirty": true + }, + "environment": { + "node": "v26.6.0", + "v8": "14.6.202.34-node.26", + "napi": "10", + "uv": "1.52.1", + "platform": "linux", + "arch": "x64", + "cpuModel": "AMD Ryzen 9 5950X 16-Core Processor", + "cpuCount": 32, + "uvThreadpoolSize": "8" + }, + "config": { + "iterations": 10000, + "writeIterations": 2000, + "samples": 6, + "warmup": 1, + "seedRows": 2000, + "connections": [1, 2, 4], + "batchSizes": [10, 100], + "resultSizes": [1, 100, 1000], + "scenarioFilters": null, + "contentionWorkers": 4, + "cryptoIterations": 10000, + "heartbeatIntervalMs": 10 + }, + "results": [ + { + "id": "warm-sync-reused-statement", + "group": "controls", + "description": "Warm DatabaseSync connection with one reused statement", + "settings": { + "implementation": "DatabaseSync", + "connection": "warm" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.523637999999664, + "operationsPerMs": 246.76955213152587, + "rowsPerMs": 246.76955213152587, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 41.10378700000001, + "operationsPerMs": 243.2865857347888, + "rowsPerMs": 243.2865857347888, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 41.00859499999933, + "operationsPerMs": 243.85131946120472, + "rowsPerMs": 243.85131946120472, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 41.78328600000168, + "operationsPerMs": 239.3301474661327, + "rowsPerMs": 239.3301474661327, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.40332599999965, + "operationsPerMs": 247.50437624863076, + "rowsPerMs": 247.50437624863076, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 40.53912799999671, + "operationsPerMs": 246.67526149059773, + "rowsPerMs": 246.67526149059773, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + } + ], + "summary": { + "medianOperationsPerMs": 245.26329047590121, + "medianRowsPerMs": 245.26329047590121, + "medianElapsedMs": 40.77386149999802, + "relativeMarginOfErrorPct": 2.4190913357869532, + "minOperationsPerMs": 239.3301474661327, + "maxOperationsPerMs": 247.50437624863076, + "medianEventLoopHeartbeats": 0, + "maxEventLoopDelayMs": null + } + }, + { + "id": "fresh-sync-connection", + "group": "controls", + "description": "Open, query, and close DatabaseSync for every operation", + "settings": { + "implementation": "DatabaseSync", + "connection": "fresh" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2392.8839150000003, + "operationsPerMs": 4.179057720817183, + "rowsPerMs": 4.179057720817183, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2412.1381600000004, + "operationsPerMs": 4.145699514989638, + "rowsPerMs": 4.145699514989638, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2390.737924000001, + "operationsPerMs": 4.182808956018383, + "rowsPerMs": 4.182808956018383, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2419.582601000002, + "operationsPerMs": 4.132944250742689, + "rowsPerMs": 4.132944250742689, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2395.9573619999974, + "operationsPerMs": 4.173696977500725, + "rowsPerMs": 4.173696977500725, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 2397.0637170000045, + "operationsPerMs": 4.171770624652103, + "rowsPerMs": 4.171770624652103, + "eventLoop": { + "heartbeats": 0, + "intervalMs": 10, + "maxDelayMs": null, + "meanDelayMs": null + } + } + ], + "summary": { + "medianOperationsPerMs": 4.172733801076414, + "medianRowsPerMs": 4.172733801076414, + "medianElapsedMs": 2396.510539500001, + "relativeMarginOfErrorPct": 0.9535607165609474, + "minOperationsPerMs": 4.132944250742689, + "maxOperationsPerMs": 4.182808956018383, + "medianEventLoopHeartbeats": 0, + "maxEventLoopDelayMs": null + } + }, + { + "id": "worker-thread-sync-control", + "group": "controls", + "description": "One DatabaseSync worker thread with per-operation messages", + "settings": { + "implementation": "worker_threads + DatabaseSync", + "workers": 1 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 82.30933499999992, + "operationsPerMs": 121.49290235427135, + "rowsPerMs": 121.49290235427135, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.012167999999292078, + "meanDelayMs": 0.002391624999859232 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 79.14401799999905, + "operationsPerMs": 126.3519372999248, + "rowsPerMs": 126.3519372999248, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.007928000000902102, + "meanDelayMs": 0.0012307142858065032 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 79.31438100000014, + "operationsPerMs": 126.08054017341424, + "rowsPerMs": 126.08054017341424, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.01030700000046636, + "meanDelayMs": 0.0018547142857901885 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 80.24122400000124, + "operationsPerMs": 124.6242205876601, + "rowsPerMs": 124.6242205876601, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.0029070000018691644, + "meanDelayMs": 0.0005488750002768938 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 80.153581999999, + "operationsPerMs": 124.76048793427752, + "rowsPerMs": 124.76048793427752, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.009136999997281237, + "meanDelayMs": 0.0019816249996438273 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 83.9640600000057, + "operationsPerMs": 119.09857622415258, + "rowsPerMs": 119.09857622415258, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.0246880000049714, + "meanDelayMs": 0.003799250000156462 + } + } + ], + "summary": { + "medianOperationsPerMs": 124.69235426096881, + "medianRowsPerMs": 124.69235426096881, + "medianElapsedMs": 80.19740300000012, + "relativeMarginOfErrorPct": 4.486063375713482, + "minOperationsPerMs": 119.09857622415258, + "maxOperationsPerMs": 126.3519372999248, + "medianEventLoopHeartbeats": 8, + "maxEventLoopDelayMs": 0.0246880000049714 + } + }, + { + "id": "pool-none-1c-point-read", + "group": "pool-scale", + "description": "1-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 1, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 268.61187299999983, + "operationsPerMs": 37.22843628732676, + "rowsPerMs": 37.22843628732676, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 13.43997599999966, + "meanDelayMs": 0.5991200799999206 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 273.2921219999989, + "operationsPerMs": 36.59088277707485, + "rowsPerMs": 36.59088277707485, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 11.237342999998873, + "meanDelayMs": 0.4537951153847294 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 248.22235299999738, + "operationsPerMs": 40.28646042203985, + "rowsPerMs": 40.28646042203985, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.039358999998512445, + "meanDelayMs": 0.007607416666663387 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 288.6838000000025, + "operationsPerMs": 34.639976333967866, + "rowsPerMs": 34.639976333967866, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 14.364740000000893, + "meanDelayMs": 0.5373910740742792 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 293.88294600000154, + "operationsPerMs": 34.0271531101364, + "rowsPerMs": 34.0271531101364, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 19.490355000001728, + "meanDelayMs": 0.7261374444444502 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 263.454227000002, + "operationsPerMs": 37.95725775164702, + "rowsPerMs": 37.95725775164702, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 12.796675999998115, + "meanDelayMs": 0.5153334399999585 + } + } + ], + "summary": { + "medianOperationsPerMs": 36.909659532200806, + "medianRowsPerMs": 36.909659532200806, + "medianElapsedMs": 270.9519974999994, + "relativeMarginOfErrorPct": 9.14882698089656, + "minOperationsPerMs": 34.0271531101364, + "maxOperationsPerMs": 40.28646042203985, + "medianEventLoopHeartbeats": 25.5, + "maxEventLoopDelayMs": 19.490355000001728 + } + }, + { + "id": "pool-none-2c-point-read", + "group": "pool-scale", + "description": "2-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 2, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 123.20647800000006, + "operationsPerMs": 81.16456344121772, + "rowsPerMs": 81.16456344121772, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.03214800000023388, + "meanDelayMs": 0.006960333333457432 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 124.81551199999922, + "operationsPerMs": 80.1182468409861, + "rowsPerMs": 80.1182468409861, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.04271800000060466, + "meanDelayMs": 0.0071638333333794435 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 125.09168599999975, + "operationsPerMs": 79.94136396882539, + "rowsPerMs": 79.94136396882539, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.04370799999742303, + "meanDelayMs": 0.0067272499994336 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 144.96003899999778, + "operationsPerMs": 68.98452890179033, + "rowsPerMs": 68.98452890179033, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 3.029392000000371, + "meanDelayMs": 0.21948707142863505 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 132.81474999999773, + "operationsPerMs": 75.29284209773516, + "rowsPerMs": 75.29284209773516, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 5.20113499999934, + "meanDelayMs": 0.43898641666661814 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 127.22765699999582, + "operationsPerMs": 78.59926242295202, + "rowsPerMs": 78.59926242295202, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.022248000001127366, + "meanDelayMs": 0.006182249999862203 + } + } + ], + "summary": { + "medianOperationsPerMs": 79.2703131958887, + "medianRowsPerMs": 79.2703131958887, + "medianElapsedMs": 126.15967149999778, + "relativeMarginOfErrorPct": 12.975581752377687, + "minOperationsPerMs": 68.98452890179033, + "maxOperationsPerMs": 81.16456344121772, + "medianEventLoopHeartbeats": 12, + "maxEventLoopDelayMs": 5.20113499999934 + } + }, + { + "id": "pool-none-4c-point-read", + "group": "pool-scale", + "description": "4-connection none pool, concurrent point reads", + "settings": { + "authorizer": "none", + "connections": 4, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.67996700000003, + "operationsPerMs": 132.13536417107576, + "rowsPerMs": 132.13536417107576, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.015658000000257744, + "meanDelayMs": 0.0032494285712475956 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.89895500000057, + "operationsPerMs": 130.04077883763082, + "rowsPerMs": 130.04077883763082, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.005338000000847387, + "meanDelayMs": 0.0017690000001623827 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.604589999999, + "operationsPerMs": 130.54048066832718, + "rowsPerMs": 130.54048066832718, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.01936800000112271, + "meanDelayMs": 0.007610571428293562 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.97699499999726, + "operationsPerMs": 126.61915029813868, + "rowsPerMs": 126.61915029813868, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.03017800000088755, + "meanDelayMs": 0.0046547142857369704 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 72.64321100000234, + "operationsPerMs": 137.65911311381427, + "rowsPerMs": 137.65911311381427, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.018897999998443993, + "meanDelayMs": 0.0053251428570157645 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.53970900000058, + "operationsPerMs": 127.3241284863931, + "rowsPerMs": 127.3241284863931, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.01282799999898998, + "meanDelayMs": 0.0024389999998675194 + } + } + ], + "summary": { + "medianOperationsPerMs": 130.290629752979, + "medianRowsPerMs": 130.290629752979, + "medianElapsedMs": 76.75177249999979, + "relativeMarginOfErrorPct": 5.655420788743873, + "minOperationsPerMs": 126.61915029813868, + "maxOperationsPerMs": 137.65911311381427, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 0.03017800000088755 + } + }, + { + "id": "pool-strict-1c-point-read", + "group": "pool-scale", + "description": "1-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 1, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 246.20119300000079, + "operationsPerMs": 40.61718742362052, + "rowsPerMs": 40.61718742362052, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.02517699999953038, + "meanDelayMs": 0.005774416666554316 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 256.4729439999992, + "operationsPerMs": 38.990467548109216, + "rowsPerMs": 38.990467548109216, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.11105900000075053, + "meanDelayMs": 0.008482200000071317 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 257.21237499999916, + "operationsPerMs": 38.87837822733075, + "rowsPerMs": 38.87837822733075, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.02545800000007148, + "meanDelayMs": 0.00586167999994359 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 243.86782900000253, + "operationsPerMs": 41.00581877078955, + "rowsPerMs": 41.00581877078955, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.03882799999701092, + "meanDelayMs": 0.005780208333059515 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 250.49681599999894, + "operationsPerMs": 39.92066709542544, + "rowsPerMs": 39.92066709542544, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.46170400000119116, + "meanDelayMs": 0.025724279999849387 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 276.8644349999995, + "operationsPerMs": 36.11875970996426, + "rowsPerMs": 36.11875970996426, + "eventLoop": { + "heartbeats": 27, + "intervalMs": 10, + "maxDelayMs": 0.03999800000019604, + "meanDelayMs": 0.007384444445051486 + } + } + ], + "summary": { + "medianOperationsPerMs": 39.45556732176733, + "medianRowsPerMs": 39.45556732176733, + "medianElapsedMs": 253.48487999999907, + "relativeMarginOfErrorPct": 8.457127443107842, + "minOperationsPerMs": 36.11875970996426, + "maxOperationsPerMs": 41.00581877078955, + "medianEventLoopHeartbeats": 25, + "maxEventLoopDelayMs": 0.46170400000119116 + } + }, + { + "id": "pool-strict-2c-point-read", + "group": "pool-scale", + "description": "2-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 2, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 120.4394169999996, + "operationsPerMs": 83.02929596545651, + "rowsPerMs": 83.02929596545651, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.021108000000822358, + "meanDelayMs": 0.004006416666773778 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 125.91169799999989, + "operationsPerMs": 79.42073817478031, + "rowsPerMs": 79.42073817478031, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.054969000000710366, + "meanDelayMs": 0.009727583333339377 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 138.05763699999807, + "operationsPerMs": 72.43351557581809, + "rowsPerMs": 72.43351557581809, + "eventLoop": { + "heartbeats": 13, + "intervalMs": 10, + "maxDelayMs": 0.19483000000036554, + "meanDelayMs": 0.02196715384567282 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 129.61066199999914, + "operationsPerMs": 77.15414646983338, + "rowsPerMs": 77.15414646983338, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.01027800000156276, + "meanDelayMs": 0.0025585000000016103 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 131.26408599999922, + "operationsPerMs": 76.18230016091422, + "rowsPerMs": 76.18230016091422, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 2.836418999999296, + "meanDelayMs": 0.23823333333348273 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 130.6233779999966, + "operationsPerMs": 76.55597453619873, + "rowsPerMs": 76.55597453619873, + "eventLoop": { + "heartbeats": 13, + "intervalMs": 10, + "maxDelayMs": 0.01926699999603443, + "meanDelayMs": 0.003023692307649001 + } + } + ], + "summary": { + "medianOperationsPerMs": 76.85506050301606, + "medianRowsPerMs": 76.85506050301606, + "medianElapsedMs": 130.11701999999786, + "relativeMarginOfErrorPct": 8.03360952685497, + "minOperationsPerMs": 72.43351557581809, + "maxOperationsPerMs": 83.02929596545651, + "medianEventLoopHeartbeats": 12, + "maxEventLoopDelayMs": 2.836418999999296 + } + }, + { + "id": "pool-strict-4c-point-read", + "group": "pool-scale", + "description": "4-connection strict pool, concurrent point reads", + "settings": { + "authorizer": "strict", + "connections": 4, + "operation": "get" + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 72.59081100000003, + "operationsPerMs": 137.75848295729875, + "rowsPerMs": 137.75848295729875, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.015988999999535736, + "meanDelayMs": 0.003430571428647714 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 72.31601699999919, + "operationsPerMs": 138.28195211581013, + "rowsPerMs": 138.28195211581013, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.49219499999890104, + "meanDelayMs": 0.07219842857141755 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.29048600000169, + "operationsPerMs": 127.72944084163412, + "rowsPerMs": 127.72944084163412, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.016557999999349704, + "meanDelayMs": 0.003812714285491633 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 77.38292200000069, + "operationsPerMs": 129.22747993413728, + "rowsPerMs": 129.22747993413728, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 1.1049039999998058, + "meanDelayMs": 0.16113428571406985 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.07975099999749, + "operationsPerMs": 128.07417892508803, + "rowsPerMs": 128.07417892508803, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.013297999998030718, + "meanDelayMs": 0.0018997142854329599 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 74.90443500000401, + "operationsPerMs": 133.50344342093314, + "rowsPerMs": 133.50344342093314, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.00842700000066543, + "meanDelayMs": 0.0024331428576260805 + } + } + ], + "summary": { + "medianOperationsPerMs": 131.36546167753522, + "medianRowsPerMs": 131.36546167753522, + "medianElapsedMs": 76.14367850000235, + "relativeMarginOfErrorPct": 5.26507527165163, + "minOperationsPerMs": 127.72944084163412, + "maxOperationsPerMs": 138.28195211581013, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 1.1049039999998058 + } + }, + { + "id": "pool-none-1c-batch-10", + "group": "batch", + "description": "One-connection none pool, explicit get batches of 10", + "settings": { + "authorizer": "none", + "connections": 1, + "batchSize": 10 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 95.22865499999898, + "operationsPerMs": 105.01040889425674, + "rowsPerMs": 105.01040889425674, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.09082799999850977, + "meanDelayMs": 0.019395999999687774 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 93.90302599999995, + "operationsPerMs": 106.49284081643977, + "rowsPerMs": 106.49284081643977, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.05886900000041351, + "meanDelayMs": 0.009821333333295316 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 102.5911840000008, + "operationsPerMs": 97.47426250583015, + "rowsPerMs": 97.47426250583015, + "eventLoop": { + "heartbeats": 10, + "intervalMs": 10, + "maxDelayMs": 0.05898900000102003, + "meanDelayMs": 0.017757800000254065 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 96.30332099999941, + "operationsPerMs": 103.83857894163442, + "rowsPerMs": 103.83857894163442, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.09103799999866169, + "meanDelayMs": 0.01330822222169243 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 96.58119499999884, + "operationsPerMs": 103.53982470397182, + "rowsPerMs": 103.53982470397182, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.057558000000426546, + "meanDelayMs": 0.01770444444466395 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 93.47407899999962, + "operationsPerMs": 106.98153014163468, + "rowsPerMs": 106.98153014163468, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.07470899999316316, + "meanDelayMs": 0.014404999999290643 + } + } + ], + "summary": { + "medianOperationsPerMs": 104.42449391794558, + "medianRowsPerMs": 104.42449391794558, + "medianElapsedMs": 95.7659879999992, + "relativeMarginOfErrorPct": 6.655748236210524, + "minOperationsPerMs": 97.47426250583015, + "maxOperationsPerMs": 106.98153014163468, + "medianEventLoopHeartbeats": 9, + "maxEventLoopDelayMs": 0.09103799999866169 + } + }, + { + "id": "pool-none-1c-batch-100", + "group": "batch", + "description": "One-connection none pool, explicit get batches of 100", + "settings": { + "authorizer": "none", + "connections": 1, + "batchSize": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 76.65837099999953, + "operationsPerMs": 130.44889774660174, + "rowsPerMs": 130.44889774660174, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.9618519999985438, + "meanDelayMs": 0.2781682857143356 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.15527400000065, + "operationsPerMs": 127.95041829166792, + "rowsPerMs": 127.95041829166792, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.4424140000010084, + "meanDelayMs": 0.12302499999974056 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 78.49306799999977, + "operationsPerMs": 127.39978516319466, + "rowsPerMs": 127.39978516319466, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 2.3578419999976177, + "meanDelayMs": 0.4108664285709632 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 74.92383600000176, + "operationsPerMs": 133.46887364389306, + "rowsPerMs": 133.46887364389306, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.526255000000674, + "meanDelayMs": 0.19650042857184807 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 79.80393800000093, + "operationsPerMs": 125.30709950679231, + "rowsPerMs": 125.30709950679231, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.5736560000004829, + "meanDelayMs": 0.17319128571450296 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 75.54800499999692, + "operationsPerMs": 132.36616903385348, + "rowsPerMs": 132.36616903385348, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.589535999999498, + "meanDelayMs": 0.08421942857135686 + } + } + ], + "summary": { + "medianOperationsPerMs": 129.19965801913483, + "medianRowsPerMs": 129.19965801913483, + "medianElapsedMs": 77.40682250000009, + "relativeMarginOfErrorPct": 3.30435520512442, + "minOperationsPerMs": 125.30709950679231, + "maxOperationsPerMs": 133.46887364389306, + "medianEventLoopHeartbeats": 7, + "maxEventLoopDelayMs": 2.3578419999976177 + } + }, + { + "id": "pool-none-all-1-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 1 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 1, + "operations": 10000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 309.52976599999965, + "operationsPerMs": 32.3070705904259, + "rowsPerMs": 32.3070705904259, + "eventLoop": { + "heartbeats": 31, + "intervalMs": 10, + "maxDelayMs": 0.04085699999995995, + "meanDelayMs": 0.008061064516075403 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 313.71792800000003, + "operationsPerMs": 31.875768349458177, + "rowsPerMs": 31.875768349458177, + "eventLoop": { + "heartbeats": 31, + "intervalMs": 10, + "maxDelayMs": 0.1540899999999965, + "meanDelayMs": 0.010439612903346348 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 307.238443000002, + "operationsPerMs": 32.548010276174764, + "rowsPerMs": 32.548010276174764, + "eventLoop": { + "heartbeats": 30, + "intervalMs": 10, + "maxDelayMs": 0.05692800000178977, + "meanDelayMs": 0.008520833333265424 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 330.27420300000085, + "operationsPerMs": 30.277871868787688, + "rowsPerMs": 30.277871868787688, + "eventLoop": { + "heartbeats": 32, + "intervalMs": 10, + "maxDelayMs": 1.6525130000009085, + "meanDelayMs": 0.057640406250357046 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 326.90340300000025, + "operationsPerMs": 30.590076176111243, + "rowsPerMs": 30.590076176111243, + "eventLoop": { + "heartbeats": 32, + "intervalMs": 10, + "maxDelayMs": 1.1888049999979557, + "meanDelayMs": 0.044920062500068525 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 324.8154330000034, + "operationsPerMs": 30.786714497029134, + "rowsPerMs": 30.786714497029134, + "eventLoop": { + "heartbeats": 32, + "intervalMs": 10, + "maxDelayMs": 0.04038800000125775, + "meanDelayMs": 0.008230031250604952 + } + } + ], + "summary": { + "medianOperationsPerMs": 31.331241423243654, + "medianRowsPerMs": 31.331241423243654, + "medianElapsedMs": 319.2666805000017, + "relativeMarginOfErrorPct": 3.8835641285136835, + "minOperationsPerMs": 30.277871868787688, + "maxOperationsPerMs": 32.548010276174764, + "medianEventLoopHeartbeats": 31.5, + "maxEventLoopDelayMs": 1.6525130000009085 + } + }, + { + "id": "pool-none-all-100-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 100 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 100, + "operations": 1000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 146.63149300000077, + "operationsPerMs": 6.819817349878547, + "rowsPerMs": 681.9817349878547, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.13010899999972025, + "meanDelayMs": 0.02474071428579399 + } + }, + { + "sample": 2, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 147.6362079999999, + "operationsPerMs": 6.773406155216346, + "rowsPerMs": 677.3406155216346, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.08833899999990535, + "meanDelayMs": 0.020105642857093438 + } + }, + { + "sample": 3, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 154.2821060000024, + "operationsPerMs": 6.481633067673996, + "rowsPerMs": 648.1633067673995, + "eventLoop": { + "heartbeats": 15, + "intervalMs": 10, + "maxDelayMs": 0.36799299999984214, + "meanDelayMs": 0.044727533333207246 + } + }, + { + "sample": 4, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 159.2928409999986, + "operationsPerMs": 6.27774602877482, + "rowsPerMs": 627.774602877482, + "eventLoop": { + "heartbeats": 15, + "intervalMs": 10, + "maxDelayMs": 1.7376930000027642, + "meanDelayMs": 0.17467879999991662 + } + }, + { + "sample": 5, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 161.2642999999989, + "operationsPerMs": 6.201000469415777, + "rowsPerMs": 620.1000469415777, + "eventLoop": { + "heartbeats": 15, + "intervalMs": 10, + "maxDelayMs": 1.8808250000001863, + "meanDelayMs": 0.21456486666672087 + } + }, + { + "sample": 6, + "logicalOperations": 1000, + "materializedRows": 100000, + "elapsedMs": 149.02381899999455, + "operationsPerMs": 6.710336687855493, + "rowsPerMs": 671.0336687855494, + "eventLoop": { + "heartbeats": 14, + "intervalMs": 10, + "maxDelayMs": 0.09377900000254158, + "meanDelayMs": 0.022538500000726863 + } + } + ], + "summary": { + "medianOperationsPerMs": 6.595984877764744, + "medianRowsPerMs": 659.5984877764745, + "medianElapsedMs": 151.65296249999847, + "relativeMarginOfErrorPct": 5.9882552138721685, + "minOperationsPerMs": 6.201000469415777, + "maxOperationsPerMs": 6.819817349878547, + "medianEventLoopHeartbeats": 14.5, + "maxEventLoopDelayMs": 1.8808250000001863 + } + }, + { + "id": "pool-none-all-1000-rows", + "group": "result-size", + "description": "One-connection none pool, all() materializing 1000 rows", + "settings": { + "authorizer": "none", + "connections": 1, + "resultSize": 1000, + "operations": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 114.15500400000019, + "operationsPerMs": 0.8760018965090645, + "rowsPerMs": 876.0018965090644, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 0.8944210000008752, + "meanDelayMs": 0.24873700000005722 + } + }, + { + "sample": 2, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 113.54968499999995, + "operationsPerMs": 0.8806717517534288, + "rowsPerMs": 880.6717517534288, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 1.049872999999934, + "meanDelayMs": 0.39781981818205997 + } + }, + { + "sample": 3, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 119.09620799999902, + "operationsPerMs": 0.8396572962255928, + "rowsPerMs": 839.6572962255929, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 1.1746950000015204, + "meanDelayMs": 0.2885483636362716 + } + }, + { + "sample": 4, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 120.90042399999948, + "operationsPerMs": 0.8271269586283704, + "rowsPerMs": 827.1269586283704, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.8101499999975204, + "meanDelayMs": 0.14329191666638508 + } + }, + { + "sample": 5, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 118.04575100000147, + "operationsPerMs": 0.8471291779066131, + "rowsPerMs": 847.1291779066131, + "eventLoop": { + "heartbeats": 11, + "intervalMs": 10, + "maxDelayMs": 1.2379649999966205, + "meanDelayMs": 0.33074399999995047 + } + }, + { + "sample": 6, + "logicalOperations": 100, + "materializedRows": 100000, + "elapsedMs": 122.52011800000037, + "operationsPerMs": 0.8161924884858477, + "rowsPerMs": 816.1924884858477, + "eventLoop": { + "heartbeats": 12, + "intervalMs": 10, + "maxDelayMs": 0.4915250000049127, + "meanDelayMs": 0.11955083333365717 + } + } + ], + "summary": { + "medianOperationsPerMs": 0.843393237066103, + "medianRowsPerMs": 843.393237066103, + "medianElapsedMs": 118.57097950000025, + "relativeMarginOfErrorPct": 4.420063269300795, + "minOperationsPerMs": 0.8161924884858477, + "maxOperationsPerMs": 0.8806717517534288, + "medianEventLoopHeartbeats": 11, + "maxEventLoopDelayMs": 1.2379649999966205 + } + }, + { + "id": "pool-strict-2c-mix-100r-0w", + "group": "read-write", + "description": "Two-connection strict pool, 100% reads and 0% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 100, + "writesPct": 0 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 30.02809300000081, + "operationsPerMs": 66.60429618357537, + "rowsPerMs": 66.60429618357537, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0.003897000000506523, + "meanDelayMs": 0.001299000000168841 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 27.184771000000183, + "operationsPerMs": 73.57060318808595, + "rowsPerMs": 73.57060318808595, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 26.689363999998022, + "operationsPerMs": 74.93621803802249, + "rowsPerMs": 74.93621803802249, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 28.09540399999969, + "operationsPerMs": 71.1860203184842, + "rowsPerMs": 71.1860203184842, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.006508000002213521, + "meanDelayMs": 0.0032540000011067605 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 25.790930999999546, + "operationsPerMs": 77.54663839006182, + "rowsPerMs": 77.54663839006182, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.0015670000029786024, + "meanDelayMs": 0.0007835000014893012 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 2000, + "elapsedMs": 27.178481000002648, + "operationsPerMs": 73.58762986054317, + "rowsPerMs": 73.58762986054317, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + } + ], + "summary": { + "medianOperationsPerMs": 73.57911652431457, + "medianRowsPerMs": 73.57911652431457, + "medianElapsedMs": 27.181626000001415, + "relativeMarginOfErrorPct": 9.479347769056641, + "minOperationsPerMs": 66.60429618357537, + "maxOperationsPerMs": 77.54663839006182, + "medianEventLoopHeartbeats": 2, + "maxEventLoopDelayMs": 0.006508000002213521 + } + }, + { + "id": "pool-strict-2c-mix-90r-10w", + "group": "read-write", + "description": "Two-connection strict pool, 90% reads and 10% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 90, + "writesPct": 10 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 28.39328800000112, + "operationsPerMs": 70.4391826688026, + "rowsPerMs": 63.39526440192235, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0.019718000001375913, + "meanDelayMs": 0.009859000000687956 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 30.415138000000297, + "operationsPerMs": 65.7567294286148, + "rowsPerMs": 59.181056485753324, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 29.789529999998194, + "operationsPerMs": 67.13768226622311, + "rowsPerMs": 60.423914039600795, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 27.886101000000053, + "operationsPerMs": 71.72031687040064, + "rowsPerMs": 64.54828518336058, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 29.817800000000716, + "operationsPerMs": 67.07402960647505, + "rowsPerMs": 60.36662664582755, + "eventLoop": { + "heartbeats": 3, + "intervalMs": 10, + "maxDelayMs": 0.008566999997128733, + "meanDelayMs": 0.0028556666657095775 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 1800, + "elapsedMs": 29.350273000003654, + "operationsPerMs": 68.14246668164725, + "rowsPerMs": 61.32822001348253, + "eventLoop": { + "heartbeats": 2, + "intervalMs": 10, + "maxDelayMs": 0, + "meanDelayMs": 0 + } + } + ], + "summary": { + "medianOperationsPerMs": 67.64007447393519, + "medianRowsPerMs": 60.87606702654166, + "medianElapsedMs": 29.569901500000924, + "relativeMarginOfErrorPct": 6.032285487854918, + "minOperationsPerMs": 65.7567294286148, + "maxOperationsPerMs": 71.72031687040064, + "medianEventLoopHeartbeats": 2.5, + "maxEventLoopDelayMs": 0.019718000001375913 + } + }, + { + "id": "pool-strict-2c-mix-0r-100w", + "group": "read-write", + "description": "Two-connection strict pool, 0% reads and 100% writes", + "settings": { + "authorizer": "strict", + "connections": 2, + "readsPct": 0, + "writesPct": 100 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 76.28576600000088, + "operationsPerMs": 26.21721069170331, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.03155799999876763, + "meanDelayMs": 0.008877714285647795 + } + }, + { + "sample": 2, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 81.36634099999901, + "operationsPerMs": 24.58018850816979, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.5707849999998871, + "meanDelayMs": 0.12582149999980174 + } + }, + { + "sample": 3, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 75.55561500000113, + "operationsPerMs": 26.47056740918554, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.015057000000524567, + "meanDelayMs": 0.00554757142890594 + } + }, + { + "sample": 4, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 81.36883999999918, + "operationsPerMs": 24.579433601364112, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.45933400000285474, + "meanDelayMs": 0.11518424999985655 + } + }, + { + "sample": 5, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 89.7813750000023, + "operationsPerMs": 22.276335153030892, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 9, + "intervalMs": 10, + "maxDelayMs": 0.8915009999982431, + "meanDelayMs": 0.1307531111108094 + } + }, + { + "sample": 6, + "logicalOperations": 2000, + "materializedRows": 0, + "elapsedMs": 83.61922399999457, + "operationsPerMs": 23.91794499312897, + "rowsPerMs": 0, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.028297999997448642, + "meanDelayMs": 0.010757749999356747 + } + } + ], + "summary": { + "medianOperationsPerMs": 24.57981105476695, + "medianRowsPerMs": 0, + "medianElapsedMs": 81.3675904999991, + "relativeMarginOfErrorPct": 9.371414192743872, + "minOperationsPerMs": 22.276335153030892, + "maxOperationsPerMs": 26.47056740918554, + "medianEventLoopHeartbeats": 8, + "maxEventLoopDelayMs": 0.8915009999982431 + } + }, + { + "id": "pool-none-repeated-identical-sql", + "group": "repeated-sql", + "description": "Repeated identical SQL text (prepare cost/cache baseline)", + "settings": { + "authorizer": "none", + "connections": 1, + "variants": 1 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 245.55165300000044, + "operationsPerMs": 40.724629127216595, + "rowsPerMs": 40.724629127216595, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.02208700000119279, + "meanDelayMs": 0.004486041666647604 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 248.60225700000046, + "operationsPerMs": 40.22489626874136, + "rowsPerMs": 40.22489626874136, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.21207999999933236, + "meanDelayMs": 0.013081249999989572 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 245.5300520000019, + "operationsPerMs": 40.72821195834685, + "rowsPerMs": 40.72821195834685, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.032327999997505685, + "meanDelayMs": 0.005311208333144653 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 245.50837200000024, + "operationsPerMs": 40.73180852667619, + "rowsPerMs": 40.73180852667619, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 1.4369490000026417, + "meanDelayMs": 0.06593095833341067 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 255.0193130000007, + "operationsPerMs": 39.21271641101148, + "rowsPerMs": 39.21271641101148, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 1.5061410000016622, + "meanDelayMs": 0.06539987999989534 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 254.73081799999636, + "operationsPerMs": 39.257126713266956, + "rowsPerMs": 39.257126713266956, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.017048000001523178, + "meanDelayMs": 0.004896439999865834 + } + } + ], + "summary": { + "medianOperationsPerMs": 40.474762697978974, + "medianRowsPerMs": 40.474762697978974, + "medianElapsedMs": 247.07695500000045, + "relativeMarginOfErrorPct": 3.1181066986972463, + "minOperationsPerMs": 39.21271641101148, + "maxOperationsPerMs": 40.73180852667619, + "medianEventLoopHeartbeats": 24, + "maxEventLoopDelayMs": 1.5061410000016622 + } + }, + { + "id": "pool-none-rotating-sql-32", + "group": "repeated-sql", + "description": "Equivalent SQL rotated across 32 distinct texts", + "settings": { + "authorizer": "none", + "connections": 1, + "variants": 32 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 252.2777819999992, + "operationsPerMs": 39.63884540573625, + "rowsPerMs": 39.63884540573625, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.02224700000078883, + "meanDelayMs": 0.004973119999995106 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 259.71899200000007, + "operationsPerMs": 38.503152668943045, + "rowsPerMs": 38.503152668943045, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 0.021298000001479522, + "meanDelayMs": 0.0036085769231179324 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 255.46157000000312, + "operationsPerMs": 39.1448310601077, + "rowsPerMs": 39.1448310601077, + "eventLoop": { + "heartbeats": 25, + "intervalMs": 10, + "maxDelayMs": 0.06204799999977695, + "meanDelayMs": 0.007742479999869829 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 262.5326739999982, + "operationsPerMs": 38.09049688039999, + "rowsPerMs": 38.09049688039999, + "eventLoop": { + "heartbeats": 26, + "intervalMs": 10, + "maxDelayMs": 0.03990899999917019, + "meanDelayMs": 0.005910730769196211 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 244.52297800000088, + "operationsPerMs": 40.89595211784131, + "rowsPerMs": 40.89595211784131, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.29762200000186567, + "meanDelayMs": 0.01643204166657597 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 248.21430200000032, + "operationsPerMs": 40.28776714083134, + "rowsPerMs": 40.28776714083134, + "eventLoop": { + "heartbeats": 24, + "intervalMs": 10, + "maxDelayMs": 0.18712000000232365, + "meanDelayMs": 0.011996333333627263 + } + } + ], + "summary": { + "medianOperationsPerMs": 39.39183823292197, + "medianRowsPerMs": 39.39183823292197, + "medianElapsedMs": 253.86967600000116, + "relativeMarginOfErrorPct": 3.8183389057032358, + "minOperationsPerMs": 38.09049688039999, + "maxOperationsPerMs": 40.89595211784131, + "medianEventLoopHeartbeats": 25, + "maxEventLoopDelayMs": 0.29762200000186567 + } + }, + { + "id": "pool-none-4c-point-read-with-crypto", + "group": "contention", + "description": "Four-connection pool competing with crypto libuv work", + "settings": { + "authorizer": "none", + "connections": 4, + "contention": "crypto", + "contentionWorkers": 4, + "cryptoIterations": 10000 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 80.38074600000073, + "operationsPerMs": 124.40790236009889, + "rowsPerMs": 124.40790236009889, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.020457999999052845, + "meanDelayMs": 0.004918124999903739 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 214, + "elapsedMs": 81.46617100000003 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 77.95432000000073, + "operationsPerMs": 128.28025438487444, + "rowsPerMs": 128.28025438487444, + "eventLoop": { + "heartbeats": 7, + "intervalMs": 10, + "maxDelayMs": 0.009087000000363332, + "meanDelayMs": 0.004845285714379445 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 204, + "elapsedMs": 79.1674579999999 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 86.85564099999829, + "operationsPerMs": 115.13356973555923, + "rowsPerMs": 115.13356973555923, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 1.54099099999803, + "meanDelayMs": 0.1942186249993938 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 234, + "elapsedMs": 87.92921699999715 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 81.482552999998, + "operationsPerMs": 122.72565882907774, + "rowsPerMs": 122.72565882907774, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.009108000002015615, + "meanDelayMs": 0.0020893750001960143 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 216, + "elapsedMs": 82.09523099999933 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 86.14773100000093, + "operationsPerMs": 116.07966784406536, + "rowsPerMs": 116.07966784406536, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.020838000000367174, + "meanDelayMs": 0.005400000000008731 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 208, + "elapsedMs": 87.40676999999778 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 80.00537099999929, + "operationsPerMs": 124.99160837589378, + "rowsPerMs": 124.99160837589378, + "eventLoop": { + "heartbeats": 8, + "intervalMs": 10, + "maxDelayMs": 0.013038000004598871, + "meanDelayMs": 0.0034553750010672957 + }, + "competingWork": { + "kind": "crypto", + "workers": 4, + "completed": 204, + "elapsedMs": 81.11654700000508 + } + } + ], + "summary": { + "medianOperationsPerMs": 123.56678059458832, + "medianRowsPerMs": 123.56678059458832, + "medianElapsedMs": 80.93164949999937, + "relativeMarginOfErrorPct": 6.824820407596204, + "minOperationsPerMs": 115.13356973555923, + "maxOperationsPerMs": 128.28025438487444, + "medianEventLoopHeartbeats": 8, + "maxEventLoopDelayMs": 1.54099099999803, + "competingWorkCompleted": 1280 + } + }, + { + "id": "pool-none-4c-point-read-with-fs", + "group": "contention", + "description": "Four-connection pool competing with fs libuv work", + "settings": { + "authorizer": "none", + "connections": 4, + "contention": "fs", + "contentionWorkers": 4, + "competingFileBytes": 1048576 + }, + "samples": [ + { + "sample": 1, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 225.2562939999989, + "operationsPerMs": 44.39387607078384, + "rowsPerMs": 44.39387607078384, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 0.30617199999869626, + "meanDelayMs": 0.06841245454548202 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1502, + "elapsedMs": 225.51514700000007 + } + }, + { + "sample": 2, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 212.03398799999923, + "operationsPerMs": 47.16225023320335, + "rowsPerMs": 47.16225023320335, + "eventLoop": { + "heartbeats": 21, + "intervalMs": 10, + "maxDelayMs": 0.18158100000073318, + "meanDelayMs": 0.03151242857148602 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1396, + "elapsedMs": 213.29208700000163 + } + }, + { + "sample": 3, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 226.30506899999818, + "operationsPerMs": 44.18813968325244, + "rowsPerMs": 44.18813968325244, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 0.4200440000022354, + "meanDelayMs": 0.041591818182000265 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1519, + "elapsedMs": 226.52463200000057 + } + }, + { + "sample": 4, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 225.38697500000126, + "operationsPerMs": 44.36813618000749, + "rowsPerMs": 44.36813618000749, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 0.6260179999990214, + "meanDelayMs": 0.06904772727268044 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1484, + "elapsedMs": 225.61369899999772 + } + }, + { + "sample": 5, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 203.24519900000087, + "operationsPerMs": 49.20165420487968, + "rowsPerMs": 49.20165420487968, + "eventLoop": { + "heartbeats": 20, + "intervalMs": 10, + "maxDelayMs": 0.8064889999986917, + "meanDelayMs": 0.12243200000011711 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1391, + "elapsedMs": 203.5048029999998 + } + }, + { + "sample": 6, + "logicalOperations": 10000, + "materializedRows": 10000, + "elapsedMs": 224.11574600000313, + "operationsPerMs": 44.61980105583416, + "rowsPerMs": 44.61980105583416, + "eventLoop": { + "heartbeats": 22, + "intervalMs": 10, + "maxDelayMs": 2.656046999996761, + "meanDelayMs": 0.2538599545451606 + }, + "competingWork": { + "kind": "fs", + "workers": 4, + "completed": 1396, + "elapsedMs": 224.30942000000505 + } + } + ], + "summary": { + "medianOperationsPerMs": 44.506838563309, + "medianRowsPerMs": 44.506838563309, + "medianElapsedMs": 224.686020000001, + "relativeMarginOfErrorPct": 10.548526458226213, + "minOperationsPerMs": 44.18813968325244, + "maxOperationsPerMs": 49.20165420487968, + "medianEventLoopHeartbeats": 22, + "maxEventLoopDelayMs": 2.656046999996761, + "competingWorkCompleted": 8688 + } + } + ] +} diff --git a/binding.gyp b/binding.gyp index b641807..b622df8 100644 --- a/binding.gyp +++ b/binding.gyp @@ -5,6 +5,7 @@ "target_name": "phstr_sqlite", "sources": [ "src/binding.cpp", + "src/async_pool_impl.cpp", "src/sqlite_impl.cpp", "src/user_function.cpp", "src/aggregate_function.cpp", @@ -60,7 +61,11 @@ "SQLITE_OMIT_DEPRECATED", "SQLITE_OMIT_SHARED_CACHE", "SQLITE_SOUNDEX", - # "SQLITE_THREADSAFE=2", # default is SQLITE_THREADSAFE=1 (serialized) + # Keep SQLite's SQLITE_THREADSAFE=1 serialized default. Setting =2 + # would change every DatabaseSync connection but would not speed pool + # handles, which explicitly request FULLMUTEX. See the threading-mode + # rationale in doc/build-flags.md. + # "SQLITE_THREADSAFE=2", "SQLITE_USE_URI=1" # https://www.sqlite.org/uri.html ], # GYP flag scoping (make generator): diff --git a/doc/build-flags.md b/doc/build-flags.md index a2b1793..46458f6 100644 --- a/doc/build-flags.md +++ b/doc/build-flags.md @@ -59,6 +59,53 @@ These include the majority of [SQLite's recommended compile options](https://sql | `SQLITE_LIKE_DOESNT_MATCH_BLOBS` | [LIKE doesn't match BLOB data](https://sqlite.org/compile.html#:~:text=SQLITE_LIKE_DOESNT_MATCH_BLOBS) | ✅ | ❌ | LIKE and GLOB operators always return FALSE if either operand is a BLOB | | `SQLITE_ENABLE_API_ARMOR` | [Validate C-API arguments](https://sqlite.org/compile.html#enable_api_armor) | ✅ | ❌ | Misused API calls return `SQLITE_MISUSE` instead of risking undefined behavior; defense-in-depth for hosted extensions (e.g. sqlite-vec) | +## SQLite threading modes and process concurrency + +This build keeps SQLite's default `SQLITE_THREADSAFE=1` configuration. SQLite +therefore includes its mutex code and, unless an open flag overrides it, opens +connections in serialized mode. + +Threading mode answers a narrow question: **may two threads call the same +`sqlite3*` connection at the same time?** It does not control whether separate +connections or separate processes may access the same database file. SQLite's +file locks, journal mode, and transaction state govern that concurrency. + +| Setting | Effect | Tradeoff | +| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `SQLITE_THREADSAFE=1` | Includes mutexes and defaults connections to serialized mode | Safest global default; a connection mutex guards accidental same-handle concurrency | +| `SQLITE_THREADSAFE=2` | Includes mutexes but defaults connections to multi-thread mode | Avoids the connection mutex unless `SQLITE_OPEN_FULLMUTEX` overrides it; every caller must serialize each handle | +| `SQLITE_THREADSAFE=0` | Omits mutex code | Smallest overhead, but serialized mode cannot be restored at runtime or per connection | +| `SQLITE_OPEN_FULLMUTEX` | Selects serialized mode for one connection | Multiple threads may safely enter that handle; the mutex adds work at SQLite API boundaries | +| `SQLITE_OPEN_NOMUTEX` | Selects multi-thread mode for one connection | Different handles remain concurrent, but the application must prevent simultaneous use of this handle and its statements | + +The stable `DatabaseSync` API does not pass either mutex open flag, so it +inherits the serialized default. Keep that behavior: asynchronous backup uses a +`DatabaseSync` source handle on a worker thread while the JavaScript object is +still alive. Changing the global default to `SQLITE_THREADSAFE=2` would remove +SQLite's same-handle protection from this and every other stable connection. + +The experimental pool currently passes `SQLITE_OPEN_FULLMUTEX` explicitly. Its +scheduler also ensures that only one native worker owns a pooled handle at a +time, so `SQLITE_OPEN_NOMUTEX` may eventually be a valid targeted optimization. +We retain `FULLMUTEX` as defense in depth until application benchmarks show that +its cost matters and concurrency/lifetime tests validate the weaker setting. +Setting `SQLITE_THREADSAFE=2` alone would not make the pool faster because its +`FULLMUTEX` open flag overrides the compile-time default. + +### PhotoStructure's two-process workload + +PhotoStructure's web and sync processes open independent SQLite connections; +they cannot share a `sqlite3*` pointer. `FULLMUTEX` therefore does not serialize +the two processes, nor does it serialize two different handles in one pool. + +For the read-heavy web process, a two-connection pool can execute two independent +reads concurrently. For the sync process, extra connections do not create a +second writer: SQLite still permits only one writer at a time. In WAL mode, web +readers can normally overlap the sync writer. Configure `busy_timeout` on every +connection so lock contention waits on a libuv worker instead of immediately +failing. Whether a second connection helps incremental sync reads is a workload +question; measure it against the extra cache, libuv-thread, and lock contention. + ### Platform-specific build settings #### Standard build flags (all platforms) @@ -181,7 +228,8 @@ so its absence on ARM64 is correct. - **STAT4**: Better query optimization with column statistics - **16MB Cache**: Larger default cache for better performance -- **Multi-thread Mode**: Optimized for Node.js worker threads +- **Serialized Threading Default**: Protects a connection used across threads; + independent handles and processes remain concurrent ## Features intentionally omitted @@ -215,7 +263,9 @@ These SQLite features are available but not enabled in our build: 1. **Larger Cache**: 16MB default vs 2MB improves read performance 2. **STAT4**: Better query optimization with advanced statistics -3. **Multi-thread Mode**: Optimized for concurrent access patterns +3. **Independent Connection Concurrency**: Separate pool handles and processes + can run concurrently; WAL and file locks, not connection mutex mode, govern + database-level overlap 4. **JSON Functions**: Faster than external JSON parsing ### Performance considerations diff --git a/doc/done/20260808-P10-experimental-async-database-pool.md b/doc/done/20260808-P10-experimental-async-database-pool.md new file mode 100644 index 0000000..a929753 --- /dev/null +++ b/doc/done/20260808-P10-experimental-async-database-pool.md @@ -0,0 +1,1057 @@ +# TPP: Experimental async `DatabasePool` + +## Current phase + +**Complete locally; awaiting a user-authorized commit (2026-08-09)**: the +implementation, focused and full test suites, memory tools, Linux glibc/musl +validation, packaging, documentation, benchmarks, and independent review all +pass. The existing GitHub Actions matrix will provide the unpublished branch's +macOS, Windows, and arm64 execution when the branch is published; this plan does +not claim that remote run occurred. + +**Next**: review the staged feature diff, then commit only after explicit user +approval. Do not stage the unrelated dependency updates currently left in the +working tree (`package.json` and `benchmark/package.json`) or the unrelated +QEMU action annotation. `package.json` and `benchmark/package.json` are `MM`, so +running `git add` on either whole file would mix those later edits into this +feature. Keep the superseded `doc/todo/P10-experimental-async-database.md` +untracked. The isolated worktree is `/tmp/node-sqlite-p10-async-pool` on +`feat/experimental-async-database-pool`; the source worktree remains untouched. + +- [x] Feasibility and API-boundary research +- [x] Warm-pool versus connection-per-operation benchmark comparison +- [x] Initial `DatabasePool` contract and authorizer modes agreed +- [x] Follow-up plan review adjudicated and contract gaps corrected +- [x] Executable API and behavior contract +- [x] One-connection executor/lifetime spike passes its go/no-go gate +- [x] Value transport, batches, and transaction semantics implemented +- [x] Connection setup and extension loading implemented +- [x] Multi-connection scheduling and close lifecycle implemented +- [x] Strict authorizer implemented +- [x] Experimental subpath, documentation, and benchmark runner integrated +- [x] Cross-platform, memory, and full-suite validation complete locally +- [x] Reviewed + +The complete vertical slice is implemented. Its API is confined to the +experimental subpath, and the original worktree plus the superseded untracked +research TPP remain untouched outside the isolated feature worktree. + +## Goal definition + +- **What success looks like**: `@photostructure/sqlite/experimental` exports a + fixed-size `DatabasePool` whose SQLite open, prepare, bind, step, finalize, + setup, and close work runs outside the JavaScript event loop; simple warm + workloads sustain multiple operations per millisecond on reference hardware. +- **Core problem**: SQLite is synchronous, while the stateful `Database` and + `Statement` API shape makes a safe async port carry persistent statement, + connection-affinity, transaction-callback, iteration, and GC lifetime + complexity that many async use cases do not need. +- **Solution**: expose only connection-independent `run`, `get`, `all`, and + explicit `batch` calls over warm SQLite connections. A transaction is one + complete batch submitted in one call. Do not expose connection or statement + identity. All per-connection state — PRAGMAs, attachments, and native + extensions — is declared once at `open()` and replayed identically on every + physical connection. +- **Primary consumer requirement**: the motivating workload opens read-write + pools whose connections need per-connection PRAGMA replay and native + extension loading (for example sqlite-vec, including an explicit entrypoint + symbol) — the same work its existing connection factory performs today. It + does not need JavaScript-defined SQL functions at query time; schema + migrations that register such functions run on `DatabaseSync` before the + pool opens. Extension loading during connection setup is therefore an MVP + requirement, not future work. +- **Key constraints**: preserve the stable root API exactly; stay on C++17 and + Node-API 8; never edit `src/upstream/`; never access JavaScript or Node-API + values from a worker thread; retain SQLite's serialized build initially; do + not add async iteration, JavaScript SQLite callbacks, prepared-statement + handles, or JavaScript transaction callbacks. +- **Success validation**: focused behavior/lifetime tests, CJS and ESM import + tests, lint/types, full suites, ASan/UBSan/LSan, Valgrind, Alpine, and the + platform/Node CI matrix pass. A benchmark records throughput, event-loop + responsiveness, batching gains, and `strict` versus `none` authorizer cost; + performance numbers are evidence, not timing assertions in functional tests. + +This plan supersedes an uncommitted local research draft about Node.js PR +#62015 and its lifetime/result traps. That draft was intentionally excluded +from the feature because this plan contains the settled implementation. Do not +implement both designs. + +## Required reading + +Study these before continuing: + +- `CLAUDE.md` +- `doc/reference/TPP-GUIDE.md` +- `doc/reference/SIMPLE-DESIGN.md` +- `doc/reference/TDD.md` +- `doc/internal/testing-philosophy.md` +- `doc/internal/threading.md` +- `binding.gyp` +- `src/binding.cpp` +- `src/sqlite_impl.h` and `src/sqlite_impl.cpp` +- `src/index.ts`, `tsup.config.ts`, `scripts/post-build.mjs`, and `package.json` +- `../node-addon-api/doc/async_worker.md`, `promises.md`, and + `async_context.md` +- Node-API's synchronous and asynchronous environment-cleanup hook sections in + `../node/doc/api/n-api.md` (or the corresponding official online docs) +- SQLite's [threading](https://sqlite.org/threadsafe.html), + [transactions](https://sqlite.org/lang_transaction.html), + [authorizer](https://sqlite.org/c3ref/set_authorizer.html), + [authorizer actions](https://sqlite.org/c3ref/c_alter_table.html), + [in-memory databases](https://sqlite.org/inmemorydb.html), and + [shared-cache](https://sqlite.org/sharedcache.html) documentation + +## User-facing contract + +The draft API is deliberately named as a pool, not as an async counterpart to +every `DatabaseSync` capability: + +```ts +import { DatabasePool } from "@photostructure/sqlite/experimental"; + +await using pool = await DatabasePool.open("app.db", { + connections: 2, + authorizer: "strict", + allowExtension: true, + connectionSetup: [ + { sql: "PRAGMA journal_mode=WAL" }, + { sql: "PRAGMA foreign_keys=ON" }, + { sql: "PRAGMA busy_timeout=5000" }, + { sql: "SELECT load_extension(?)", params: [vecExtensionPath] }, + { + sql: "SELECT load_extension(?, ?)", + params: [seededRandomPath, "sqlite3_seededrandom_init"], + }, + { sql: "ATTACH DATABASE ? AS analytics", params: [analyticsPath] }, + ], +}); + +await pool.run("INSERT INTO users(name) VALUES (?)", ["Ada"]); +const user = await pool.get("SELECT * FROM users WHERE id = ?", [1]); +const users = await pool.all("SELECT * FROM users ORDER BY id"); + +const results = await pool.batch( + [ + { + kind: "run", + sql: "UPDATE account SET balance=balance-? WHERE id=?", + params: [10, 1], + }, + { + kind: "run", + sql: "UPDATE account SET balance=balance+? WHERE id=?", + params: [10, 2], + }, + { kind: "get", sql: "SELECT balance FROM account WHERE id=?", params: [2] }, + ], + { transaction: "immediate" }, +); +``` + +### MVP surface and semantics + +- `DatabasePool.open(location, options)` opens and configures every physical + connection asynchronously before resolving. The fixed connection count + defaults to one; do not add min/max growth or idle timers in the MVP. Accept + the same `string | Buffer | URL` location types as `DatabaseSync`, but + normalize and copy the location before queueing native open work. +- `options.authorizer` is `"strict" | "none"` and defaults to `"strict"`. + `"none"` means exactly that no restrictive SQLite authorizer is installed; + it does not claim that arbitrary SQL is safe for a pool. +- `options.readBigInts` and `options.returnArrays` are pool-wide immutable + result policies with the same defaults and behavior as `DatabaseSync`. They + do not require connection affinity. Do not add per-call or persistent + statement configuration in the MVP. +- `connectionSetup` is declarative data, not a JavaScript callback. Each entry + is `{ sql, params? }`. The caller declares it once; the pool executes the + same ordered statements once on every physical connection before admitting + that connection. Setup must be safe to replay once on each independently + opened connection and intended for connection configuration. Each entry has + the same exactly-one-executable-statement rule as a public operation. + Database migrations belong in ordinary application batches, not in + per-connection setup — migrations that need JavaScript-defined SQL functions + must run on a `DatabaseSync` connection before the pool opens. +- `options.allowExtension` (default false) enables extension loading only + while setup statements run, so setup can load native extensions with + ordinary statements such as `SELECT load_extension(?, ?)` (parameters avoid + escaping platform-specific paths). The pool disables loading again before + the connection is admitted, so `load_extension()` is never available to user + statements in either authorizer mode. A load failure fails `open()`. + Implementation note: `DatabaseSync` uses + `SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION`, which enables only the C API; the + SQL function additionally requires `sqlite3_enable_load_extension()`, which + the pool must call around setup and then revoke. +- `run`, `get`, and `all` accept one SQL statement and zero or one parameter + container: an array for positional values or an object for named values. + Named objects use the synchronous API's default binding policy: bare or + explicitly prefixed names are accepted, conflicting bare names and unknown + keys are rejected. Reject a second executable statement; comments and + whitespace after the one statement are allowed without writing a SQL parser. +- `run` steps to completion and returns operation-local `changes`, not arbitrary + history from the leased connection. Snapshot `sqlite3_total_changes64()` + before stepping; after success, report zero when the total did not advance, + otherwise report `sqlite3_changes64()`, which preserves direct-row semantics + for ordinary INSERT/UPDATE/DELETE. Deliberately omit `lastInsertRowid`: SQLite + exposes it as connection history, so it cannot be returned coherently for + non-insert operations on a pool. Use `get`/`all` with `RETURNING` when an + application needs generated values. `get` retains at most the first row and + then finalizes. `all` steps to completion and materializes all rows. +- `batch` executes explicit operation descriptors sequentially in one native + worker job on one leased connection. It supports no transaction, or + `deferred`, `immediate`, or `exclusive`. On a transactional error it rolls + back and rejects the whole batch. A non-transactional batch is fail-fast, but + earlier successful operations may already have committed. +- Batch SQL and parameters must all be known when `batch()` is called. Results + from one operation cannot be interpreted by JavaScript to construct a later + operation inside the same transaction. Express that logic in SQL or use a + different API. +- Awaiting call A before issuing call B provides application ordering. Calls + submitted concurrently can run and complete in different orders on different + connections. Do not manufacture cross-connection completion order. +- `close()` and `[Symbol.asyncDispose]()` are idempotent. Enter the closing state + synchronously, reject new work, drain accepted queued/in-flight work, and + close each connection exactly once. +- Input values are `null`, number, bigint, string, and copied + `ArrayBufferView` data. Match the synchronous API where the behavior has a + direct equivalent: null-prototype object rows, array rows under + `returnArrays`, duplicate-column last-wins behavior, byte ranges, integer + output under `readBigInts`, unsafe-integer errors when it is false, and + detailed SQLite errors. +- `all()` intentionally materializes the full native result before creating + JavaScript objects on the event-loop thread. Document both the native/JS + memory peak and the fact that constructing a very large result can still + pause JavaScript even though SQLite execution is off-thread. + +### `strict` and `none` + +After successful setup, `strict` installs one native `sqlite3_set_authorizer()` +callback on every connection. It must deny at least: + +- `PRAGMA`; +- `ATTACH` and `DETACH`; +- transaction and savepoint control from user SQL; +- creation, deletion, or mutation of the temp schema; +- SQL extension loading; and +- connection-observing functions whose answers depend on which pool member was + leased, including `last_insert_rowid`, `changes`, and `total_changes`. + +`strict` is a pool-consistency policy, not a read-only mode or a sandbox for +untrusted SQL. It still permits ordinary reads and writes, does not impose +SQLite resource limits, and cannot make arbitrary native-extension functions +connection-independent. Applications that load extensions must trust those +extensions and decide whether their functions are suitable for pooled calls. + +The authorizer is a non-throwing C callback over owned native state. It stays +installed while stepping because SQLite can re-prepare after a schema change. +Internal trusted transaction control uses an executor-owned flag scoped only +around preparing and stepping the executor's own `BEGIN`, `COMMIT`, or +`ROLLBACK` statement. Clear it before preparing any user operation in the +batch. Never globally remove/reinstall the callback around user work. + +`none` skips installation to remove authorizer preparation overhead for trusted +production SQL. Both modes retain structural invariants: + +- one executable statement per operation; +- every statement finalized on every path; +- no extension loading outside setup — loading is enabled only while setup + statements run (and only when `allowExtension` is set), and is revoked + before the connection is admitted; and +- `sqlite3_get_autocommit()` true before a connection returns to the pool. + +If user SQL leaves autocommit off, attempt a rollback, reject the request, and +reuse the connection only if its clean state is proven. If cleanup fails, close +the connection, fail the pool visibly, and reject queued work rather than +silently shrinking the fixed pool or improvising a reconnect policy. In `none`, +later PRAGMAs, attachments, temp state, or connection-observing SQL are +explicitly the caller's responsibility and can produce nondeterministic pool +behavior. A connection-observing function may be used deliberately after an +earlier operation in the same batch establishes its value; never rely on such +state across separate calls. + +### Deliberately out of scope + +- Public `Statement`/`prepare`, prepared-statement caching, iteration, streams, + or incremental result delivery. +- `function`, `aggregate`, custom authorizers, or any other SQLite callback + into JavaScript. Workloads that need JavaScript-defined SQL functions keep + using `DatabaseSync`. +- JavaScript transaction callbacks or data-dependent transaction builders. +- Sessions, changesets, backup, serialize/deserialize, tag stores, limits, + runtime (post-open) extension loading, cancellation/`AbortSignal`, or + conversion between pooled and synchronous connections. +- `readOnly` or other `DatabaseSync` open-option pass-throughs, and + reader/writer connection roles inside the pool. WAL plus a + `busy_timeout` setup PRAGMA already handles read-mostly workloads with + occasional writes. A read-only pass-through can be considered later if a + consumer needs its write-prevention guarantee; it would not require a + reader/writer pool architecture. +- Automatic microtask batching. Explicit non-transactional `batch()` is the + first throughput tool; add automatic batching only after measurement proves + a need that explicit batches cannot meet. +- `SQLITE_OPEN_NOMUTEX` optimization or a global SQLite threading-mode change. + Correct ownership under the existing serialized build comes first. + +## Context research and lore + +### Why warm connections + +An ad hoc local benchmark compared a reused connection with +open/prepare/step/finalize/close per operation against an on-disk WAL database. +Opening alone was inexpensive, but repeated schema/WAL/cache initialization +made a fresh point read materially slower, and fresh autocommit writes missed +the required multiple-operations-per-millisecond target. Warm reads and writes +cleared it by a large margin. Reproduce this with a checked-in benchmark rather +than preserving machine-specific counts in this TPP. + +This evidence rejects connection-per-operation as the shipped design, but keep +it as a benchmark control. It is useful for measuring how much complexity the +warm pool buys. + +### Existing patterns worth retaining + +- `BackupJob` in `src/sqlite_impl.{h,cpp}` is a useful local reference for + `Napi::AsyncWorker`, promises, named async resources, and shutdown signaling. + It is not proof of the pool's teardown model and is not reusable wholesale: + it retains a raw connection owned by `DatabaseSync`, releases its JavaScript + reference from a synchronous environment-cleanup hook, and supports progress + callbacks. Task 1 must independently prove that an environment cannot destroy + or close pool state while `Execute()` is using it. +- `AddonData` is per Node environment. Async constructor caches, cleanup state, + and native handles must remain per environment so imports in + `worker_threads` do not share JavaScript state. +- `DatabaseSync::InternalOpen`, `StatementSync::BindParameters`, and row/error + conversion define compatibility behavior, but are coupled to `Napi::Value` + and main-thread objects. Extract only transport-neutral seams proven by tests; + temporary duplication is safer than destabilizing `DatabaseSync` early. +- `binding.gyp` uses SQLite's default serialized mode and + `SQLITE_OMIT_SHARED_CACHE`. A slot still permits only one in-flight job, even + though SQLite also serializes access internally. +- The libuv thread pool is process-global and shared with filesystem, DNS, + crypto, and zlib work. A pool must not queue libuv workers that merely block + waiting for a SQLite connection. Keep waiting requests in the JavaScript + scheduler and queue native work only after leasing a slot. Idle connections + consume native resources but no libuv worker. +- An async-work execute callback cannot touch `Napi::Env`, `Napi::Value`, + `Napi::Reference`, promises, or JavaScript. Copy all input strings and view + bytes before queueing; copy all SQLite result/error data into C++17 transport + values before returning to the event loop. +- Node 26's environment cleanup drains queued and executing Node-API work before + it invokes asynchronous cleanup hooks. A hook therefore cannot cancel or + interrupt existing pool work; it is a lifetime barrier that closes quiescent + handles. Public cancellation remains out of scope, and abrupt termination may + wait for long-running SQL. +- The raw Node-API completion callback always deletes its async-work handle and + removes worker accounting, even when JavaScript calls fail during environment + teardown. Cleanup ownership must not depend on promise settlement. +- C++ exceptions must never cross the SQLite authorizer C callback. Return an + SQLite authorization result and save owned diagnostic data if necessary. +- Preserve ordered column name/value pairs in native results. An unordered map + changes property order and duplicate-column behavior. +- The project deliberately does not run useful TSan coverage under stock Node. + Use explicit ownership review, atomics/mutexes where shared state exists, + concurrency stress, ASan/UBSan/LSan, and Valgrind. + +### Pool and SQLite limitations + +- SQLite permits multiple readers but only one simultaneous writer. WAL can + overlap readers with a writer; more pool connections do not create multiple + SQLite writers. +- Plain `:memory:` and empty-name temporary databases are distinct per + connection. This build omits shared cache, and SQLite discourages shared-cache + mode. Permit them only with `connections: 1`; also detect SQLite URI + `mode=memory` for the same validation. +- Open and run connection setup sequentially in the first implementation. + This avoids racing database-persistent PRAGMAs such as `journal_mode` during + pool construction. Parallel initialization can be measured later without an + API change. +- `connectionSetup` is replayed per connection. `ATTACH`, `foreign_keys`, + `busy_timeout`, and many other settings are connection-local even though some + PRAGMAs, such as `journal_mode`, also affect persistent database state. + +### Relationship to prior async work + +Research into Node.js PR #62015 supplied transport, teardown, async-context, +row-shape, and terminal-step-error test cases. Its public +`Database`/`Statement` identity, per-connection microtask batching, statement +IDs, GC finalization, and FIFO statement executor are not requirements here. +Do not mechanically port the PR. + +The older `doc/internal/async-design.md` recommendation for a separate package +also does not apply. A subpath in this package shares one SQLite amalgamation, +native prebuild matrix, error/value behavior, and release. A second npm package +would duplicate the binary or introduce a tightly coupled internal binding +package. Keep the stable root export unchanged instead. + +## Preferred architecture + +### TypeScript facade and scheduler + +Add `src/experimental.ts` as the public subpath. `DatabasePool` owns a fixed +array of hidden native connection handles, an idle-slot queue, a pending-request +queue, and an explicit `open`/`closing`/`closed` state machine. + +The scheduler leases an idle slot in request order and only then invokes its +native async execute method. A native job completion releases the slot and +schedules the next accepted request. A failure rejects that request without +breaking the scheduler. No raw native pointer or statement ID is visible to +JavaScript. + +Do not add automatic batching. One public `run`/`get`/`all` call is one native +worker job; one explicit `batch` is one worker job containing all its operations. + +### Native executor + +Add first-party `src/async_pool_impl.{h,cpp}` and compile it from `binding.gyp`. +Do not modify `src/upstream/`. + +- A hidden native connection wrapper holds a `std::shared_ptr`. + The state owns exactly one `sqlite3*`, policy/setup configuration, explicit + lifecycle state, and the synchronization needed for teardown. It owns no + persistent `sqlite3_stmt*` between requests. +- `AsyncRequest` and `AsyncResult` are C++17 tagged variants containing only + owned native data. All statement prepare/bind/step/finalize operations occur + in `AsyncRequestWorker::Execute()` on a libuv worker. +- `OnOK()`/`OnError()` run on the event-loop thread, create JavaScript rows and + detailed errors, settle the internal promise, and release no SQLite object + directly. Give work an async resource name such as + `photostructure.sqlite.pool.request` and prove `AsyncLocalStorage` behavior. +- Every SQLite error path captures primary and extended codes, names, and + messages before finalization or close can overwrite connection error state. +- The worker/state owns statement and connection lifetimes independently of GC. + Close is a queued native operation after accepted work. Environment teardown + must both prevent promise/reference access after JavaScript becomes + unavailable and keep native state alive until in-flight SQLite calls finish + and every handle closes. A synchronous `napi_add_env_cleanup_hook()` that + merely flips a flag is not sufficient evidence; Task 1 must determine and + prove the `napi_add_async_cleanup_hook()`/native completion coordination (or + an equally safe Node-API 8 mechanism) before broader implementation. +- A defensive native busy invariant should fail visibly if the JavaScript + scheduler ever submits concurrent jobs to one slot. Do not rely on this guard + as the scheduler. + +Expose hidden binding plumbing only as needed by `src/experimental.ts`. It must +not enlarge the documented or enumerable stable root surface. + +## Alternatives rejected or deferred + +### Fresh connection per operation + +This provides the smallest native lifetime graph, but benchmark evidence shows +that repeated schema/cache/WAL setup, especially for writes, conflicts with the +throughput requirement. Retain it only as a benchmark control. + +### Stateful async `Database` and `Statement` + +This follows Node.js PR #62015 but requires connection and statement identity, +statement finalization ordering, GC retention, async iteration decisions, and a +transaction-affinity model. It solves a larger problem than requested and is +superseded by this plan. + +### `worker_threads` pool around `DatabaseSync` + +This is a useful correctness/control implementation because it reuses the sync +API, but every physical connection pays for a Node isolate and structured-clone +transport. Retain it as a benchmark fallback if the native lifetime spike fails; +do not ship it without evidence that its memory and throughput are preferable. + +### Separate npm package + +Rejected while the async surface shares this SQLite binary and release policy. +The experimental subpath provides API isolation without duplicating prebuilds or +creating a version-locked core package. + +### Dedicated `std::thread` per connection + +Deferred. One owned thread per slot with its own request queue (completion via +`Napi::ThreadSafeFunction`) would isolate the pool from libuv thread-pool +contention and make exclusive `sqlite3*` ownership structural. But TSFN +teardown adds exactly the lifetime complexity this codebase has been burned by +before. Raw Node-API async work avoids a second callback/queue mechanism while +allowing explicit no-throw completion and destruction ordering. Task 1, not +`BackupJob`, proves its teardown semantics for pool-owned handles. +Because waiting requests queue in JavaScript and only leased slots submit +native work, the executor can be swapped later without any API change. Revisit +only if benchmarks show libuv pool contention actually harms the workload. + +### Persistent prepared-statement cache + +Deferred. It reintroduces per-connection lifetime and schema-invalidation state. +Measure prepare cost after the uncached implementation meets correctness gates. +The benchmark must include a repeated-identical-SQL case so this decision is +made on evidence: the motivating consumer's query layer caches prepared +statements today, and the pool must show acceptable throughput without one. + +## Tasks + +### Do not blindly follow this section + +These tasks capture the best route known at planning time. Revise this TPP when +tests or measurements invalidate an assumption. Prefer the smallest design that +passes the frozen contract; do not preserve planned abstractions merely because +they appear below. + +### Task 0: Freeze the contract and baseline + +**Success**: focused tests describe the agreed public API and fail only because +the experimental pool does not exist; the plan records enough starting state to +avoid overwriting or staging unrelated worktree changes. + +**Recorded baseline (2026-08-07)**: + +- Feature branch: `feat/experimental-async-database-pool` +- Feature worktree: `/tmp/node-sqlite-p10-async-pool` +- Starting commit: `9ac2e43995ae039488590ea5999576884c5990fb` +- The source worktree was on `main` with extensive pre-existing tracked, + staged, and untracked work. This feature is isolated from those edits; never + copy, stage, or commit the source worktree wholesale. +- Pre-existing source-worktree changes overlap likely feature files: + `binding.gyp`, `package.json`, `package-lock.json`, `benchmark/README.md`, + `benchmark/drivers.ts`, `benchmark/index.ts`, `src/sqlite_impl.cpp`, and + `src/sqlite_impl.h`. The source worktree also contains edits under + `src/upstream/`; those remain unrelated and must not be transferred. +- Both async TPPs were untracked in the source worktree and were copied into the + isolated worktree so the superseding plan and its retained research remain + available on this branch. No implementation file had changed when this + baseline was recorded. +- Focused suites now pin the API, values, detailed errors, setup, + batching, concurrency, lifecycle, and authorizer behavior. A separate async + benchmark scaffold records the intended comparison matrix without changing + the existing synchronous benchmark runner. +- Initial red proof: + `npm run test:serial -- test/async-pool-*.test.ts` fails at TypeScript + compilation with only `TS2307: Cannot find module +'../src/experimental'`, as expected before implementation. + +1. Record `git rev-parse HEAD`, `git status --short`, and the pre-existing diffs + in files the feature will touch. The current planning tree is already dirty, + including first-party and `src/upstream/` files; never stage the whole file or + assume an existing diff belongs to this feature. +2. Add focused tests under `test/async-pool-*.test.ts` for the API shown above, + lifecycle, ordering, values/rows, setup/policy, transactions, and invalid + multi-connection in-memory locations. +3. Pin `authorizer: "strict" | "none"` with strict as the default; fixed + `connections` with one as the default; immutable `readBigInts` and + `returnArrays` result policies; `{ sql, params? }` connection setup with + `allowExtension`-guarded extension loading; and + `run`/`get`/`all`/`batch`/close only. Pin the accepted location types and + default named-binding policy as well. Except for the tests that pin the + authorizer default itself, contract tests pass `authorizer` explicitly so + the strict implementation can land after the scheduler without blocking + earlier tasks. +4. Add API-surface assertions proving the stable root exports are unchanged and + `DatabasePool` exists only under the experimental subpath. +5. Add a focused benchmark entry that can compare warm sync, fresh sync, + one-slot async, multi-slot async, strict/none, and explicit batch sizes. Do + not turn throughput observations into functional-test timing assertions. + +**Proof**: + +- [x] Focused tests fail for the missing implementation: + `npm run test:serial -- test/async-pool-*.test.ts` +- [x] Starting SHA and overlapping worktree state are recorded in this TPP +- [x] No implementation file has changed in this task + +### Task 1: Prove one-slot execution and ownership + +**Success**: one asynchronously opened connection can execute and close without +blocking the event loop, leaking, hanging, touching Node-API off-thread, or +depending on JavaScript object reachability. + +**Implementation checkpoint (2026-08-08)**: + +- The hidden native boundary is fixed as one non-enumerable + `_openAsyncPoolConnection()` binding that resolves a per-connection handle; + the handle exposes only `execute()` and `close()`. The TypeScript facade has + the fixed-slot FIFO scheduler, copies operations and view byte ranges at call + time, queues native work only after leasing a handle, and drains accepted work + before close. +- One per-environment coordinator is registered before instance data is + published and owns pool state independently of JavaScript wrappers. The + implementation uses raw Node-API async work so its completion path remains + exception-proof while Node drains the environment, and the coordinator + removes its async cleanup hook only after every native worker and SQLite + handle has drained. +- Node calls `Environment::CleanupHandles()` before it drains asynchronous + cleanup hooks. That first phase waits for queued and executing Node-API work, + so the pool hook cannot interrupt an already executing SQLite call. Abrupt + worker termination may therefore wait for long-running trusted SQL (and + arbitrary extension functions remain unbounded), but it cannot close a live + `sqlite3*` concurrently with execution. The lifecycle proofs cover both a + request queued behind an occupied libuv pool and an insert known to hold + SQLite's write lock when termination begins. +- The first teardown prototype tried to construct a close worker from the + asynchronous cleanup hook and produced a deterministic native abort in + `napi_create_object`: cleanup runs without a V8 handle scope. That approach + was also conceptually too late because Node has already drained Node-API + work. The shipped hook creates no N-API work; after the drain boundary it + synchronously closes only quiescent handles. The queued and executing abrupt + termination tests are regressions for this historical crash. +- Worker-side connection, request, result, and error state remains pure C++. + Promise settlement and JavaScript row/error creation remain confined to + event-loop completion callbacks. The raw Node-API completion thunk catches + every native exception and deletes its async-work handle before notifying the + coordinator; Node's pre-hook drain completes this path before teardown closes + connection state. +- The addon builds and all executor, value, error, setup, batch, policy, + concurrency, heartbeat, async-context, close, disposal, dropped-wrapper, and + abrupt queued/executing worker-termination tests pass. Repeated stress, + unsuppressed focused LSan, full ASan/UBSan, and Valgrind close the Task 1 + go/no-go gate. + +1. Add `src/async_pool_impl.{h,cpp}` with owned connection state and the smallest + no-row operation needed to prove open, execute, and close. +2. Use one raw Node-API async-work request per native call. Do not add persistent + statements, automatic batching, or a process-global native scheduler. +3. Hold connection state with explicit shared ownership through in-flight work. + Add environment cleanup and dropped-reference tests before adding more SQL + behavior. Prototype abrupt teardown with Node-API 8 asynchronous cleanup + coordination; do not infer safety from `BackupJob`'s synchronous cleanup + hook. Use a long-running statement to prove teardown owns the handle until + the worker exits and only then closes it. +4. Verify a long recursive SQLite operation leaves a deterministic event-loop + heartbeat responsive. Verify `AsyncLocalStorage` and `async_hooks` observe the + named native resource on success and rejection. +5. Exercise abrupt `worker_threads` environment termination using the existing + worker-test patterns. Cover both a worker queued behind a deliberately + occupied libuv thread and one already executing SQLite. Node drains both + before invoking the async cleanup hook; no arbitrary sleeps or forced GC may + be required for correctness. + +**Go/no-go gate**: + +- [x] Focused executor/lifecycle tests pass in a repeated stress loop +- [x] ASan/UBSan/LSan and Valgrind report no first-party defect +- [x] Scope review finds no Node-API handles in worker-side transport/state +- [x] Abrupt environment teardown cannot close a `sqlite3*` concurrently with + `Execute()` and does not finish until all pool-owned native handles close +- [x] No stable root behavior or `src/upstream/` file changed + +Stop and revise this TPP if safe teardown requires process-global connection +state, detached threads, or a persistent statement/object graph. + +### Task 2: Add transport-safe `run`, `get`, and `all` + +**Success**: individual operations match the frozen parameter, result, and error +contract while owning no SQLite statement across calls. + +1. Implement C++17 owned variants for null, int64, double, UTF-8 text, blobs, + ordered rows, run metadata, and detailed errors. +2. Convert/copy JavaScript inputs before queueing. Prepare, bind, step, and + finalize entirely in `Execute()`. Construct all JavaScript outputs only in + completion callbacks. +3. Reject a second executable statement by repeatedly asking SQLite to prepare + the tail until either no statement or a second statement appears; do not + parse SQL comments manually. +4. Preserve ArrayBufferView byte ranges, null prototypes, array mode, column + order, duplicate-column last-wins behavior, terminal step errors, and the + synchronous API's safe-integer policy where equivalent. +5. Normalize `run` metadata per operation as defined in the contract. Test an + INSERT followed by zero-row DML, SELECT, and DDL on the same connection so + none of them inherits the prior `changes`; assert that no + `lastInsertRowid` field exists. Repeat across batch operations in Task 4 and + multiple slots in Task 5. +6. Always finalize the statement and verify autocommit before releasing the + slot. Test an error after at least one emitted row. + +**Proof**: + +- [x] `npm run test:serial -- test/async-pool-values.test.ts` +- [x] `npm run test:serial -- test/async-pool-errors.test.ts` +- [x] Scope searches show no `Napi::`/`napi_` value stored in worker-side + request/result types + +### Task 3: Add one-connection setup and extension loading + +**Success**: setup statements and native extensions configure the one-slot +executor successfully; the connection enforces statement-finalization and +autocommit invariants. Task 5 proves identical replay after the +multi-connection scheduler exists. No authorizer work happens in this task. + +1. Open the connection and execute setup statements sequentially off-thread. If + setup fails or leaves autocommit off, close the connection and reject + `open()`. +2. Implement setup `params` binding, reusing the parameter conversion seam + from Task 2. +3. When `allowExtension` is set, enable extension loading (including the SQL + `load_extension()` function, via `sqlite3_enable_load_extension()`) only + while setup statements run, and revoke it before the connection is + admitted. Use failure-path cleanup that either proves loading was revoked or + closes the connection; never admit a connection after revocation fails. + Verify `load_extension()` fails in post-setup user SQL under `none`; Task 6 + repeats the assertion under `strict`. Test the explicit entrypoint form + (`SELECT load_extension(?, ?)`) and a load failure rejecting `open()`. +4. On leaked transaction state, roll back, reject, and discard a connection + whose clean state cannot be established. Fail the pool rather than silently + returning a poisoned slot or reducing its fixed size. +5. Use `test/fixtures/test-extension` for the real extension proof. A fixture + build failure must fail this focused suite rather than conditionally skip the + behavior. + +**Proof**: + +- [x] `npm run test:serial -- test/async-pool-setup.test.ts` +- [x] Real-extension tests are mandatory (never conditionally skipped), pass on + local glibc and Alpine/musl x64, and are wired into every existing CI + platform; live macOS, Windows, and arm64 execution follows publication +- [x] `load_extension()` is proven unavailable after setup completes + +### Task 4: Add explicit batches and transactions + +**Success**: one batch is one leased connection and one native job; operation +results remain ordered; transactional failure always rolls back; no JavaScript +callback executes inside a transaction. + +1. Add run/get/all operation descriptors and ordered result variants. +2. Implement no-transaction and deferred/immediate/exclusive modes. Internal + transaction control is executor-owned; its interplay with the strict + authorizer (the trusted flag) is added and tested in the strict task. +3. Finalize each statement before the next operation. Treat every terminal + result other than `SQLITE_DONE` as an error. +4. Apply the operation-local `run` metadata baseline separately to each run + descriptor so earlier operations in the same batch cannot leak metadata. +5. Define and test fail-fast behavior for a non-transactional batch and complete + rollback for a transactional batch. +6. Add explicit-batch throughput cases before considering automatic batching. + +**Proof**: + +- [x] `npm run test:serial -- test/async-pool-batch.test.ts` +- [x] Transaction tests prove commit and rollback from a separate connection +- [x] No public transaction callback or connection handle exists in types or + runtime surface + +### Task 5: Add the fixed multi-connection scheduler and close state machine + +**Success**: waiting requests consume no libuv worker, each slot has at most one +in-flight job, different connections can overlap, failures do not stall the +queue, and close drains accepted work exactly once. + +1. Implement the TypeScript idle-slot and request queues with explicit open, + closing, and closed states. Queue native work only after leasing a slot. +2. Preserve request assignment fairness without promising cross-connection + completion order. Test awaited ordering and document concurrent ordering as + unspecified. +3. Reject plain `:memory:`, empty temporary names, and URI `mode=memory` when + `connections > 1`; permit them with one connection. +4. Prove setup replay across every slot with concurrent operations that require + the configured attachment/PRAGMA/extension; avoid a test-only public slot ID. + A later-slot open/setup failure must close every earlier slot before + `open()` rejects. +5. Make close reject new work immediately, drain accepted queued/in-flight + requests, close slots, and settle repeated close/async-dispose calls + idempotently. +6. Stress errors, dropped promises/references, GC, queue bursts, worker + termination, and concurrent close. Use condition-based synchronization, not + sleeps. + +**Proof**: + +- [x] `npm run test:serial -- test/async-pool-concurrency.test.ts test/async-pool-lifecycle.test.ts` +- [x] Stress loop completes without unresolved promises or open handles +- [x] Native busy invariant is never reached in normal tests +- [x] Memory tools report no first-party leak/use-after-free + +### Task 6: Add the `strict` authorizer + +**Success**: strict rejects connection-affine SQL; none omits the restrictive +authorizer entirely; internal transaction control still works under strict. + +This lands after the scheduler deliberately: every earlier task runs with +explicit `authorizer: "none"`, so a working, benchmarkable pool exists before +the largest piece of novel native policy code. Everything before this task is a +coherent trusted-SQL implementation for development and measurement, but it is +not the frozen public MVP: do not publish the default-`strict` contract until +this task passes. + +1. Install the pure native strict authorizer only after setup and extension + loading. Cover every denied action/function listed in the contract, + including automatic re-prepare during `sqlite3_step()`. Repeat the + post-setup `load_extension()` denial proof under strict. +2. In none mode, do not leave a persistent or restrictive callback installed. + Exact one-statement validation temporarily installs a callback that ignores + prepare-time PRAGMA actions while SQLite scans the complete tail, because + some PRAGMAs mutate connection state in `sqlite3_prepare_v2()` before a + rejected second statement is discovered. The accepted statement is then + prepared again with no authorizer. Retain structural/autocommit checks and + measure this validation cost in the none-mode benchmark. +3. Add the executor-owned trusted flag so internal `BEGIN`/`COMMIT`/`ROLLBACK` + from `batch` pass while user transaction control is denied. Scope the flag + to each internal statement, clear it before user SQL prepare/step, and test + user transaction control in every position of a strict batch. +4. Benchmark strict and none separately. Keep the public option even if the + measured strict cost is small. + +**Proof**: + +- [x] `npm run test:serial -- test/async-pool-policy.test.ts` +- [x] A strict-mode scope test covers every intended SQLite authorizer action +- [x] None-mode benchmark measures the transient validation callback cost, and + focused policy tests confirm no persistent restrictive callback remains +- [x] Throw/exception review confirms no C++ exception crosses the C callback + +### Task 7: Package the experimental subpath + +**Success**: CJS and ESM consumers load `DatabasePool` and its declarations from +the experimental subpath while the stable root's runtime, type, and enumerable +surface remains unchanged. + +**Checkpoint (2026-08-07)**: the second tsup entry, conditional package export, +CommonJS declaration copy, TypeDoc entry, CJS/ESM runtime self-import test, and +NodeNext `.d.cts`/`.d.ts` fixtures are implemented. The generated constructor +is private, while runtime construction still throws. `npm run build:dist` and +`npm run test:exports` pass; the built stable root keys remain identical to the +recorded baseline. + +1. Add `src/experimental.ts` as a second `tsup` entry. +2. Add conditional CommonJS/ESM/type exports for `./experimental` and teach + `scripts/post-build.mjs` to create the corresponding `.d.cts` file. +3. Keep hidden native constructors/functions undocumented and non-enumerable + where practical. +4. Add CJS, ESM, declarations, illegal-constructor, and async-dispose loading + checks on the Node 22 floor. + +**Proof**: + +- [x] `npm run build:dist` +- [x] CJS and ESM experimental import checks pass +- [x] Stable root API-surface and `npm run test:api` tests pass unchanged + +### Task 8: Benchmark and document operational tradeoffs + +**Success**: maintainers can reproduce throughput and responsiveness evidence, +and users can tell exactly when the pool API is unsuitable. + +**Completion checkpoint (2026-08-08)**: the user guide and README integration +cover the intended API and operational limits. The separate async benchmark +now covers warm/fresh sync, a worker-thread control, strict/none, +one/two/three/four connections, explicit batch sizes, result sizes, read/write +mixes, +repeated and rotating SQL, competing crypto/filesystem libuv work, and event-loop +heartbeats. Two reference reports retain their complete raw inputs and results +for the default and eight-thread libuv pools. + +1. Benchmark warm sync, fresh sync, strict/none async, + one/two/three/four connections, + individual calls, explicit batch sizes, result sizes, read/write mixes, + repeated identical SQL (to quantify per-call prepare cost against a future + statement cache), and a `worker_threads` sync control. +2. Run with libuv's default pool and one larger startup-time + `UV_THREADPOOL_SIZE`. Record that the libuv pool is global and shared; add a + representative competing filesystem or crypto workload, and do not mutate + the pool size from library code. +3. Confirm multiple simple operations per millisecond on reference hardware and + record environment plus raw benchmark output. Use relative comparisons and + repeated samples; never fail CI on wall-clock throughput. +4. Verify the synchronous API has no measurable regression outside combined + measurement noise. +5. Document setup replay, authorizer modes, explicit ordering, one-writer + reality (writes on a warm pool contend via `busy_timeout` on a worker + thread, never the event loop), extension setup constraints, full-result + memory/main-thread conversion, in-memory restrictions, close requirements, + and omitted stateful features. + For libuv contention, give concrete guidance: idle SQLite connections use no + libuv worker, but each concurrently submitted pool operation competes for + the process-global threads. More busy slots than `UV_THREADPOOL_SIZE` + (default 4) do not create more simultaneous SQLite execution and can delay + unrelated thread-pool work. Applications should size connections and, only + after measurement, set a larger `UV_THREADPOOL_SIZE` before startup; the + library never mutates it. + +**Proof**: + +- [x] Focused benchmark commands and raw result locations are documented +- [x] Event-loop responsiveness has a deterministic functional test +- [x] Documentation includes examples for strict, none, setup, ordered awaits, + batches, transactions, and shutdown + +### Task 9: Full integration and native-resource review + +**Success**: every validation gate passes from a clean feature diff, limitations +are explicit, and an independent reviewer can trace every connection, request, +statement, promise, and environment-teardown ownership edge. + +Re-read this TPP, remove obsolete scaffolding, record lasting lore, and move the +completed plan to `doc/done/` using the project naming convention. + +**Review checkpoint (2026-08-08)**: the required cross-model review could not +run because the installed Claude client exhausted its retries with +`API Error: Unable to connect to API (ENOTIMP)`. A fresh-context same-model +fallback returned `REVISE`. Accepted findings are: validate unsafe integer +results on the worker before a transactional commit; skip named parameter slots +when consuming positional arrays; synchronize the queued-worker teardown proof +after native submission; and bring this TPP up to date. The claimed active-work +interrupt expectation was vetoed against Node's cleanup ordering: an executing +Node-API request finishes before the async cleanup hook runs, so the test +correctly proves safe completion and handle release rather than rollback. The +none-mode authorizer finding was accepted as a contract-discovery update: the +transient validation callback is required to prevent prepare-time PRAGMA side +effects, while no persistent restrictive authorizer is installed. + +The final bounded review found three more native-tooling issues, all accepted +and fixed: the hidden native constructor reference was weak; wrapper-allocation +failure could retain a successfully opened handle; broad Node/V8/libuv LSan +suppressions could hide pool allocations; and one exact Valgrind suppression +named a glibc symbol version. The constructor and per-environment token are now +strong references, wrapper failure requests native close, the focused pool LSan +probe uses no suppressions and forces GC, and the Valgrind stack retains its +exact context while matching `pthread_create` across glibc versions. The +follow-up review returned `PASS` with no remaining concrete issue. + +An initial cross-model gate then ran successfully through the local Claude +executable and returned `REVISE` with four findings. The native close-latch +finding was accepted: a fatal request could queue an internal close worker just +before the TypeScript failure path called `close()`, causing that second caller +to reject and clear another owner's latch. Close callers now share the active +close worker's completion, pinned by a one-thread-libuv raw-native concurrent +close test. The sanitizer finding was also accepted against the pre-feature +script as a factual narrowing, but vetoed as a defect after execution. Enabling +LSan for Jest exposes retained VM state and forced-exit Node/V8 allocations in +spawned tests, including expected stderr that invalidates their protocol +assertions; `symbolize=1` also deadlocks even the clean pool probe against the +local LLVM symbolizer. The reliable split therefore remains full-suite +ASan/UBSan plus the unsuppressed, normal-exit 100-cycle pool LSan probe. The +absolute source-worktree path in this checked-in plan was accepted as a +portability defect and removed. The claim that project policy prohibits +“smoke” as a test label was vetoed: an `rg` check found no such repository +instruction and found pre-existing uses. The suggested “sanity run” wording +was adopted anyway because it is more precise. + +Separately, the user's generic benchmark run found that its +`strictNullChecks: false` TypeScript program could not narrow the compound +null/`typeof` guard in `snapshotValue()`; splitting the null branch is pinned by +the benchmark package's `prebench` check. The requested scaling follow-up adds +three connections without removing the existing one/two/four cases, producing +an explicit one/two/three/four matrix in both authorizer modes. + +The corrected-diff cross-model follow-up returned `REVISE` with one High +finding, which was accepted. A failed executor `COMMIT` unconditionally called +`ROLLBACK`; when SQLite had already restored autocommit, that compensating +statement failed and falsely marked a clean connection fatal. The bundled +`sqlite3_get_autocommit()` contract says this function is the only proof after +an automatic rollback. A deterministic none-mode batch that explicitly rolls +back reproduced `fatal: true` followed by a closed pool. `RollbackAndVerify` +now succeeds immediately when autocommit is already true; the regression proves +that the batch still rejects normally and the same one-connection pool remains +usable. + +### Cross-model review ledger + +**Verdict: LAND** + +| Scope | Model | Finding | Severity | Accept/Veto | Evidence (one line) | Verdict | +| --------------------- | ------------- | ----------------------------------------------------------------------------------- | -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Initial staged diff | Claude Opus 5 | Concurrent native close could reject and clear another close owner's latch | Medium | Accept | One-thread-libuv regression now proves two callers share one close worker and a third close is idempotent | LAND | +| Initial staged diff | Claude Opus 5 | Full-suite LSan/symbolization settings weaken the memory gate | Medium | Veto | Jest retains unrelated Node/V8 graphs, local symbolization deadlocks, and the unsuppressed normal-exit 100-pool process remains the attributable leak gate | LAND | +| Initial staged diff | Claude Opus 5 | Repository policy prohibits “smoke” as a test label | Medium | Veto | Repository instructions contain no such rule and tracked files already use the term; wording changed only for clarity | LAND | +| Initial staged diff | Claude Opus 5 | Checked-in plan contained an absolute source-worktree path | Medium | Accept | The machine-specific path was removed | LAND | +| Corrected staged diff | Claude Opus 5 | Failed `COMMIT` falsely poisoned a connection whose autocommit was already restored | High | Accept | Bundled SQLite contract plus a live reproducer showed `fatal: true`; the pinned batch now rejects normally and reuses the connection | LAND | +| Final staged diff | Claude Opus 5 | Archived plan required an intentionally untracked superseded research file | Medium | Accept | Required-reading and dependency-style references to the local draft were removed | LAND | + +Every accepted finding is fixed and pinned where behavioral. The last review's +only surviving issue was the archived-plan reference; the post-fix check finds +no remaining dependency-style reference to that local draft in the checked-in +plan. + +**Post-completion threading clarification (2026-08-08)**: the build keeps +`SQLITE_THREADSAFE=1`, and pooled handles explicitly request `FULLMUTEX`. +Connection mutex mode governs concurrent threads entering one `sqlite3*`; it +does not serialize separate handles or PhotoStructure's web and sync processes. +The build-flags guide now explains why a global `SQLITE_THREADSAFE=2` change +would affect stable `DatabaseSync` and asynchronous backup without accelerating +the current pool, plus how WAL, per-connection `busy_timeout`, and a two-handle +read-heavy web pool fit PhotoStructure's one-writer workload. The public pool +guide repeats the operational distinction in a short comparison table and +PhotoStructure-specific checklist. + +## Completion evidence (2026-08-08) + +- `npm run test:serial -- test/async-pool-*.test.ts` passes; rerun on + 2026-08-09 after the final review. +- `npm run test:all`: build, full CJS, full ESM, CJS/ESM package resolution, + declaration resolution, illegal construction, and stable-root export checks + pass after the final autocommit repair, with the repository's expected + conditional skips. +- Node 22 floor: the package-export/declaration check passes, and the full + ASan/UBSan run passes with the expected conditional skips. +- The focused LSan process opens, uses, errors, closes, and periodically + force-collects 100 pools with no suppressions and reports no first-party + leak. The final run classified only Node/system/dependency allocations. This + gate first exposed a 120-byte/three-object per-open Node-API token leak; the + per-environment symbol replacement removes it. +- Valgrind exercises sync objects plus the experimental pool and reports zero + definite, indirect, or possible lost bytes and zero error contexts. +- Alpine/musl x64 builds and full tests pass on Node 22, 24, and 26; Node 22 was + rerun after the final constructor-lifetime change. The local script default + now matches the package and CI support floor instead of testing Node 20. +- `npm run lint`, `npm run lint:native`, `npm run docs`, `npm run test:api`, + `npm run test:node`, `npm pack --dry-run`, and `git diff --check` pass. + clang-tidy reports advisory baseline/style warnings but no configured error. +- Reference benchmark reports are + `benchmark/results/async-pool-default.json` and + `benchmark/results/async-pool-uv8.json`. They record Node 26.6.0, Linux x64, + Ryzen 9 5950X, one warmup, and six samples. Representative medians are 37.9 + ops/ms for one `none` connection, 39.9 for one `strict` connection, 132.7 for + four `none` connections, and 131.6 for 100-operation batches. Under four + competing PBKDF2 jobs, the four-connection pool rises from 11.3 ops/ms with + the default libuv pool to 123.6 ops/ms with `UV_THREADPOOL_SIZE=8`. +- The configurable scaling matrix now includes pool sizes one, two, three, and + four in both authorizer modes. `npm run typecheck:async`, the scenario list, + and a tiny scaling execution run passed. The checked-in reference JSON + remains schema-valid historical evidence for its recorded one/two/four + matrix; the fixed four-worker contention cases remain intentional. +- A post-review run of the benchmark package's generic `npm run bench` exposed + a configuration-specific TypeScript error in `snapshotValue()`: its + `strictNullChecks: false` program did not narrow a compound null/`typeof` + guard from `unknown` to `PoolValue`, even though the repository's strict + program passed. Keeping the null return as a separate branch makes both + compiler configurations pass. The exact benchmark command then completed + the full scenario matrix for `@photostructure/sqlite` and `node:sqlite`; the + local optional `better-sqlite3` driver remained N/A because its Node 26 + native binary was not built. +- Existing CI already runs Node 22/24/26 across Linux glibc and musl, macOS, + Windows, x64, and arm64, and `npm test` now includes built-package export and + declaration checks. A live remote run is intentionally deferred until the + branch is published. + +### Release posture + +The current native diff has no proven memory/resource defect after source +ownership review, focused lifecycle tests, ASan/UBSan, the unsuppressed pool +LSan probe, and Valgrind. This does not prove race freedom: useful TSan or +Helgrind coverage has not run under stock Node. Keep `FULLMUTEX` for the first +release. The recommended PhotoStructure rollout is a guarded two-connection +web-process canary under WAL and per-connection `busy_timeout`, while the sync +process initially remains on `DatabaseSync`; instrument queue latency, +`SQLITE_BUSY`/`SQLITE_LOCKED`, memory, and process exits before expanding use or +testing `NOMUTEX`. See `doc/experimental-async-pool.md` for the operational +rationale. + +## Validation + +- [x] Focused: `npm run test:serial -- test/async-pool-*.test.ts` +- [x] Build: `npm run build` +- [x] Types/lint: `npm run lint` +- [x] Full CJS and ESM: `npm run test:all` +- [x] Stable API compatibility: `npm run test:api` +- [x] Node compatibility: `npm run test:node` +- [x] Memory/UB: `npm run memory:asan` +- [x] Independent memory check: `npm run memory:valgrind` +- [x] Alpine: `npm run test:docker:alpine` +- [x] CI coverage: the existing Linux, macOS, Windows, x64, arm64, and + Node-version matrix runs the new mandatory suites after publication +- [x] Benchmark evidence recorded without functional-test timing assertions +- [x] Benchmark package typecheck and generic runner: `cd benchmark && npm run bench` +- [x] Stable root runtime/type/enumerable surface unchanged +- [x] No feature edits under `src/upstream/`; pre-existing worktree changes are + preserved and excluded from this feature's staging + +## Definition of complete + +The feature is complete only when SQLite work is demonstrably off the event +loop; every accepted operation resolves or rejects; a pool slot can never be +used concurrently or returned with unproven transaction state; setup and strict +policy block the documented sources of accidental connection affinity; none +mode removes the persistent restrictive authorizer while clearly assigning +state discipline to the caller; disposal and environment teardown are safe; the stable sync API is +untouched; and benchmark evidence shows the warm design meets the requested +throughput class. diff --git a/doc/experimental-async-pool.md b/doc/experimental-async-pool.md new file mode 100644 index 0000000..55e43ae --- /dev/null +++ b/doc/experimental-async-pool.md @@ -0,0 +1,271 @@ +# Experimental async database pool + +`@photostructure/sqlite/experimental` provides a fixed-size pool of warm SQLite +connections. Database open, prepare, bind, step, finalize, setup, and close work +runs on libuv worker threads rather than on the JavaScript event-loop thread. + +The API is experimental. Its compatibility policy is separate from the stable +`@photostructure/sqlite` entry point: the pool may change as production usage +and benchmarks reveal better semantics. Importing the stable entry point does +not expose `DatabasePool` or change its runtime and TypeScript API. + +## Quick start + +```typescript +import { DatabasePool } from "@photostructure/sqlite/experimental"; + +await using pool = await DatabasePool.open("app.db", { + connections: 2, + // "strict" is the default. It prevents user SQL from depending on which + // physical connection the pool leases. + authorizer: "strict", + connectionSetup: [ + { sql: "PRAGMA journal_mode=WAL" }, + { sql: "PRAGMA foreign_keys=ON" }, + { sql: "PRAGMA busy_timeout=5000" }, + ], +}); + +await pool.run( + "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, name TEXT)", +); +await pool.run("INSERT INTO users(name) VALUES (?)", ["Ada"]); + +const user = await pool.get("SELECT * FROM users WHERE id = ?", [1]); +const users = await pool.all("SELECT * FROM users ORDER BY id"); +``` + +CommonJS consumers use the same subpath: + +```javascript +const { DatabasePool } = require("@photostructure/sqlite/experimental"); +``` + +`DatabasePool.open()` accepts a string, `Buffer`, or `URL` location. The pool +has one connection by default. Each `run()`, `get()`, and `all()` call accepts +exactly one executable SQL statement and an optional parameter array or named +parameter object. + +## Strict and none authorizers + +The `authorizer` option controls whether user SQL may create connection-local +state: + +| Mode | Use it when | Behavior | +| -------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| `"strict"` (default) | Calls may be leased to any pool connection | Allows ordinary main-schema reads and writes, while rejecting connection-affine SQL | +| `"none"` | All SQL is trusted and the application accepts connection-affinity risks | Installs no persistent restrictive authorizer | + +Strict mode rejects user `PRAGMA`, `ATTACH`/`DETACH`, transaction and savepoint +control, temp-schema mutation, extension loading, and connection-observing +functions such as `last_insert_rowid()`, `changes()`, and `total_changes()`. +Use `connectionSetup` for required PRAGMAs and attachments, and use +`batch(..., { transaction: ... })` for transactions. + +Strict mode is a pool-consistency policy, not a read-only mode or a sandbox for +untrusted SQL. It permits ordinary writes, does not impose SQLite resource +limits, and cannot make arbitrary native-extension functions +connection-independent. + +None mode permits SQL such as PRAGMAs, attachments, and connection-observing +functions after open. Such state can differ across physical connections, so a +later call may observe a different value. A batch deliberately stays on one +connection, but separate calls must not rely on connection-local state. + +Both modes still require one executable statement per operation, finalize every +statement, and restore autocommit before returning a connection to the pool. +To enforce that structural rule without allowing a rejected multi-statement +PRAGMA to mutate the connection during SQLite's prepare phase, both modes use a +short-lived validation callback while scanning the SQL tail. In none mode the +accepted statement is then prepared and executed with no authorizer installed. + +## Connection setup and extensions + +`connectionSetup` is an ordered list of `{ sql, params? }` operations. The pool +runs the complete list once on every physical connection before `open()` +resolves: + +```typescript +const pool = await DatabasePool.open("app.db", { + connections: 2, + authorizer: "strict", + allowExtension: true, + connectionSetup: [ + { sql: "PRAGMA journal_mode=WAL" }, + { sql: "PRAGMA busy_timeout=5000" }, + { + sql: "SELECT load_extension(?, ?)", + params: [extensionPath, "sqlite3_myextension_init"], + }, + { sql: "ATTACH DATABASE ? AS analytics", params: [analyticsPath] }, + ], +}); +``` + +Setup must be safe to replay independently on each connection. Use it for +connection configuration, not schema migrations. Run migrations before opening +the pool, using ordinary SQL or `DatabaseSync` when a migration needs a +JavaScript-defined SQL function. + +`allowExtension` defaults to false. When true, SQL extension loading is enabled +only while setup runs and is revoked before the connection is admitted to the +pool. A setup or extension-load failure rejects `open()` and closes every +connection opened so far. User operations cannot call `load_extension()` in +either authorizer mode. + +## Ordering, concurrency, and batches + +Await one operation before issuing the next when application order matters: + +```typescript +await pool.run("INSERT INTO jobs(id, state) VALUES (?, ?)", [1, "queued"]); +await pool.run("UPDATE jobs SET state = ? WHERE id = ?", ["ready", 1]); +const job = await pool.get("SELECT * FROM jobs WHERE id = ?", [1]); +``` + +Calls submitted concurrently may run and complete in different orders on +different connections. The pool does not manufacture cross-connection +completion order. + +`batch()` executes all descriptors sequentially in one worker job on one leased +connection. All SQL and parameters must be known when `batch()` is called: + +```typescript +const results = await pool.batch( + [ + { + kind: "run", + sql: "UPDATE account SET balance = balance - ? WHERE id = ?", + params: [10, 1], + }, + { + kind: "run", + sql: "UPDATE account SET balance = balance + ? WHERE id = ?", + params: [10, 2], + }, + { + kind: "get", + sql: "SELECT balance FROM account WHERE id = ?", + params: [2], + }, + ], + { transaction: "immediate" }, +); +``` + +Transaction modes are `deferred`, `immediate`, and `exclusive`. A transactional +error rolls back and rejects the whole batch. A batch without `transaction` is +fail-fast, but earlier successful operations may already have committed. Batch +results preserve descriptor order. + +## Values and results + +Bind values may be `null`, number, bigint, string, or an `ArrayBufferView`. +Views are copied when the request is submitted, including only the view's byte +range. Named objects accept the synchronous API's default bare-name behavior. + +- `run()` returns `{ changes }`. It intentionally has no `lastInsertRowid` + because that value is connection history and is ambiguous in a pool. Use + `INSERT ... RETURNING` with `get()` or `all()` when generated values matter. +- `get()` returns the first row or `undefined`. +- `all()` returns all rows. +- Rows are null-prototype objects by default. `returnArrays: true` returns + arrays instead. +- `readBigInts: true` returns SQLite integers as bigint. With the default false, + unsafe integers reject instead of silently losing precision. + +`all()` materializes the complete native result before creating JavaScript +objects on the event-loop thread. A large query can therefore have a high peak +native-plus-JavaScript memory footprint, and converting a very large result can +still pause JavaScript even though SQLite execution itself is off-thread. The +MVP does not provide streaming or incremental iteration. + +## SQLite and libuv limits + +Multiple connections allow reads to overlap, but SQLite still permits only one +writer at a time. WAL can overlap readers with a writer; it does not create +multiple simultaneous writers. Configure a suitable `busy_timeout` in setup so +write contention waits on a worker thread rather than blocking the event loop. + +SQLite's threading mode is not a database-concurrency setting. It controls +threads sharing one connection handle; WAL and file locks control connections +and processes sharing one database. + +| Mechanism | What it controls | Cost or risk | +| ----------- | -------------------------------------------------- | --------------------------------------------------------- | +| `FULLMUTEX` | Concurrent threads entering the same `sqlite3*` | Small mutex overhead; protects against ownership mistakes | +| `NOMUTEX` | The application must serialize each `sqlite3*` | Less overhead; overlapping use of one handle is unsafe | +| WAL/locks | Different handles and processes using one database | Governs PhotoStructure's actual web/sync concurrency | + +This pool currently uses `FULLMUTEX` as defense in depth. Its scheduler still +runs different handles concurrently, and `FULLMUTEX` does not serialize another +process. See the [longer build-flag rationale](build-flags.md#sqlite-threading-modes-and-process-concurrency) +for the compile-time choices. + +For PhotoStructure: + +- The read-heavy web process can use a two-connection pool. `FULLMUTEX` does not + prevent those handles from reading concurrently. +- In WAL mode, web readers can normally overlap the sync writer. +- Sync still has only one writer, regardless of its connection count. +- Configure `busy_timeout` on every connection; unlike WAL mode, it is + connection-local. +- A second sync connection might help incremental reads, but it can also add + cache, libuv-thread, and lock contention. Measure the complete workload. + +Plain `:memory:`, empty temporary locations, and SQLite URI locations with +`mode=memory` are private to one connection in this build. They require +`connections: 1`. Use an on-disk database for a multi-connection pool. + +Active pool operations use Node's process-global libuv thread pool, which is +also shared with filesystem, DNS, crypto, and zlib work. Requests waiting for a +SQLite connection stay in JavaScript and consume no libuv worker, and idle +connections consume no worker. However, more busy pool slots than +`UV_THREADPOOL_SIZE` (four by default) do not create more simultaneous SQLite +execution and can delay unrelated thread-pool work. + +Size both the connection pool and libuv pool from measurements of the complete +application. If a larger libuv pool helps, set `UV_THREADPOOL_SIZE` before the +process starts. This package never mutates it. + +## Closing the pool + +`close()` and `Symbol.asyncDispose` are idempotent. Closing begins immediately: +new work is rejected, already accepted queued and in-flight work drains, and +then each physical connection closes exactly once. + +Abrupt `worker_threads` termination also drains Node-API work before the +environment cleanup hook can close connections. This preserves handle safety, +but it means termination can wait for a long-running SQLite statement; SQLite +cannot forcibly bound arbitrary trusted SQL or native extension functions. + +Prefer explicit resource management: + +```typescript +await using pool = await DatabasePool.open("app.db"); +// The pool closes when this scope exits, including on an exception. +``` + +Without `await using`, always use `try`/`finally`: + +```typescript +const pool = await DatabasePool.open("app.db"); +try { + await pool.get("SELECT 1 AS ready"); +} finally { + await pool.close(); +} +``` + +## Deliberately omitted + +The pool exposes only connection-independent `run`, `get`, `all`, and `batch` +operations. It does not expose prepared-statement handles, iteration, streams, +JavaScript transaction callbacks, user functions or aggregates, sessions, +changesets, backup, serialization, runtime extension loading, cancellation, or +conversion between pooled and synchronous connections. Keep using +`DatabaseSync` when a workload needs those stateful capabilities. + +See the [benchmark guide](../benchmark/README.md#experimental-async-pool) for +commands that measure warm/fresh connections, pool size, authorizer overhead, +batching, event-loop responsiveness, and libuv-pool sizing on your hardware. diff --git a/eslint.config.mjs b/eslint.config.mjs index fab7f77..df58c9e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,16 @@ export default [ }, }, }, + // Additional configuration for CommonJS maintenance scripts + { + files: ["scripts/*.cjs"], + languageOptions: { + globals: { + ...globals.node, + ...globals.commonjs, + }, + }, + }, // Additional configuration for CommonJS test files { files: ["test/**/*.js", "test/**/*.cjs"], diff --git a/package.json b/package.json index 947d8c2..e2f9541 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,16 @@ "default": "./dist/index.mjs" } }, + "./experimental": { + "require": { + "types": "./dist/experimental.d.cts", + "default": "./dist/experimental.cjs" + }, + "import": { + "types": "./dist/experimental.d.ts", + "default": "./dist/experimental.mjs" + } + }, "./package.json": "./package.json" }, "//files": "Everything binding.gyp compiles must ship: node-gyp-build falls back to a source build on platforms without a prebuild.", @@ -62,10 +72,11 @@ "sync:sqlite": "tsx scripts/sync-from-sqlite.ts", "sync:tests": "tsx scripts/sync-node-tests.ts", "//test": "'test' is quick dev feedback, 'test:all' is comprehensive", - "test": "npm run build:dist && node --expose-gc node_modules/jest/bin/jest.js --no-coverage", + "test": "npm run build:dist && node --expose-gc node_modules/jest/bin/jest.js --no-coverage && npm run test:exports", "test:cjs": "node --expose-gc node_modules/jest/bin/jest.js", "test:esm": "cross-env TEST_ESM=1 node --expose-gc --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js", - "test:all": "npm run build && run-s test:cjs test:esm", + "test:all": "npm run build && run-s test:cjs test:esm test:exports", + "test:exports": "node --test test/package-exports.test.mjs && tsc -p test/fixtures/package-exports/tsconfig.json", "test:serial": "node --expose-gc node_modules/jest/bin/jest.js --runInBand --no-coverage", "test:api": "jest --config jest.config.api-compat.cjs", "test:node": "node --expose-gc --test test/node-compat/*.js test/node-compat/*.mjs", @@ -93,6 +104,7 @@ "memory:asan": "bash scripts/sanitizers-test.sh", "//bench": "benchmarks", "bench": "cd benchmark && npm install && npm run bench", + "bench:async": "cd benchmark && npm install && npm run bench:async", "bench:memory": "cd benchmark && npm install && npm run bench:memory", "bench:full": "cd benchmark && npm install && npm run bench && npm run bench:memory", "//stress": "stress tests", diff --git a/scripts/clang-tidy.ts b/scripts/clang-tidy.ts index 116ea8c..09adc8d 100755 --- a/scripts/clang-tidy.ts +++ b/scripts/clang-tidy.ts @@ -146,6 +146,7 @@ async function getSourceFiles(): Promise { // For node-sqlite, we'll check specific files rather than scanning const files = [ "src/binding.cpp", + "src/async_pool_impl.cpp", "src/sqlite_impl.cpp", "src/user_function.cpp", "src/aggregate_function.cpp", diff --git a/scripts/lsan-pool-test.cjs b/scripts/lsan-pool-test.cjs new file mode 100644 index 0000000..c3322b0 --- /dev/null +++ b/scripts/lsan-pool-test.cjs @@ -0,0 +1,53 @@ +"use strict"; + +const vm = require("node:vm"); +const v8 = require("node:v8"); +const { DatabasePool } = require("../dist/experimental.cjs"); + +v8.setFlagsFromString("--expose_gc"); +const collectGarbage = vm.runInNewContext("gc"); + +async function main() { + for (let index = 0; index < 100; index++) { + const pool = await DatabasePool.open(":memory:", { + authorizer: "strict", + connectionSetup: [{ sql: "PRAGMA foreign_keys=ON" }], + }); + try { + await pool.run( + "CREATE TABLE item(id INTEGER PRIMARY KEY, value BLOB NOT NULL)", + ); + const results = await pool.batch( + [ + { + kind: "run", + sql: "INSERT INTO item(value) VALUES (?)", + params: [new Uint8Array([index & 0xff, 2, 3])], + }, + { kind: "get", sql: "SELECT id, value FROM item" }, + { kind: "all", sql: "SELECT id, value FROM item" }, + ], + { transaction: "immediate" }, + ); + if (results.length !== 3) { + throw new Error("Async pool batch returned the wrong result count"); + } + await pool.run("INSERT INTO missing_table VALUES (1)").then( + () => { + throw new Error("Async pool error path unexpectedly succeeded"); + }, + () => undefined, + ); + } finally { + await pool.close(); + } + if ((index + 1) % 10 === 0) { + collectGarbage(); + } + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs index e4e26c5..c343812 100644 --- a/scripts/post-build.mjs +++ b/scripts/post-build.mjs @@ -10,8 +10,13 @@ const distDir = join(__dirname, "..", "dist"); // Copy .d.ts to .d.cts for CommonJS type safety async function createCjsTypes() { try { - await copyFile(join(distDir, "index.d.ts"), join(distDir, "index.d.cts")); - console.log("Created index.d.cts for CommonJS type safety"); + for (const entry of ["index", "experimental"]) { + await copyFile( + join(distDir, `${entry}.d.ts`), + join(distDir, `${entry}.d.cts`), + ); + } + console.log("Created CommonJS declaration files"); } catch (error) { console.error("Error creating .d.cts file:", error); process.exit(1); diff --git a/scripts/sanitizers-test.sh b/scripts/sanitizers-test.sh index c252039..e8b5058 100755 --- a/scripts/sanitizers-test.sh +++ b/scripts/sanitizers-test.sh @@ -72,8 +72,13 @@ export CFLAGS="$SANITIZE_FLAGS" export CXXFLAGS="$SANITIZE_FLAGS" export LDFLAGS="-fsanitize=address,undefined" -# Comprehensive ASAN options combining both implementations -export ASAN_OPTIONS="detect_leaks=1:halt_on_error=0:print_stats=1:check_initialization_order=1:strict_init_order=1:print_module_map=1:suppressions=$(pwd)/.asan-suppressions.txt" +# Comprehensive ASAN options combining both implementations. Keep leak +# detection off while node-gyp runs: LD_PRELOAD also instruments its Node and +# Python helper processes, and LeakSanitizer cannot inspect those helpers in +# ptrace-based CI/sandbox environments. Enable it immediately before the test +# process below. +ASAN_OPTIONS_BASE="halt_on_error=0:symbolize=0:print_stats=1:check_initialization_order=1:strict_init_order=1:print_module_map=1:suppressions=$(pwd)/.asan-suppressions.txt" +export ASAN_OPTIONS="detect_leaks=0:$ASAN_OPTIONS_BASE" # print_suppressions=1 lists which LSan rules actually fired, so dead ones can be # pruned. It is VERBOSE-only on purpose: LSan writes that summary to *stderr*, @@ -86,6 +91,10 @@ if [[ "$VERBOSE" == "1" ]]; then fi # The suppressions file must never wildcard napi_/Napi:: frames -- see its header. export LSAN_OPTIONS="suppressions=$(pwd)/.lsan-suppressions.txt:print_suppressions=$LSAN_PRINT_SUPPRESSIONS" +# The focused pool probe is small enough to run without suppressions. In +# particular, broad Node/V8/libuv patterns can otherwise match beneath an +# addon's allocation frame and hide exactly the leaks this probe targets. +LSAN_POOL_OPTIONS="print_suppressions=$LSAN_PRINT_SUPPRESSIONS" export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1" # Increase Node.js heap size for ASan overhead @@ -194,15 +203,38 @@ npm run build:native:rebuild echo "Building distribution bundle..." npm run build:dist -# Run tests and capture output. LD_PRELOAD is applied here and nowhere else: -# the addon is instrumented, but node is not, so the ASan runtime has to be -# loaded first -- and only for processes that actually load the addon. +# Run tests and capture output. Sanitizer instrumentation is deliberately run +# in-band: a default Jest worker fan-out can saturate the machine with dozens of +# instrumented Node processes and make unrelated fixed-timeout multi-process +# tests fail from scheduler contention. Leak-at-exit is disabled for Jest: its +# retained VM graph is noisy and too broad for first-party attribution. +# LD_PRELOAD is scoped to the Node processes that load the instrumented addon; +# exporting it would also instrument npm, node-gyp, and shell helpers. echo -e "${YELLOW}Running tests with AddressSanitizer...${NC}" +ASAN_TEST_OPTIONS="detect_leaks=0:$ASAN_OPTIONS_BASE" +LSAN_TEST_OPTIONS="detect_leaks=1:$ASAN_OPTIONS_BASE" set +e # Don't exit on test failure -LD_PRELOAD="$SAN_PRELOAD" npm test -- --no-coverage --forceExit 2>&1 | tee "$OUTPUT_FILE" -TEST_EXIT_CODE=${PIPESTATUS[0]} +LD_PRELOAD="$SAN_PRELOAD" ASAN_OPTIONS="$ASAN_TEST_OPTIONS" \ + node --expose-gc node_modules/jest/bin/jest.js --runInBand --no-coverage --forceExit 2>&1 | tee "$OUTPUT_FILE" +ASAN_TEST_EXIT_CODE=${PIPESTATUS[0]} + +# A minimal process gives LSan an attributable native ownership graph without +# Jest/TypeScript/compiler state. This loop covers open, transport, batches, +# errors, and close often enough to expose per-connection leaks. +echo -e "${YELLOW}Running focused LeakSanitizer lifecycle loop...${NC}" +LD_PRELOAD="$SAN_PRELOAD" ASAN_OPTIONS="$LSAN_TEST_OPTIONS" \ + LSAN_OPTIONS="$LSAN_POOL_OPTIONS" node scripts/lsan-pool-test.cjs 2>&1 | tee -a "$OUTPUT_FILE" +LSAN_TEST_EXIT_CODE=${PIPESTATUS[0]} set -e +TEST_EXIT_CODE=0 +if [[ "$ASAN_TEST_EXIT_CODE" -ne 0 ]] || [[ "$LSAN_TEST_EXIT_CODE" -ne 0 ]]; then + TEST_EXIT_CODE=1 +fi + +# Do not instrument the shell utilities used to classify the captured report. +export ASAN_OPTIONS="$ASAN_TEST_OPTIONS" + echo -e "${BLUE}\nFull ASAN output saved to: $OUTPUT_FILE${NC}" # Analyze output for errors specific to our code @@ -217,7 +249,7 @@ SYSTEM_LEAKS=0 TOTAL_LEAKS=0 # Check for ASAN errors in our code (not V8/Node internals) -if grep -E "(ERROR: AddressSanitizer|ERROR: LeakSanitizer)" "$OUTPUT_FILE" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl)" > /dev/null; then +if grep -E "(ERROR: AddressSanitizer|ERROR: LeakSanitizer)" "$OUTPUT_FILE" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl|async_pool_impl)" > /dev/null; then OUR_ERRORS=1 fi @@ -226,7 +258,7 @@ fi # amalgamation is excluded from UB instrumentation (.ubsan-ignorelist.txt), so # anything here is ours. We build with -fno-sanitize-recover=undefined, so this # should already have aborted the run -- this catches it either way. -if grep -E "runtime error:" "$OUTPUT_FILE" | grep -E "(sqlite_impl|user_function|aggregate_function|binding)\.(cpp|h)" > /dev/null; then +if grep -E "runtime error:" "$OUTPUT_FILE" | grep -E "(sqlite_impl|async_pool_impl|user_function|aggregate_function|binding)\.(cpp|h)" > /dev/null; then OUR_UB=1 fi @@ -243,7 +275,7 @@ if grep -q "SUMMARY: AddressSanitizer.*leaked" "$OUTPUT_FILE"; then context=$(sed -n "${start},${end}p" "$OUTPUT_FILE") # Check if this leak is from our code - if echo "$context" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl|photostructure)" > /dev/null && ! echo "$context" | grep -E "/node_modules/" > /dev/null; then + if echo "$context" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl|async_pool_impl|photostructure)" > /dev/null && ! echo "$context" | grep -E "/node_modules/" > /dev/null; then OUR_LEAKS=$((OUR_LEAKS + 1)) # Check if this leak is from Python elif echo "$context" | grep -iE "(python|libpython|\.py:|Py_|PyObject)" > /dev/null; then @@ -275,7 +307,7 @@ fi if [[ "$OUR_UB" -eq 1 ]]; then echo -e "${RED}\n✗ UndefinedBehaviorSanitizer found undefined behavior in sqlite code:${NC}" - grep -E "runtime error:" "$OUTPUT_FILE" | grep -E "(sqlite_impl|user_function|aggregate_function|binding)\.(cpp|h)" | head -20 + grep -E "runtime error:" "$OUTPUT_FILE" | grep -E "(sqlite_impl|async_pool_impl|user_function|aggregate_function|binding)\.(cpp|h)" | head -20 EXIT_CODE=1 fi @@ -286,7 +318,7 @@ if [[ "$OUR_LEAKS" -gt 0 ]]; then start=$((line_num - 2)) end=$((line_num + 15)) context=$(sed -n "${start},${end}p" "$OUTPUT_FILE") - if echo "$context" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl|photostructure)" > /dev/null; then + if echo "$context" | grep -E "(phstr_sqlite\.node|/src/|aggregate_function|user_function|sqlite_impl|async_pool_impl|photostructure)" > /dev/null; then echo "$context" echo "---" fi @@ -346,4 +378,4 @@ fi echo -e "\n${YELLOW}Cleaning up ASAN build...${NC}" npm run clean:native > /dev/null 2>&1 || true -exit $EXIT_CODE \ No newline at end of file +exit $EXIT_CODE diff --git a/scripts/test-docker-alpine.sh b/scripts/test-docker-alpine.sh index 35f7946..d0e4de6 100755 --- a/scripts/test-docker-alpine.sh +++ b/scripts/test-docker-alpine.sh @@ -3,7 +3,7 @@ # This validates that cleanup hooks work correctly in musl libc environments # # Usage: -# ./test-docker-alpine.sh # Test all versions (20, 22, 24) +# ./test-docker-alpine.sh # Test all versions (22, 24, 26) # ./test-docker-alpine.sh 22 # Test only Node 22 # NODE_VERSION=22 ./test-docker-alpine.sh # Test only Node 22 (via env var) # @@ -22,7 +22,7 @@ if [ -n "${NODE_VERSION:-}" ]; then elif [ $# -eq 1 ]; then NODE_VERSIONS=("$1") else - NODE_VERSIONS=("20" "22" "24") + NODE_VERSIONS=("22" "24" "26") fi echo "Testing @photostructure/sqlite in Alpine (musl) Docker containers..." diff --git a/scripts/valgrind-test.sh b/scripts/valgrind-test.sh index baefc07..8d22b6b 100755 --- a/scripts/valgrind-test.sh +++ b/scripts/valgrind-test.sh @@ -1,7 +1,7 @@ #!/bin/bash # Valgrind memory leak detection script for CI/CD -set -e +set -euo pipefail SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" ROOT_DIR="$(dirname "$SCRIPT_DIR")" @@ -28,6 +28,8 @@ echo -e "${GREEN}Running valgrind memory leak detection...${NC}" # Path to the dedicated valgrind test script VALGRIND_TEST="$SCRIPT_DIR/valgrind-test.ts" +NODE_BIN="$(command -v node)" +TSX_CLI="$ROOT_DIR/node_modules/tsx/dist/cli.mjs" # Ensure the test script exists if [ ! -f "$VALGRIND_TEST" ]; then @@ -43,9 +45,9 @@ fi # Pre-flight check: run the test script without valgrind first echo "Running pre-flight check..." -if ! npx tsx "$VALGRIND_TEST" > /dev/null 2>&1; then +if ! "$NODE_BIN" "$TSX_CLI" "$VALGRIND_TEST" > /dev/null 2>&1; then echo -e "${RED}Error: Test script failed to run. Running again to show error:${NC}" - npx tsx "$VALGRIND_TEST" + "$NODE_BIN" "$TSX_CLI" "$VALGRIND_TEST" exit 1 fi echo -e "${GREEN}✓ Pre-flight check passed${NC}" @@ -60,10 +62,10 @@ if [ ! -f "$SUPP_FILE" ]; then fi # Run valgrind with appropriate options -VALGRIND_OPTS="--leak-check=full --show-leak-kinds=definite,indirect,possible --track-origins=yes --suppressions=$SUPP_FILE --gen-suppressions=all" +VALGRIND_OPTS="--leak-check=full --show-leak-kinds=definite,indirect,possible --track-origins=yes --error-exitcode=99 --suppressions=$SUPP_FILE --gen-suppressions=all" echo "Running valgrind tests..." -if valgrind $VALGRIND_OPTS npx tsx "$VALGRIND_TEST" 2>&1 | tee "$ROOT_DIR/valgrind.log"; then +if valgrind $VALGRIND_OPTS "$NODE_BIN" "$TSX_CLI" "$VALGRIND_TEST" 2>&1 | tee "$ROOT_DIR/valgrind.log"; then # Extract leak counts from the LEAK SUMMARY DEFINITELY_LOST=$(grep "definitely lost:" "$ROOT_DIR/valgrind.log" | sed -E 's/.*definitely lost: ([0-9,]+) bytes.*/\1/' | tr -d ',') INDIRECTLY_LOST=$(grep "indirectly lost:" "$ROOT_DIR/valgrind.log" | sed -E 's/.*indirectly lost: ([0-9,]+) bytes.*/\1/' | tr -d ',') @@ -87,11 +89,11 @@ else fi # Keep log file for debugging in CI -if [[ -n "${GITHUB_ACTIONS}" ]]; then +if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo -e "${YELLOW}Valgrind log saved to: $ROOT_DIR/valgrind.log${NC}" else # Cleanup log file only in local development rm -f "$ROOT_DIR/valgrind.log" fi -exit $RESULT \ No newline at end of file +exit $RESULT diff --git a/scripts/valgrind-test.ts b/scripts/valgrind-test.ts index 398f3ca..63b6866 100644 --- a/scripts/valgrind-test.ts +++ b/scripts/valgrind-test.ts @@ -15,6 +15,7 @@ // Import from the built dist directory (we assume build has been done) // We use any type here since this is a test script and dist doesn't have types const { DatabaseSync } = require("../dist/index.cjs") as any; +const { DatabasePool } = require("../dist/experimental.cjs") as any; async function runTests() { console.log("Starting valgrind memory leak tests..."); @@ -135,6 +136,39 @@ async function runTests() { db.close(); } + // Test 5: Exercise async pool workers, transport, rollback, and close. + console.log("Test 5: Experimental async pool lifecycle"); + for (let i = 0; i < 10; i++) { + const pool = await DatabasePool.open(":memory:", { + authorizer: "strict", + connectionSetup: [{ sql: "PRAGMA foreign_keys=ON" }], + }); + await pool.run("CREATE TABLE pool_test(id INTEGER PRIMARY KEY, data BLOB)"); + const results = await pool.batch( + [ + { + kind: "run", + sql: "INSERT INTO pool_test(data) VALUES (?)", + params: [new Uint8Array([1, 2, 3])], + }, + { kind: "get", sql: "SELECT id, data FROM pool_test" }, + ], + { transaction: "immediate" }, + ); + if (results.length !== 2) { + throw new Error("Async pool batch returned the wrong result count"); + } + try { + await pool.run("INSERT INTO missing_pool_table VALUES (1)"); + throw new Error("Async pool error path unexpectedly succeeded"); + } catch (error) { + if (!/missing_pool_table|no such table/i.test((error as Error).message)) { + throw error; + } + } + await pool.close(); + } + console.log("Valgrind tests completed successfully"); } diff --git a/src/async_pool_impl.cpp b/src/async_pool_impl.cpp new file mode 100644 index 0000000..74e12b7 --- /dev/null +++ b/src/async_pool_impl.cpp @@ -0,0 +1,1913 @@ +#include "async_pool_impl.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "shims/sqlite_errors.h" +#include "sqlite_impl.h" + +namespace photostructure::sqlite { +namespace { + +constexpr int64_t kJsMaxSafeInteger = 9007199254740991LL; +constexpr int64_t kJsMinSafeInteger = -9007199254740991LL; + +enum class OperationKind { kRun, kGet, kAll }; + +using Blob = std::vector; +using ValueData = + std::variant; + +struct NativeValue { + ValueData data = nullptr; +}; + +struct NativeParams { + bool named = false; + std::vector> values; +}; + +struct NativeOperation { + OperationKind kind = OperationKind::kRun; + std::string sql; + std::optional params; +}; + +enum class TransactionMode { kNone, kDeferred, kImmediate, kExclusive }; + +struct NativeRequest { + std::vector operations; + TransactionMode transaction = TransactionMode::kNone; +}; + +struct NativeColumn { + std::string name; + NativeValue value; +}; + +using NativeRow = std::vector; + +struct NativeOperationResult { + OperationKind kind = OperationKind::kRun; + int64_t changes = 0; + std::vector rows; +}; + +struct NativeError { + bool present = false; + bool sqlite = false; + bool range = false; + bool fatal = false; + std::string message; + int sqlite_code = SQLITE_ERROR; + int sqlite_extended_code = SQLITE_ERROR; + std::string sqlite_error_string; +}; + +struct NativeResponse { + std::vector results; + NativeError error; +}; + +struct OpenConfiguration { + std::string location; + bool read_big_ints = false; + bool return_arrays = false; + bool strict_authorizer = true; + bool allow_extension = false; + std::vector setup; +}; + +bool AsciiEqualsIgnoreCase(const char *left, const char *right) noexcept { + if (left == nullptr || right == nullptr) { + return left == right; + } + while (*left != '\0' && *right != '\0') { + char a = *left++; + char b = *right++; + if (a >= 'A' && a <= 'Z') { + a = static_cast(a - 'A' + 'a'); + } + if (b >= 'A' && b <= 'Z') { + b = static_cast(b - 'A' + 'a'); + } + if (a != b) { + return false; + } + } + return *left == *right; +} + +class AsyncConnectionState { +public: + explicit AsyncConnectionState(bool read_big_ints, bool return_arrays, + bool strict_authorizer) + : read_big_ints_(read_big_ints), return_arrays_(return_arrays), + strict_authorizer_(strict_authorizer) {} + + ~AsyncConnectionState() { + std::lock_guard lock(handle_mutex_); + // The environment coordinator and close worker must have consumed the + // handle before the last shared owner disappears. + if (db_ != nullptr) { + if (sqlite3_close(db_) == SQLITE_OK) { + db_ = nullptr; + } + } + } + + AsyncConnectionState(const AsyncConnectionState &) = delete; + AsyncConnectionState &operator=(const AsyncConnectionState &) = delete; + AsyncConnectionState(AsyncConnectionState &&) = delete; + AsyncConnectionState &operator=(AsyncConnectionState &&) = delete; + + void Publish(sqlite3 *db) noexcept { + std::lock_guard lock(handle_mutex_); + db_ = db; + } + + sqlite3 *HandleForWorker() const noexcept { + std::lock_guard lock(handle_mutex_); + return db_; + } + + bool IsOpen() const noexcept { + std::lock_guard lock(handle_mutex_); + return db_ != nullptr; + } + + int Close() noexcept { + std::lock_guard lock(handle_mutex_); + if (db_ == nullptr) { + return SQLITE_OK; + } + const int rc = sqlite3_close(db_); + if (rc == SQLITE_OK) { + db_ = nullptr; + } + return rc; + } + + bool read_big_ints() const noexcept { return read_big_ints_; } + bool return_arrays() const noexcept { return return_arrays_; } + bool strict_authorizer() const noexcept { return strict_authorizer_; } + bool strict_authorizer_installed() const noexcept { + return strict_authorizer_installed_; + } + void set_strict_authorizer_installed(bool installed) noexcept { + strict_authorizer_installed_ = installed; + } + + bool trusted_transaction() const noexcept { return trusted_transaction_; } + void set_trusted_transaction(bool trusted) noexcept { + trusted_transaction_ = trusted; + } + + void set_environment(AsyncPoolEnvironment *environment) noexcept { + environment_.store(environment, std::memory_order_release); + } + AsyncPoolEnvironment *environment() const noexcept { + return environment_.load(std::memory_order_acquire); + } + + std::atomic close_requested{false}; + +private: + mutable std::mutex handle_mutex_; + sqlite3 *db_ = nullptr; + const bool read_big_ints_; + const bool return_arrays_; + const bool strict_authorizer_; + // Only read by SQLite callbacks synchronously on the connection's one + // active worker thread. + bool trusted_transaction_ = false; + bool strict_authorizer_installed_ = false; + std::atomic environment_{nullptr}; +}; + +class TrustedTransactionGuard { +public: + explicit TrustedTransactionGuard(AsyncConnectionState *state) noexcept + : state_(state) { + state_->set_trusted_transaction(true); + } + ~TrustedTransactionGuard() { state_->set_trusted_transaction(false); } + TrustedTransactionGuard(const TrustedTransactionGuard &) = delete; + TrustedTransactionGuard &operator=(const TrustedTransactionGuard &) = delete; + TrustedTransactionGuard(TrustedTransactionGuard &&) = delete; + TrustedTransactionGuard &operator=(TrustedTransactionGuard &&) = delete; + +private: + AsyncConnectionState *state_; +}; + +bool IsTempMutation(int action, const char *param1, + const char *database_name) noexcept { + switch (action) { + case SQLITE_CREATE_TEMP_INDEX: + case SQLITE_CREATE_TEMP_TABLE: + case SQLITE_CREATE_TEMP_TRIGGER: + case SQLITE_CREATE_TEMP_VIEW: + case SQLITE_DROP_TEMP_INDEX: + case SQLITE_DROP_TEMP_TABLE: + case SQLITE_DROP_TEMP_TRIGGER: + case SQLITE_DROP_TEMP_VIEW: + return true; + case SQLITE_INSERT: + case SQLITE_UPDATE: + case SQLITE_DELETE: + case SQLITE_CREATE_INDEX: + case SQLITE_CREATE_TABLE: + case SQLITE_CREATE_TRIGGER: + case SQLITE_CREATE_VIEW: + case SQLITE_DROP_INDEX: + case SQLITE_DROP_TABLE: + case SQLITE_DROP_TRIGGER: + case SQLITE_DROP_VIEW: + case SQLITE_CREATE_VTABLE: + case SQLITE_DROP_VTABLE: + case SQLITE_REINDEX: + case SQLITE_ANALYZE: + return AsciiEqualsIgnoreCase(database_name, "temp"); + case SQLITE_ALTER_TABLE: + // SQLITE_ALTER_TABLE is exceptional: its first callback string is the + // database name, rather than the usual fifth callback argument. + return AsciiEqualsIgnoreCase(param1, "temp"); + default: + return false; + } +} + +int StrictAuthorizer(void *user_data, int action, const char *param1, + const char *param2, const char *database_name, + const char * /*trigger*/) noexcept { + auto *state = static_cast(user_data); + switch (action) { + case SQLITE_PRAGMA: + case SQLITE_ATTACH: + case SQLITE_DETACH: + return SQLITE_DENY; + case SQLITE_TRANSACTION: + case SQLITE_SAVEPOINT: + return state->trusted_transaction() ? SQLITE_OK : SQLITE_DENY; + case SQLITE_FUNCTION: + if (AsciiEqualsIgnoreCase(param2, "load_extension") || + AsciiEqualsIgnoreCase(param2, "last_insert_rowid") || + AsciiEqualsIgnoreCase(param2, "changes") || + AsciiEqualsIgnoreCase(param2, "total_changes")) { + return SQLITE_DENY; + } + break; + default: + break; + } + return IsTempMutation(action, param1, database_name) ? SQLITE_DENY + : SQLITE_OK; +} + +int ValidationAuthorizer(void * /*user_data*/, int action, + const char * /*param1*/, const char * /*param2*/, + const char * /*database_name*/, + const char * /*trigger*/) noexcept { + // Some PRAGMAs take effect during sqlite3_prepare_v2(), before step. Ignore + // them while validating the complete SQL tail so a rejected multi-statement + // operation cannot change connection state. The statement is prepared again + // under the real authorizer before execution. + return action == SQLITE_PRAGMA ? SQLITE_IGNORE : SQLITE_OK; +} + +void SetPlainError(NativeError *error, std::string message) { + if (error->present) { + return; + } + error->present = true; + error->message = std::move(message); +} + +void SetRangeError(NativeError *error, int64_t value) { + if (error->present) { + return; + } + error->present = true; + error->range = true; + error->message = "Value is too large to be represented as a JavaScript " + "number: " + + std::to_string(value); +} + +void SetSqliteError(sqlite3 *db, int rc, NativeError *error, + const char *fallback = nullptr) { + if (error->present) { + return; + } + error->present = true; + error->sqlite = true; + error->sqlite_code = db != nullptr ? sqlite3_errcode(db) : (rc & 0xff); + error->sqlite_extended_code = + db != nullptr ? sqlite3_extended_errcode(db) : rc; + const char *message = db != nullptr ? sqlite3_errmsg(db) : nullptr; + if (message != nullptr && std::strcmp(message, "not an error") != 0) { + error->message = message; + } else if (fallback != nullptr) { + error->message = fallback; + } else { + const char *text = sqlite3_errstr(rc); + error->message = text != nullptr ? text : "SQLite error"; + } + const char *error_string = sqlite3_errstr(error->sqlite_code); + if (error_string != nullptr) { + error->sqlite_error_string = error_string; + } +} + +bool CopyViewBytes(Napi::Value value, Blob *out) { + if (value.IsDataView()) { + const Napi::DataView view = value.As(); + Napi::ArrayBuffer buffer = view.ArrayBuffer(); + const size_t length = view.ByteLength(); + if (length == 0) { + out->clear(); + } else { + const auto *start = + static_cast(buffer.Data()) + view.ByteOffset(); + out->assign(start, start + length); + } + return true; + } + if (value.IsBuffer()) { + const Napi::Buffer buffer = value.As>(); + if (buffer.Length() == 0) { + out->clear(); + } else { + out->assign(buffer.Data(), buffer.Data() + buffer.Length()); + } + return true; + } + if (value.IsTypedArray()) { + const Napi::TypedArray view = value.As(); + Napi::ArrayBuffer buffer = view.ArrayBuffer(); + const size_t length = view.ByteLength(); + if (length == 0) { + out->clear(); + } else { + const auto *start = + static_cast(buffer.Data()) + view.ByteOffset(); + out->assign(start, start + length); + } + return true; + } + return false; +} + +bool ParseValue(Napi::Env env, Napi::Value value, NativeValue *out, + std::string *message) { + if (value.IsNull()) { + out->data = nullptr; + return true; + } + if (value.IsBigInt()) { + bool lossless = false; + const int64_t integer = value.As().Int64Value(&lossless); + if (!lossless) { + *message = "BigInt value is too large to bind to SQLite"; + return false; + } + out->data = integer; + return true; + } + if (value.IsNumber()) { + const double number = value.As().DoubleValue(); + if (std::isfinite(number) && std::trunc(number) == number && + number >= static_cast(INT32_MIN) && + number <= static_cast(INT32_MAX)) { + out->data = static_cast(number); + } else { + out->data = number; + } + return true; + } + if (value.IsString()) { + out->data = value.As().Utf8Value(); + return true; + } + Blob blob; + if (CopyViewBytes(value, &blob)) { + out->data = std::move(blob); + return true; + } + *message = + "Bind parameter must be null, number, bigint, string, or ArrayBufferView"; + return false; +} + +bool ParseParams(Napi::Env env, Napi::Value value, + std::optional *out, std::string *message) { + if (value.IsUndefined()) { + out->reset(); + return true; + } + + NativeParams params; + if (value.IsArray()) { + const Napi::Array array = value.As(); + params.values.reserve(array.Length()); + for (uint32_t index = 0; index < array.Length(); ++index) { + NativeValue native; + if (!ParseValue(env, array.Get(index), &native, message)) { + return false; + } + params.values.emplace_back(std::string(), std::move(native)); + } + } else if (value.IsObject() && !value.IsTypedArray() && !value.IsDataView() && + !value.IsBuffer() && !value.IsArrayBuffer()) { + params.named = true; + const Napi::Object object = value.As(); + const Napi::Array keys = object.GetPropertyNames(); + params.values.reserve(keys.Length()); + for (uint32_t index = 0; index < keys.Length(); ++index) { + const std::string key = keys.Get(index).As().Utf8Value(); + NativeValue native; + if (!ParseValue(env, object.Get(key), &native, message)) { + return false; + } + params.values.emplace_back(key, std::move(native)); + } + } else { + *message = "Parameters must be an array or plain object"; + return false; + } + + *out = std::move(params); + return true; +} + +bool ParseOperation(Napi::Env env, Napi::Value value, NativeOperation *out, + std::string *message) { + if (!value.IsObject() || value.IsArray()) { + *message = "Operation descriptor must be an object"; + return false; + } + const Napi::Object object = value.As(); + const Napi::Value kind_value = object.Get("kind"); + const Napi::Value sql_value = object.Get("sql"); + if (!kind_value.IsString() || !sql_value.IsString()) { + *message = "Operation descriptor requires string kind and sql fields"; + return false; + } + const std::string kind = kind_value.As().Utf8Value(); + if (kind == "run") { + out->kind = OperationKind::kRun; + } else if (kind == "get") { + out->kind = OperationKind::kGet; + } else if (kind == "all") { + out->kind = OperationKind::kAll; + } else { + *message = "Operation kind must be run, get, or all"; + return false; + } + out->sql = sql_value.As().Utf8Value(); + return ParseParams(env, object.Get("params"), &out->params, message); +} + +} // namespace + +namespace { + +class PoolWorker; +class AsyncPoolConnection; +NativeResponse +ExecuteRequest(const std::shared_ptr &state, + const NativeRequest &request); +bool RunSetup(sqlite3 *db, AsyncConnectionState *state, + const std::vector &setup, NativeError *error); + +} // namespace + +class AsyncPoolEnvironment { +public: + AsyncPoolEnvironment(napi_env env, AddonData *addon_data) + : env_(env), addon_data_(addon_data) {} + ~AsyncPoolEnvironment(); + + AsyncPoolEnvironment(const AsyncPoolEnvironment &) = delete; + AsyncPoolEnvironment &operator=(const AsyncPoolEnvironment &) = delete; + AsyncPoolEnvironment(AsyncPoolEnvironment &&) = delete; + AsyncPoolEnvironment &operator=(AsyncPoolEnvironment &&) = delete; + + bool Initialize(); + bool shutting_down() const noexcept { return shutting_down_; } + napi_env env() const noexcept { return env_; } + AddonData *addon_data() const noexcept { return addon_data_; } + + void AddState(const std::shared_ptr &state); + bool QueueWorker(PoolWorker *worker, + const std::shared_ptr &state); + bool AttachCloseDeferred(const std::shared_ptr &state, + const Napi::Promise::Deferred &deferred); + void WorkerDestroyed(const std::shared_ptr &state, + bool was_close_worker) noexcept; + void RequestClose(const std::shared_ptr &state); + +private: + static void CleanupHook(napi_async_cleanup_hook_handle handle, + void *data) noexcept; + + void BeginCleanup(napi_async_cleanup_hook_handle handle) noexcept; + void + QueueCloseIfIdle(const std::shared_ptr &state) noexcept; + void RemoveClosedStates() noexcept; + void TryFinishCleanup() noexcept; + void FinishCleanup() noexcept; + + napi_env env_; + AddonData *addon_data_; + napi_async_cleanup_hook_handle cleanup_hook_ = nullptr; + bool shutting_down_ = false; + bool hook_started_ = false; + bool hook_finished_ = false; + std::vector> states_; + std::unordered_map active_workers_; +}; + +namespace { + +class PoolWorker { +public: + PoolWorker(Napi::Env env, const char *resource_name, + AsyncPoolEnvironment *environment, + std::shared_ptr state, + std::optional deferred, + bool close_worker = false) + : env_(env), environment_(environment), state_(std::move(state)), + close_worker_(close_worker) { + if (deferred.has_value()) { + deferreds_.push_back(*deferred); + } + napi_value resource; + napi_value name; + napi_status status = napi_create_object(env_, &resource); + if (status == napi_ok) { + status = napi_create_string_latin1(env_, resource_name, NAPI_AUTO_LENGTH, + &name); + } + if (status == napi_ok) { + status = napi_create_async_work(env_, resource, name, ExecuteThunk, + CompleteThunk, this, &work_); + } + if (status != napi_ok) { + throw Napi::Error::New(env, "Failed to create async SQLite work"); + } + } + + virtual ~PoolWorker() { + if (work_ != nullptr) { + (void)napi_delete_async_work(env_, work_); + work_ = nullptr; + } + } + + PoolWorker(const PoolWorker &) = delete; + PoolWorker &operator=(const PoolWorker &) = delete; + PoolWorker(PoolWorker &&) = delete; + PoolWorker &operator=(PoolWorker &&) = delete; + + bool Queue() noexcept { + return napi_queue_async_work(env_, work_) == napi_ok; + } + + virtual bool + AttachCloseDeferred(const Napi::Promise::Deferred & /*deferred*/) { + return false; + } + +protected: + virtual void Execute() = 0; + virtual void OnOK() = 0; + + Napi::Env Env() const { return Napi::Env(env_); } + + void Resolve(Napi::Value value) { + for (const Napi::Promise::Deferred &deferred : deferreds_) { + deferred.Resolve(value); + } + } + + void Reject(Napi::Value value) { + for (const Napi::Promise::Deferred &deferred : deferreds_) { + deferred.Reject(value); + } + } + + bool has_deferred() const noexcept { return !deferreds_.empty(); } + void AddDeferred(const Napi::Promise::Deferred &deferred) { + deferreds_.push_back(deferred); + } + const std::shared_ptr &state() const noexcept { + return state_; + } + AsyncPoolEnvironment *environment() const noexcept { return environment_; } + +private: + static void ExecuteThunk(napi_env /*env*/, void *data) noexcept { + auto *worker = static_cast(data); + try { + worker->Execute(); + } catch (...) { + // Native allocation and conversion can throw. Once execution has begun, + // conservatively discard the connection rather than return a possibly + // transactional or partially configured handle to the scheduler. + worker->unexpected_error_ = true; + worker->state_->close_requested.store(true, std::memory_order_release); + (void)worker->state_->Close(); + } + } + + static void CompleteThunk(napi_env env, napi_status status, + void *data) noexcept { + auto *worker = static_cast(data); + if (!worker->environment_->shutting_down() && status != napi_cancelled) { + try { + Napi::HandleScope scope(env); + if (worker->unexpected_error_) { + Napi::Error error = Napi::Error::New( + env, "Unexpected native error during async SQLite work"); + error.Set("fatal", Napi::Boolean::New(env, true)); + worker->Reject(error.Value()); + } else { + worker->OnOK(); + } + } catch (const Napi::Error &error) { + try { + worker->Reject(error.Value()); + } catch (...) { + } + } catch (...) { + try { + worker->Reject( + Napi::Error::New(env, "Native async SQLite completion failed") + .Value()); + } catch (...) { + } + } + } + worker->DestroyAndNotify(); + } + + void DestroyAndNotify() noexcept { + AsyncPoolEnvironment *environment = environment_; + std::shared_ptr state = state_; + const bool close_worker = close_worker_; + if (work_ != nullptr) { + (void)napi_delete_async_work(env_, work_); + work_ = nullptr; + } + delete this; + environment->WorkerDestroyed(state, close_worker); + } + + napi_env env_; + napi_async_work work_ = nullptr; + AsyncPoolEnvironment *environment_; + std::shared_ptr state_; + std::vector deferreds_; + bool close_worker_; + bool unexpected_error_ = false; +}; + +Napi::Error CreateNativeError(Napi::Env env, const NativeError &native) { + Napi::Error error = + native.range ? Napi::Error(Napi::RangeError::New(env, native.message)) + : Napi::Error::New(env, native.message); + if (native.range) { + error.Set("code", Napi::String::New(env, "ERR_OUT_OF_RANGE")); + } + if (native.sqlite) { + error.Set("code", Napi::String::New(env, "ERR_SQLITE_ERROR")); + error.Set("errcode", Napi::Number::New(env, native.sqlite_code)); + error.Set("errstr", Napi::String::New(env, native.sqlite_error_string)); + error.Set("sqliteCode", Napi::Number::New(env, native.sqlite_code)); + error.Set("sqliteExtendedCode", + Napi::Number::New(env, native.sqlite_extended_code)); + error.Set("sqliteCodeName", + Napi::String::New( + env, node::GetSqliteErrorCodeName(native.sqlite_code))); + error.Set("sqliteErrorString", + Napi::String::New(env, native.sqlite_error_string)); + } + if (native.fatal) { + error.Set("fatal", Napi::Boolean::New(env, true)); + } + return error; +} + +bool ToJsValue(Napi::Env env, const NativeValue &native, bool read_big_ints, + napi_value *out, NativeError *error) { + if (std::holds_alternative(native.data)) { + *out = env.Null(); + return true; + } + if (const auto *integer = std::get_if(&native.data)) { + if (read_big_ints) { + *out = Napi::BigInt::New(env, *integer); + return true; + } + if (*integer > kJsMaxSafeInteger || *integer < kJsMinSafeInteger) { + SetRangeError(error, *integer); + return false; + } + *out = Napi::Number::New(env, static_cast(*integer)); + return true; + } + if (const auto *number = std::get_if(&native.data)) { + *out = Napi::Number::New(env, *number); + return true; + } + if (const auto *text = std::get_if(&native.data)) { + *out = Napi::String::New(env, text->data(), text->size()); + return true; + } + const Blob &blob = std::get(native.data); + Napi::ArrayBuffer array_buffer = Napi::ArrayBuffer::New(env, blob.size()); + if (!blob.empty()) { + std::memcpy(array_buffer.Data(), blob.data(), blob.size()); + } + *out = Napi::Uint8Array::New(env, blob.size(), array_buffer, 0); + return true; +} + +bool ToJsRow(Napi::Env env, const NativeRow &row, + const AsyncConnectionState &state, napi_value *out, + NativeError *error) { + if (state.return_arrays()) { + Napi::Array array = Napi::Array::New(env, row.size()); + for (size_t index = 0; index < row.size(); ++index) { + napi_value value; + if (!ToJsValue(env, row[index].value, state.read_big_ints(), &value, + error)) { + return false; + } + array.Set(static_cast(index), value); + } + *out = array; + return true; + } + + Napi::Object object = CreateObjectWithNullPrototype(env); + for (const NativeColumn &column : row) { + napi_value value; + if (!ToJsValue(env, column.value, state.read_big_ints(), &value, error)) { + return false; + } + object.Set(column.name, value); + } + *out = object; + return true; +} + +bool ToJsResults(Napi::Env env, const NativeResponse &response, + const AsyncConnectionState &state, Napi::Array *out, + NativeError *error) { + *out = Napi::Array::New(env, response.results.size()); + for (size_t index = 0; index < response.results.size(); ++index) { + const NativeOperationResult &native = response.results[index]; + napi_value result; + if (native.kind == OperationKind::kRun) { + Napi::Object run = Napi::Object::New(env); + if (state.read_big_ints()) { + run.Set("changes", Napi::BigInt::New(env, native.changes)); + } else { + run.Set("changes", + Napi::Number::New(env, static_cast(native.changes))); + } + result = run; + } else if (native.kind == OperationKind::kGet) { + if (native.rows.empty()) { + result = env.Undefined(); + } else if (!ToJsRow(env, native.rows.front(), state, &result, error)) { + return false; + } + } else { + Napi::Array rows = Napi::Array::New(env, native.rows.size()); + for (size_t row_index = 0; row_index < native.rows.size(); ++row_index) { + napi_value row; + if (!ToJsRow(env, native.rows[row_index], state, &row, error)) { + return false; + } + rows.Set(static_cast(row_index), row); + } + result = rows; + } + out->Set(static_cast(index), result); + } + return true; +} + +class AsyncPoolConnection : public Napi::ObjectWrap { +public: + static Napi::Function CreateConstructor(Napi::Env env) { + return DefineClass( + env, "AsyncPoolConnection", + {InstanceMethod("execute", &AsyncPoolConnection::Execute), + InstanceMethod("close", &AsyncPoolConnection::Close)}); + } + + static Napi::Object + NewInstance(const std::shared_ptr &state, + AddonData *addon_data) { + Napi::Object object = addon_data->asyncPoolConnectionConstructor.New( + {addon_data->asyncPoolConnectionToken.Value()}); + AsyncPoolConnection *connection = Unwrap(object); + connection->state_ = state; + return object; + } + + explicit AsyncPoolConnection(const Napi::CallbackInfo &info) + : Napi::ObjectWrap(info) { + AddonData *addon_data = GetAddonData(info.Env()); + if (info.Length() != 1 || addon_data == nullptr || + addon_data->asyncPoolConnectionToken.IsEmpty() || + !info[0].StrictEquals(addon_data->asyncPoolConnectionToken.Value())) { + throw Napi::Error::New(info.Env(), "Illegal constructor"); + } + } + + ~AsyncPoolConnection() override { + if (state_ != nullptr && state_->IsOpen()) { + AsyncPoolEnvironment *environment = state_->environment(); + if (environment != nullptr) { + environment->RequestClose(state_); + } + } + } + + AsyncPoolConnection(const AsyncPoolConnection &) = delete; + AsyncPoolConnection &operator=(const AsyncPoolConnection &) = delete; + AsyncPoolConnection(AsyncPoolConnection &&) = delete; + AsyncPoolConnection &operator=(AsyncPoolConnection &&) = delete; + +private: + Napi::Value Execute(const Napi::CallbackInfo &info); + Napi::Value Close(const Napi::CallbackInfo &info); + + std::shared_ptr state_; +}; + +class OpenWorker final : public PoolWorker { +public: + OpenWorker(Napi::Env env, AsyncPoolEnvironment *environment, + std::shared_ptr state, + OpenConfiguration configuration, Napi::Promise::Deferred deferred) + : PoolWorker(env, "photostructure.sqlite.pool.open", environment, + std::move(state), deferred), + configuration_(std::move(configuration)) {} + + void Execute() override { + sqlite3 *db = nullptr; + int rc = sqlite3_open_v2(configuration_.location.c_str(), &db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | + SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nullptr); + if (db != nullptr) { + state()->Publish(db); + sqlite3_extended_result_codes(db, 1); + } + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, &response_.error); + state()->close_requested.store(true, std::memory_order_release); + (void)state()->Close(); + return; + } + bool extension_enabled = false; + if (configuration_.allow_extension) { + rc = sqlite3_enable_load_extension(db, 1); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, &response_.error, + "Failed to enable extension loading"); + } else { + extension_enabled = true; + } + } + + if (!response_.error.present && + !RunSetup(db, state().get(), configuration_.setup, &response_.error)) { + // Error is already captured. + } + + if (extension_enabled) { + rc = sqlite3_enable_load_extension(db, 0); + if (rc != SQLITE_OK && !response_.error.present) { + SetSqliteError(db, rc, &response_.error, + "Failed to disable extension loading"); + } + } + + if (!response_.error.present && sqlite3_get_autocommit(db) == 0) { + SetPlainError(&response_.error, + "Connection setup left a transaction open; autocommit is " + "required"); + } + + if (!response_.error.present && configuration_.strict_authorizer) { + rc = sqlite3_set_authorizer(db, StrictAuthorizer, state().get()); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, &response_.error, + "Failed to install strict authorizer"); + } else { + state()->set_strict_authorizer_installed(true); + } + } + + if (response_.error.present) { + state()->close_requested.store(true, std::memory_order_release); + (void)state()->Close(); + } + } + + void OnOK() override { + Napi::Env env = Env(); + if (response_.error.present) { + Reject(CreateNativeError(env, response_.error).Value()); + return; + } + try { + Napi::Object connection = AsyncPoolConnection::NewInstance( + state(), environment()->addon_data()); + Resolve(connection); + } catch (...) { + // A completed open has published a live sqlite3 handle. If allocating its + // JavaScript wrapper fails, close it after this worker is destroyed + // instead of retaining an unreachable connection until environment exit. + state()->close_requested.store(true, std::memory_order_release); + throw; + } + } + +private: + OpenConfiguration configuration_; + NativeResponse response_; +}; + +class RequestWorker final : public PoolWorker { +public: + RequestWorker(Napi::Env env, AsyncPoolEnvironment *environment, + std::shared_ptr state, + NativeRequest request, Napi::Promise::Deferred deferred) + : PoolWorker(env, "photostructure.sqlite.pool.request", environment, + std::move(state), deferred), + request_(std::move(request)) {} + + void Execute() override { response_ = ExecuteRequest(state(), request_); } + + void OnOK() override { + Napi::Env env = Env(); + if (response_.error.present) { + Reject(CreateNativeError(env, response_.error).Value()); + return; + } + Napi::Array results; + NativeError conversion_error; + if (!ToJsResults(env, response_, *state(), &results, &conversion_error)) { + Reject(CreateNativeError(env, conversion_error).Value()); + return; + } + Resolve(results); + } + +private: + NativeRequest request_; + NativeResponse response_; +}; + +class CloseWorker final : public PoolWorker { +public: + CloseWorker(Napi::Env env, AsyncPoolEnvironment *environment, + std::shared_ptr state, + std::optional deferred) + : PoolWorker(env, "photostructure.sqlite.pool.close", environment, + std::move(state), deferred, true) {} + + bool AttachCloseDeferred(const Napi::Promise::Deferred &deferred) override { + AddDeferred(deferred); + return true; + } + + void Execute() override { + const int rc = state()->Close(); + if (rc != SQLITE_OK) { + SetPlainError(&error_, + "Failed to close SQLite connection: outstanding native " + "resources remain"); + } + } + + void OnOK() override { + if (!has_deferred()) { + return; + } + if (error_.present) { + Reject(CreateNativeError(Env(), error_).Value()); + } else { + Resolve(Env().Undefined()); + } + } + +private: + NativeError error_; +}; + +} // namespace + +namespace { + +class StatementHolder { +public: + StatementHolder() = default; + ~StatementHolder() { + if (statement_ != nullptr) { + sqlite3_finalize(statement_); + } + } + StatementHolder(const StatementHolder &) = delete; + StatementHolder &operator=(const StatementHolder &) = delete; + StatementHolder(StatementHolder &&) = delete; + StatementHolder &operator=(StatementHolder &&) = delete; + + sqlite3_stmt **Out() { return &statement_; } + sqlite3_stmt *get() const { return statement_; } + + int Finalize() noexcept { + if (statement_ == nullptr) { + return SQLITE_OK; + } + sqlite3_stmt *statement = statement_; + statement_ = nullptr; + return sqlite3_finalize(statement); + } + +private: + sqlite3_stmt *statement_ = nullptr; +}; + +class ValidationAuthorizerGuard { +public: + ValidationAuthorizerGuard(sqlite3 *db, AsyncConnectionState *state, + NativeError *error) + : db_(db), state_(state) { + const int rc = sqlite3_set_authorizer(db_, ValidationAuthorizer, nullptr); + if (rc == SQLITE_OK) { + installed_ = true; + } else { + SetSqliteError(db_, rc, error, + "Failed to install SQL validation authorizer"); + } + } + + ~ValidationAuthorizerGuard() { + if (installed_) { + (void)Restore(); + } + } + + ValidationAuthorizerGuard(const ValidationAuthorizerGuard &) = delete; + ValidationAuthorizerGuard & + operator=(const ValidationAuthorizerGuard &) = delete; + ValidationAuthorizerGuard(ValidationAuthorizerGuard &&) = delete; + ValidationAuthorizerGuard &operator=(ValidationAuthorizerGuard &&) = delete; + + bool installed() const noexcept { return installed_; } + + int Restore() noexcept { + if (!installed_) { + return SQLITE_OK; + } + installed_ = false; + return state_->strict_authorizer_installed() + ? sqlite3_set_authorizer(db_, StrictAuthorizer, state_) + : sqlite3_set_authorizer(db_, nullptr, nullptr); + } + +private: + sqlite3 *db_; + AsyncConnectionState *state_; + bool installed_ = false; +}; + +bool PrepareExactlyOne(sqlite3 *db, AsyncConnectionState *state, + const std::string &sql, StatementHolder *first, + NativeError *error) { + if (sql.find('\0') != std::string::npos) { + SetPlainError(error, "SQL must not contain null bytes"); + return false; + } + const char *cursor = sql.c_str(); + bool found = false; + ValidationAuthorizerGuard validation_authorizer(db, state, error); + if (!validation_authorizer.installed()) { + return false; + } + + while (cursor != nullptr && *cursor != '\0') { + const char *tail = nullptr; + StatementHolder candidate; + const int rc = sqlite3_prepare_v2(db, cursor, -1, candidate.Out(), &tail); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, error); + return false; + } + if (candidate.get() != nullptr) { + if (found) { + SetPlainError(error, + "Operation contains multiple statements; exactly one " + "SQL statement is required"); + return false; + } + // StatementHolder is deliberately non-movable. Transfer the raw pointer + // through finalize-safe ownership by preparing the first statement once + // more after the tail has been validated below. + found = true; + } + if (tail == nullptr || tail <= cursor) { + break; + } + cursor = tail; + } + + if (!found) { + SetPlainError(error, + "Each operation must contain exactly one SQL statement"); + return false; + } + + const int restore_rc = validation_authorizer.Restore(); + if (restore_rc != SQLITE_OK) { + SetSqliteError(db, restore_rc, error, + "Failed to restore SQLite authorizer after SQL validation"); + error->fatal = true; + state->close_requested.store(true, std::memory_order_release); + return false; + } + + const char *tail = nullptr; + const int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, first->Out(), &tail); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, error); + return false; + } + // The first prepare can yield no statement when SQL begins with comments. + // Walk to the first executable statement while retaining only that one. + cursor = tail; + while (first->get() == nullptr && cursor != nullptr && *cursor != '\0') { + const char *next = nullptr; + const int next_rc = sqlite3_prepare_v2(db, cursor, -1, first->Out(), &next); + if (next_rc != SQLITE_OK) { + SetSqliteError(db, next_rc, error); + return false; + } + if (next == nullptr || next <= cursor) { + break; + } + cursor = next; + } + if (first->get() == nullptr) { + SetPlainError(error, + "Each operation must contain exactly one SQL statement"); + return false; + } + return true; +} + +int BindValue(sqlite3_stmt *statement, int index, const NativeValue &value) { + if (std::holds_alternative(value.data)) { + return sqlite3_bind_null(statement, index); + } + if (const auto *integer = std::get_if(&value.data)) { + return sqlite3_bind_int64(statement, index, + static_cast(*integer)); + } + if (const auto *number = std::get_if(&value.data)) { + return sqlite3_bind_double(statement, index, *number); + } + if (const auto *text = std::get_if(&value.data)) { + return sqlite3_bind_text64(statement, index, text->data(), text->size(), + SQLITE_TRANSIENT, SQLITE_UTF8); + } + const Blob &blob = std::get(value.data); + const void *data = blob.empty() ? static_cast("") + : static_cast(blob.data()); + return sqlite3_bind_blob64(statement, index, data, blob.size(), + SQLITE_TRANSIENT); +} + +bool BindParameters(sqlite3 *db, sqlite3_stmt *statement, + const std::optional ¶ms, + NativeError *error) { + if (!params.has_value()) { + return true; + } + + if (!params->named) { + int index = 1; + for (const auto &[unused, value] : params->values) { + (void)unused; + while (true) { + const char *name = sqlite3_bind_parameter_name(statement, index); + if (name == nullptr || name[0] == '?') { + break; + } + ++index; + } + const int rc = BindValue(statement, index++, value); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, error); + return false; + } + } + return true; + } + + std::unordered_map bare_names; + const int parameter_count = sqlite3_bind_parameter_count(statement); + for (int index = 1; index <= parameter_count; ++index) { + const char *name = sqlite3_bind_parameter_name(statement, index); + if (name == nullptr || (*name != ':' && *name != '$' && *name != '@')) { + continue; + } + const std::string bare(name + 1); + const auto [entry, inserted] = bare_names.emplace(bare, name); + if (!inserted && entry->second != name) { + SetPlainError(error, "Cannot bind bare named parameter '" + bare + + "' because of conflicting names '" + + entry->second + "' and '" + name + "'"); + return false; + } + } + + for (const auto &[key, value] : params->values) { + int index = sqlite3_bind_parameter_index(statement, key.c_str()); + if (index == 0) { + const auto found = bare_names.find(key); + if (found != bare_names.end()) { + index = sqlite3_bind_parameter_index(statement, found->second.c_str()); + } + } + if (index == 0) { + SetPlainError(error, "Unknown named parameter '" + key + "'"); + return false; + } + const int rc = BindValue(statement, index, value); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, error); + return false; + } + } + return true; +} + +bool ReadRow(sqlite3 *db, sqlite3_stmt *statement, + const AsyncConnectionState *state, NativeRow *row, + NativeError *error) { + const int count = sqlite3_column_count(statement); + row->reserve(static_cast(count)); + for (int index = 0; index < count; ++index) { + const char *column_name = sqlite3_column_name(statement, index); + if (column_name == nullptr) { + SetSqliteError(db, SQLITE_NOMEM, error, "Cannot read column name"); + return false; + } + + NativeColumn column; + column.name = column_name; + switch (sqlite3_column_type(statement, index)) { + case SQLITE_NULL: + column.value.data = nullptr; + break; + case SQLITE_INTEGER: + column.value.data = + static_cast(sqlite3_column_int64(statement, index)); + if (!state->read_big_ints()) { + const int64_t value = std::get(column.value.data); + if (value > kJsMaxSafeInteger || value < kJsMinSafeInteger) { + SetRangeError(error, value); + return false; + } + } + break; + case SQLITE_FLOAT: + column.value.data = sqlite3_column_double(statement, index); + break; + case SQLITE_TEXT: { + const unsigned char *text = sqlite3_column_text(statement, index); + const int bytes = sqlite3_column_bytes(statement, index); + if (text == nullptr && bytes != 0) { + SetSqliteError(db, SQLITE_NOMEM, error, "Cannot read text column"); + return false; + } + column.value.data = std::string( + text != nullptr ? reinterpret_cast(text) : "", + static_cast(bytes)); + break; + } + case SQLITE_BLOB: { + const void *data = sqlite3_column_blob(statement, index); + const int bytes = sqlite3_column_bytes(statement, index); + Blob blob(static_cast(bytes)); + if (bytes > 0) { + if (data == nullptr) { + SetSqliteError(db, SQLITE_NOMEM, error, "Cannot read blob column"); + return false; + } + std::memcpy(blob.data(), data, static_cast(bytes)); + } + column.value.data = std::move(blob); + break; + } + default: + column.value.data = nullptr; + break; + } + row->push_back(std::move(column)); + } + return true; +} + +bool ExecuteOperation(sqlite3 *db, AsyncConnectionState *state, + const NativeOperation &operation, + NativeOperationResult *result, NativeError *error) { + StatementHolder statement; + if (!PrepareExactlyOne(db, state, operation.sql, &statement, error) || + !BindParameters(db, statement.get(), operation.params, error)) { + return false; + } + + result->kind = operation.kind; + const sqlite3_int64 changes_before = sqlite3_total_changes64(db); + + if (operation.kind == OperationKind::kGet) { + const int rc = sqlite3_step(statement.get()); + if (rc == SQLITE_ROW) { + NativeRow row; + if (!ReadRow(db, statement.get(), state, &row, error)) { + return false; + } + result->rows.push_back(std::move(row)); + } else if (rc != SQLITE_DONE) { + SetSqliteError(db, rc, error); + return false; + } + } else { + while (true) { + const int rc = sqlite3_step(statement.get()); + if (rc == SQLITE_ROW) { + if (operation.kind == OperationKind::kAll) { + NativeRow row; + if (!ReadRow(db, statement.get(), state, &row, error)) { + return false; + } + result->rows.push_back(std::move(row)); + } + continue; + } + if (rc == SQLITE_DONE) { + break; + } + SetSqliteError(db, rc, error); + return false; + } + } + + const int finalize_rc = statement.Finalize(); + if (finalize_rc != SQLITE_OK) { + SetSqliteError(db, finalize_rc, error); + return false; + } + + if (operation.kind == OperationKind::kRun && + sqlite3_total_changes64(db) != changes_before) { + result->changes = static_cast(sqlite3_changes64(db)); + } + return true; +} + +bool ExecuteControl(sqlite3 *db, AsyncConnectionState *state, const char *sql, + NativeError *error) { + TrustedTransactionGuard trusted(state); + StatementHolder statement; + if (!PrepareExactlyOne(db, state, sql, &statement, error)) { + return false; + } + int rc = sqlite3_step(statement.get()); + if (rc != SQLITE_DONE) { + SetSqliteError(db, rc, error); + return false; + } + rc = statement.Finalize(); + if (rc != SQLITE_OK) { + SetSqliteError(db, rc, error); + return false; + } + return true; +} + +bool RollbackAndVerify(sqlite3 *db, AsyncConnectionState *state) { + if (sqlite3_get_autocommit(db) != 0) { + return true; + } + NativeError ignored; + if (!ExecuteControl(db, state, "ROLLBACK", &ignored)) { + return false; + } + return sqlite3_get_autocommit(db) != 0; +} + +const char *BeginSql(TransactionMode mode) { + switch (mode) { + case TransactionMode::kDeferred: + return "BEGIN DEFERRED"; + case TransactionMode::kImmediate: + return "BEGIN IMMEDIATE"; + case TransactionMode::kExclusive: + return "BEGIN EXCLUSIVE"; + case TransactionMode::kNone: + return nullptr; + } + return nullptr; +} + +NativeResponse +ExecuteRequest(const std::shared_ptr &state, + const NativeRequest &request) { + NativeResponse response; + sqlite3 *db = state->HandleForWorker(); + if (db == nullptr) { + SetPlainError(&response.error, "Database connection is closed"); + return response; + } + + const char *begin = BeginSql(request.transaction); + if (begin != nullptr && + !ExecuteControl(db, state.get(), begin, &response.error)) { + return response; + } + + response.results.reserve(request.operations.size()); + for (const NativeOperation &operation : request.operations) { + NativeOperationResult result; + if (!ExecuteOperation(db, state.get(), operation, &result, + &response.error)) { + break; + } + response.results.push_back(std::move(result)); + } + + if (response.error.present) { + if (sqlite3_get_autocommit(db) == 0 && + !RollbackAndVerify(db, state.get())) { + state->close_requested.store(true, std::memory_order_release); + response.error.fatal = true; + } + return response; + } + + if (begin != nullptr) { + if (!ExecuteControl(db, state.get(), "COMMIT", &response.error)) { + if (!RollbackAndVerify(db, state.get())) { + state->close_requested.store(true, std::memory_order_release); + response.error.fatal = true; + } + return response; + } + } + + if (sqlite3_get_autocommit(db) == 0) { + const bool clean = RollbackAndVerify(db, state.get()); + SetPlainError(&response.error, + "Operation left the connection in a transaction; it was " + "rolled back to restore autocommit"); + if (!clean) { + state->close_requested.store(true, std::memory_order_release); + response.error.fatal = true; + } + } + return response; +} + +bool RunSetup(sqlite3 *db, AsyncConnectionState *state, + const std::vector &setup, NativeError *error) { + for (const NativeOperation &operation : setup) { + NativeOperationResult ignored; + if (!ExecuteOperation(db, state, operation, &ignored, error)) { + return false; + } + if (sqlite3_get_autocommit(db) == 0) { + SetPlainError(error, + "Connection setup left a transaction open; autocommit is " + "required"); + return false; + } + } + return true; +} + +} // namespace + +AsyncPoolEnvironment::~AsyncPoolEnvironment() { + for (const auto &state : states_) { + state->set_environment(nullptr); + } + if (cleanup_hook_ != nullptr && !hook_started_) { + napi_async_cleanup_hook_handle handle = cleanup_hook_; + cleanup_hook_ = nullptr; + napi_remove_async_cleanup_hook(handle); + } +} + +bool AsyncPoolEnvironment::Initialize() { + const napi_status status = + napi_add_async_cleanup_hook(env_, CleanupHook, this, &cleanup_hook_); + return status == napi_ok; +} + +void AsyncPoolEnvironment::AddState( + const std::shared_ptr &state) { + state->set_environment(this); + states_.push_back(state); +} + +bool AsyncPoolEnvironment::QueueWorker( + PoolWorker *worker, const std::shared_ptr &state) { + const auto [entry, inserted] = active_workers_.emplace(state.get(), worker); + if (!inserted) { + delete worker; + return false; + } + try { + if (worker->Queue()) { + return true; + } + active_workers_.erase(entry); + delete worker; + return false; + } catch (...) { + active_workers_.erase(entry); + delete worker; + return false; + } +} + +bool AsyncPoolEnvironment::AttachCloseDeferred( + const std::shared_ptr &state, + const Napi::Promise::Deferred &deferred) { + const auto found = active_workers_.find(state.get()); + return found != active_workers_.end() && + found->second->AttachCloseDeferred(deferred); +} + +void AsyncPoolEnvironment::WorkerDestroyed( + const std::shared_ptr &state, + bool was_close_worker) noexcept { + active_workers_.erase(state.get()); + + if (!was_close_worker && (shutting_down_ || state->close_requested.load( + std::memory_order_acquire))) { + QueueCloseIfIdle(state); + } + RemoveClosedStates(); + TryFinishCleanup(); +} + +void AsyncPoolEnvironment::RequestClose( + const std::shared_ptr &state) { + state->close_requested.store(true, std::memory_order_release); + QueueCloseIfIdle(state); + RemoveClosedStates(); +} + +void AsyncPoolEnvironment::QueueCloseIfIdle( + const std::shared_ptr &state) noexcept { + if (!state->IsOpen() || active_workers_.count(state.get()) != 0) { + return; + } + if (shutting_down_) { + // Cleanup hooks run without a V8 HandleScope and JavaScript execution is + // disallowed. Do not construct new napi_async_work here. With no active + // worker and no persistent statements, sqlite3_close is non-blocking in + // the ordinary case; SQLITE_BUSY deliberately leaves the hook pending as + // a visible invariant failure. + (void)state->Close(); + return; + } + try { + auto *worker = new CloseWorker(Napi::Env(env_), this, state, std::nullopt); + (void)QueueWorker(worker, state); + } catch (...) { + // Keep the state registered and the async cleanup hook alive. Silently + // dropping or zombie-closing the handle would hide a teardown failure. + } +} + +void AsyncPoolEnvironment::RemoveClosedStates() noexcept { + states_.erase(std::remove_if(states_.begin(), states_.end(), + [](const auto &state) { + if (state->IsOpen()) { + return false; + } + state->set_environment(nullptr); + return true; + }), + states_.end()); +} + +void AsyncPoolEnvironment::CleanupHook(napi_async_cleanup_hook_handle handle, + void *data) noexcept { + static_cast(data)->BeginCleanup(handle); +} + +void AsyncPoolEnvironment::BeginCleanup( + napi_async_cleanup_hook_handle handle) noexcept { + hook_started_ = true; + shutting_down_ = true; + cleanup_hook_ = handle; + + for (const auto &state : states_) { + state->close_requested.store(true, std::memory_order_release); + } + + // Node drains every queued napi_async_work completion before invoking + // environment cleanup hooks. TryFinishCleanup retains the hook and all state + // if that ordering invariant changes or sqlite3_close reports SQLITE_BUSY. + TryFinishCleanup(); +} + +void AsyncPoolEnvironment::TryFinishCleanup() noexcept { + if (!hook_started_ || hook_finished_) { + return; + } + if (!active_workers_.empty()) { + return; + } + + for (const auto &state : states_) { + (void)state->Close(); + } + RemoveClosedStates(); + if (states_.empty()) { + FinishCleanup(); + } +} + +void AsyncPoolEnvironment::FinishCleanup() noexcept { + if (hook_finished_ || cleanup_hook_ == nullptr) { + return; + } + hook_finished_ = true; + napi_async_cleanup_hook_handle handle = cleanup_hook_; + cleanup_hook_ = nullptr; + (void)napi_remove_async_cleanup_hook(handle); +} + +namespace { + +TransactionMode ParseTransaction(Napi::Value value, std::string *message) { + if (value.IsUndefined()) { + return TransactionMode::kNone; + } + if (!value.IsString()) { + *message = "Transaction must be deferred, immediate, or exclusive"; + return TransactionMode::kNone; + } + const std::string transaction = value.As().Utf8Value(); + if (transaction == "deferred") { + return TransactionMode::kDeferred; + } + if (transaction == "immediate") { + return TransactionMode::kImmediate; + } + if (transaction == "exclusive") { + return TransactionMode::kExclusive; + } + *message = "Transaction must be deferred, immediate, or exclusive"; + return TransactionMode::kNone; +} + +Napi::Value AsyncPoolConnection::Execute(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + if (state_ == nullptr || !state_->IsOpen() || + state_->close_requested.load(std::memory_order_acquire)) { + throw Napi::Error::New(env, "Database connection is closed"); + } + AsyncPoolEnvironment *environment = state_->environment(); + if (environment == nullptr || environment->shutting_down()) { + throw Napi::Error::New(env, "Database environment is shutting down"); + } + if (info.Length() < 1 || !info[0].IsObject()) { + throw Napi::TypeError::New(env, "Request must be an object"); + } + + const Napi::Object object = info[0].As(); + const Napi::Value operations_value = object.Get("operations"); + if (!operations_value.IsArray()) { + throw Napi::TypeError::New(env, "Request operations must be an array"); + } + const Napi::Array operations = operations_value.As(); + NativeRequest request; + request.operations.reserve(operations.Length()); + std::string message; + for (uint32_t index = 0; index < operations.Length(); ++index) { + NativeOperation operation; + if (!ParseOperation(env, operations.Get(index), &operation, &message)) { + throw Napi::TypeError::New(env, message); + } + request.operations.push_back(std::move(operation)); + } + request.transaction = ParseTransaction(object.Get("transaction"), &message); + if (!message.empty()) { + throw Napi::TypeError::New(env, message); + } + + Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env); + Napi::Promise promise = deferred.Promise(); + auto *worker = + new RequestWorker(env, environment, state_, std::move(request), deferred); + if (!environment->QueueWorker(worker, state_)) { + deferred.Reject( + Napi::Error::New(env, "Database connection is busy").Value()); + } + return promise; +} + +Napi::Value AsyncPoolConnection::Close(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env); + Napi::Promise promise = deferred.Promise(); + if (state_ == nullptr) { + deferred.Resolve(env.Undefined()); + return promise; + } + AsyncPoolEnvironment *environment = state_->environment(); + if (environment == nullptr || environment->shutting_down()) { + deferred.Resolve(env.Undefined()); + return promise; + } + + if (environment->AttachCloseDeferred(state_, deferred)) { + return promise; + } + if (!state_->IsOpen()) { + deferred.Resolve(env.Undefined()); + return promise; + } + + state_->close_requested.store(true, std::memory_order_release); + auto *worker = new CloseWorker(env, environment, state_, deferred); + if (!environment->QueueWorker(worker, state_)) { + deferred.Reject( + Napi::Error::New(env, "Cannot close a busy database connection") + .Value()); + } + return promise; +} + +bool ParseOpenConfiguration(Napi::Env env, Napi::Value location_value, + Napi::Value options_value, + OpenConfiguration *configuration) { + const std::optional location = + ValidateDatabasePath(env, location_value, "location"); + if (!location.has_value()) { + return false; + } + configuration->location = *location; + if (!options_value.IsObject() || options_value.IsArray()) { + throw Napi::TypeError::New(env, "Pool options must be an object"); + } + const Napi::Object options = options_value.As(); + + const Napi::Value read_big_ints = options.Get("readBigInts"); + const Napi::Value return_arrays = options.Get("returnArrays"); + const Napi::Value authorizer = options.Get("authorizer"); + const Napi::Value allow_extension = options.Get("allowExtension"); + const Napi::Value setup_value = options.Get("connectionSetup"); + if (!read_big_ints.IsBoolean() || !return_arrays.IsBoolean() || + !authorizer.IsString() || !allow_extension.IsBoolean() || + !setup_value.IsArray()) { + throw Napi::TypeError::New(env, "Invalid native async pool options"); + } + configuration->read_big_ints = read_big_ints.As().Value(); + configuration->return_arrays = return_arrays.As().Value(); + configuration->allow_extension = allow_extension.As().Value(); + const std::string authorizer_name = authorizer.As().Utf8Value(); + if (authorizer_name == "strict") { + configuration->strict_authorizer = true; + } else if (authorizer_name == "none") { + configuration->strict_authorizer = false; + } else { + throw Napi::TypeError::New(env, "Authorizer must be strict or none"); + } + + const Napi::Array setup = setup_value.As(); + configuration->setup.reserve(setup.Length()); + std::string message; + for (uint32_t index = 0; index < setup.Length(); ++index) { + NativeOperation operation; + if (!ParseOperation(env, setup.Get(index), &operation, &message)) { + throw Napi::TypeError::New(env, message); + } + operation.kind = OperationKind::kRun; + configuration->setup.push_back(std::move(operation)); + } + return true; +} + +Napi::Value OpenAsyncPoolConnection(const Napi::CallbackInfo &info) { + Napi::Env env = info.Env(); + AddonData *addon_data = GetAddonData(env); + if (addon_data == nullptr || addon_data->async_pool_environment == nullptr) { + throw Napi::Error::New(env, "Async pool environment is unavailable"); + } + if (info.Length() < 2) { + throw Napi::TypeError::New(env, "Location and options are required"); + } + + OpenConfiguration configuration; + if (!ParseOpenConfiguration(env, info[0], info[1], &configuration)) { + return env.Undefined(); + } + + AsyncPoolEnvironment *environment = addon_data->async_pool_environment; + if (environment->shutting_down()) { + throw Napi::Error::New(env, "Database environment is shutting down"); + } + auto state = std::make_shared( + configuration.read_big_ints, configuration.return_arrays, + configuration.strict_authorizer); + environment->AddState(state); + + Napi::Promise::Deferred deferred = Napi::Promise::Deferred::New(env); + Napi::Promise promise = deferred.Promise(); + auto *worker = new OpenWorker(env, environment, state, + std::move(configuration), deferred); + if (!environment->QueueWorker(worker, state)) { + environment->RequestClose(state); + deferred.Reject( + Napi::Error::New(env, "Failed to queue database open").Value()); + } + return promise; +} + +} // namespace + +bool InitializeAsyncPool(Napi::Env env, Napi::Object exports, + AddonData *addon_data) { + try { + auto *environment = new AsyncPoolEnvironment(env, addon_data); + addon_data->async_pool_environment = environment; + if (!environment->Initialize()) { + Napi::Error::New(env, "Failed to register async pool cleanup") + .ThrowAsJavaScriptException(); + return false; + } + + Napi::Function constructor = AsyncPoolConnection::CreateConstructor(env); + addon_data->asyncPoolConnectionConstructor = + Napi::Reference::New(constructor, 1); + addon_data->asyncPoolConnectionToken = Napi::Reference::New( + Napi::Symbol::New(env, "AsyncPoolConnection token"), 1); + + Napi::Function open = Napi::Function::New(env, OpenAsyncPoolConnection, + "_openAsyncPoolConnection"); + exports.DefineProperty(Napi::PropertyDescriptor::Value( + "_openAsyncPoolConnection", open, napi_default)); + return true; + } catch (const Napi::Error &error) { + error.ThrowAsJavaScriptException(); + return false; + } catch (const std::exception &error) { + Napi::Error::New(env, error.what()).ThrowAsJavaScriptException(); + return false; + } +} + +void DestroyAsyncPoolEnvironment(AddonData *addon_data) noexcept { + if (addon_data == nullptr || addon_data->async_pool_environment == nullptr) { + return; + } + AsyncPoolEnvironment *environment = addon_data->async_pool_environment; + addon_data->async_pool_environment = nullptr; + delete environment; +} + +} // namespace photostructure::sqlite diff --git a/src/async_pool_impl.h b/src/async_pool_impl.h new file mode 100644 index 0000000..6b5f022 --- /dev/null +++ b/src/async_pool_impl.h @@ -0,0 +1,22 @@ +#ifndef SRC_ASYNC_POOL_IMPL_H_ +#define SRC_ASYNC_POOL_IMPL_H_ + +#include + +namespace photostructure::sqlite { + +struct AddonData; +class AsyncPoolEnvironment; + +// Installs the per-environment cleanup coordinator, caches the hidden native +// connection constructor, and defines the non-enumerable open function. +bool InitializeAsyncPool(Napi::Env env, Napi::Object exports, + AddonData *addon_data); + +// Called from the instance-data finalizer, after asynchronous environment +// cleanup has drained every pool worker and closed every SQLite handle. +void DestroyAsyncPoolEnvironment(AddonData *addon_data) noexcept; + +} // namespace photostructure::sqlite + +#endif // SRC_ASYNC_POOL_IMPL_H_ diff --git a/src/binding.cpp b/src/binding.cpp index 2411c80..896662b 100644 --- a/src/binding.cpp +++ b/src/binding.cpp @@ -3,6 +3,7 @@ #include #include "aggregate_function.h" +#include "async_pool_impl.h" #include "sqlite_impl.h" namespace photostructure::sqlite { @@ -12,6 +13,10 @@ void CleanupAddonData([[maybe_unused]] napi_env env, void *finalize_data, [[maybe_unused]] void *finalize_hint) { auto *addon_data = static_cast(finalize_data); + // The asynchronous cleanup hook runs before instance-data finalizers. At + // this point all pool work and SQLite handles have drained. + DestroyAsyncPoolEnvironment(addon_data); + // Clean up any remaining database connections { const std::lock_guard mutex_lock(addon_data->mutex); @@ -70,9 +75,19 @@ void UnregisterDatabaseInstance(Napi::Env env, DatabaseSync *database) { Napi::Object Init(Napi::Env env, Napi::Object exports) { // Set up per-worker instance data AddonData *addon_data = new AddonData(); + + // Register asynchronous pool cleanup before publishing AddonData as instance + // data. If either step fails, remove the hook before deleting its argument. + if (!InitializeAsyncPool(env, exports, addon_data)) { + DestroyAsyncPoolEnvironment(addon_data); + delete addon_data; + return exports; + } + napi_status status = napi_set_instance_data(env, addon_data, CleanupAddonData, nullptr); if (status != napi_ok) { + DestroyAsyncPoolEnvironment(addon_data); delete addon_data; Napi::Error::New(env, "Failed to set instance data") .ThrowAsJavaScriptException(); diff --git a/src/experimental.ts b/src/experimental.ts new file mode 100644 index 0000000..a0f298b --- /dev/null +++ b/src/experimental.ts @@ -0,0 +1,568 @@ +import nodeGypBuild from "node-gyp-build"; +import { join } from "node:path"; +import { _dirname } from "./dirname"; + +export type PoolAuthorizer = "strict" | "none"; +export type PoolTransaction = "deferred" | "immediate" | "exclusive"; +export type PoolValue = + null | number | bigint | string | ArrayBufferView; +export type PoolParams = + readonly PoolValue[] | Readonly>; + +export interface PoolSetupOperation { + readonly sql: string; + readonly params?: PoolParams; +} + +export interface DatabasePoolOptions { + readonly connections?: number; + readonly authorizer?: PoolAuthorizer; + readonly readBigInts?: boolean; + readonly returnArrays?: boolean; + readonly allowExtension?: boolean; + readonly connectionSetup?: readonly PoolSetupOperation[]; +} + +export interface PoolRunOperation extends PoolSetupOperation { + readonly kind: "run"; +} + +export interface PoolGetOperation extends PoolSetupOperation { + readonly kind: "get"; +} + +export interface PoolAllOperation extends PoolSetupOperation { + readonly kind: "all"; +} + +export type PoolOperation = + PoolRunOperation | PoolGetOperation | PoolAllOperation; + +export interface PoolBatchOptions { + readonly transaction?: PoolTransaction; +} + +export interface PoolRunResult { + readonly changes: number | bigint; +} + +export type PoolObjectRow = Record; +export type PoolArrayRow = PoolValue[]; +export type PoolRow = PoolObjectRow | PoolArrayRow; +export type PoolOperationResult = + PoolRunResult | PoolRow | PoolRow[] | undefined; + +type NativeParams = PoolValue[] | Record; + +interface NativeOperation { + kind: "run" | "get" | "all"; + sql: string; + params?: NativeParams; +} + +interface NativeConnection { + execute(request: { + operations: NativeOperation[]; + transaction?: PoolTransaction; + }): Promise; + close(): Promise; +} + +interface NativeBinding { + _openAsyncPoolConnection( + location: string | Buffer | URL, + options: { + readBigInts: boolean; + returnArrays: boolean; + authorizer: PoolAuthorizer; + allowExtension: boolean; + connectionSetup: NativeOperation[]; + }, + ): Promise; +} + +interface NormalizedOptions { + connections: number; + authorizer: PoolAuthorizer; + readBigInts: boolean; + returnArrays: boolean; + allowExtension: boolean; + connectionSetup: NativeOperation[]; +} + +interface PendingRequest { + execute(connection: NativeConnection): Promise; + resolve(value: T | PromiseLike): void; + reject(reason?: unknown): void; +} + +type PoolState = "open" | "closing" | "closed" | "failed"; + +const binding = nodeGypBuild(join(_dirname(), "..")) as NativeBinding; +const constructorToken = Symbol("DatabasePool constructor token"); + +function invalidArgument(message: string): TypeError { + const error = new TypeError(message); + (error as NodeJS.ErrnoException).code = "ERR_INVALID_ARG_TYPE"; + return error; +} + +function snapshotLocation( + location: string | Buffer | URL, +): string | Buffer | URL { + if (typeof location === "string") { + if (location.includes("\0")) { + throw invalidArgument( + 'The "location" argument must not contain null bytes.', + ); + } + return location; + } + if (Buffer.isBuffer(location)) { + if (location.includes(0)) { + throw invalidArgument( + 'The "location" argument must not contain null bytes.', + ); + } + return Buffer.from(location); + } + if (location instanceof URL) return new URL(location.href); + throw invalidArgument( + 'The "location" argument must be a string, Buffer, or URL.', + ); +} + +function locationText(location: string | Buffer | URL): string { + if (typeof location === "string") return location; + if (Buffer.isBuffer(location)) return location.toString(); + return location.href; +} + +function isPrivateMemoryLocation(location: string | Buffer | URL): boolean { + const text = locationText(location); + if (text === "" || text === ":memory:") return true; + if (!text.startsWith("file:")) return false; + const queryStart = text.indexOf("?"); + const filePath = text.slice(0, queryStart === -1 ? text.length : queryStart); + if (filePath === "file:") return true; + if ( + text.toLowerCase() === "file::memory:" || + /^file::memory:\?/i.test(text) + ) { + return true; + } + try { + const query = text.includes("?") ? text.slice(text.indexOf("?")) : ""; + return new URLSearchParams(query).get("mode")?.toLowerCase() === "memory"; + } catch { + return /[?&]mode=memory(?:&|$)/i.test(text); + } +} + +function snapshotValue(value: unknown, label: string): PoolValue { + // Keep the null branch separate: the benchmark package intentionally checks + // this source with strictNullChecks disabled, where a compound null/typeof + // guard does not reliably narrow `unknown` to PoolValue. + if (value === null) return null; + if ( + typeof value === "number" || + typeof value === "bigint" || + typeof value === "string" + ) { + return value; + } + if (ArrayBuffer.isView(value)) { + return Buffer.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + ); + } + throw invalidArgument( + `The ${label} bind parameter must be null, a number, bigint, string, or ArrayBufferView.`, + ); +} + +function snapshotParams( + params: unknown, + label: string, +): NativeParams | undefined { + if (params === undefined) return undefined; + if (Array.isArray(params)) { + return params.map((value, index) => + snapshotValue(value, `${label}[${index}]`), + ); + } + if ( + params === null || + typeof params !== "object" || + ArrayBuffer.isView(params) + ) { + throw invalidArgument( + `The ${label} parameters must be an array or object.`, + ); + } + const prototype = Object.getPrototypeOf(params); + if (prototype !== Object.prototype && prototype !== null) { + throw invalidArgument(`The ${label} parameters must be a plain object.`); + } + const copied: Record = Object.create(null); + for (const key of Object.keys(params)) { + copied[key] = snapshotValue( + (params as Record)[key], + `${label}.${key}`, + ); + } + return copied; +} + +function snapshotOperation( + operation: unknown, + index: number, + setup: boolean, +): NativeOperation { + if ( + operation === null || + typeof operation !== "object" || + Array.isArray(operation) + ) { + throw invalidArgument( + `${setup ? "connectionSetup entry" : "operation descriptor"} ${index} must be an object.`, + ); + } + const candidate = operation as Record; + const kind = setup ? "run" : candidate["kind"]; + if (kind !== "run" && kind !== "get" && kind !== "all") { + throw invalidArgument( + `The operation descriptor ${index} kind must be "run", "get", or "all".`, + ); + } + if (typeof candidate["sql"] !== "string") { + throw invalidArgument( + `The ${setup ? "connectionSetup entry" : "operation descriptor"} ${index} sql must be a string.`, + ); + } + const params = snapshotParams(candidate["params"], `operation ${index}`); + const copied: NativeOperation = { + kind, + sql: candidate["sql"], + }; + if (params !== undefined) copied.params = params; + return copied; +} + +function booleanOption( + options: Record, + name: string, + defaultValue: boolean, +): boolean { + const value = options[name]; + if (value === undefined) return defaultValue; + if (typeof value !== "boolean") { + throw invalidArgument(`The "options.${name}" argument must be a boolean.`); + } + return value; +} + +function normalizeOptions(options: unknown): NormalizedOptions { + if (options === undefined) options = {}; + if ( + options === null || + typeof options !== "object" || + Array.isArray(options) + ) { + throw invalidArgument('The "options" argument must be an object.'); + } + const input = options as Record; + const allowed = new Set([ + "connections", + "authorizer", + "readBigInts", + "returnArrays", + "allowExtension", + "connectionSetup", + ]); + for (const key of Object.keys(input)) { + if (!allowed.has(key)) { + throw invalidArgument(`Unknown DatabasePool option "${key}".`); + } + } + + const connections = input["connections"] ?? 1; + if (!Number.isSafeInteger(connections) || (connections as number) <= 0) { + throw invalidArgument( + 'The "options.connections" argument must be a positive safe integer.', + ); + } + const authorizer = input["authorizer"] ?? "strict"; + if (authorizer !== "strict" && authorizer !== "none") { + throw invalidArgument( + 'The "options.authorizer" argument must be "strict" or "none".', + ); + } + const setup = input["connectionSetup"] ?? []; + if (!Array.isArray(setup)) { + throw invalidArgument( + 'The "options.connectionSetup" argument must be an array.', + ); + } + + return { + connections: connections as number, + authorizer, + readBigInts: booleanOption(input, "readBigInts", false), + returnArrays: booleanOption(input, "returnArrays", false), + allowExtension: booleanOption(input, "allowExtension", false), + connectionSetup: setup.map((operation, index) => + snapshotOperation(operation, index, true), + ), + }; +} + +function snapshotTransaction(options: unknown): PoolTransaction | undefined { + if (options === undefined) return undefined; + if ( + options === null || + typeof options !== "object" || + Array.isArray(options) + ) { + throw invalidArgument('The "batch options" argument must be an object.'); + } + const input = options as Record; + for (const key of Object.keys(input)) { + if (key !== "transaction") { + throw invalidArgument(`Unknown batch option "${key}".`); + } + } + const transaction = input["transaction"]; + if (transaction === undefined) return undefined; + if ( + transaction !== "deferred" && + transaction !== "immediate" && + transaction !== "exclusive" + ) { + throw invalidArgument( + 'The "batch options.transaction" argument must be "deferred", "immediate", or "exclusive".', + ); + } + return transaction; +} + +/** + * An experimental fixed-size pool of warm SQLite connections. + * + * SQL execution and connection lifecycle work run on libuv worker threads. + * Calls waiting for a connection stay in the JavaScript scheduler and consume + * no libuv worker. + */ +export class DatabasePool { + readonly #connections: NativeConnection[]; + readonly #idle: NativeConnection[]; + readonly #pending: PendingRequest[] = []; + #inFlight = 0; + #state: PoolState = "open"; + #closePromise?: Promise; + #resolveClose?: () => void; + #rejectClose?: (reason?: unknown) => void; + #nativeCloseStarted = false; + + private constructor(token?: symbol, connections: NativeConnection[] = []) { + if (token !== constructorToken) throw new TypeError("Illegal constructor"); + this.#connections = connections; + this.#idle = [...connections]; + } + + static async open( + location: string | Buffer | URL, + options?: DatabasePoolOptions, + ): Promise { + const copiedLocation = snapshotLocation(location); + const normalized = normalizeOptions(options); + if (normalized.connections > 1 && isPrivateMemoryLocation(copiedLocation)) { + throw invalidArgument( + "In-memory and temporary databases require exactly one pool connection.", + ); + } + + const connections: NativeConnection[] = []; + try { + for (let index = 0; index < normalized.connections; index++) { + connections.push( + await binding._openAsyncPoolConnection(copiedLocation, { + readBigInts: normalized.readBigInts, + returnArrays: normalized.returnArrays, + authorizer: normalized.authorizer, + allowExtension: normalized.allowExtension, + connectionSetup: normalized.connectionSetup, + }), + ); + } + return new DatabasePool(constructorToken, connections); + } catch (error) { + await Promise.allSettled( + connections.map((connection) => + Promise.resolve().then(() => connection.close()), + ), + ); + throw error; + } + } + + run(sql: string, params?: PoolParams): Promise { + return this.#single("run", sql, params) as Promise; + } + + get(sql: string, params?: PoolParams): Promise { + return this.#single("get", sql, params) as Promise; + } + + all(sql: string, params?: PoolParams): Promise { + return this.#single("all", sql, params) as Promise; + } + + batch( + operations: readonly PoolOperation[], + options?: PoolBatchOptions, + ): Promise { + try { + if (!Array.isArray(operations)) { + throw invalidArgument('The "operations" argument must be an array.'); + } + const copied = operations.map((operation, index) => + snapshotOperation(operation, index, false), + ); + const transaction = snapshotTransaction(options); + return this.#submit((connection) => { + const request: { + operations: NativeOperation[]; + transaction?: PoolTransaction; + } = { operations: copied }; + if (transaction !== undefined) request.transaction = transaction; + return connection.execute(request); + }); + } catch (error) { + return Promise.reject(error); + } + } + + close(): Promise { + if (this.#closePromise) return this.#closePromise; + if (this.#state === "closed") return Promise.resolve(); + + this.#state = "closing"; + this.#closePromise = new Promise((resolve, reject) => { + this.#resolveClose = resolve; + this.#rejectClose = reject; + }); + this.#dispatch(); + return this.#closePromise; + } + + [Symbol.asyncDispose](): Promise { + return this.close(); + } + + #single( + kind: "run" | "get" | "all", + sql: unknown, + params: unknown, + ): Promise { + try { + const operation = snapshotOperation({ kind, sql, params }, 0, false); + return this.#submit(async (connection) => { + const results = await connection.execute({ operations: [operation] }); + return results[0]; + }); + } catch (error) { + return Promise.reject(error); + } + } + + #submit( + execute: (connection: NativeConnection) => Promise, + ): Promise { + if (this.#state !== "open") { + return Promise.reject( + new Error( + this.#state === "closing" + ? "DatabasePool is closing" + : "DatabasePool is closed", + ), + ); + } + const promise = new Promise((resolve, reject) => { + this.#pending.push({ + execute, + resolve, + reject, + } as PendingRequest); + }); + this.#dispatch(); + return promise; + } + + #dispatch(): void { + while (this.#idle.length > 0 && this.#pending.length > 0) { + const connection = this.#idle.shift()!; + const request = this.#pending.shift()!; + this.#inFlight++; + void Promise.resolve() + .then(() => request.execute(connection)) + .then( + (value) => request.resolve(value), + (error) => { + request.reject(error); + if ( + error !== null && + typeof error === "object" && + (error as { fatal?: boolean }).fatal === true + ) { + this.#fail(error); + } + }, + ) + .finally(() => { + this.#inFlight--; + this.#idle.push(connection); + this.#dispatch(); + }); + } + + if ( + this.#state === "closing" && + this.#pending.length === 0 && + this.#inFlight === 0 + ) { + void this.#closeConnections(); + } + if (this.#state === "failed" && this.#inFlight === 0) { + void this.#closeConnections(); + } + } + + #fail(error: unknown): void { + if (this.#state === "failed" || this.#state === "closed") return; + this.#state = "failed"; + while (this.#pending.length > 0) this.#pending.shift()!.reject(error); + if (!this.#closePromise) { + this.#closePromise = new Promise((resolve, reject) => { + this.#resolveClose = resolve; + this.#rejectClose = reject; + }); + } + } + + async #closeConnections(): Promise { + if (this.#state === "closed" || this.#nativeCloseStarted) return; + this.#nativeCloseStarted = true; + const results = await Promise.allSettled( + this.#connections.map((connection) => + Promise.resolve().then(() => connection.close()), + ), + ); + this.#state = "closed"; + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) this.#rejectClose?.(failed.reason); + else this.#resolveClose?.(); + } +} diff --git a/src/sqlite_impl.h b/src/sqlite_impl.h index a979745..2cc4dde 100644 --- a/src/sqlite_impl.h +++ b/src/sqlite_impl.h @@ -35,6 +35,7 @@ class StatementSync; class StatementSyncIterator; class Session; class BackupJob; +class AsyncPoolEnvironment; // Per-worker instance data struct AddonData { @@ -47,6 +48,12 @@ struct AddonData { Napi::FunctionReference statementSyncConstructor; Napi::FunctionReference statementSyncIteratorConstructor; Napi::FunctionReference sessionConstructor; + Napi::FunctionReference asyncPoolConnectionConstructor; + Napi::Reference asyncPoolConnectionToken; + + // Per-Node-environment ownership and teardown coordination for the hidden + // async pool connections. The concrete type lives in async_pool_impl.cpp. + AsyncPoolEnvironment *async_pool_environment = nullptr; // Cached Object.create function for creating objects with null prototype Napi::FunctionReference objectCreateFn; diff --git a/test/async-pool-api.test.ts b/test/async-pool-api.test.ts new file mode 100644 index 0000000..51eb599 --- /dev/null +++ b/test/async-pool-api.test.ts @@ -0,0 +1,139 @@ +import { pathToFileURL } from "node:url"; +import * as stable from "../src"; +import { DatabasePool } from "../src/experimental"; +import { useTempDir } from "./test-utils"; + +describe("experimental DatabasePool API", () => { + const tempDir = useTempDir("sqlite-async-pool-api-"); + + test("does not change the stable root export surface", () => { + expect(Object.keys(stable).sort()).toEqual([ + "DatabaseSync", + "SQLTagStore", + "Session", + "StatementSync", + "backup", + "constants", + "default", + "enhance", + "isEnhanced", + ]); + expect("DatabasePool" in stable).toBe(false); + }); + + test("exports only the stateless pool operations", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + expect(DatabasePool.name).toBe("DatabasePool"); + expect(typeof pool.run).toBe("function"); + expect(typeof pool.get).toBe("function"); + expect(typeof pool.all).toBe("function"); + expect(typeof pool.batch).toBe("function"); + expect(typeof pool.close).toBe("function"); + expect(typeof pool[Symbol.asyncDispose]).toBe("function"); + expect("prepare" in pool).toBe(false); + expect("function" in pool).toBe(false); + expect("aggregate" in pool).toBe(false); + expect("iterate" in pool).toBe(false); + expect("loadExtension" in pool).toBe(false); + } finally { + await pool.close(); + } + }); + + test("cannot be constructed directly", () => { + expect(() => { + // @ts-expect-error DatabasePool instances must come from the async factory. + new DatabasePool(); + }).toThrow(/illegal constructor/i); + }); + + test.each([ + ["string", () => tempDir.getDbPath("string.db")], + ["Buffer", () => Buffer.from(tempDir.getDbPath("buffer.db"))], + ["URL", () => pathToFileURL(tempDir.getDbPath("url.db"))], + ])("accepts a %s location", async (_label, makeLocation) => { + const pool = await DatabasePool.open(makeLocation(), { + authorizer: "none", + }); + await pool.run("CREATE TABLE accepted(value TEXT)"); + await pool.close(); + }); + + test("copies Buffer and URL locations before asynchronous open", async () => { + const bufferPath = tempDir.getDbPath("copied-buffer.db"); + const buffer = Buffer.from(bufferPath); + const bufferOpening = DatabasePool.open(buffer, { authorizer: "none" }); + buffer.fill("x"); + const bufferPool = await bufferOpening; + await bufferPool.run("CREATE TABLE from_buffer(value)"); + await bufferPool.close(); + + const urlPath = tempDir.getDbPath("copied-url.db"); + const url = pathToFileURL(urlPath); + const urlOpening = DatabasePool.open(url, { authorizer: "none" }); + url.pathname = "/mutated-after-open.db"; + const urlPool = await urlOpening; + await urlPool.run("CREATE TABLE from_url(value)"); + await urlPool.close(); + }); + + test.each([ + ":memory:", + "", + "file:", + "file:?cache=shared", + "file::memory:?cache=shared", + "file:pool-memory?mode=memory&cache=shared", + ])( + "rejects multi-connection private or URI memory location %p", + async (location) => { + await expect( + DatabasePool.open(location, { connections: 2, authorizer: "none" }), + ).rejects.toThrow(/in-memory|temporary/i); + }, + ); + + test("permits in-memory locations with one connection", async () => { + const pool = await DatabasePool.open(":memory:", { + connections: 1, + authorizer: "none", + }); + await pool.close(); + }); + + test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid connection count %p", + async (connections) => { + await expect( + DatabasePool.open(tempDir.getDbPath(), { + connections, + authorizer: "none", + }), + ).rejects.toThrow(/connections/i); + }, + ); + + test("defaults to one strict connection", async () => { + const pool = await DatabasePool.open(":memory:"); + try { + await expect(pool.get("PRAGMA user_version")).rejects.toThrow( + /not authorized/i, + ); + } finally { + await pool.close(); + } + }); + + test("validates the closed authorizer and setup option vocabulary", async () => { + await expect( + DatabasePool.open(tempDir.getDbPath(), { authorizer: "allow" as any }), + ).rejects.toThrow(/authorizer/i); + await expect( + DatabasePool.open(tempDir.getDbPath(), { + authorizer: "none", + connectionSetup: [() => undefined as any] as any, + }), + ).rejects.toThrow(/connectionSetup/i); + }); +}); diff --git a/test/async-pool-batch.test.ts b/test/async-pool-batch.test.ts new file mode 100644 index 0000000..3f9492a --- /dev/null +++ b/test/async-pool-batch.test.ts @@ -0,0 +1,232 @@ +import { DatabaseSync } from "../src"; +import { DatabasePool } from "../src/experimental"; +import { useTempDir } from "./test-utils"; + +describe("DatabasePool batches", () => { + const tempDir = useTempDir("sqlite-async-pool-batch-"); + + test("returns ordered results from one native job", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)"); + const results = await pool.batch([ + { + kind: "run", + sql: "INSERT INTO item(value) VALUES (?)", + params: ["a"], + }, + { + kind: "run", + sql: "INSERT INTO item(value) VALUES (?)", + params: ["b"], + }, + { + kind: "get", + sql: "SELECT value FROM item WHERE id = ?", + params: [2], + }, + { kind: "all", sql: "SELECT value FROM item ORDER BY id" }, + ]); + + expect(results).toEqual([ + { changes: 1 }, + { changes: 1 }, + { value: "b" }, + [{ value: "a" }, { value: "b" }], + ]); + } finally { + await pool.close(); + } + }); + + test("an empty batch is a successful no-op", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect(pool.batch([])).resolves.toEqual([]); + await expect( + pool.batch([], { transaction: "immediate" }), + ).resolves.toEqual([]); + } finally { + await pool.close(); + } + }); + + test("copies every batch descriptor before it waits for a connection", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(value BLOB)"); + const blocker = pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 250000 + ) + SELECT max(x) AS value FROM n + `); + const bytes = new Uint8Array([1, 2, 3]); + const params: any[] = [bytes]; + const operations: any[] = [ + { kind: "run", sql: "INSERT INTO item VALUES (?)", params }, + { kind: "get", sql: "SELECT value FROM item" }, + ]; + const pending = pool.batch(operations); + + operations[0].sql = "SELECT * FROM missing_after_submission"; + operations.push({ kind: "run", sql: "SELECT 2" }); + params[0] = new Uint8Array([9]); + bytes.fill(8); + + await blocker; + const results = await pending; + expect(results[0]).toEqual({ changes: 1 }); + expect(Array.from((results[1] as any).value)).toEqual([1, 2, 3]); + } finally { + await pool.close(); + } + }); + + test.each(["deferred", "immediate", "exclusive"] as const)( + "commits a %s transaction", + async (transaction) => { + const dbPath = tempDir.getDbPath(`${transaction}.db`); + const pool = await DatabasePool.open(dbPath, { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(value TEXT)"); + await pool.batch( + [ + { kind: "run", sql: "INSERT INTO item VALUES ('one')" }, + { kind: "run", sql: "INSERT INTO item VALUES ('two')" }, + ], + { transaction }, + ); + } finally { + await pool.close(); + } + + const check = new DatabaseSync(dbPath); + expect(check.prepare("SELECT count(*) AS count FROM item").get()).toEqual( + { + count: 2, + }, + ); + check.close(); + }, + ); + + test("rolls back the whole transactional batch on failure", async () => { + const dbPath = tempDir.getDbPath("rollback.db"); + const setup = new DatabaseSync(dbPath); + setup.exec("CREATE TABLE item(value TEXT UNIQUE)"); + setup.close(); + + const pool = await DatabasePool.open(dbPath, { authorizer: "none" }); + try { + await expect( + pool.batch( + [ + { kind: "run", sql: "INSERT INTO item VALUES ('same')" }, + { kind: "run", sql: "INSERT INTO item VALUES ('same')" }, + ], + { transaction: "immediate" }, + ), + ).rejects.toThrow(/unique/i); + } finally { + await pool.close(); + } + + const check = new DatabaseSync(dbPath); + expect(check.prepare("SELECT count(*) AS count FROM item").get()).toEqual({ + count: 0, + }); + check.close(); + }); + + test("an explicit rollback does not poison an already-restored connection", async () => { + const pool = await DatabasePool.open(":memory:", { + connections: 1, + authorizer: "none", + }); + try { + const error = await pool + .batch([{ kind: "run", sql: "ROLLBACK" }], { + transaction: "deferred", + }) + .then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toMatchObject({ + name: "Error", + code: "ERR_SQLITE_ERROR", + }); + expect(error).not.toHaveProperty("fatal", true); + // ROLLBACK already made sqlite3_get_autocommit() true, so the failed + // wrapper COMMIT must reject without retiring this native connection. + await expect(pool.get("SELECT 42 AS value")).resolves.toEqual({ + value: 42, + }); + } finally { + await pool.close(); + } + }); + + test("rolls back when a transactional result cannot be represented", async () => { + const dbPath = tempDir.getDbPath("result-conversion-rollback.db"); + const setup = new DatabaseSync(dbPath); + setup.exec("CREATE TABLE item(value TEXT)"); + setup.close(); + + const pool = await DatabasePool.open(dbPath, { authorizer: "none" }); + try { + await expect( + pool.batch( + [ + { kind: "run", sql: "INSERT INTO item VALUES ('rolled back')" }, + { kind: "get", sql: "SELECT 9007199254740992 AS value" }, + ], + { transaction: "immediate" }, + ), + ).rejects.toMatchObject({ + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + }); + } finally { + await pool.close(); + } + + const check = new DatabaseSync(dbPath); + expect(check.prepare("SELECT count(*) AS count FROM item").get()).toEqual({ + count: 0, + }); + check.close(); + }); + + test("non-transactional batches fail fast without undoing earlier work", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(value TEXT UNIQUE)"); + await expect( + pool.batch([ + { kind: "run", sql: "INSERT INTO item VALUES ('kept')" }, + { kind: "run", sql: "INSERT INTO item VALUES ('kept')" }, + { kind: "run", sql: "INSERT INTO item VALUES ('not-run')" }, + ]), + ).rejects.toThrow(/unique/i); + await expect(pool.all("SELECT value FROM item")).resolves.toEqual([ + { value: "kept" }, + ]); + } finally { + await pool.close(); + } + }); + + test("does not accept callbacks or data-dependent batch construction", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect(pool.batch([() => undefined] as any)).rejects.toThrow( + /operation|descriptor/i, + ); + } finally { + await pool.close(); + } + }); +}); diff --git a/test/async-pool-concurrency.test.ts b/test/async-pool-concurrency.test.ts new file mode 100644 index 0000000..92ca506 --- /dev/null +++ b/test/async-pool-concurrency.test.ts @@ -0,0 +1,130 @@ +import { createHook } from "node:async_hooks"; +import { DatabaseSync } from "../src"; +import { DatabasePool } from "../src/experimental"; +import { waitForCondition } from "./test-reliability-utils"; +import { useTempDir } from "./test-utils"; + +describe("DatabasePool scheduling", () => { + const tempDir = useTempDir("sqlite-async-pool-concurrency-"); + + test("awaited calls preserve application order", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)"); + await pool.run("INSERT INTO item VALUES (1, 'first')"); + await pool.run("UPDATE item SET value = 'second' WHERE id = 1"); + await expect( + pool.get("SELECT value FROM item WHERE id = 1"), + ).resolves.toEqual({ + value: "second", + }); + } finally { + await pool.close(); + } + }); + + test("a failed request does not stall queued work", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + const failed = pool.get("SELECT * FROM missing_table"); + const succeeded = pool.get("SELECT 42 AS value"); + await expect(failed).rejects.toThrow(/missing_table|no such table/i); + await expect(succeeded).resolves.toEqual({ value: 42 }); + } finally { + await pool.close(); + } + }); + + test("replays connection-local setup on every physical connection", async () => { + const pool = await DatabasePool.open(tempDir.getDbPath("setup-replay.db"), { + connections: 2, + authorizer: "strict", + connectionSetup: [ + { sql: "CREATE TEMP TABLE configured(value TEXT)" }, + { sql: "INSERT INTO configured VALUES ('ready')" }, + ], + }); + try { + const [first, second] = await Promise.all([ + pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 100000 + ) + SELECT (SELECT value FROM configured) AS value, max(x) AS count FROM n + `), + pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 100000 + ) + SELECT (SELECT value FROM configured) AS value, max(x) AS count FROM n + `), + ]); + expect(first).toEqual({ value: "ready", count: 100000 }); + expect(second).toEqual({ value: "ready", count: 100000 }); + } finally { + await pool.close(); + } + }); + + test("waiting requests remain in JavaScript rather than occupying libuv workers", async () => { + const dbPath = tempDir.getDbPath("queued-workers.db"); + const setup = new DatabaseSync(dbPath); + setup.exec("CREATE TABLE item(value TEXT); BEGIN IMMEDIATE"); + let blockerActive = true; + + const pool = await DatabasePool.open(dbPath, { + connections: 2, + authorizer: "none", + connectionSetup: [{ sql: "PRAGMA busy_timeout=10000" }], + }); + let requestResources = 0; + const hook = createHook({ + init(_asyncId, type) { + if (type === "photostructure.sqlite.pool.request") { + requestResources++; + } + }, + }); + hook.enable(); + + try { + const writes = Array.from({ length: 6 }, (_, i) => + pool.run("INSERT INTO item VALUES (?)", [`value-${i}`]), + ); + expect( + await waitForCondition(() => requestResources === 2, { + maxAttempts: 100, + delay: 10, + description: "two leased native requests", + }), + ).toBe(true); + expect(requestResources).toBe(2); + + setup.exec("ROLLBACK"); + blockerActive = false; + await expect(Promise.all(writes)).resolves.toHaveLength(6); + } finally { + hook.disable(); + if (setup.isOpen) { + if (blockerActive) setup.exec("ROLLBACK"); + setup.close(); + } + await pool.close(); + } + }); + + test("a later connection setup failure closes earlier slots", async () => { + const dbPath = tempDir.getDbPath("partial-open.db"); + await expect( + DatabasePool.open(dbPath, { + connections: 2, + authorizer: "none", + connectionSetup: [{ sql: "CREATE TABLE only_once(value)" }], + }), + ).rejects.toThrow(/already exists/i); + + const db = new DatabaseSync(dbPath); + db.exec("DROP TABLE only_once"); + db.close(); + }); +}); diff --git a/test/async-pool-errors.test.ts b/test/async-pool-errors.test.ts new file mode 100644 index 0000000..dc1b88c --- /dev/null +++ b/test/async-pool-errors.test.ts @@ -0,0 +1,124 @@ +import { DatabasePool } from "../src/experimental"; + +describe("DatabasePool errors and connection invariants", () => { + test("returns detailed SQLite errors", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.get("SELECT * FROM missing_table"), + ).rejects.toMatchObject({ + code: "ERR_SQLITE_ERROR", + sqliteCode: expect.any(Number), + sqliteExtendedCode: expect.any(Number), + sqliteCodeName: expect.stringMatching(/^SQLITE_/), + }); + } finally { + await pool.close(); + } + }); + + test("does not discard a terminal step error after producing a row", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.all(` + WITH input(value) AS (VALUES ('{"ok": 1}'), ('not-json')) + SELECT json_extract(value, '$.ok') AS value FROM input + `), + ).rejects.toThrow(/malformed JSON/i); + } finally { + await pool.close(); + } + }); + + test("checks the complete SQL tail before allowing first-statement side effects", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.run("CREATE TABLE should_not_exist(value); SELECT 1"), + ).rejects.toThrow(/one statement|multiple statements/i); + await expect( + pool.get( + "SELECT name FROM sqlite_schema WHERE name = 'should_not_exist'", + ), + ).resolves.toBeUndefined(); + } finally { + await pool.close(); + } + }); + + test.each([ + "PRAGMA foreign_keys=OFF; SELECT 1", + "SELECT 1; PRAGMA foreign_keys=OFF", + ])( + "tail validation prevents prepare-time PRAGMA side effects: %s", + async (sql) => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + // SQLite applies foreign_keys during sqlite3_prepare_v2(), not step. + // Validation must therefore compile under an IGNORE authorizer before + // preparing the one accepted statement under the real policy. + await expect(pool.run(sql)).rejects.toThrow( + /one statement|multiple statements/i, + ); + await expect(pool.get("PRAGMA foreign_keys")).resolves.toEqual({ + foreign_keys: 1, + }); + } finally { + await pool.close(); + } + }, + ); + + test.each(["", " -- comments only\n "])( + "rejects SQL without an executable statement: %p", + async (sql) => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect(pool.get(sql)).rejects.toThrow(/one statement|statement/i); + } finally { + await pool.close(); + } + }, + ); + + test("run changes are operation-local and never expose lastInsertRowid", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(id INTEGER PRIMARY KEY, value TEXT)"); + const inserted = await pool.run( + "INSERT INTO item(value) VALUES ('first'), ('second')", + ); + const noUpdate = await pool.run( + "UPDATE item SET value = 'missing' WHERE id = 999", + ); + const selected = await pool.run("SELECT * FROM item"); + const ddl = await pool.run("CREATE INDEX item_value ON item(value)"); + + expect(inserted).toEqual({ changes: 2 }); + expect(noUpdate).toEqual({ changes: 0 }); + expect(selected).toEqual({ changes: 0 }); + expect(ddl).toEqual({ changes: 0 }); + expect("lastInsertRowid" in inserted).toBe(false); + } finally { + await pool.close(); + } + }); + + test("rolls back user SQL that leaves autocommit disabled", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE item(value TEXT)"); + await expect(pool.run("BEGIN")).rejects.toThrow( + /transaction|autocommit/i, + ); + await expect( + pool.run("INSERT INTO item VALUES ('usable')"), + ).resolves.toEqual({ + changes: 1, + }); + } finally { + await pool.close(); + } + }); +}); diff --git a/test/async-pool-lifecycle.test.ts b/test/async-pool-lifecycle.test.ts new file mode 100644 index 0000000..19bbe71 --- /dev/null +++ b/test/async-pool-lifecycle.test.ts @@ -0,0 +1,333 @@ +import { jest } from "@jest/globals"; +import { AsyncLocalStorage, createHook } from "node:async_hooks"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { Worker } from "node:worker_threads"; +import { DatabaseSync } from "../src"; +import { DatabasePool } from "../src/experimental"; +import { waitForCondition } from "./test-reliability-utils"; +import { getTestTimeout, projectRoot, useTempDir } from "./test-utils"; + +describe("DatabasePool lifecycle", () => { + jest.setTimeout(getTestTimeout(30_000)); + const tempDir = useTempDir("sqlite-async-pool-lifecycle-"); + + test("SQLite execution does not block the event loop", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + let heartbeats = 0; + let done = false; + const beat = () => { + heartbeats++; + if (!done) setImmediate(beat); + }; + setImmediate(beat); + + try { + await pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 1000000 + ) + SELECT max(x) AS value FROM n + `); + done = true; + expect(heartbeats).toBeGreaterThan(0); + } finally { + done = true; + await pool.close(); + } + }); + + test("preserves AsyncLocalStorage and exposes the named async resource", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + const storage = new AsyncLocalStorage(); + const observedTypes = new Set(); + const hook = createHook({ + init(_asyncId, type) { + if (type.startsWith("photostructure.sqlite.pool")) { + observedTypes.add(type); + } + }, + }); + hook.enable(); + + try { + await storage.run("request-context", async () => { + await expect(pool.get("SELECT 1 AS value")).resolves.toEqual({ + value: 1, + }); + expect(storage.getStore()).toBe("request-context"); + await expect(pool.get("SELECT * FROM missing_table")).rejects.toThrow(); + expect(storage.getStore()).toBe("request-context"); + }); + expect(observedTypes).toContain("photostructure.sqlite.pool.request"); + } finally { + hook.disable(); + await pool.close(); + } + }); + + test("close enters closing synchronously, drains accepted work, and is idempotent", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + await pool.run("CREATE TABLE item(value TEXT)"); + + const longRead = pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 500000 + ) + SELECT max(x) AS value FROM n + `); + const acceptedWrite = pool.run("INSERT INTO item VALUES ('accepted')"); + const firstClose = pool.close(); + const secondClose = pool.close(); + + await expect(pool.get("SELECT 1")).rejects.toThrow(/closing|closed/i); + await expect(longRead).resolves.toEqual({ value: 500000 }); + await expect(acceptedWrite).resolves.toEqual({ changes: 1 }); + await expect(firstClose).resolves.toBeUndefined(); + await expect(secondClose).resolves.toBeUndefined(); + }); + + test("raw native concurrent close callers share the active close worker", async () => { + const root = projectRoot(); + const childScript = ` + const { pbkdf2 } = require('node:crypto'); + const binding = require('node-gyp-build')(${JSON.stringify(root)}); + + (async () => { + const connection = await binding._openAsyncPoolConnection(':memory:', { + readBigInts: false, + returnArrays: false, + authorizer: 'none', + allowExtension: false, + connectionSetup: [], + }); + const blocker = new Promise((resolve, reject) => { + pbkdf2('password', 'salt', 500000, 32, 'sha256', (error) => { + if (error) reject(error); + else resolve(); + }); + }); + + // Contract ground truth: close is idempotent and settles after native + // closure. Fatal auto-close can win this same race with TS cleanup. The + // blocker keeps the sole libuv worker busy so both callers join one + // CloseWorker. + const first = connection.close(); + const second = connection.close(); + const statuses = (await Promise.allSettled([first, second])).map( + ({ status }) => status, + ); + await blocker; + if (statuses.some((status) => status !== 'fulfilled')) { + throw new Error('concurrent native close rejected'); + } + await connection.close(); + process.send({ statuses, third: 'fulfilled' }, () => process.exit(0)); + })().catch((error) => { + process.stderr.write(error.stack || error.message); + process.exit(2); + }); + `; + const child = spawn(process.execPath, ["-e", childScript], { + cwd: root, + env: { ...process.env, UV_THREADPOOL_SIZE: "1" }, + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + let stderr = ""; + let message: unknown; + child.stderr!.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); + child.once("message", (value) => (message = value)); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + + expect({ exitCode, message, stderr }).toEqual({ + exitCode: 0, + message: { + statuses: ["fulfilled", "fulfilled"], + third: "fulfilled", + }, + stderr: "", + }); + }); + + test("Symbol.asyncDispose closes idempotently", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + await pool[Symbol.asyncDispose](); + await pool[Symbol.asyncDispose](); + await expect(pool.get("SELECT 1")).rejects.toThrow(/closed/i); + }); + + test("in-flight work owns native state independently of the public wrapper", async () => { + let pool: DatabasePool | undefined = await DatabasePool.open(":memory:", { + authorizer: "none", + }); + const weak = new WeakRef(pool); + const operation = pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 250000 + ) + SELECT max(x) AS value FROM n + `); + pool = undefined; + expect(pool).toBeUndefined(); + global.gc?.(); + + await expect(operation).resolves.toEqual({ value: 250000 }); + await weak.deref()?.close(); + }); + + test("abrupt worker termination does not hang or race an executing connection", async () => { + const dbPath = tempDir.getDbPath("terminated-executing.db"); + const setup = new DatabaseSync(dbPath); + setup.exec(` + CREATE TABLE item(value INTEGER); + CREATE TRIGGER slow_insert AFTER INSERT ON item BEGIN + SELECT max(x) FROM ( + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 10000000 + ) + SELECT x FROM n + ); + END; + `); + setup.close(); + + const worker = new Worker( + ` + const { parentPort, workerData } = require('node:worker_threads'); + const binding = require('node-gyp-build')(workerData.root); + (async () => { + const connection = await binding._openAsyncPoolConnection( + workerData.dbPath, + { + readBigInts: false, + returnArrays: false, + authorizer: 'none', + allowExtension: false, + connectionSetup: [], + }, + ); + const pending = connection.execute({ + operations: [{ kind: 'run', sql: 'INSERT INTO item VALUES (1)' }], + }); + parentPort.postMessage('submitted'); + await pending; + })().catch((error) => parentPort.postMessage({ error: error.message })); + `, + { + eval: true, + workerData: { + dbPath, + root: projectRoot(), + }, + }, + ); + + await new Promise((resolve, reject) => { + worker.once("message", (message) => { + if (message === "submitted") resolve(); + else + reject( + new Error( + `worker failed before execution: ${JSON.stringify(message)}`, + ), + ); + }); + worker.once("error", reject); + }); + + const probe = new DatabaseSync(dbPath, { timeout: 0 }); + let terminated = false; + try { + expect( + await waitForCondition( + () => { + try { + probe.exec("BEGIN IMMEDIATE; ROLLBACK"); + return false; + } catch (error) { + if (/busy|locked/i.test((error as Error).message)) return true; + throw error; + } + }, + { + maxAttempts: 1_000, + delay: 1, + description: "executing SQLite write lock", + }, + ), + ).toBe(true); + await expect(worker.terminate()).resolves.toBeGreaterThanOrEqual(0); + terminated = true; + expect(probe.prepare("SELECT count(*) AS count FROM item").get()).toEqual( + { + count: 1, + }, + ); + } finally { + if (!terminated) await worker.terminate(); + if (probe.isOpen) probe.close(); + } + }); + + test("abrupt worker termination safely drains native work queued in libuv", async () => { + const root = projectRoot(); + const dbPath = tempDir.getDbPath("terminated-queued.db"); + const childScript = ` + const { pbkdf2 } = require('node:crypto'); + const { Worker } = require('node:worker_threads'); + + // With UV_THREADPOOL_SIZE=1 this request is ahead of SQLite in libuv's + // FIFO, so the connection open below is deterministically still queued. + pbkdf2('password', 'salt', 20000000, 32, 'sha256', () => {}); + + const worker = new Worker(\` + const { parentPort, workerData } = require('node:worker_threads'); + const binding = require('node-gyp-build')(workerData.root); + const opening = binding._openAsyncPoolConnection(workerData.dbPath, { + readBigInts: false, + returnArrays: false, + authorizer: 'none', + allowExtension: false, + connectionSetup: [], + }); + parentPort.postMessage('open-submitted'); + void opening; + \`, { + eval: true, + workerData: ${JSON.stringify({ root, dbPath })}, + }); + + worker.once('message', async (message) => { + if (message !== 'open-submitted') process.exit(2); + await worker.terminate(); + process.send('queued work drained', () => process.exit(0)); + }); + worker.once('error', (error) => { + process.stderr.write(error.stack || error.message); + process.exit(3); + }); + `; + const child = spawn(process.execPath, ["-e", childScript], { + cwd: root, + env: { ...process.env, UV_THREADPOOL_SIZE: "1" }, + stdio: ["ignore", "ignore", "pipe", "ipc"], + }); + let stderr = ""; + let message: unknown; + child.stderr!.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); + child.once("message", (value) => (message = value)); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + expect({ exitCode, message, stderr }).toEqual({ + exitCode: 0, + message: "queued work drained", + stderr: "", + }); + expect(existsSync(dbPath)).toBe(true); + }); +}); diff --git a/test/async-pool-policy.test.ts b/test/async-pool-policy.test.ts new file mode 100644 index 0000000..afb34e6 --- /dev/null +++ b/test/async-pool-policy.test.ts @@ -0,0 +1,113 @@ +import { DatabasePool } from "../src/experimental"; + +describe("DatabasePool authorizer policy", () => { + test("strict permits ordinary main-schema reads and writes", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "strict" }); + try { + await pool.run("CREATE TABLE item(value TEXT)"); + await pool.run("INSERT INTO item VALUES ('allowed')"); + await expect(pool.get("SELECT value FROM item")).resolves.toEqual({ + value: "allowed", + }); + } finally { + await pool.close(); + } + }); + + test.each([ + "PRAGMA user_version", + "ATTACH DATABASE ':memory:' AS attached", + "DETACH DATABASE attached", + "BEGIN", + "COMMIT", + "ROLLBACK", + "SAVEPOINT user_savepoint", + "RELEASE user_savepoint", + "CREATE TEMP TABLE temp_item(value)", + "CREATE TABLE temp.temp_item(value)", + "SELECT last_insert_rowid()", + "SELECT changes()", + "SELECT total_changes()", + "SELECT load_extension('not-available')", + ])("strict rejects connection-affine SQL: %s", async (sql) => { + const pool = await DatabasePool.open(":memory:", { authorizer: "strict" }); + try { + await expect(pool.run(sql)).rejects.toThrow(/not authorized/i); + } finally { + await pool.close(); + } + }); + + test("strict permits executor-owned transactions while rejecting user control in a batch", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "strict" }); + try { + await pool.run("CREATE TABLE item(value TEXT)"); + await expect( + pool.batch( + [{ kind: "run", sql: "INSERT INTO item VALUES ('committed')" }], + { transaction: "immediate" }, + ), + ).resolves.toEqual([{ changes: 1 }]); + + for (const operations of [ + [ + { kind: "run" as const, sql: "BEGIN" }, + { kind: "run" as const, sql: "INSERT INTO item VALUES ('bad')" }, + ], + [ + { kind: "run" as const, sql: "INSERT INTO item VALUES ('bad')" }, + { kind: "run" as const, sql: "COMMIT" }, + ], + ]) { + await expect( + pool.batch(operations, { transaction: "deferred" }), + ).rejects.toThrow(/not authorized/i); + } + await expect(pool.all("SELECT value FROM item")).resolves.toEqual([ + { value: "committed" }, + ]); + } finally { + await pool.close(); + } + }); + + test.each([ + "INSERT INTO temp_item VALUES ('denied')", + "UPDATE temp_item SET value = 'denied'", + "DELETE FROM temp_item", + "ALTER TABLE temp_item RENAME TO renamed_temp_item", + "DROP TABLE temp_item", + "CREATE INDEX temp.temp_item_value ON temp_item(value)", + "CREATE VIRTUAL TABLE temp.temp_search USING fts5(value)", + ])("strict rejects every tested temp-schema mutation: %s", async (sql) => { + const pool = await DatabasePool.open(":memory:", { + authorizer: "strict", + connectionSetup: [ + { sql: "CREATE TEMP TABLE temp_item(value TEXT)" }, + { sql: "INSERT INTO temp_item VALUES ('kept')" }, + ], + }); + try { + await expect(pool.run(sql)).rejects.toThrow(/not authorized/i); + await expect(pool.all("SELECT value FROM temp_item")).resolves.toEqual([ + { value: "kept" }, + ]); + } finally { + await pool.close(); + } + }); + + test("none installs no restrictive authorizer", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect(pool.get("PRAGMA user_version")).resolves.toEqual({ + user_version: 0, + }); + await expect(pool.get("SELECT changes() AS value")).resolves.toEqual({ + value: 0, + }); + } finally { + await pool.close(); + } + }); +}); diff --git a/test/async-pool-setup.test.ts b/test/async-pool-setup.test.ts new file mode 100644 index 0000000..02185b4 --- /dev/null +++ b/test/async-pool-setup.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { DatabaseSync } from "../src"; +import { DatabasePool } from "../src/experimental"; +import { getDirname, useTempDir } from "./test-utils"; + +const extensionDir = path.join(getDirname(), "fixtures", "test-extension"); +const extensionBase = path.join(extensionDir, "test_extension"); +const extensionFile = + extensionBase + + (process.platform === "win32" + ? ".dll" + : process.platform === "darwin" + ? ".dylib" + : ".so"); + +describe("DatabasePool connection setup", () => { + const tempDir = useTempDir("sqlite-async-pool-setup-"); + + beforeAll(() => { + execFileSync(process.execPath, ["build.js"], { + cwd: extensionDir, + stdio: "inherit", + }); + if (!fs.existsSync(extensionFile)) { + throw new Error(`Test extension was not built at ${extensionFile}`); + } + }); + + test("runs ordered parameterized setup before admitting the connection", async () => { + const attachedPath = tempDir.getDbPath("attached.db"); + const pool = await DatabasePool.open(tempDir.getDbPath("main.db"), { + authorizer: "strict", + connectionSetup: [ + { sql: "PRAGMA foreign_keys=ON" }, + { sql: "ATTACH DATABASE ? AS analytics", params: [attachedPath] }, + { sql: "CREATE TABLE analytics.config(value TEXT)" }, + { sql: "INSERT INTO analytics.config VALUES (?)", params: ["ready"] }, + ], + }); + try { + await expect( + pool.get("SELECT value FROM analytics.config"), + ).resolves.toEqual({ + value: "ready", + }); + } finally { + await pool.close(); + } + }); + + test("copies setup descriptors before opening connections sequentially", async () => { + const setup: any[] = [ + { sql: "CREATE TEMP TABLE configured(value TEXT)" }, + { sql: "INSERT INTO configured VALUES (?)", params: ["ready"] }, + ]; + const opening = DatabasePool.open(tempDir.getDbPath("copied-setup.db"), { + connections: 2, + authorizer: "strict", + connectionSetup: setup, + }); + setup[0].sql = "SELECT * FROM missing_after_open"; + setup[1].params[0] = "mutated"; + + const pool = await opening; + try { + const [first, second] = await Promise.all([ + pool.get("SELECT value FROM configured"), + pool.get("SELECT value FROM configured"), + ]); + expect(first).toEqual({ value: "ready" }); + expect(second).toEqual({ value: "ready" }); + } finally { + await pool.close(); + } + }); + + test.each(["none", "strict"] as const)( + "loads an extension during setup and revokes it under %s", + async (authorizer) => { + const pool = await DatabasePool.open(":memory:", { + authorizer, + allowExtension: true, + connectionSetup: [ + { + sql: "SELECT load_extension(?, ?)", + params: [extensionBase, "sqlite3_testextension_init"], + }, + ], + }); + try { + await expect( + pool.get("SELECT test_extension_add(?, ?) AS sum", [2, 3]), + ).resolves.toEqual({ sum: 5 }); + await expect( + pool.get("SELECT load_extension(?)", [extensionBase]), + ).rejects.toThrow(/not authorized|not enabled/i); + } finally { + await pool.close(); + } + }, + ); + + test("does not enable SQL extension loading unless explicitly allowed", async () => { + await expect( + DatabasePool.open(":memory:", { + authorizer: "none", + connectionSetup: [ + { sql: "SELECT load_extension(?)", params: [extensionBase] }, + ], + }), + ).rejects.toThrow(/not authorized|not enabled/i); + }); + + test("rejects open when an enabled extension fails to load", async () => { + await expect( + DatabasePool.open(":memory:", { + authorizer: "none", + allowExtension: true, + connectionSetup: [ + { sql: "SELECT load_extension(?)", params: ["missing-extension"] }, + ], + }), + ).rejects.toThrow(/missing-extension|load|shared|dynamic/i); + }); + + test("rejects open on setup errors and releases the database handle", async () => { + const dbPath = tempDir.getDbPath("failed-open.db"); + await expect( + DatabasePool.open(dbPath, { + authorizer: "none", + connectionSetup: [{ sql: "SELECT * FROM missing_setup_table" }], + }), + ).rejects.toThrow(/missing_setup_table|no such table/i); + + const db = new DatabaseSync(dbPath); + db.exec("CREATE TABLE handle_was_released(value)"); + db.close(); + }); + + test("rejects setup that contains multiple statements or leaks a transaction", async () => { + await expect( + DatabasePool.open(":memory:", { + authorizer: "none", + connectionSetup: [{ sql: "SELECT 1; SELECT 2" }], + }), + ).rejects.toThrow(/one statement|multiple statements/i); + await expect( + DatabasePool.open(":memory:", { + authorizer: "none", + connectionSetup: [{ sql: "BEGIN" }], + }), + ).rejects.toThrow(/transaction|autocommit/i); + }); + + test.each(["", " -- setup comment only\n "])( + "rejects setup without an executable statement: %p", + async (sql) => { + const dbPath = tempDir.getDbPath("empty-setup.db"); + await expect( + DatabasePool.open(dbPath, { + authorizer: "none", + connectionSetup: [{ sql }], + }), + ).rejects.toThrow(/one statement|statement/i); + + const db = new DatabaseSync(dbPath); + db.exec("CREATE TABLE handle_was_released(value)"); + db.close(); + }, + ); +}); diff --git a/test/async-pool-values.test.ts b/test/async-pool-values.test.ts new file mode 100644 index 0000000..200dc67 --- /dev/null +++ b/test/async-pool-values.test.ts @@ -0,0 +1,167 @@ +import { DatabasePool } from "../src/experimental"; + +describe("DatabasePool values and rows", () => { + test("run, get, and all execute one operation without exposing connection state", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.run("CREATE TABLE item(id INTEGER PRIMARY KEY, name TEXT)"), + ).resolves.toEqual({ changes: 0 }); + await expect( + pool.run("INSERT INTO item(name) VALUES (?)", ["Ada"]), + ).resolves.toEqual({ changes: 1 }); + await expect(pool.get("SELECT id, name FROM item", [])).resolves.toEqual({ + id: 1, + name: "Ada", + }); + await expect(pool.all("SELECT id, name FROM item", [])).resolves.toEqual([ + { id: 1, name: "Ada" }, + ]); + } finally { + await pool.close(); + } + }); + + test("returns null-prototype object rows and duplicate columns are last-wins", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + const row = await pool.get( + "SELECT 1 AS first, 2 AS duplicate, 3 AS duplicate, 4 AS last", + ); + expect(Object.getPrototypeOf(row!)).toBeNull(); + expect(Object.keys(row!)).toEqual(["first", "duplicate", "last"]); + expect(row).toEqual({ first: 1, duplicate: 3, last: 4 }); + } finally { + await pool.close(); + } + }); + + test("returnArrays and readBigInts are immutable pool-wide result policies", async () => { + const pool = await DatabasePool.open(":memory:", { + authorizer: "none", + readBigInts: true, + returnArrays: true, + }); + try { + await expect( + pool.get("SELECT 9007199254740992, 'value'"), + ).resolves.toEqual([9007199254740992n, "value"]); + await expect(pool.run("CREATE TABLE item(value TEXT)")).resolves.toEqual({ + changes: 0n, + }); + } finally { + await pool.close(); + } + }); + + test("unsafe integers reject unless readBigInts is enabled", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.get("SELECT 9007199254740992 AS value"), + ).rejects.toMatchObject({ + name: "RangeError", + code: "ERR_OUT_OF_RANGE", + }); + } finally { + await pool.close(); + } + }); + + test("positional arrays skip named parameter slots like DatabaseSync", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.get( + "SELECT $named AS named, ? AS first_positional, ? AS second_positional", + [42, 84], + ), + ).resolves.toEqual({ + named: null, + first_positional: 42, + second_positional: 84, + }); + } finally { + await pool.close(); + } + }); + + test("copies every supported input value before asynchronous execution", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await pool.run("CREATE TABLE values_table(n, i, f, s, b)"); + const blocker = pool.get(` + WITH RECURSIVE n(x) AS ( + VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 250000 + ) + SELECT max(x) AS value FROM n + `); + const backing = new Uint8Array([99, 1, 2, 3, 88]); + const view = new DataView(backing.buffer, 1, 3); + const params: any[] = [null, 42n, 1.5, "text", view]; + const promise = pool.run( + "INSERT INTO values_table VALUES (?, ?, ?, ?, ?)", + params, + ); + backing.fill(0); + params[3] = "mutated"; + params[4] = new Uint8Array([9]); + await blocker; + await promise; + + const row = await pool.get("SELECT n, i, f, s, b FROM values_table"); + expect(row).toMatchObject({ n: null, i: 42, f: 1.5, s: "text" }); + expect(Array.from((row as any).b)).toEqual([1, 2, 3]); + } finally { + await pool.close(); + } + }); + + test.each([true, undefined, Symbol("value"), {}, new ArrayBuffer(2)])( + "rejects unsupported bind value %p", + async (value) => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.get("SELECT ? AS value", [value as any]), + ).rejects.toThrow(/bind|parameter/i); + } finally { + await pool.close(); + } + }, + ); + + test("accepts bare and prefixed names and rejects conflicts and unknown names", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect( + pool.get("SELECT $name AS value", { name: "bare" }), + ).resolves.toEqual({ value: "bare" }); + await expect( + pool.get("SELECT $name AS value", { $name: "full" }), + ).resolves.toEqual({ value: "full" }); + await expect(pool.get("SELECT $name", { unknown: 1 })).rejects.toThrow( + /unknown named parameter/i, + ); + await expect( + pool.get("SELECT $value, :value", { value: 1 }), + ).rejects.toThrow(/conflicting names/i); + } finally { + await pool.close(); + } + }); + + test("rejects a second executable statement but permits trailing comments", async () => { + const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); + try { + await expect(pool.get("SELECT 1; SELECT 2")).rejects.toThrow( + /one statement|multiple statements/i, + ); + await expect( + pool.get("SELECT 1 AS value; -- trailing comment\n"), + ).resolves.toEqual({ value: 1 }); + } finally { + await pool.close(); + } + }); +}); diff --git a/test/fixtures/package-exports/experimental-import.cts b/test/fixtures/package-exports/experimental-import.cts new file mode 100644 index 0000000..d362305 --- /dev/null +++ b/test/fixtures/package-exports/experimental-import.cts @@ -0,0 +1,42 @@ +import * as stable from "@photostructure/sqlite"; +import { + DatabasePool, + type DatabasePoolOptions, + type PoolOperation, + type PoolRow, + type PoolRunResult, +} from "@photostructure/sqlite/experimental"; + +type AssertFalse = T; +type StableRootHasNoPool = AssertFalse< + "DatabasePool" extends keyof typeof stable ? true : false +>; + +const options = { + connections: 1, + authorizer: "strict", + readBigInts: true, + returnArrays: false, + connectionSetup: [{ sql: "PRAGMA foreign_keys=ON" }], +} satisfies DatabasePoolOptions; + +const operations = [ + { kind: "run", sql: "INSERT INTO item(value) VALUES (?)", params: [1n] }, + { kind: "get", sql: "SELECT value FROM item" }, +] satisfies readonly PoolOperation[]; + +// @ts-expect-error DatabasePool instances must come from DatabasePool.open(). +new DatabasePool(); + +async function consumeCommonJsDeclarations(): Promise { + const pool = await DatabasePool.open(":memory:", options); + const run: PoolRunResult = await pool.run("CREATE TABLE item(value INTEGER)"); + const row: PoolRow | undefined = await pool.get("SELECT value FROM item"); + await pool.batch(operations, { transaction: "immediate" }); + await pool[Symbol.asyncDispose](); + void run; + void row; +} + +void consumeCommonJsDeclarations; +void (null as unknown as StableRootHasNoPool); diff --git a/test/fixtures/package-exports/experimental-import.mts b/test/fixtures/package-exports/experimental-import.mts new file mode 100644 index 0000000..5f45738 --- /dev/null +++ b/test/fixtures/package-exports/experimental-import.mts @@ -0,0 +1,31 @@ +import * as stable from "@photostructure/sqlite"; +import { + DatabasePool, + type DatabasePoolOptions, + type PoolOperationResult, +} from "@photostructure/sqlite/experimental"; + +type AssertFalse = T; +type StableRootHasNoPool = AssertFalse< + "DatabasePool" extends keyof typeof stable ? true : false +>; + +const options = { + connections: 1, + authorizer: "none", + returnArrays: true, +} satisfies DatabasePoolOptions; + +// @ts-expect-error DatabasePool instances must come from DatabasePool.open(). +new DatabasePool(); + +async function consumeEsmDeclarations(): Promise { + await using pool = await DatabasePool.open(":memory:", options); + const results: PoolOperationResult[] = await pool.batch([ + { kind: "get", sql: "SELECT 1" }, + ]); + void results; +} + +void consumeEsmDeclarations; +void (null as unknown as StableRootHasNoPool); diff --git a/test/fixtures/package-exports/tsconfig.json b/test/fixtures/package-exports/tsconfig.json new file mode 100644 index 0000000..99316f0 --- /dev/null +++ b/test/fixtures/package-exports/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "ESNext.Disposable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "types": ["node"] + }, + "files": [], + "include": ["experimental-import.cts", "experimental-import.mts"], + "exclude": [] +} diff --git a/test/package-exports.test.mjs b/test/package-exports.test.mjs new file mode 100644 index 0000000..dae3d5e --- /dev/null +++ b/test/package-exports.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const stableKeys = [ + "DatabaseSync", + "SQLTagStore", + "Session", + "StatementSync", + "backup", + "constants", + "default", + "enhance", + "isEnhanced", +]; + +function checkExperimentalModule(module, label) { + assert.deepEqual(Object.keys(module).sort(), ["DatabasePool"]); + assert.equal(typeof module.DatabasePool, "function"); + assert.throws(() => new module.DatabasePool(), /illegal constructor/i); + assert.equal(typeof module.DatabasePool.open, "function"); + assert.equal(typeof module.DatabasePool.prototype.run, "function"); + assert.equal(typeof module.DatabasePool.prototype.get, "function"); + assert.equal(typeof module.DatabasePool.prototype.all, "function"); + assert.equal(typeof module.DatabasePool.prototype.batch, "function"); + assert.equal(typeof module.DatabasePool.prototype.close, "function"); + assert.equal( + typeof module.DatabasePool.prototype[Symbol.asyncDispose], + "function", + ); + + assert.equal( + "DatabasePool" in module, + true, + `${label} should expose DatabasePool`, + ); +} + +test("CommonJS resolves the built experimental subpath", () => { + const resolved = require.resolve("@photostructure/sqlite/experimental"); + assert.equal( + resolved.endsWith(join("dist", "experimental.cjs")), + true, + resolved, + ); + checkExperimentalModule( + require("@photostructure/sqlite/experimental"), + "CommonJS", + ); +}); + +test("ESM resolves the built experimental subpath", async () => { + const resolved = fileURLToPath( + import.meta.resolve("@photostructure/sqlite/experimental"), + ); + assert.equal( + resolved.endsWith(join("dist", "experimental.mjs")), + true, + resolved, + ); + checkExperimentalModule( + await import("@photostructure/sqlite/experimental"), + "ESM", + ); +}); + +test("the built stable root export surface is unchanged", async () => { + const commonjs = require("@photostructure/sqlite"); + const esm = await import("@photostructure/sqlite"); + + assert.deepEqual(Object.keys(commonjs).sort(), stableKeys); + assert.deepEqual(Object.keys(esm).sort(), stableKeys); + assert.equal("DatabasePool" in commonjs, false); + assert.equal("DatabasePool" in esm, false); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 3e7700c..8e7519d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/experimental.ts"], format: ["cjs", "esm"], dts: true, // Generate .d.ts files automatically clean: true, // Clean dist before each build diff --git a/typedoc.json b/typedoc.json index 89b9a67..91f16b2 100644 --- a/typedoc.json +++ b/typedoc.json @@ -1,5 +1,5 @@ { - "entryPoints": ["src/index.ts"], + "entryPoints": ["src/index.ts", "src/experimental.ts"], "out": "build/docs", "name": "@photostructure/sqlite", "includeVersion": false, @@ -25,6 +25,7 @@ "doc/working-with-data.md", "doc/extending-sqlite.md", "doc/advanced-patterns.md", + "doc/experimental-async-pool.md", "doc/api-reference.md", "doc/features.md", "doc/library-comparison.md", From f0803e7f518b74bf51eaf8013f31d4bd791ea5e9 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 16:50:44 -0700 Subject: [PATCH 2/7] chore(bench): keep generated async results local Remove machine-specific raw reports from version control while retaining reproducible commands and summarized evidence. --- .gitignore | 1 + benchmark/README.md | 28 +- benchmark/results/async-pool-default.json | 2347 ----------------- benchmark/results/async-pool-uv8.json | 2347 ----------------- ...08-P10-experimental-async-database-pool.md | 25 +- 5 files changed, 26 insertions(+), 4722 deletions(-) delete mode 100644 benchmark/results/async-pool-default.json delete mode 100644 benchmark/results/async-pool-uv8.json diff --git a/.gitignore b/.gitignore index 9adff2c..71e59bc 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ coverage/ test/*.db stress-test-results.json stress-test-results.md +benchmark/results/ # Generated documentation is now in build/docs/ (covered by build/ ignore) diff --git a/benchmark/README.md b/benchmark/README.md index 2e3c987..79343a2 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -137,11 +137,11 @@ UV_THREADPOOL_SIZE=8 npm run bench:async -- \ --output=results/async-pool-uv8.json ``` -The checked-in reference run used Node 26.6.0 on Linux x64 with an AMD Ryzen 9 -5950X (32 logical CPUs). Each figure below is the median of six measured -samples after one warmup; the raw reports retain every sample. These reports -predate the current addition of a three-connection scaling case and remain -valid historical results for their recorded one/two/four-connection matrix. +The reference summary below used Node 26.6.0 on Linux x64 with an AMD Ryzen 9 +5950X (32 logical CPUs). Each figure is the median of six measured samples after +one warmup. The run predates the current addition of a three-connection scaling +case and covers its recorded one/two/four-connection matrix. Raw benchmark +reports are local artifacts and are not versioned. | Reference scenario | Median ops/ms | | -------------------------------------------------------- | ------------------------: | @@ -161,16 +161,14 @@ also make the global-libuv tradeoff concrete: increasing the startup-time pool size mostly mattered when four SQLite jobs competed with four crypto jobs. It did not materially improve the uncontended four-connection point-read case. -The canonical raw result locations are -`benchmark/results/async-pool-default.json` and -`benchmark/results/async-pool-uv8.json`. The versioned JSON schema records the -package and SQLite versions, git revision and dirty state, Node/V8/N-API/libuv -versions, CPU and platform, effective `UV_THREADPOOL_SIZE`, all CLI options, each -scenario's settings, every raw sample, and the computed summaries. Do not -publish only the console table: keep both raw files and the exact commands with -any comparison. Absolute throughput is machine-specific, so compare repeated -samples on the same otherwise-idle machine rather than treating one run as a -performance guarantee. +The commands above write local reports under `benchmark/results/`, which Git +ignores. The JSON schema records the package and SQLite versions, git revision +and dirty state, Node/V8/N-API/libuv versions, CPU and platform, effective +`UV_THREADPOOL_SIZE`, all CLI options, each scenario's settings, every raw +sample, and the computed summaries. When sharing a comparison, attach the raw +files and exact commands outside the repository. Absolute throughput is +machine-specific, so compare repeated samples on the same otherwise-idle +machine rather than treating one run as a performance guarantee. Runtime and scope are fully controllable. List scenario IDs and groups with `npm run bench:async -- --list`, or see all options with diff --git a/benchmark/results/async-pool-default.json b/benchmark/results/async-pool-default.json deleted file mode 100644 index 0ff4c83..0000000 --- a/benchmark/results/async-pool-default.json +++ /dev/null @@ -1,2347 +0,0 @@ -{ - "schemaVersion": 1, - "generatedAt": "2026-08-08T08:35:27.062Z", - "package": { - "name": "@photostructure/sqlite", - "version": "2.2.0", - "sqlite": "3.53.4" - }, - "git": { - "commit": "9ac2e43995ae039488590ea5999576884c5990fb", - "dirty": true - }, - "environment": { - "node": "v26.6.0", - "v8": "14.6.202.34-node.26", - "napi": "10", - "uv": "1.52.1", - "platform": "linux", - "arch": "x64", - "cpuModel": "AMD Ryzen 9 5950X 16-Core Processor", - "cpuCount": 32, - "uvThreadpoolSize": "default (4)" - }, - "config": { - "iterations": 10000, - "writeIterations": 2000, - "samples": 6, - "warmup": 1, - "seedRows": 2000, - "connections": [1, 2, 4], - "batchSizes": [10, 100], - "resultSizes": [1, 100, 1000], - "scenarioFilters": null, - "contentionWorkers": 4, - "cryptoIterations": 10000, - "heartbeatIntervalMs": 10 - }, - "results": [ - { - "id": "warm-sync-reused-statement", - "group": "controls", - "description": "Warm DatabaseSync connection with one reused statement", - "settings": { - "implementation": "DatabaseSync", - "connection": "warm" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 39.89086800000041, - "operationsPerMs": 250.68394099621742, - "rowsPerMs": 250.68394099621742, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.301274999997986, - "operationsPerMs": 248.13110751460096, - "rowsPerMs": 248.13110751460096, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.97745399999985, - "operationsPerMs": 244.03663536538986, - "rowsPerMs": 244.03663536538986, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 41.0031650000019, - "operationsPerMs": 243.88361239917788, - "rowsPerMs": 243.88361239917788, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.76803099999961, - "operationsPerMs": 245.2902373430813, - "rowsPerMs": 245.2902373430813, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 41.41780099999596, - "operationsPerMs": 241.44207945759783, - "rowsPerMs": 241.44207945759783, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - } - ], - "summary": { - "medianOperationsPerMs": 244.66343635423556, - "medianRowsPerMs": 244.66343635423556, - "medianElapsedMs": 40.87274249999973, - "relativeMarginOfErrorPct": 2.46072920894689, - "minOperationsPerMs": 241.44207945759783, - "maxOperationsPerMs": 250.68394099621742, - "medianEventLoopHeartbeats": 0, - "maxEventLoopDelayMs": null - } - }, - { - "id": "fresh-sync-connection", - "group": "controls", - "description": "Open, query, and close DatabaseSync for every operation", - "settings": { - "implementation": "DatabaseSync", - "connection": "fresh" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2386.6517239999994, - "operationsPerMs": 4.189970367037936, - "rowsPerMs": 4.189970367037936, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2394.9195359999994, - "operationsPerMs": 4.175505627509317, - "rowsPerMs": 4.175505627509317, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2419.2674449999977, - "operationsPerMs": 4.133482646024698, - "rowsPerMs": 4.133482646024698, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2425.2475830000003, - "operationsPerMs": 4.1232903684126665, - "rowsPerMs": 4.1232903684126665, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2401.127956999997, - "operationsPerMs": 4.164709327900267, - "rowsPerMs": 4.164709327900267, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2425.4228460000013, - "operationsPerMs": 4.122992416143834, - "rowsPerMs": 4.122992416143834, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - } - ], - "summary": { - "medianOperationsPerMs": 4.149095986962482, - "medianRowsPerMs": 4.149095986962482, - "medianElapsedMs": 2410.1977009999973, - "relativeMarginOfErrorPct": 0.9851394184152676, - "minOperationsPerMs": 4.122992416143834, - "maxOperationsPerMs": 4.189970367037936, - "medianEventLoopHeartbeats": 0, - "maxEventLoopDelayMs": null - } - }, - { - "id": "worker-thread-sync-control", - "group": "controls", - "description": "One DatabaseSync worker thread with per-operation messages", - "settings": { - "implementation": "worker_threads + DatabaseSync", - "workers": 1 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 81.47510200000033, - "operationsPerMs": 122.73688224409905, - "rowsPerMs": 122.73688224409905, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.01117799999883573, - "meanDelayMs": 0.003837624999732725 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 80.01745099999971, - "operationsPerMs": 124.97273875919937, - "rowsPerMs": 124.97273875919937, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.01195700000062061, - "meanDelayMs": 0.0018865000001824228 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 84.85943100000077, - "operationsPerMs": 117.84194027885844, - "rowsPerMs": 117.84194027885844, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.0108879999970668, - "meanDelayMs": 0.003023875000053522 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 82.30318499999703, - "operationsPerMs": 121.50198075567016, - "rowsPerMs": 121.50198075567016, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.007267000000865664, - "meanDelayMs": 0.0025897500004248286 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 79.74285700000473, - "operationsPerMs": 125.40308155750435, - "rowsPerMs": 125.40308155750435, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.011797999999544118, - "meanDelayMs": 0.002886374999434338 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 79.5606140000018, - "operationsPerMs": 125.69033215354237, - "rowsPerMs": 125.69033215354237, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.010108000002219342, - "meanDelayMs": 0.0034938750004585017 - } - } - ], - "summary": { - "medianOperationsPerMs": 123.85481050164921, - "medianRowsPerMs": 123.85481050164921, - "medianElapsedMs": 80.74627650000002, - "relativeMarginOfErrorPct": 4.854773261076283, - "minOperationsPerMs": 117.84194027885844, - "maxOperationsPerMs": 125.69033215354237, - "medianEventLoopHeartbeats": 8, - "maxEventLoopDelayMs": 0.01195700000062061 - } - }, - { - "id": "pool-none-1c-point-read", - "group": "pool-scale", - "description": "1-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 1, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 271.02584899999965, - "operationsPerMs": 36.89684964329736, - "rowsPerMs": 36.89684964329736, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 7.335345000001325, - "meanDelayMs": 0.2869932692308514 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 261.37703599999986, - "operationsPerMs": 38.258908100863174, - "rowsPerMs": 38.258908100863174, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 5.170264000000316, - "meanDelayMs": 0.21071608000005654 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 234.13464499999827, - "operationsPerMs": 42.71046687686939, - "rowsPerMs": 42.71046687686939, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.02646699999968405, - "meanDelayMs": 0.004386652174023608 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 259.38880699999936, - "operationsPerMs": 38.552164666072215, - "rowsPerMs": 38.552164666072215, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 10.189727999997558, - "meanDelayMs": 0.4113015199995425 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 280.94486500000494, - "operationsPerMs": 35.59417254342707, - "rowsPerMs": 35.59417254342707, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 8.49209300000075, - "meanDelayMs": 0.3205250740742216 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 266.7048949999953, - "operationsPerMs": 37.494624911178235, - "rowsPerMs": 37.494624911178235, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 13.120420999999624, - "meanDelayMs": 0.5315571200000704 - } - } - ], - "summary": { - "medianOperationsPerMs": 37.8767665060207, - "medianRowsPerMs": 37.8767665060207, - "medianElapsedMs": 264.0409654999976, - "relativeMarginOfErrorPct": 12.761649994807103, - "minOperationsPerMs": 35.59417254342707, - "maxOperationsPerMs": 42.71046687686939, - "medianEventLoopHeartbeats": 25, - "maxEventLoopDelayMs": 13.120420999999624 - } - }, - { - "id": "pool-none-2c-point-read", - "group": "pool-scale", - "description": "2-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 2, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 114.44737799999893, - "operationsPerMs": 87.37640105656324, - "rowsPerMs": 87.37640105656324, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.01839800000016112, - "meanDelayMs": 0.004044636363522097 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 132.91185099999893, - "operationsPerMs": 75.23783563890086, - "rowsPerMs": 75.23783563890086, - "eventLoop": { - "heartbeats": 13, - "intervalMs": 10, - "maxDelayMs": 0.019148999999742955, - "meanDelayMs": 0.003566846153821993 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 117.99337100000048, - "operationsPerMs": 84.75052382391854, - "rowsPerMs": 84.75052382391854, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.020908000002236804, - "meanDelayMs": 0.004493090909156969 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 140.7281460000013, - "operationsPerMs": 71.05899057321417, - "rowsPerMs": 71.05899057321417, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 1.7491440000012517, - "meanDelayMs": 0.12700785714280624 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 128.5818769999969, - "operationsPerMs": 77.77145763706841, - "rowsPerMs": 77.77145763706841, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 5.4007369999999355, - "meanDelayMs": 0.4539042499997474 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 111.37105300000258, - "operationsPerMs": 89.78993850403631, - "rowsPerMs": 89.78993850403631, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.016376999999920372, - "meanDelayMs": 0.0029496363634031945 - } - } - ], - "summary": { - "medianOperationsPerMs": 81.26099073049348, - "medianRowsPerMs": 81.26099073049348, - "medianElapsedMs": 123.28762399999869, - "relativeMarginOfErrorPct": 12.554609617196022, - "minOperationsPerMs": 71.05899057321417, - "maxOperationsPerMs": 89.78993850403631, - "medianEventLoopHeartbeats": 11.5, - "maxEventLoopDelayMs": 5.4007369999999355 - } - }, - { - "id": "pool-none-4c-point-read", - "group": "pool-scale", - "description": "4-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 4, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.26489500000025, - "operationsPerMs": 131.1219270674924, - "rowsPerMs": 131.1219270674924, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.4946749999999156, - "meanDelayMs": 0.0740668571428874 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 74.57006000000001, - "operationsPerMs": 134.10207796533888, - "rowsPerMs": 134.10207796533888, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.006978000001254259, - "meanDelayMs": 0.0020472857140703127 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 73.04440800000157, - "operationsPerMs": 136.9030193248987, - "rowsPerMs": 136.9030193248987, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.011137999998027226, - "meanDelayMs": 0.002937714285508264 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.17312400000083, - "operationsPerMs": 131.27989866872062, - "rowsPerMs": 131.27989866872062, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.015956999999616528, - "meanDelayMs": 0.0024320000000963254 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 73.48715400000219, - "operationsPerMs": 136.0782049063936, - "rowsPerMs": 136.0782049063936, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.009028000000398606, - "meanDelayMs": 0.001933285714455581 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 83.54730199999904, - "operationsPerMs": 119.69267421705749, - "rowsPerMs": 119.69267421705749, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.03473800000210758, - "meanDelayMs": 0.007624250000844768 - } - } - ], - "summary": { - "medianOperationsPerMs": 132.69098831702973, - "medianRowsPerMs": 132.69098831702973, - "medianElapsedMs": 75.37159200000042, - "relativeMarginOfErrorPct": 9.79592831799266, - "minOperationsPerMs": 119.69267421705749, - "maxOperationsPerMs": 136.9030193248987, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 0.4946749999999156 - } - }, - { - "id": "pool-strict-1c-point-read", - "group": "pool-scale", - "description": "1-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 1, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 229.5558170000004, - "operationsPerMs": 43.56238988271851, - "rowsPerMs": 43.56238988271851, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.021917000000030384, - "meanDelayMs": 0.003915347826161609 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 246.2147530000002, - "operationsPerMs": 40.61495047780501, - "rowsPerMs": 40.61495047780501, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.039579000000230735, - "meanDelayMs": 0.005832249999987956 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 237.67262699999992, - "operationsPerMs": 42.07468115375357, - "rowsPerMs": 42.07468115375357, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.04747900000074878, - "meanDelayMs": 0.006448347826137805 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 277.8643799999991, - "operationsPerMs": 35.988779850083816, - "rowsPerMs": 35.988779850083816, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 0.01817799999844283, - "meanDelayMs": 0.004078666666626102 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 254.8640009999981, - "operationsPerMs": 39.23661231387509, - "rowsPerMs": 39.23661231387509, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.01589800000147079, - "meanDelayMs": 0.005199000000138767 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 265.22155300000304, - "operationsPerMs": 37.70432639009502, - "rowsPerMs": 37.70432639009502, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 0.04338799999823095, - "meanDelayMs": 0.005498961538810713 - } - } - ], - "summary": { - "medianOperationsPerMs": 39.92578139584005, - "medianRowsPerMs": 39.92578139584005, - "medianElapsedMs": 250.53937699999915, - "relativeMarginOfErrorPct": 9.860800235124357, - "minOperationsPerMs": 35.988779850083816, - "maxOperationsPerMs": 43.56238988271851, - "medianEventLoopHeartbeats": 24.5, - "maxEventLoopDelayMs": 0.04747900000074878 - } - }, - { - "id": "pool-strict-2c-point-read", - "group": "pool-scale", - "description": "2-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 2, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 117.3946020000003, - "operationsPerMs": 85.18279230590154, - "rowsPerMs": 85.18279230590154, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.06333900000026915, - "meanDelayMs": 0.009333454545412678 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 119.03946700000051, - "operationsPerMs": 84.00575247871328, - "rowsPerMs": 84.00575247871328, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.008748000000196043, - "meanDelayMs": 0.0017004545454917454 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 119.96544999999969, - "operationsPerMs": 83.35733329887918, - "rowsPerMs": 83.35733329887918, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.02072799999950803, - "meanDelayMs": 0.005629666666512397 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 144.8033859999996, - "operationsPerMs": 69.05915860282458, - "rowsPerMs": 69.05915860282458, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.04496800000197254, - "meanDelayMs": 0.008438928571714703 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 147.0498090000001, - "operationsPerMs": 68.00416857392854, - "rowsPerMs": 68.00416857392854, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.0673979999992298, - "meanDelayMs": 0.011706357142819408 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 121.74475600000005, - "operationsPerMs": 82.13906149682533, - "rowsPerMs": 82.13906149682533, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.025988000001234468, - "meanDelayMs": 0.004957250000491816 - } - } - ], - "summary": { - "medianOperationsPerMs": 82.74819739785227, - "medianRowsPerMs": 82.74819739785227, - "medianElapsedMs": 120.85510299999987, - "relativeMarginOfErrorPct": 17.817945632138215, - "minOperationsPerMs": 68.00416857392854, - "maxOperationsPerMs": 85.18279230590154, - "medianEventLoopHeartbeats": 12, - "maxEventLoopDelayMs": 0.0673979999992298 - } - }, - { - "id": "pool-strict-4c-point-read", - "group": "pool-scale", - "description": "4-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 4, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 73.4352429999999, - "operationsPerMs": 136.17439789775074, - "rowsPerMs": 136.17439789775074, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.8465699999997014, - "meanDelayMs": 0.12437414285705017 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.18273899999986, - "operationsPerMs": 133.0092536266871, - "rowsPerMs": 133.0092536266871, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.021457999999256572, - "meanDelayMs": 0.0037279999999425073 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.4475429999984, - "operationsPerMs": 132.54242089765881, - "rowsPerMs": 132.54242089765881, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.009357999999338062, - "meanDelayMs": 0.002467428571565376 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 74.26627500000177, - "operationsPerMs": 134.65062035223607, - "rowsPerMs": 134.65062035223607, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.02097700000012992, - "meanDelayMs": 0.004445857142335237 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 77.19305900000109, - "operationsPerMs": 129.54532608948506, - "rowsPerMs": 129.54532608948506, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.013398000002780464, - "meanDelayMs": 0.0029804285716506585 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 71.43470399999933, - "operationsPerMs": 139.9879811918881, - "rowsPerMs": 139.9879811918881, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.04663799999980256, - "meanDelayMs": 0.009260428570477026 - } - } - ], - "summary": { - "medianOperationsPerMs": 133.8299369894616, - "medianRowsPerMs": 133.8299369894616, - "medianElapsedMs": 74.72450700000081, - "relativeMarginOfErrorPct": 4.601395129485432, - "minOperationsPerMs": 129.54532608948506, - "maxOperationsPerMs": 139.9879811918881, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 0.8465699999997014 - } - }, - { - "id": "pool-none-1c-batch-10", - "group": "batch", - "description": "One-connection none pool, explicit get batches of 10", - "settings": { - "authorizer": "none", - "connections": 1, - "batchSize": 10 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 90.43914500000028, - "operationsPerMs": 110.57158932672317, - "rowsPerMs": 110.57158932672317, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.05556799999976647, - "meanDelayMs": 0.01128344444441609 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 95.79387299999871, - "operationsPerMs": 104.39081004690284, - "rowsPerMs": 104.39081004690284, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.07578800000010233, - "meanDelayMs": 0.01799622222218507 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 98.02843700000085, - "operationsPerMs": 102.01121537824696, - "rowsPerMs": 102.01121537824696, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.05361799999809591, - "meanDelayMs": 0.01534144444465508 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 91.43435899999895, - "operationsPerMs": 109.36807682985031, - "rowsPerMs": 109.36807682985031, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.061219000002893154, - "meanDelayMs": 0.010767000000163939 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 94.84660900000017, - "operationsPerMs": 105.43339509375588, - "rowsPerMs": 105.43339509375588, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.0735290000011446, - "meanDelayMs": 0.022648888889509382 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 91.95921600000293, - "operationsPerMs": 108.74385879931471, - "rowsPerMs": 108.74385879931471, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.05652900000131922, - "meanDelayMs": 0.013001555555901077 - } - } - ], - "summary": { - "medianOperationsPerMs": 107.0886269465353, - "medianRowsPerMs": 107.0886269465353, - "medianElapsedMs": 93.40291250000155, - "relativeMarginOfErrorPct": 4.741317274357502, - "minOperationsPerMs": 102.01121537824696, - "maxOperationsPerMs": 110.57158932672317, - "medianEventLoopHeartbeats": 9, - "maxEventLoopDelayMs": 0.07578800000010233 - } - }, - { - "id": "pool-none-1c-batch-100", - "group": "batch", - "description": "One-connection none pool, explicit get batches of 100", - "settings": { - "authorizer": "none", - "connections": 1, - "batchSize": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.63658100000066, - "operationsPerMs": 130.48598814709536, - "rowsPerMs": 130.48598814709536, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.463434999999663, - "meanDelayMs": 0.11668257142862005 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.66998600000079, - "operationsPerMs": 132.15279305060128, - "rowsPerMs": 132.15279305060128, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.6201769999988755, - "meanDelayMs": 0.09955214285686712 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 74.39640800000052, - "operationsPerMs": 134.41509165334878, - "rowsPerMs": 134.41509165334878, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.40053400000033434, - "meanDelayMs": 0.10578228571414781 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.85759399999733, - "operationsPerMs": 130.11076042792007, - "rowsPerMs": 130.11076042792007, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.2888219999986177, - "meanDelayMs": 0.09183171428542534 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.9785609999999, - "operationsPerMs": 131.61607522416767, - "rowsPerMs": 131.61607522416767, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.6835870000031719, - "meanDelayMs": 0.160616000000508 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.96198100000038, - "operationsPerMs": 131.64480268096156, - "rowsPerMs": 131.64480268096156, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.5098050000015064, - "meanDelayMs": 0.14099128571459524 - } - } - ], - "summary": { - "medianOperationsPerMs": 131.63043895256462, - "medianRowsPerMs": 131.63043895256462, - "medianElapsedMs": 75.97027100000014, - "relativeMarginOfErrorPct": 2.115508178004074, - "minOperationsPerMs": 130.11076042792007, - "maxOperationsPerMs": 134.41509165334878, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 0.6835870000031719 - } - }, - { - "id": "pool-none-all-1-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 1 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 1, - "operations": 10000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 305.196903, - "operationsPerMs": 32.76573222631948, - "rowsPerMs": 32.76573222631948, - "eventLoop": { - "heartbeats": 30, - "intervalMs": 10, - "maxDelayMs": 0.05655800000022282, - "meanDelayMs": 0.009397733333450257 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 315.00035800000114, - "operationsPerMs": 31.745995666455602, - "rowsPerMs": 31.745995666455602, - "eventLoop": { - "heartbeats": 31, - "intervalMs": 10, - "maxDelayMs": 0.06419799999821407, - "meanDelayMs": 0.009284516128959254 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 313.5787470000032, - "operationsPerMs": 31.88991631502341, - "rowsPerMs": 31.88991631502341, - "eventLoop": { - "heartbeats": 31, - "intervalMs": 10, - "maxDelayMs": 0.022257999997236766, - "meanDelayMs": 0.005710580645339383 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 301.54837000000043, - "operationsPerMs": 33.162175607183634, - "rowsPerMs": 33.162175607183634, - "eventLoop": { - "heartbeats": 30, - "intervalMs": 10, - "maxDelayMs": 0.04451799999878858, - "meanDelayMs": 0.007417700000102437 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 291.61203300000125, - "operationsPerMs": 34.29213773219008, - "rowsPerMs": 34.29213773219008, - "eventLoop": { - "heartbeats": 29, - "intervalMs": 10, - "maxDelayMs": 0.5656960000014806, - "meanDelayMs": 0.025075103448185036 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 362.0278410000028, - "operationsPerMs": 27.62218500206431, - "rowsPerMs": 27.62218500206431, - "eventLoop": { - "heartbeats": 36, - "intervalMs": 10, - "maxDelayMs": 0.03493700000399258, - "meanDelayMs": 0.006498166666966022 - } - } - ], - "summary": { - "medianOperationsPerMs": 32.32782427067144, - "medianRowsPerMs": 32.32782427067144, - "medianElapsedMs": 309.3878250000016, - "relativeMarginOfErrorPct": 14.556003612269691, - "minOperationsPerMs": 27.62218500206431, - "maxOperationsPerMs": 34.29213773219008, - "medianEventLoopHeartbeats": 30.5, - "maxEventLoopDelayMs": 0.5656960000014806 - } - }, - { - "id": "pool-none-all-100-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 100 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 100, - "operations": 1000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 147.23000200000024, - "operationsPerMs": 6.792093910315904, - "rowsPerMs": 679.2093910315904, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.9585110000007262, - "meanDelayMs": 0.1243367857141493 - } - }, - { - "sample": 2, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 148.6136630000001, - "operationsPerMs": 6.728856417461424, - "rowsPerMs": 672.8856417461424, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 2.8122090000033495, - "meanDelayMs": 0.22726735714318888 - } - }, - { - "sample": 3, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 146.65315399999963, - "operationsPerMs": 6.818810047549353, - "rowsPerMs": 681.8810047549352, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 2.8612390000016603, - "meanDelayMs": 0.22714114285736287 - } - }, - { - "sample": 4, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 144.09150600000066, - "operationsPerMs": 6.940034341788303, - "rowsPerMs": 694.0034341788304, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.12848900000244612, - "meanDelayMs": 0.026480499999836735 - } - }, - { - "sample": 5, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 140.82045799999833, - "operationsPerMs": 7.101240929070205, - "rowsPerMs": 710.1240929070206, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.09440900000117836, - "meanDelayMs": 0.022187428571019803 - } - }, - { - "sample": 6, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 149.70001900000352, - "operationsPerMs": 6.680025872274448, - "rowsPerMs": 668.0025872274448, - "eventLoop": { - "heartbeats": 15, - "intervalMs": 10, - "maxDelayMs": 0.09124899999733316, - "meanDelayMs": 0.01689159999950789 - } - } - ], - "summary": { - "medianOperationsPerMs": 6.805451978932629, - "medianRowsPerMs": 680.5451978932629, - "medianElapsedMs": 146.94157799999994, - "relativeMarginOfErrorPct": 4.346352763243923, - "minOperationsPerMs": 6.680025872274448, - "maxOperationsPerMs": 7.101240929070205, - "medianEventLoopHeartbeats": 14, - "maxEventLoopDelayMs": 2.8612390000016603 - } - }, - { - "id": "pool-none-all-1000-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 1000 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 1000, - "operations": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 113.87814000000071, - "operationsPerMs": 0.8781316589821311, - "rowsPerMs": 878.1316589821311, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.8754100000005565, - "meanDelayMs": 0.2584219090908432 - } - }, - { - "sample": 2, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 116.0306210000017, - "operationsPerMs": 0.8618414616603537, - "rowsPerMs": 861.8414616603537, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.8194100000000617, - "meanDelayMs": 0.2345876363641847 - } - }, - { - "sample": 3, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 116.74758199999997, - "operationsPerMs": 0.8565487891646444, - "rowsPerMs": 856.5487891646444, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.8269999999974971, - "meanDelayMs": 0.31447300000019657 - } - }, - { - "sample": 4, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 109.84334099999978, - "operationsPerMs": 0.9103874580799595, - "rowsPerMs": 910.3874580799595, - "eventLoop": { - "heartbeats": 10, - "intervalMs": 10, - "maxDelayMs": 0.8320000000021537, - "meanDelayMs": 0.23501549999964483 - } - }, - { - "sample": 5, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 110.75461399999767, - "operationsPerMs": 0.9028969212966793, - "rowsPerMs": 902.8969212966794, - "eventLoop": { - "heartbeats": 10, - "intervalMs": 10, - "maxDelayMs": 2.739688000001479, - "meanDelayMs": 0.5377443000001222 - } - }, - { - "sample": 6, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 112.61935100000119, - "operationsPerMs": 0.8879468680298019, - "rowsPerMs": 887.946868029802, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.864610000004177, - "meanDelayMs": 0.2676293636369254 - } - } - ], - "summary": { - "medianOperationsPerMs": 0.8830392635059665, - "medianRowsPerMs": 883.0392635059666, - "medianElapsedMs": 113.24874550000095, - "relativeMarginOfErrorPct": 3.097053064821978, - "minOperationsPerMs": 0.8565487891646444, - "maxOperationsPerMs": 0.9103874580799595, - "medianEventLoopHeartbeats": 11, - "maxEventLoopDelayMs": 2.739688000001479 - } - }, - { - "id": "pool-strict-2c-mix-100r-0w", - "group": "read-write", - "description": "Two-connection strict pool, 100% reads and 0% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 100, - "writesPct": 0 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 28.72264300000097, - "operationsPerMs": 69.63147507003211, - "rowsPerMs": 69.63147507003211, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 29.4094640000003, - "operationsPerMs": 68.00531964812346, - "rowsPerMs": 68.00531964812346, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 23.602717999998276, - "operationsPerMs": 84.73600370940949, - "rowsPerMs": 84.73600370940949, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.00824799999827519, - "meanDelayMs": 0.004123999999137595 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 27.546125999997457, - "operationsPerMs": 72.60549087738089, - "rowsPerMs": 72.60549087738089, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.05385799999930896, - "meanDelayMs": 0.02692899999965448 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 31.16189900000245, - "operationsPerMs": 64.18094096254669, - "rowsPerMs": 64.18094096254669, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0.005386999997426756, - "meanDelayMs": 0.0017956666658089186 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 26.927566999998817, - "operationsPerMs": 74.27332740459202, - "rowsPerMs": 74.27332740459202, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.010307999997166917, - "meanDelayMs": 0.005153999998583458 - } - } - ], - "summary": { - "medianOperationsPerMs": 71.1184829737065, - "medianRowsPerMs": 71.1184829737065, - "medianElapsedMs": 28.134384499999214, - "relativeMarginOfErrorPct": 19.147653558270598, - "minOperationsPerMs": 64.18094096254669, - "maxOperationsPerMs": 84.73600370940949, - "medianEventLoopHeartbeats": 2, - "maxEventLoopDelayMs": 0.05385799999930896 - } - }, - { - "id": "pool-strict-2c-mix-90r-10w", - "group": "read-write", - "description": "Two-connection strict pool, 90% reads and 10% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 90, - "writesPct": 10 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 26.965908000000127, - "operationsPerMs": 74.1677231858831, - "rowsPerMs": 66.75095086729479, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.020617999998648884, - "meanDelayMs": 0.010308999999324442 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 29.946211999998923, - "operationsPerMs": 66.78641024781605, - "rowsPerMs": 60.107769223034445, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 28.30192800000077, - "operationsPerMs": 70.66656377614788, - "rowsPerMs": 63.5999073985331, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.013216999999713153, - "meanDelayMs": 0.006608499999856576 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 29.964131999997335, - "operationsPerMs": 66.74646874470376, - "rowsPerMs": 60.071821870233386, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0.03139800000280957, - "meanDelayMs": 0.012451666666796276 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 28.382559000001493, - "operationsPerMs": 70.46580965443937, - "rowsPerMs": 63.419228688995425, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.004198000002361368, - "meanDelayMs": 0.002099000001180684 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 25.487796000001254, - "operationsPerMs": 78.46892685424434, - "rowsPerMs": 70.62203416881991, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.004617999999027234, - "meanDelayMs": 0.002308999999513617 - } - } - ], - "summary": { - "medianOperationsPerMs": 70.56618671529363, - "medianRowsPerMs": 63.50956804376426, - "medianElapsedMs": 28.342243500001132, - "relativeMarginOfErrorPct": 11.199046606889953, - "minOperationsPerMs": 66.74646874470376, - "maxOperationsPerMs": 78.46892685424434, - "medianEventLoopHeartbeats": 2, - "maxEventLoopDelayMs": 0.03139800000280957 - } - }, - { - "id": "pool-strict-2c-mix-0r-100w", - "group": "read-write", - "description": "Two-connection strict pool, 0% reads and 100% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 0, - "writesPct": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 73.92749100000037, - "operationsPerMs": 27.053535470316312, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.030968000000939355, - "meanDelayMs": 0.008867285714066904 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 83.06493599999885, - "operationsPerMs": 24.077548196750886, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.9219509999966249, - "meanDelayMs": 0.12009424999996554 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 79.05309599999964, - "operationsPerMs": 25.299451902554317, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.010586999997030944, - "meanDelayMs": 0.0030617142848703744 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 81.68555599999672, - "operationsPerMs": 24.484132788421984, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.049738000001525506, - "meanDelayMs": 0.01080912499992337 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 88.17844100000002, - "operationsPerMs": 22.681281017431456, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.7250480000002426, - "meanDelayMs": 0.0963053749997016 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 74.26834600000439, - "operationsPerMs": 26.92937311408392, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.7746289999995497, - "meanDelayMs": 0.1838812857147007 - } - } - ], - "summary": { - "medianOperationsPerMs": 24.89179234548815, - "medianRowsPerMs": 0, - "medianElapsedMs": 80.36932599999818, - "relativeMarginOfErrorPct": 8.880482760645267, - "minOperationsPerMs": 22.681281017431456, - "maxOperationsPerMs": 27.053535470316312, - "medianEventLoopHeartbeats": 7.5, - "maxEventLoopDelayMs": 0.9219509999966249 - } - }, - { - "id": "pool-none-repeated-identical-sql", - "group": "repeated-sql", - "description": "Repeated identical SQL text (prepare cost/cache baseline)", - "settings": { - "authorizer": "none", - "connections": 1, - "variants": 1 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 236.07812300000114, - "operationsPerMs": 42.358859317091195, - "rowsPerMs": 42.358859317091195, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.022916999998415122, - "meanDelayMs": 0.004516347826052958 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 275.79601999999795, - "operationsPerMs": 36.25868132542331, - "rowsPerMs": 36.25868132542331, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 0.026458000000275206, - "meanDelayMs": 0.005265999999850195 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 235.78777799999807, - "operationsPerMs": 42.41101928531716, - "rowsPerMs": 42.41101928531716, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.03669799999988754, - "meanDelayMs": 0.006068391304364448 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 238.10461300000316, - "operationsPerMs": 41.99834633191196, - "rowsPerMs": 41.99834633191196, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.027728999997634673, - "meanDelayMs": 0.006695478260751216 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 249.44867100000192, - "operationsPerMs": 40.08840760670929, - "rowsPerMs": 40.08840760670929, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.03462800000124844, - "meanDelayMs": 0.006612599999934901 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 233.59345600000233, - "operationsPerMs": 42.80941842822814, - "rowsPerMs": 42.80941842822814, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 0.6995380000007572, - "meanDelayMs": 0.036415434782532466 - } - } - ], - "summary": { - "medianOperationsPerMs": 42.17860282450158, - "medianRowsPerMs": 42.17860282450158, - "medianElapsedMs": 237.09136800000215, - "relativeMarginOfErrorPct": 14.035366519156922, - "minOperationsPerMs": 36.25868132542331, - "maxOperationsPerMs": 42.80941842822814, - "medianEventLoopHeartbeats": 23, - "maxEventLoopDelayMs": 0.6995380000007572 - } - }, - { - "id": "pool-none-rotating-sql-32", - "group": "repeated-sql", - "description": "Equivalent SQL rotated across 32 distinct texts", - "settings": { - "authorizer": "none", - "connections": 1, - "variants": 32 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 240.61484000000019, - "operationsPerMs": 41.56019637026541, - "rowsPerMs": 41.56019637026541, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.0361880000000383, - "meanDelayMs": 0.006066166666641948 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 264.21473900000274, - "operationsPerMs": 37.84800211315954, - "rowsPerMs": 37.84800211315954, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 0.02372799999648123, - "meanDelayMs": 0.005754192307414576 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 273.4476140000006, - "operationsPerMs": 36.570075904922604, - "rowsPerMs": 36.570075904922604, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 0.05230899999878602, - "meanDelayMs": 0.008179259258920664 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 250.49144499999966, - "operationsPerMs": 39.92152306838269, - "rowsPerMs": 39.92152306838269, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.03387799999836716, - "meanDelayMs": 0.004606279999861727 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 275.80725000000166, - "operationsPerMs": 36.25720498645318, - "rowsPerMs": 36.25720498645318, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 0.03032800000073621, - "meanDelayMs": 0.006997074074105412 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 240.13446299999487, - "operationsPerMs": 41.643335467430234, - "rowsPerMs": 41.643335467430234, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.0500979999997071, - "meanDelayMs": 0.0072168749999643 - } - } - ], - "summary": { - "medianOperationsPerMs": 38.88476259077112, - "medianRowsPerMs": 38.88476259077112, - "medianElapsedMs": 257.3530920000012, - "relativeMarginOfErrorPct": 7.0942258428853915, - "minOperationsPerMs": 36.25720498645318, - "maxOperationsPerMs": 41.643335467430234, - "medianEventLoopHeartbeats": 25.5, - "maxEventLoopDelayMs": 0.05230899999878602 - } - }, - { - "id": "pool-none-4c-point-read-with-crypto", - "group": "contention", - "description": "Four-connection pool competing with crypto libuv work", - "settings": { - "authorizer": "none", - "connections": 4, - "contention": "crypto", - "contentionWorkers": 4, - "cryptoIterations": 10000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 886.2084159999995, - "operationsPerMs": 11.284027345549386, - "rowsPerMs": 11.284027345549386, - "eventLoop": { - "heartbeats": 88, - "intervalMs": 10, - "maxDelayMs": 0.7559990000008838, - "meanDelayMs": 0.1120148749999275 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2671, - "elapsedMs": 887.5312259999992 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 881.668608, - "operationsPerMs": 11.342130035325019, - "rowsPerMs": 11.342130035325019, - "eventLoop": { - "heartbeats": 88, - "intervalMs": 10, - "maxDelayMs": 0.7239480000025651, - "meanDelayMs": 0.14702252272739066 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2656, - "elapsedMs": 883.0418989999998 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 893.7825470000025, - "operationsPerMs": 11.188403749396521, - "rowsPerMs": 11.188403749396521, - "eventLoop": { - "heartbeats": 89, - "intervalMs": 10, - "maxDelayMs": 0.723308000000543, - "meanDelayMs": 0.13688935955049988 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2673, - "elapsedMs": 895.1825179999978 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 889.2503799999977, - "operationsPerMs": 11.245426737967799, - "rowsPerMs": 11.245426737967799, - "eventLoop": { - "heartbeats": 88, - "intervalMs": 10, - "maxDelayMs": 0.8550299999988056, - "meanDelayMs": 0.13210646590906353 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2653, - "elapsedMs": 890.607799999998 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 881.7975609999994, - "operationsPerMs": 11.340471376059982, - "rowsPerMs": 11.340471376059982, - "eventLoop": { - "heartbeats": 88, - "intervalMs": 10, - "maxDelayMs": 1.6911929999987478, - "meanDelayMs": 0.12364790909090186 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2660, - "elapsedMs": 883.1098700000002 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 874.8997779999991, - "operationsPerMs": 11.429880600564069, - "rowsPerMs": 11.429880600564069, - "eventLoop": { - "heartbeats": 87, - "intervalMs": 10, - "maxDelayMs": 0.9678410000051372, - "meanDelayMs": 0.11863824137931103 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 2644, - "elapsedMs": 876.2240979999988 - } - } - ], - "summary": { - "medianOperationsPerMs": 11.312249360804685, - "medianRowsPerMs": 11.312249360804685, - "medianElapsedMs": 884.0029884999994, - "relativeMarginOfErrorPct": 1.0947920918121865, - "minOperationsPerMs": 11.188403749396521, - "maxOperationsPerMs": 11.429880600564069, - "medianEventLoopHeartbeats": 88, - "maxEventLoopDelayMs": 1.6911929999987478, - "competingWorkCompleted": 15957 - } - }, - { - "id": "pool-none-4c-point-read-with-fs", - "group": "contention", - "description": "Four-connection pool competing with fs libuv work", - "settings": { - "authorizer": "none", - "connections": 4, - "contention": "fs", - "contentionWorkers": 4, - "competingFileBytes": 1048576 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 204.43516700000146, - "operationsPerMs": 48.91526319441864, - "rowsPerMs": 48.91526319441864, - "eventLoop": { - "heartbeats": 20, - "intervalMs": 10, - "maxDelayMs": 0.7697389999993902, - "meanDelayMs": 0.06979479999999967 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1505, - "elapsedMs": 204.78272199999992 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 223.03371100000004, - "operationsPerMs": 44.83627141010983, - "rowsPerMs": 44.83627141010983, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 0.292741000001115, - "meanDelayMs": 0.02984372727289817 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1524, - "elapsedMs": 224.106026999998 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 215.79303400000208, - "operationsPerMs": 46.34069883831331, - "rowsPerMs": 46.34069883831331, - "eventLoop": { - "heartbeats": 21, - "intervalMs": 10, - "maxDelayMs": 0.8562090000013995, - "meanDelayMs": 0.11661842857124395 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1493, - "elapsedMs": 215.99518700000044 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 216.09976900000038, - "operationsPerMs": 46.274922209657625, - "rowsPerMs": 46.274922209657625, - "eventLoop": { - "heartbeats": 21, - "intervalMs": 10, - "maxDelayMs": 1.4112789999999222, - "meanDelayMs": 0.13819904761909302 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1509, - "elapsedMs": 216.34005199999956 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 235.63410699999804, - "operationsPerMs": 42.43867803059632, - "rowsPerMs": 42.43867803059632, - "eventLoop": { - "heartbeats": 23, - "intervalMs": 10, - "maxDelayMs": 1.378328999999212, - "meanDelayMs": 0.16631900000008856 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1570, - "elapsedMs": 235.87956000000122 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 227.007408999998, - "operationsPerMs": 44.05142565192702, - "rowsPerMs": 44.05142565192702, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 1.0763839999999618, - "meanDelayMs": 0.10758809090938568 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1529, - "elapsedMs": 227.0916309999957 - } - } - ], - "summary": { - "medianOperationsPerMs": 45.555596809883724, - "medianRowsPerMs": 45.555596809883724, - "medianElapsedMs": 219.5667400000002, - "relativeMarginOfErrorPct": 7.3748707509106906, - "minOperationsPerMs": 42.43867803059632, - "maxOperationsPerMs": 48.91526319441864, - "medianEventLoopHeartbeats": 21.5, - "maxEventLoopDelayMs": 1.4112789999999222, - "competingWorkCompleted": 9130 - } - } - ] -} diff --git a/benchmark/results/async-pool-uv8.json b/benchmark/results/async-pool-uv8.json deleted file mode 100644 index 0c2cc41..0000000 --- a/benchmark/results/async-pool-uv8.json +++ /dev/null @@ -1,2347 +0,0 @@ -{ - "schemaVersion": 1, - "generatedAt": "2026-08-08T08:36:15.628Z", - "package": { - "name": "@photostructure/sqlite", - "version": "2.2.0", - "sqlite": "3.53.4" - }, - "git": { - "commit": "9ac2e43995ae039488590ea5999576884c5990fb", - "dirty": true - }, - "environment": { - "node": "v26.6.0", - "v8": "14.6.202.34-node.26", - "napi": "10", - "uv": "1.52.1", - "platform": "linux", - "arch": "x64", - "cpuModel": "AMD Ryzen 9 5950X 16-Core Processor", - "cpuCount": 32, - "uvThreadpoolSize": "8" - }, - "config": { - "iterations": 10000, - "writeIterations": 2000, - "samples": 6, - "warmup": 1, - "seedRows": 2000, - "connections": [1, 2, 4], - "batchSizes": [10, 100], - "resultSizes": [1, 100, 1000], - "scenarioFilters": null, - "contentionWorkers": 4, - "cryptoIterations": 10000, - "heartbeatIntervalMs": 10 - }, - "results": [ - { - "id": "warm-sync-reused-statement", - "group": "controls", - "description": "Warm DatabaseSync connection with one reused statement", - "settings": { - "implementation": "DatabaseSync", - "connection": "warm" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.523637999999664, - "operationsPerMs": 246.76955213152587, - "rowsPerMs": 246.76955213152587, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 41.10378700000001, - "operationsPerMs": 243.2865857347888, - "rowsPerMs": 243.2865857347888, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 41.00859499999933, - "operationsPerMs": 243.85131946120472, - "rowsPerMs": 243.85131946120472, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 41.78328600000168, - "operationsPerMs": 239.3301474661327, - "rowsPerMs": 239.3301474661327, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.40332599999965, - "operationsPerMs": 247.50437624863076, - "rowsPerMs": 247.50437624863076, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 40.53912799999671, - "operationsPerMs": 246.67526149059773, - "rowsPerMs": 246.67526149059773, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - } - ], - "summary": { - "medianOperationsPerMs": 245.26329047590121, - "medianRowsPerMs": 245.26329047590121, - "medianElapsedMs": 40.77386149999802, - "relativeMarginOfErrorPct": 2.4190913357869532, - "minOperationsPerMs": 239.3301474661327, - "maxOperationsPerMs": 247.50437624863076, - "medianEventLoopHeartbeats": 0, - "maxEventLoopDelayMs": null - } - }, - { - "id": "fresh-sync-connection", - "group": "controls", - "description": "Open, query, and close DatabaseSync for every operation", - "settings": { - "implementation": "DatabaseSync", - "connection": "fresh" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2392.8839150000003, - "operationsPerMs": 4.179057720817183, - "rowsPerMs": 4.179057720817183, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2412.1381600000004, - "operationsPerMs": 4.145699514989638, - "rowsPerMs": 4.145699514989638, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2390.737924000001, - "operationsPerMs": 4.182808956018383, - "rowsPerMs": 4.182808956018383, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2419.582601000002, - "operationsPerMs": 4.132944250742689, - "rowsPerMs": 4.132944250742689, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2395.9573619999974, - "operationsPerMs": 4.173696977500725, - "rowsPerMs": 4.173696977500725, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 2397.0637170000045, - "operationsPerMs": 4.171770624652103, - "rowsPerMs": 4.171770624652103, - "eventLoop": { - "heartbeats": 0, - "intervalMs": 10, - "maxDelayMs": null, - "meanDelayMs": null - } - } - ], - "summary": { - "medianOperationsPerMs": 4.172733801076414, - "medianRowsPerMs": 4.172733801076414, - "medianElapsedMs": 2396.510539500001, - "relativeMarginOfErrorPct": 0.9535607165609474, - "minOperationsPerMs": 4.132944250742689, - "maxOperationsPerMs": 4.182808956018383, - "medianEventLoopHeartbeats": 0, - "maxEventLoopDelayMs": null - } - }, - { - "id": "worker-thread-sync-control", - "group": "controls", - "description": "One DatabaseSync worker thread with per-operation messages", - "settings": { - "implementation": "worker_threads + DatabaseSync", - "workers": 1 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 82.30933499999992, - "operationsPerMs": 121.49290235427135, - "rowsPerMs": 121.49290235427135, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.012167999999292078, - "meanDelayMs": 0.002391624999859232 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 79.14401799999905, - "operationsPerMs": 126.3519372999248, - "rowsPerMs": 126.3519372999248, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.007928000000902102, - "meanDelayMs": 0.0012307142858065032 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 79.31438100000014, - "operationsPerMs": 126.08054017341424, - "rowsPerMs": 126.08054017341424, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.01030700000046636, - "meanDelayMs": 0.0018547142857901885 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 80.24122400000124, - "operationsPerMs": 124.6242205876601, - "rowsPerMs": 124.6242205876601, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.0029070000018691644, - "meanDelayMs": 0.0005488750002768938 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 80.153581999999, - "operationsPerMs": 124.76048793427752, - "rowsPerMs": 124.76048793427752, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.009136999997281237, - "meanDelayMs": 0.0019816249996438273 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 83.9640600000057, - "operationsPerMs": 119.09857622415258, - "rowsPerMs": 119.09857622415258, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.0246880000049714, - "meanDelayMs": 0.003799250000156462 - } - } - ], - "summary": { - "medianOperationsPerMs": 124.69235426096881, - "medianRowsPerMs": 124.69235426096881, - "medianElapsedMs": 80.19740300000012, - "relativeMarginOfErrorPct": 4.486063375713482, - "minOperationsPerMs": 119.09857622415258, - "maxOperationsPerMs": 126.3519372999248, - "medianEventLoopHeartbeats": 8, - "maxEventLoopDelayMs": 0.0246880000049714 - } - }, - { - "id": "pool-none-1c-point-read", - "group": "pool-scale", - "description": "1-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 1, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 268.61187299999983, - "operationsPerMs": 37.22843628732676, - "rowsPerMs": 37.22843628732676, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 13.43997599999966, - "meanDelayMs": 0.5991200799999206 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 273.2921219999989, - "operationsPerMs": 36.59088277707485, - "rowsPerMs": 36.59088277707485, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 11.237342999998873, - "meanDelayMs": 0.4537951153847294 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 248.22235299999738, - "operationsPerMs": 40.28646042203985, - "rowsPerMs": 40.28646042203985, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.039358999998512445, - "meanDelayMs": 0.007607416666663387 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 288.6838000000025, - "operationsPerMs": 34.639976333967866, - "rowsPerMs": 34.639976333967866, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 14.364740000000893, - "meanDelayMs": 0.5373910740742792 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 293.88294600000154, - "operationsPerMs": 34.0271531101364, - "rowsPerMs": 34.0271531101364, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 19.490355000001728, - "meanDelayMs": 0.7261374444444502 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 263.454227000002, - "operationsPerMs": 37.95725775164702, - "rowsPerMs": 37.95725775164702, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 12.796675999998115, - "meanDelayMs": 0.5153334399999585 - } - } - ], - "summary": { - "medianOperationsPerMs": 36.909659532200806, - "medianRowsPerMs": 36.909659532200806, - "medianElapsedMs": 270.9519974999994, - "relativeMarginOfErrorPct": 9.14882698089656, - "minOperationsPerMs": 34.0271531101364, - "maxOperationsPerMs": 40.28646042203985, - "medianEventLoopHeartbeats": 25.5, - "maxEventLoopDelayMs": 19.490355000001728 - } - }, - { - "id": "pool-none-2c-point-read", - "group": "pool-scale", - "description": "2-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 2, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 123.20647800000006, - "operationsPerMs": 81.16456344121772, - "rowsPerMs": 81.16456344121772, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.03214800000023388, - "meanDelayMs": 0.006960333333457432 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 124.81551199999922, - "operationsPerMs": 80.1182468409861, - "rowsPerMs": 80.1182468409861, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.04271800000060466, - "meanDelayMs": 0.0071638333333794435 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 125.09168599999975, - "operationsPerMs": 79.94136396882539, - "rowsPerMs": 79.94136396882539, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.04370799999742303, - "meanDelayMs": 0.0067272499994336 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 144.96003899999778, - "operationsPerMs": 68.98452890179033, - "rowsPerMs": 68.98452890179033, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 3.029392000000371, - "meanDelayMs": 0.21948707142863505 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 132.81474999999773, - "operationsPerMs": 75.29284209773516, - "rowsPerMs": 75.29284209773516, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 5.20113499999934, - "meanDelayMs": 0.43898641666661814 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 127.22765699999582, - "operationsPerMs": 78.59926242295202, - "rowsPerMs": 78.59926242295202, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.022248000001127366, - "meanDelayMs": 0.006182249999862203 - } - } - ], - "summary": { - "medianOperationsPerMs": 79.2703131958887, - "medianRowsPerMs": 79.2703131958887, - "medianElapsedMs": 126.15967149999778, - "relativeMarginOfErrorPct": 12.975581752377687, - "minOperationsPerMs": 68.98452890179033, - "maxOperationsPerMs": 81.16456344121772, - "medianEventLoopHeartbeats": 12, - "maxEventLoopDelayMs": 5.20113499999934 - } - }, - { - "id": "pool-none-4c-point-read", - "group": "pool-scale", - "description": "4-connection none pool, concurrent point reads", - "settings": { - "authorizer": "none", - "connections": 4, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.67996700000003, - "operationsPerMs": 132.13536417107576, - "rowsPerMs": 132.13536417107576, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.015658000000257744, - "meanDelayMs": 0.0032494285712475956 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.89895500000057, - "operationsPerMs": 130.04077883763082, - "rowsPerMs": 130.04077883763082, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.005338000000847387, - "meanDelayMs": 0.0017690000001623827 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.604589999999, - "operationsPerMs": 130.54048066832718, - "rowsPerMs": 130.54048066832718, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.01936800000112271, - "meanDelayMs": 0.007610571428293562 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.97699499999726, - "operationsPerMs": 126.61915029813868, - "rowsPerMs": 126.61915029813868, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.03017800000088755, - "meanDelayMs": 0.0046547142857369704 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 72.64321100000234, - "operationsPerMs": 137.65911311381427, - "rowsPerMs": 137.65911311381427, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.018897999998443993, - "meanDelayMs": 0.0053251428570157645 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.53970900000058, - "operationsPerMs": 127.3241284863931, - "rowsPerMs": 127.3241284863931, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.01282799999898998, - "meanDelayMs": 0.0024389999998675194 - } - } - ], - "summary": { - "medianOperationsPerMs": 130.290629752979, - "medianRowsPerMs": 130.290629752979, - "medianElapsedMs": 76.75177249999979, - "relativeMarginOfErrorPct": 5.655420788743873, - "minOperationsPerMs": 126.61915029813868, - "maxOperationsPerMs": 137.65911311381427, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 0.03017800000088755 - } - }, - { - "id": "pool-strict-1c-point-read", - "group": "pool-scale", - "description": "1-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 1, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 246.20119300000079, - "operationsPerMs": 40.61718742362052, - "rowsPerMs": 40.61718742362052, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.02517699999953038, - "meanDelayMs": 0.005774416666554316 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 256.4729439999992, - "operationsPerMs": 38.990467548109216, - "rowsPerMs": 38.990467548109216, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.11105900000075053, - "meanDelayMs": 0.008482200000071317 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 257.21237499999916, - "operationsPerMs": 38.87837822733075, - "rowsPerMs": 38.87837822733075, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.02545800000007148, - "meanDelayMs": 0.00586167999994359 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 243.86782900000253, - "operationsPerMs": 41.00581877078955, - "rowsPerMs": 41.00581877078955, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.03882799999701092, - "meanDelayMs": 0.005780208333059515 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 250.49681599999894, - "operationsPerMs": 39.92066709542544, - "rowsPerMs": 39.92066709542544, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.46170400000119116, - "meanDelayMs": 0.025724279999849387 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 276.8644349999995, - "operationsPerMs": 36.11875970996426, - "rowsPerMs": 36.11875970996426, - "eventLoop": { - "heartbeats": 27, - "intervalMs": 10, - "maxDelayMs": 0.03999800000019604, - "meanDelayMs": 0.007384444445051486 - } - } - ], - "summary": { - "medianOperationsPerMs": 39.45556732176733, - "medianRowsPerMs": 39.45556732176733, - "medianElapsedMs": 253.48487999999907, - "relativeMarginOfErrorPct": 8.457127443107842, - "minOperationsPerMs": 36.11875970996426, - "maxOperationsPerMs": 41.00581877078955, - "medianEventLoopHeartbeats": 25, - "maxEventLoopDelayMs": 0.46170400000119116 - } - }, - { - "id": "pool-strict-2c-point-read", - "group": "pool-scale", - "description": "2-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 2, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 120.4394169999996, - "operationsPerMs": 83.02929596545651, - "rowsPerMs": 83.02929596545651, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.021108000000822358, - "meanDelayMs": 0.004006416666773778 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 125.91169799999989, - "operationsPerMs": 79.42073817478031, - "rowsPerMs": 79.42073817478031, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.054969000000710366, - "meanDelayMs": 0.009727583333339377 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 138.05763699999807, - "operationsPerMs": 72.43351557581809, - "rowsPerMs": 72.43351557581809, - "eventLoop": { - "heartbeats": 13, - "intervalMs": 10, - "maxDelayMs": 0.19483000000036554, - "meanDelayMs": 0.02196715384567282 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 129.61066199999914, - "operationsPerMs": 77.15414646983338, - "rowsPerMs": 77.15414646983338, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.01027800000156276, - "meanDelayMs": 0.0025585000000016103 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 131.26408599999922, - "operationsPerMs": 76.18230016091422, - "rowsPerMs": 76.18230016091422, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 2.836418999999296, - "meanDelayMs": 0.23823333333348273 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 130.6233779999966, - "operationsPerMs": 76.55597453619873, - "rowsPerMs": 76.55597453619873, - "eventLoop": { - "heartbeats": 13, - "intervalMs": 10, - "maxDelayMs": 0.01926699999603443, - "meanDelayMs": 0.003023692307649001 - } - } - ], - "summary": { - "medianOperationsPerMs": 76.85506050301606, - "medianRowsPerMs": 76.85506050301606, - "medianElapsedMs": 130.11701999999786, - "relativeMarginOfErrorPct": 8.03360952685497, - "minOperationsPerMs": 72.43351557581809, - "maxOperationsPerMs": 83.02929596545651, - "medianEventLoopHeartbeats": 12, - "maxEventLoopDelayMs": 2.836418999999296 - } - }, - { - "id": "pool-strict-4c-point-read", - "group": "pool-scale", - "description": "4-connection strict pool, concurrent point reads", - "settings": { - "authorizer": "strict", - "connections": 4, - "operation": "get" - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 72.59081100000003, - "operationsPerMs": 137.75848295729875, - "rowsPerMs": 137.75848295729875, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.015988999999535736, - "meanDelayMs": 0.003430571428647714 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 72.31601699999919, - "operationsPerMs": 138.28195211581013, - "rowsPerMs": 138.28195211581013, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.49219499999890104, - "meanDelayMs": 0.07219842857141755 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.29048600000169, - "operationsPerMs": 127.72944084163412, - "rowsPerMs": 127.72944084163412, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.016557999999349704, - "meanDelayMs": 0.003812714285491633 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 77.38292200000069, - "operationsPerMs": 129.22747993413728, - "rowsPerMs": 129.22747993413728, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 1.1049039999998058, - "meanDelayMs": 0.16113428571406985 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.07975099999749, - "operationsPerMs": 128.07417892508803, - "rowsPerMs": 128.07417892508803, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.013297999998030718, - "meanDelayMs": 0.0018997142854329599 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 74.90443500000401, - "operationsPerMs": 133.50344342093314, - "rowsPerMs": 133.50344342093314, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.00842700000066543, - "meanDelayMs": 0.0024331428576260805 - } - } - ], - "summary": { - "medianOperationsPerMs": 131.36546167753522, - "medianRowsPerMs": 131.36546167753522, - "medianElapsedMs": 76.14367850000235, - "relativeMarginOfErrorPct": 5.26507527165163, - "minOperationsPerMs": 127.72944084163412, - "maxOperationsPerMs": 138.28195211581013, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 1.1049039999998058 - } - }, - { - "id": "pool-none-1c-batch-10", - "group": "batch", - "description": "One-connection none pool, explicit get batches of 10", - "settings": { - "authorizer": "none", - "connections": 1, - "batchSize": 10 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 95.22865499999898, - "operationsPerMs": 105.01040889425674, - "rowsPerMs": 105.01040889425674, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.09082799999850977, - "meanDelayMs": 0.019395999999687774 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 93.90302599999995, - "operationsPerMs": 106.49284081643977, - "rowsPerMs": 106.49284081643977, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.05886900000041351, - "meanDelayMs": 0.009821333333295316 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 102.5911840000008, - "operationsPerMs": 97.47426250583015, - "rowsPerMs": 97.47426250583015, - "eventLoop": { - "heartbeats": 10, - "intervalMs": 10, - "maxDelayMs": 0.05898900000102003, - "meanDelayMs": 0.017757800000254065 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 96.30332099999941, - "operationsPerMs": 103.83857894163442, - "rowsPerMs": 103.83857894163442, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.09103799999866169, - "meanDelayMs": 0.01330822222169243 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 96.58119499999884, - "operationsPerMs": 103.53982470397182, - "rowsPerMs": 103.53982470397182, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.057558000000426546, - "meanDelayMs": 0.01770444444466395 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 93.47407899999962, - "operationsPerMs": 106.98153014163468, - "rowsPerMs": 106.98153014163468, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.07470899999316316, - "meanDelayMs": 0.014404999999290643 - } - } - ], - "summary": { - "medianOperationsPerMs": 104.42449391794558, - "medianRowsPerMs": 104.42449391794558, - "medianElapsedMs": 95.7659879999992, - "relativeMarginOfErrorPct": 6.655748236210524, - "minOperationsPerMs": 97.47426250583015, - "maxOperationsPerMs": 106.98153014163468, - "medianEventLoopHeartbeats": 9, - "maxEventLoopDelayMs": 0.09103799999866169 - } - }, - { - "id": "pool-none-1c-batch-100", - "group": "batch", - "description": "One-connection none pool, explicit get batches of 100", - "settings": { - "authorizer": "none", - "connections": 1, - "batchSize": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 76.65837099999953, - "operationsPerMs": 130.44889774660174, - "rowsPerMs": 130.44889774660174, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.9618519999985438, - "meanDelayMs": 0.2781682857143356 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.15527400000065, - "operationsPerMs": 127.95041829166792, - "rowsPerMs": 127.95041829166792, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.4424140000010084, - "meanDelayMs": 0.12302499999974056 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 78.49306799999977, - "operationsPerMs": 127.39978516319466, - "rowsPerMs": 127.39978516319466, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 2.3578419999976177, - "meanDelayMs": 0.4108664285709632 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 74.92383600000176, - "operationsPerMs": 133.46887364389306, - "rowsPerMs": 133.46887364389306, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.526255000000674, - "meanDelayMs": 0.19650042857184807 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 79.80393800000093, - "operationsPerMs": 125.30709950679231, - "rowsPerMs": 125.30709950679231, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.5736560000004829, - "meanDelayMs": 0.17319128571450296 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 75.54800499999692, - "operationsPerMs": 132.36616903385348, - "rowsPerMs": 132.36616903385348, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.589535999999498, - "meanDelayMs": 0.08421942857135686 - } - } - ], - "summary": { - "medianOperationsPerMs": 129.19965801913483, - "medianRowsPerMs": 129.19965801913483, - "medianElapsedMs": 77.40682250000009, - "relativeMarginOfErrorPct": 3.30435520512442, - "minOperationsPerMs": 125.30709950679231, - "maxOperationsPerMs": 133.46887364389306, - "medianEventLoopHeartbeats": 7, - "maxEventLoopDelayMs": 2.3578419999976177 - } - }, - { - "id": "pool-none-all-1-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 1 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 1, - "operations": 10000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 309.52976599999965, - "operationsPerMs": 32.3070705904259, - "rowsPerMs": 32.3070705904259, - "eventLoop": { - "heartbeats": 31, - "intervalMs": 10, - "maxDelayMs": 0.04085699999995995, - "meanDelayMs": 0.008061064516075403 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 313.71792800000003, - "operationsPerMs": 31.875768349458177, - "rowsPerMs": 31.875768349458177, - "eventLoop": { - "heartbeats": 31, - "intervalMs": 10, - "maxDelayMs": 0.1540899999999965, - "meanDelayMs": 0.010439612903346348 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 307.238443000002, - "operationsPerMs": 32.548010276174764, - "rowsPerMs": 32.548010276174764, - "eventLoop": { - "heartbeats": 30, - "intervalMs": 10, - "maxDelayMs": 0.05692800000178977, - "meanDelayMs": 0.008520833333265424 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 330.27420300000085, - "operationsPerMs": 30.277871868787688, - "rowsPerMs": 30.277871868787688, - "eventLoop": { - "heartbeats": 32, - "intervalMs": 10, - "maxDelayMs": 1.6525130000009085, - "meanDelayMs": 0.057640406250357046 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 326.90340300000025, - "operationsPerMs": 30.590076176111243, - "rowsPerMs": 30.590076176111243, - "eventLoop": { - "heartbeats": 32, - "intervalMs": 10, - "maxDelayMs": 1.1888049999979557, - "meanDelayMs": 0.044920062500068525 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 324.8154330000034, - "operationsPerMs": 30.786714497029134, - "rowsPerMs": 30.786714497029134, - "eventLoop": { - "heartbeats": 32, - "intervalMs": 10, - "maxDelayMs": 0.04038800000125775, - "meanDelayMs": 0.008230031250604952 - } - } - ], - "summary": { - "medianOperationsPerMs": 31.331241423243654, - "medianRowsPerMs": 31.331241423243654, - "medianElapsedMs": 319.2666805000017, - "relativeMarginOfErrorPct": 3.8835641285136835, - "minOperationsPerMs": 30.277871868787688, - "maxOperationsPerMs": 32.548010276174764, - "medianEventLoopHeartbeats": 31.5, - "maxEventLoopDelayMs": 1.6525130000009085 - } - }, - { - "id": "pool-none-all-100-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 100 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 100, - "operations": 1000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 146.63149300000077, - "operationsPerMs": 6.819817349878547, - "rowsPerMs": 681.9817349878547, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.13010899999972025, - "meanDelayMs": 0.02474071428579399 - } - }, - { - "sample": 2, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 147.6362079999999, - "operationsPerMs": 6.773406155216346, - "rowsPerMs": 677.3406155216346, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.08833899999990535, - "meanDelayMs": 0.020105642857093438 - } - }, - { - "sample": 3, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 154.2821060000024, - "operationsPerMs": 6.481633067673996, - "rowsPerMs": 648.1633067673995, - "eventLoop": { - "heartbeats": 15, - "intervalMs": 10, - "maxDelayMs": 0.36799299999984214, - "meanDelayMs": 0.044727533333207246 - } - }, - { - "sample": 4, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 159.2928409999986, - "operationsPerMs": 6.27774602877482, - "rowsPerMs": 627.774602877482, - "eventLoop": { - "heartbeats": 15, - "intervalMs": 10, - "maxDelayMs": 1.7376930000027642, - "meanDelayMs": 0.17467879999991662 - } - }, - { - "sample": 5, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 161.2642999999989, - "operationsPerMs": 6.201000469415777, - "rowsPerMs": 620.1000469415777, - "eventLoop": { - "heartbeats": 15, - "intervalMs": 10, - "maxDelayMs": 1.8808250000001863, - "meanDelayMs": 0.21456486666672087 - } - }, - { - "sample": 6, - "logicalOperations": 1000, - "materializedRows": 100000, - "elapsedMs": 149.02381899999455, - "operationsPerMs": 6.710336687855493, - "rowsPerMs": 671.0336687855494, - "eventLoop": { - "heartbeats": 14, - "intervalMs": 10, - "maxDelayMs": 0.09377900000254158, - "meanDelayMs": 0.022538500000726863 - } - } - ], - "summary": { - "medianOperationsPerMs": 6.595984877764744, - "medianRowsPerMs": 659.5984877764745, - "medianElapsedMs": 151.65296249999847, - "relativeMarginOfErrorPct": 5.9882552138721685, - "minOperationsPerMs": 6.201000469415777, - "maxOperationsPerMs": 6.819817349878547, - "medianEventLoopHeartbeats": 14.5, - "maxEventLoopDelayMs": 1.8808250000001863 - } - }, - { - "id": "pool-none-all-1000-rows", - "group": "result-size", - "description": "One-connection none pool, all() materializing 1000 rows", - "settings": { - "authorizer": "none", - "connections": 1, - "resultSize": 1000, - "operations": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 114.15500400000019, - "operationsPerMs": 0.8760018965090645, - "rowsPerMs": 876.0018965090644, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 0.8944210000008752, - "meanDelayMs": 0.24873700000005722 - } - }, - { - "sample": 2, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 113.54968499999995, - "operationsPerMs": 0.8806717517534288, - "rowsPerMs": 880.6717517534288, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 1.049872999999934, - "meanDelayMs": 0.39781981818205997 - } - }, - { - "sample": 3, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 119.09620799999902, - "operationsPerMs": 0.8396572962255928, - "rowsPerMs": 839.6572962255929, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 1.1746950000015204, - "meanDelayMs": 0.2885483636362716 - } - }, - { - "sample": 4, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 120.90042399999948, - "operationsPerMs": 0.8271269586283704, - "rowsPerMs": 827.1269586283704, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.8101499999975204, - "meanDelayMs": 0.14329191666638508 - } - }, - { - "sample": 5, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 118.04575100000147, - "operationsPerMs": 0.8471291779066131, - "rowsPerMs": 847.1291779066131, - "eventLoop": { - "heartbeats": 11, - "intervalMs": 10, - "maxDelayMs": 1.2379649999966205, - "meanDelayMs": 0.33074399999995047 - } - }, - { - "sample": 6, - "logicalOperations": 100, - "materializedRows": 100000, - "elapsedMs": 122.52011800000037, - "operationsPerMs": 0.8161924884858477, - "rowsPerMs": 816.1924884858477, - "eventLoop": { - "heartbeats": 12, - "intervalMs": 10, - "maxDelayMs": 0.4915250000049127, - "meanDelayMs": 0.11955083333365717 - } - } - ], - "summary": { - "medianOperationsPerMs": 0.843393237066103, - "medianRowsPerMs": 843.393237066103, - "medianElapsedMs": 118.57097950000025, - "relativeMarginOfErrorPct": 4.420063269300795, - "minOperationsPerMs": 0.8161924884858477, - "maxOperationsPerMs": 0.8806717517534288, - "medianEventLoopHeartbeats": 11, - "maxEventLoopDelayMs": 1.2379649999966205 - } - }, - { - "id": "pool-strict-2c-mix-100r-0w", - "group": "read-write", - "description": "Two-connection strict pool, 100% reads and 0% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 100, - "writesPct": 0 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 30.02809300000081, - "operationsPerMs": 66.60429618357537, - "rowsPerMs": 66.60429618357537, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0.003897000000506523, - "meanDelayMs": 0.001299000000168841 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 27.184771000000183, - "operationsPerMs": 73.57060318808595, - "rowsPerMs": 73.57060318808595, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 26.689363999998022, - "operationsPerMs": 74.93621803802249, - "rowsPerMs": 74.93621803802249, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 28.09540399999969, - "operationsPerMs": 71.1860203184842, - "rowsPerMs": 71.1860203184842, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.006508000002213521, - "meanDelayMs": 0.0032540000011067605 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 25.790930999999546, - "operationsPerMs": 77.54663839006182, - "rowsPerMs": 77.54663839006182, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.0015670000029786024, - "meanDelayMs": 0.0007835000014893012 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 2000, - "elapsedMs": 27.178481000002648, - "operationsPerMs": 73.58762986054317, - "rowsPerMs": 73.58762986054317, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - } - ], - "summary": { - "medianOperationsPerMs": 73.57911652431457, - "medianRowsPerMs": 73.57911652431457, - "medianElapsedMs": 27.181626000001415, - "relativeMarginOfErrorPct": 9.479347769056641, - "minOperationsPerMs": 66.60429618357537, - "maxOperationsPerMs": 77.54663839006182, - "medianEventLoopHeartbeats": 2, - "maxEventLoopDelayMs": 0.006508000002213521 - } - }, - { - "id": "pool-strict-2c-mix-90r-10w", - "group": "read-write", - "description": "Two-connection strict pool, 90% reads and 10% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 90, - "writesPct": 10 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 28.39328800000112, - "operationsPerMs": 70.4391826688026, - "rowsPerMs": 63.39526440192235, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0.019718000001375913, - "meanDelayMs": 0.009859000000687956 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 30.415138000000297, - "operationsPerMs": 65.7567294286148, - "rowsPerMs": 59.181056485753324, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 29.789529999998194, - "operationsPerMs": 67.13768226622311, - "rowsPerMs": 60.423914039600795, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 27.886101000000053, - "operationsPerMs": 71.72031687040064, - "rowsPerMs": 64.54828518336058, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 29.817800000000716, - "operationsPerMs": 67.07402960647505, - "rowsPerMs": 60.36662664582755, - "eventLoop": { - "heartbeats": 3, - "intervalMs": 10, - "maxDelayMs": 0.008566999997128733, - "meanDelayMs": 0.0028556666657095775 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 1800, - "elapsedMs": 29.350273000003654, - "operationsPerMs": 68.14246668164725, - "rowsPerMs": 61.32822001348253, - "eventLoop": { - "heartbeats": 2, - "intervalMs": 10, - "maxDelayMs": 0, - "meanDelayMs": 0 - } - } - ], - "summary": { - "medianOperationsPerMs": 67.64007447393519, - "medianRowsPerMs": 60.87606702654166, - "medianElapsedMs": 29.569901500000924, - "relativeMarginOfErrorPct": 6.032285487854918, - "minOperationsPerMs": 65.7567294286148, - "maxOperationsPerMs": 71.72031687040064, - "medianEventLoopHeartbeats": 2.5, - "maxEventLoopDelayMs": 0.019718000001375913 - } - }, - { - "id": "pool-strict-2c-mix-0r-100w", - "group": "read-write", - "description": "Two-connection strict pool, 0% reads and 100% writes", - "settings": { - "authorizer": "strict", - "connections": 2, - "readsPct": 0, - "writesPct": 100 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 76.28576600000088, - "operationsPerMs": 26.21721069170331, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.03155799999876763, - "meanDelayMs": 0.008877714285647795 - } - }, - { - "sample": 2, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 81.36634099999901, - "operationsPerMs": 24.58018850816979, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.5707849999998871, - "meanDelayMs": 0.12582149999980174 - } - }, - { - "sample": 3, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 75.55561500000113, - "operationsPerMs": 26.47056740918554, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.015057000000524567, - "meanDelayMs": 0.00554757142890594 - } - }, - { - "sample": 4, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 81.36883999999918, - "operationsPerMs": 24.579433601364112, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.45933400000285474, - "meanDelayMs": 0.11518424999985655 - } - }, - { - "sample": 5, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 89.7813750000023, - "operationsPerMs": 22.276335153030892, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 9, - "intervalMs": 10, - "maxDelayMs": 0.8915009999982431, - "meanDelayMs": 0.1307531111108094 - } - }, - { - "sample": 6, - "logicalOperations": 2000, - "materializedRows": 0, - "elapsedMs": 83.61922399999457, - "operationsPerMs": 23.91794499312897, - "rowsPerMs": 0, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.028297999997448642, - "meanDelayMs": 0.010757749999356747 - } - } - ], - "summary": { - "medianOperationsPerMs": 24.57981105476695, - "medianRowsPerMs": 0, - "medianElapsedMs": 81.3675904999991, - "relativeMarginOfErrorPct": 9.371414192743872, - "minOperationsPerMs": 22.276335153030892, - "maxOperationsPerMs": 26.47056740918554, - "medianEventLoopHeartbeats": 8, - "maxEventLoopDelayMs": 0.8915009999982431 - } - }, - { - "id": "pool-none-repeated-identical-sql", - "group": "repeated-sql", - "description": "Repeated identical SQL text (prepare cost/cache baseline)", - "settings": { - "authorizer": "none", - "connections": 1, - "variants": 1 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 245.55165300000044, - "operationsPerMs": 40.724629127216595, - "rowsPerMs": 40.724629127216595, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.02208700000119279, - "meanDelayMs": 0.004486041666647604 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 248.60225700000046, - "operationsPerMs": 40.22489626874136, - "rowsPerMs": 40.22489626874136, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.21207999999933236, - "meanDelayMs": 0.013081249999989572 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 245.5300520000019, - "operationsPerMs": 40.72821195834685, - "rowsPerMs": 40.72821195834685, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.032327999997505685, - "meanDelayMs": 0.005311208333144653 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 245.50837200000024, - "operationsPerMs": 40.73180852667619, - "rowsPerMs": 40.73180852667619, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 1.4369490000026417, - "meanDelayMs": 0.06593095833341067 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 255.0193130000007, - "operationsPerMs": 39.21271641101148, - "rowsPerMs": 39.21271641101148, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 1.5061410000016622, - "meanDelayMs": 0.06539987999989534 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 254.73081799999636, - "operationsPerMs": 39.257126713266956, - "rowsPerMs": 39.257126713266956, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.017048000001523178, - "meanDelayMs": 0.004896439999865834 - } - } - ], - "summary": { - "medianOperationsPerMs": 40.474762697978974, - "medianRowsPerMs": 40.474762697978974, - "medianElapsedMs": 247.07695500000045, - "relativeMarginOfErrorPct": 3.1181066986972463, - "minOperationsPerMs": 39.21271641101148, - "maxOperationsPerMs": 40.73180852667619, - "medianEventLoopHeartbeats": 24, - "maxEventLoopDelayMs": 1.5061410000016622 - } - }, - { - "id": "pool-none-rotating-sql-32", - "group": "repeated-sql", - "description": "Equivalent SQL rotated across 32 distinct texts", - "settings": { - "authorizer": "none", - "connections": 1, - "variants": 32 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 252.2777819999992, - "operationsPerMs": 39.63884540573625, - "rowsPerMs": 39.63884540573625, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.02224700000078883, - "meanDelayMs": 0.004973119999995106 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 259.71899200000007, - "operationsPerMs": 38.503152668943045, - "rowsPerMs": 38.503152668943045, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 0.021298000001479522, - "meanDelayMs": 0.0036085769231179324 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 255.46157000000312, - "operationsPerMs": 39.1448310601077, - "rowsPerMs": 39.1448310601077, - "eventLoop": { - "heartbeats": 25, - "intervalMs": 10, - "maxDelayMs": 0.06204799999977695, - "meanDelayMs": 0.007742479999869829 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 262.5326739999982, - "operationsPerMs": 38.09049688039999, - "rowsPerMs": 38.09049688039999, - "eventLoop": { - "heartbeats": 26, - "intervalMs": 10, - "maxDelayMs": 0.03990899999917019, - "meanDelayMs": 0.005910730769196211 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 244.52297800000088, - "operationsPerMs": 40.89595211784131, - "rowsPerMs": 40.89595211784131, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.29762200000186567, - "meanDelayMs": 0.01643204166657597 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 248.21430200000032, - "operationsPerMs": 40.28776714083134, - "rowsPerMs": 40.28776714083134, - "eventLoop": { - "heartbeats": 24, - "intervalMs": 10, - "maxDelayMs": 0.18712000000232365, - "meanDelayMs": 0.011996333333627263 - } - } - ], - "summary": { - "medianOperationsPerMs": 39.39183823292197, - "medianRowsPerMs": 39.39183823292197, - "medianElapsedMs": 253.86967600000116, - "relativeMarginOfErrorPct": 3.8183389057032358, - "minOperationsPerMs": 38.09049688039999, - "maxOperationsPerMs": 40.89595211784131, - "medianEventLoopHeartbeats": 25, - "maxEventLoopDelayMs": 0.29762200000186567 - } - }, - { - "id": "pool-none-4c-point-read-with-crypto", - "group": "contention", - "description": "Four-connection pool competing with crypto libuv work", - "settings": { - "authorizer": "none", - "connections": 4, - "contention": "crypto", - "contentionWorkers": 4, - "cryptoIterations": 10000 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 80.38074600000073, - "operationsPerMs": 124.40790236009889, - "rowsPerMs": 124.40790236009889, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.020457999999052845, - "meanDelayMs": 0.004918124999903739 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 214, - "elapsedMs": 81.46617100000003 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 77.95432000000073, - "operationsPerMs": 128.28025438487444, - "rowsPerMs": 128.28025438487444, - "eventLoop": { - "heartbeats": 7, - "intervalMs": 10, - "maxDelayMs": 0.009087000000363332, - "meanDelayMs": 0.004845285714379445 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 204, - "elapsedMs": 79.1674579999999 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 86.85564099999829, - "operationsPerMs": 115.13356973555923, - "rowsPerMs": 115.13356973555923, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 1.54099099999803, - "meanDelayMs": 0.1942186249993938 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 234, - "elapsedMs": 87.92921699999715 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 81.482552999998, - "operationsPerMs": 122.72565882907774, - "rowsPerMs": 122.72565882907774, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.009108000002015615, - "meanDelayMs": 0.0020893750001960143 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 216, - "elapsedMs": 82.09523099999933 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 86.14773100000093, - "operationsPerMs": 116.07966784406536, - "rowsPerMs": 116.07966784406536, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.020838000000367174, - "meanDelayMs": 0.005400000000008731 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 208, - "elapsedMs": 87.40676999999778 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 80.00537099999929, - "operationsPerMs": 124.99160837589378, - "rowsPerMs": 124.99160837589378, - "eventLoop": { - "heartbeats": 8, - "intervalMs": 10, - "maxDelayMs": 0.013038000004598871, - "meanDelayMs": 0.0034553750010672957 - }, - "competingWork": { - "kind": "crypto", - "workers": 4, - "completed": 204, - "elapsedMs": 81.11654700000508 - } - } - ], - "summary": { - "medianOperationsPerMs": 123.56678059458832, - "medianRowsPerMs": 123.56678059458832, - "medianElapsedMs": 80.93164949999937, - "relativeMarginOfErrorPct": 6.824820407596204, - "minOperationsPerMs": 115.13356973555923, - "maxOperationsPerMs": 128.28025438487444, - "medianEventLoopHeartbeats": 8, - "maxEventLoopDelayMs": 1.54099099999803, - "competingWorkCompleted": 1280 - } - }, - { - "id": "pool-none-4c-point-read-with-fs", - "group": "contention", - "description": "Four-connection pool competing with fs libuv work", - "settings": { - "authorizer": "none", - "connections": 4, - "contention": "fs", - "contentionWorkers": 4, - "competingFileBytes": 1048576 - }, - "samples": [ - { - "sample": 1, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 225.2562939999989, - "operationsPerMs": 44.39387607078384, - "rowsPerMs": 44.39387607078384, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 0.30617199999869626, - "meanDelayMs": 0.06841245454548202 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1502, - "elapsedMs": 225.51514700000007 - } - }, - { - "sample": 2, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 212.03398799999923, - "operationsPerMs": 47.16225023320335, - "rowsPerMs": 47.16225023320335, - "eventLoop": { - "heartbeats": 21, - "intervalMs": 10, - "maxDelayMs": 0.18158100000073318, - "meanDelayMs": 0.03151242857148602 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1396, - "elapsedMs": 213.29208700000163 - } - }, - { - "sample": 3, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 226.30506899999818, - "operationsPerMs": 44.18813968325244, - "rowsPerMs": 44.18813968325244, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 0.4200440000022354, - "meanDelayMs": 0.041591818182000265 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1519, - "elapsedMs": 226.52463200000057 - } - }, - { - "sample": 4, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 225.38697500000126, - "operationsPerMs": 44.36813618000749, - "rowsPerMs": 44.36813618000749, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 0.6260179999990214, - "meanDelayMs": 0.06904772727268044 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1484, - "elapsedMs": 225.61369899999772 - } - }, - { - "sample": 5, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 203.24519900000087, - "operationsPerMs": 49.20165420487968, - "rowsPerMs": 49.20165420487968, - "eventLoop": { - "heartbeats": 20, - "intervalMs": 10, - "maxDelayMs": 0.8064889999986917, - "meanDelayMs": 0.12243200000011711 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1391, - "elapsedMs": 203.5048029999998 - } - }, - { - "sample": 6, - "logicalOperations": 10000, - "materializedRows": 10000, - "elapsedMs": 224.11574600000313, - "operationsPerMs": 44.61980105583416, - "rowsPerMs": 44.61980105583416, - "eventLoop": { - "heartbeats": 22, - "intervalMs": 10, - "maxDelayMs": 2.656046999996761, - "meanDelayMs": 0.2538599545451606 - }, - "competingWork": { - "kind": "fs", - "workers": 4, - "completed": 1396, - "elapsedMs": 224.30942000000505 - } - } - ], - "summary": { - "medianOperationsPerMs": 44.506838563309, - "medianRowsPerMs": 44.506838563309, - "medianElapsedMs": 224.686020000001, - "relativeMarginOfErrorPct": 10.548526458226213, - "minOperationsPerMs": 44.18813968325244, - "maxOperationsPerMs": 49.20165420487968, - "medianEventLoopHeartbeats": 22, - "maxEventLoopDelayMs": 2.656046999996761, - "competingWorkCompleted": 8688 - } - } - ] -} diff --git a/doc/done/20260808-P10-experimental-async-database-pool.md b/doc/done/20260808-P10-experimental-async-database-pool.md index a929753..29799aa 100644 --- a/doc/done/20260808-P10-experimental-async-database-pool.md +++ b/doc/done/20260808-P10-experimental-async-database-pool.md @@ -827,8 +827,9 @@ now covers warm/fresh sync, a worker-thread control, strict/none, one/two/three/four connections, explicit batch sizes, result sizes, read/write mixes, repeated and rotating SQL, competing crypto/filesystem libuv work, and event-loop -heartbeats. Two reference reports retain their complete raw inputs and results -for the default and eight-thread libuv pools. +heartbeats. Two local reference reports captured their complete raw inputs and +results for the default and eight-thread libuv pools; generated benchmark +reports are not versioned. 1. Benchmark warm sync, fresh sync, strict/none async, one/two/three/four connections, @@ -986,19 +987,17 @@ PhotoStructure-specific checklist. - `npm run lint`, `npm run lint:native`, `npm run docs`, `npm run test:api`, `npm run test:node`, `npm pack --dry-run`, and `git diff --check` pass. clang-tidy reports advisory baseline/style warnings but no configured error. -- Reference benchmark reports are - `benchmark/results/async-pool-default.json` and - `benchmark/results/async-pool-uv8.json`. They record Node 26.6.0, Linux x64, - Ryzen 9 5950X, one warmup, and six samples. Representative medians are 37.9 - ops/ms for one `none` connection, 39.9 for one `strict` connection, 132.7 for - four `none` connections, and 131.6 for 100-operation batches. Under four - competing PBKDF2 jobs, the four-connection pool rises from 11.3 ops/ms with - the default libuv pool to 123.6 ops/ms with `UV_THREADPOOL_SIZE=8`. +- Local reference benchmark reports (not versioned) recorded Node 26.6.0, + Linux x64, Ryzen 9 5950X, one warmup, and six samples. Representative medians + were 37.9 ops/ms for one `none` connection, 39.9 for one `strict` connection, + 132.7 for four `none` connections, and 131.6 for 100-operation batches. Under + four competing PBKDF2 jobs, the four-connection pool rose from 11.3 ops/ms + with the default libuv pool to 123.6 ops/ms with `UV_THREADPOOL_SIZE=8`. - The configurable scaling matrix now includes pool sizes one, two, three, and four in both authorizer modes. `npm run typecheck:async`, the scenario list, - and a tiny scaling execution run passed. The checked-in reference JSON - remains schema-valid historical evidence for its recorded one/two/four - matrix; the fixed four-worker contention cases remain intentional. + and a tiny scaling execution run passed. The local reference reports covered + the recorded one/two/four matrix; the fixed four-worker contention cases + remain intentional. - A post-review run of the benchmark package's generic `npm run bench` exposed a configuration-specific TypeScript error in `snapshotValue()`: its `strictNullChecks: false` program did not narrow a compound null/`typeof` From 0aad6f9a6d768a8425f55063cc3da56745fc4adb Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 17:29:32 -0700 Subject: [PATCH 3/7] fix(native): restore macOS and Windows prebuilds Avoid deployment-gated variant access and initialize Node-API handles so platform compilers can prove the native paths are safe. --- src/async_pool_impl.cpp | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/async_pool_impl.cpp b/src/async_pool_impl.cpp index 74e12b7..fbe0697 100644 --- a/src/async_pool_impl.cpp +++ b/src/async_pool_impl.cpp @@ -558,8 +558,8 @@ class PoolWorker { if (deferred.has_value()) { deferreds_.push_back(*deferred); } - napi_value resource; - napi_value name; + napi_value resource = nullptr; + napi_value name = nullptr; napi_status status = napi_create_object(env_, &resource); if (status == napi_ok) { status = napi_create_string_latin1(env_, resource_name, NAPI_AUTO_LENGTH, @@ -741,12 +741,16 @@ bool ToJsValue(Napi::Env env, const NativeValue &native, bool read_big_ints, *out = Napi::String::New(env, text->data(), text->size()); return true; } - const Blob &blob = std::get(native.data); - Napi::ArrayBuffer array_buffer = Napi::ArrayBuffer::New(env, blob.size()); - if (!blob.empty()) { - std::memcpy(array_buffer.Data(), blob.data(), blob.size()); + const auto *blob = std::get_if(&native.data); + if (blob == nullptr) { + SetPlainError(error, "Cannot convert SQLite value"); + return false; + } + Napi::ArrayBuffer array_buffer = Napi::ArrayBuffer::New(env, blob->size()); + if (!blob->empty()) { + std::memcpy(array_buffer.Data(), blob->data(), blob->size()); } - *out = Napi::Uint8Array::New(env, blob.size(), array_buffer, 0); + *out = Napi::Uint8Array::New(env, blob->size(), array_buffer, 0); return true; } @@ -1206,10 +1210,13 @@ int BindValue(sqlite3_stmt *statement, int index, const NativeValue &value) { return sqlite3_bind_text64(statement, index, text->data(), text->size(), SQLITE_TRANSIENT, SQLITE_UTF8); } - const Blob &blob = std::get(value.data); - const void *data = blob.empty() ? static_cast("") - : static_cast(blob.data()); - return sqlite3_bind_blob64(statement, index, data, blob.size(), + const auto *blob = std::get_if(&value.data); + if (blob == nullptr) { + return SQLITE_MISUSE; + } + const void *data = blob->empty() ? static_cast("") + : static_cast(blob->data()); + return sqlite3_bind_blob64(statement, index, data, blob->size(), SQLITE_TRANSIENT); } @@ -1296,17 +1303,18 @@ bool ReadRow(sqlite3 *db, sqlite3_stmt *statement, case SQLITE_NULL: column.value.data = nullptr; break; - case SQLITE_INTEGER: - column.value.data = + case SQLITE_INTEGER: { + const int64_t value = static_cast(sqlite3_column_int64(statement, index)); + column.value.data = value; if (!state->read_big_ints()) { - const int64_t value = std::get(column.value.data); if (value > kJsMaxSafeInteger || value < kJsMinSafeInteger) { SetRangeError(error, value); return false; } } break; + } case SQLITE_FLOAT: column.value.data = sqlite3_column_double(statement, index); break; From 910c5dc91ed302616e3a2e5de5a4c5265a425980 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 20:05:09 -0700 Subject: [PATCH 4/7] test: prevent parallel extension build races Build the shared fixture once in Jest global setup before worker suites start. Skip only extension-dependent tests when compilation is unavailable. --- jest.config.cjs | 1 + test/async-pool-setup.test.ts | 23 ++-------- test/extension-loading.test.ts | 77 ++++++---------------------------- test/global-setup.cjs | 19 +++++++++ 4 files changed, 35 insertions(+), 85 deletions(-) create mode 100644 test/global-setup.cjs diff --git a/jest.config.cjs b/jest.config.cjs index feb4964..a43441d 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -44,6 +44,7 @@ const maxWorkers = getMaxWorkers(); const config = { displayName: `@photostructure/sqlite (${isESM ? "ESM" : "CJS"})`, testEnvironment: "jest-environment-node", + globalSetup: "/test/global-setup.cjs", // Limit parallelism in CI to avoid resource contention ...(maxWorkers != null && { maxWorkers }), roots: ["/src", "/test", "/benchmark"], diff --git a/test/async-pool-setup.test.ts b/test/async-pool-setup.test.ts index 02185b4..8947ba5 100644 --- a/test/async-pool-setup.test.ts +++ b/test/async-pool-setup.test.ts @@ -1,5 +1,3 @@ -import { execFileSync } from "node:child_process"; -import * as fs from "node:fs"; import * as path from "node:path"; import { DatabaseSync } from "../src"; import { DatabasePool } from "../src/experimental"; @@ -7,27 +5,12 @@ import { getDirname, useTempDir } from "./test-utils"; const extensionDir = path.join(getDirname(), "fixtures", "test-extension"); const extensionBase = path.join(extensionDir, "test_extension"); -const extensionFile = - extensionBase + - (process.platform === "win32" - ? ".dll" - : process.platform === "darwin" - ? ".dylib" - : ".so"); +const testWithExtension = + process.env["TEST_EXTENSION_BUILT"] === "1" ? test : test.skip; describe("DatabasePool connection setup", () => { const tempDir = useTempDir("sqlite-async-pool-setup-"); - beforeAll(() => { - execFileSync(process.execPath, ["build.js"], { - cwd: extensionDir, - stdio: "inherit", - }); - if (!fs.existsSync(extensionFile)) { - throw new Error(`Test extension was not built at ${extensionFile}`); - } - }); - test("runs ordered parameterized setup before admitting the connection", async () => { const attachedPath = tempDir.getDbPath("attached.db"); const pool = await DatabasePool.open(tempDir.getDbPath("main.db"), { @@ -76,7 +59,7 @@ describe("DatabasePool connection setup", () => { } }); - test.each(["none", "strict"] as const)( + testWithExtension.each(["none", "strict"] as const)( "loads an extension during setup and revokes it under %s", async (authorizer) => { const pool = await DatabasePool.open(":memory:", { diff --git a/test/extension-loading.test.ts b/test/extension-loading.test.ts index c5319bb..281b1b8 100644 --- a/test/extension-loading.test.ts +++ b/test/extension-loading.test.ts @@ -1,60 +1,12 @@ -import { execSync } from "node:child_process"; -import * as fs from "node:fs"; import * as path from "node:path"; import { DatabaseSync } from "../src"; import { getDirname, rm } from "./test-utils"; -// Build the test extension at module load time so we can conditionally skip tests +// Jest's global setup builds this once before parallel test workers start. const extensionDir = path.join(getDirname(), "fixtures", "test-extension"); - -// Track extension build status -let testExtensionPath: string | undefined; -let extensionBuildError: string | undefined; - -function buildExtension(): void { - // Try to build the extension - but don't throw on failure - // On some platforms (e.g., ARM64 QEMU emulation), native builds may fail - try { - execSync("node build.js", { cwd: extensionDir, stdio: "inherit" }); - } catch (error) { - extensionBuildError = `Failed to build test extension: ${error}`; - return; - } - - // SQLite automatically adds the platform-specific extension, so we just provide the base name - const basePath = path.join(extensionDir, "test_extension"); - - // Verify the extension was built - check with actual file extension - let actualExtensionPath: string; - if (process.platform === "win32") { - actualExtensionPath = basePath + ".dll"; - } else if (process.platform === "darwin") { - actualExtensionPath = basePath + ".dylib"; - } else { - actualExtensionPath = basePath + ".so"; - } - - if (!fs.existsSync(actualExtensionPath)) { - extensionBuildError = `Test extension not found at ${actualExtensionPath}`; - return; - } - - testExtensionPath = basePath; -} - -// Build at module load time -buildExtension(); - -// Log build status -if (extensionBuildError) { - console.warn(extensionBuildError); - console.warn( - "Tests that require the real extension will be skipped on this platform", - ); -} - -// Conditional describe for tests that require the real extension -const describeWithExtension = testExtensionPath ? describe : describe.skip; +const testExtensionPath = path.join(extensionDir, "test_extension"); +const describeWithExtension = + process.env["TEST_EXTENSION_BUILT"] === "1" ? describe : describe.skip; describe("Extension Loading Tests", () => { describe("allowExtension option", () => { @@ -260,8 +212,6 @@ describe("Extension Loading Tests", () => { }); }); - // These tests require the real extension to be built - // They will be skipped on platforms where native builds fail (e.g., ARM64 QEMU emulation) describeWithExtension("loading real extension", () => { test("can load test extension and use its functions", () => { const db = new DatabaseSync(":memory:", { allowExtension: true }); @@ -269,7 +219,7 @@ describe("Extension Loading Tests", () => { // Load the test extension expect(() => { - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); }).not.toThrow(); // Test the version function @@ -297,7 +247,7 @@ describe("Extension Loading Tests", () => { // Load with explicit entry point expect(() => { - db.loadExtension(testExtensionPath!, "sqlite3_testextension_init"); + db.loadExtension(testExtensionPath, "sqlite3_testextension_init"); }).not.toThrow(); // Verify it loaded @@ -321,10 +271,7 @@ describe("Extension Loading Tests", () => { let caught: unknown; try { - db.loadExtension( - testExtensionPath!, - "sqlite3_testextension_query_init", - ); + db.loadExtension(testExtensionPath, "sqlite3_testextension_query_init"); } catch (error) { caught = error; } @@ -349,7 +296,7 @@ describe("Extension Loading Tests", () => { db.enableLoadExtension(true); // Load extension - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); // Disable extension loading db.enableLoadExtension(false); @@ -360,7 +307,7 @@ describe("Extension Loading Tests", () => { // But can't load new extensions expect(() => { - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); }).toThrow(/Extension loading is not enabled/); db.close(); @@ -369,7 +316,7 @@ describe("Extension Loading Tests", () => { test("extension functions work with various data types", () => { const db = new DatabaseSync(":memory:", { allowExtension: true }); db.enableLoadExtension(true); - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); // Test with integers const intResult = db.prepare("SELECT test_extension_add(42, 8)").get(); @@ -403,7 +350,7 @@ describe("Extension Loading Tests", () => { test("extension function errors are properly handled", () => { const db = new DatabaseSync(":memory:", { allowExtension: true }); db.enableLoadExtension(true); - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); // Wrong number of arguments for add // Error message varies across platforms - match common patterns or SQLite fallback @@ -429,7 +376,7 @@ describe("Extension Loading Tests", () => { db.enableLoadExtension(true); // Load extension - db.loadExtension(testExtensionPath!); + db.loadExtension(testExtensionPath); // Create a table and use extension function db.exec("CREATE TABLE test (input TEXT, output TEXT)"); diff --git a/test/global-setup.cjs b/test/global-setup.cjs new file mode 100644 index 0000000..15f8964 --- /dev/null +++ b/test/global-setup.cjs @@ -0,0 +1,19 @@ +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +module.exports = function globalSetup() { + const extensionDir = path.join(__dirname, "fixtures", "test-extension"); + process.env.TEST_EXTENSION_BUILT = "0"; + try { + execFileSync(process.execPath, ["build.js"], { + cwd: extensionDir, + stdio: "inherit", + }); + process.env.TEST_EXTENSION_BUILT = "1"; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `Test extension build failed; extension-dependent tests will be skipped: ${message}`, + ); + } +}; From abec34997284cd7c52b17d68fa46cf409de3ba2d Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 20:14:36 -0700 Subject: [PATCH 5/7] fix(experimental): make pool teardown reliable Fall back to SQLite's deferred close for retained native resources and observe fatal cleanup failures without causing unhandled rejections. --- src/async_pool_impl.cpp | 39 ++-- src/experimental.ts | 7 +- test/async-pool-lifecycle.test.ts | 170 +++++++++++++++++- test/fixtures/test-extension/test_extension.c | 54 ++++++ 4 files changed, 251 insertions(+), 19 deletions(-) diff --git a/src/async_pool_impl.cpp b/src/async_pool_impl.cpp index fbe0697..c786804 100644 --- a/src/async_pool_impl.cpp +++ b/src/async_pool_impl.cpp @@ -124,11 +124,7 @@ class AsyncConnectionState { std::lock_guard lock(handle_mutex_); // The environment coordinator and close worker must have consumed the // handle before the last shared owner disappears. - if (db_ != nullptr) { - if (sqlite3_close(db_) == SQLITE_OK) { - db_ = nullptr; - } - } + (void)CloseLocked(); } AsyncConnectionState(const AsyncConnectionState &) = delete; @@ -153,14 +149,7 @@ class AsyncConnectionState { int Close() noexcept { std::lock_guard lock(handle_mutex_); - if (db_ == nullptr) { - return SQLITE_OK; - } - const int rc = sqlite3_close(db_); - if (rc == SQLITE_OK) { - db_ = nullptr; - } - return rc; + return CloseLocked(); } bool read_big_ints() const noexcept { return read_big_ints_; } @@ -188,6 +177,23 @@ class AsyncConnectionState { std::atomic close_requested{false}; private: + int CloseLocked() noexcept { + if (db_ == nullptr) { + return SQLITE_OK; + } + int rc = sqlite3_close(db_); + if (rc != SQLITE_OK) { + // Extensions can retain statements, blobs, or backups that make the + // legacy close report SQLITE_BUSY. close_v2 safely transfers the handle + // to SQLite's zombie lifecycle so environment teardown can finish; the + // handle is finally freed when the retained resource is released. + rc = sqlite3_close_v2(db_); + } + if (rc == SQLITE_OK) { + db_ = nullptr; + } + return rc; + } mutable std::mutex handle_mutex_; sqlite3 *db_ = nullptr; const bool read_big_ints_; @@ -1613,9 +1619,8 @@ void AsyncPoolEnvironment::QueueCloseIfIdle( if (shutting_down_) { // Cleanup hooks run without a V8 HandleScope and JavaScript execution is // disallowed. Do not construct new napi_async_work here. With no active - // worker and no persistent statements, sqlite3_close is non-blocking in - // the ordinary case; SQLITE_BUSY deliberately leaves the hook pending as - // a visible invariant failure. + // worker, Close() either closes immediately or transfers a handle with + // extension-owned resources to SQLite's close_v2 zombie lifecycle. (void)state->Close(); return; } @@ -1657,7 +1662,7 @@ void AsyncPoolEnvironment::BeginCleanup( // Node drains every queued napi_async_work completion before invoking // environment cleanup hooks. TryFinishCleanup retains the hook and all state - // if that ordering invariant changes or sqlite3_close reports SQLITE_BUSY. + // if that ordering invariant changes. TryFinishCleanup(); } diff --git a/src/experimental.ts b/src/experimental.ts index a0f298b..db85bea 100644 --- a/src/experimental.ts +++ b/src/experimental.ts @@ -543,10 +543,15 @@ export class DatabasePool { this.#state = "failed"; while (this.#pending.length > 0) this.#pending.shift()!.reject(error); if (!this.#closePromise) { - this.#closePromise = new Promise((resolve, reject) => { + const closePromise = new Promise((resolve, reject) => { this.#resolveClose = resolve; this.#rejectClose = reject; }); + // Fatal cleanup starts without a close() caller. Mark its rejection as + // internally observed while preserving the original promise for a later + // close() caller that wants to inspect the native cleanup result. + void closePromise.catch(() => undefined); + this.#closePromise = closePromise; } } diff --git a/test/async-pool-lifecycle.test.ts b/test/async-pool-lifecycle.test.ts index 19bbe71..2016714 100644 --- a/test/async-pool-lifecycle.test.ts +++ b/test/async-pool-lifecycle.test.ts @@ -1,12 +1,22 @@ import { jest } from "@jest/globals"; +import nodeGypBuild from "node-gyp-build"; import { AsyncLocalStorage, createHook } from "node:async_hooks"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; +import * as path from "node:path"; import { Worker } from "node:worker_threads"; import { DatabaseSync } from "../src"; import { DatabasePool } from "../src/experimental"; import { waitForCondition } from "./test-reliability-utils"; -import { getTestTimeout, projectRoot, useTempDir } from "./test-utils"; +import { + getDirname, + getTestTimeout, + projectRoot, + useTempDir, +} from "./test-utils"; + +const testWithExtension = + process.env["TEST_EXTENSION_BUILT"] === "1" ? test : test.skip; describe("DatabasePool lifecycle", () => { jest.setTimeout(getTestTimeout(30_000)); @@ -152,6 +162,164 @@ describe("DatabasePool lifecycle", () => { }); }); + testWithExtension( + "native close accepts extension-owned statements without hanging teardown", + async () => { + const binding = nodeGypBuild(projectRoot()) as { + _openAsyncPoolConnection( + location: string, + options: { + readBigInts: boolean; + returnArrays: boolean; + authorizer: string; + allowExtension: boolean; + connectionSetup: Array<{ + kind: string; + sql: string; + params?: unknown[]; + }>; + }, + ): Promise<{ + execute(request: { + operations: Array<{ kind: string; sql: string }>; + }): Promise; + close(): Promise; + }>; + }; + const extensionBase = path.join( + getDirname(), + "fixtures", + "test-extension", + "test_extension", + ); + const connection = await binding._openAsyncPoolConnection(":memory:", { + readBigInts: false, + returnArrays: false, + authorizer: "none", + allowExtension: true, + connectionSetup: [ + { + kind: "run", + sql: "SELECT load_extension(?, ?)", + params: [extensionBase, "sqlite3_testextension_init"], + }, + ], + }); + + try { + await expect( + connection.execute({ + operations: [ + { + kind: "get", + sql: "SELECT test_extension_hold_statement() AS held", + }, + ], + }), + ).resolves.toEqual([{ held: 1 }]); + await expect(connection.close()).resolves.toBeUndefined(); + } finally { + const releaser = new DatabaseSync(":memory:", { allowExtension: true }); + try { + releaser.enableLoadExtension(true); + releaser.loadExtension(extensionBase, "sqlite3_testextension_init"); + expect( + releaser + .prepare("SELECT test_extension_release_statement() AS released") + .get(), + ).toEqual({ released: 1 }); + } finally { + releaser.close(); + await connection.close(); + } + } + }, + ); + + test("fatal auto-close observes its internal cleanup rejection", async () => { + const root = projectRoot(); + const childScript = ` + const Module = require('node:module'); + const originalLoad = Module._load; + Module._load = function(request, parent, isMain) { + if (request === 'node-gyp-build') { + return () => ({ + async _openAsyncPoolConnection() { + return { + execute() { + return Promise.reject( + Object.assign(new Error('synthetic fatal request'), { + fatal: true, + }), + ); + }, + close() { + return Promise.reject( + new Error('synthetic native close failure'), + ); + }, + }; + }, + }); + } + return originalLoad.call(this, request, parent, isMain); + }; + const { DatabasePool } = require('./src/experimental.ts'); + Module._load = originalLoad; + + (async () => { + const pool = await DatabasePool.open(':memory:', { authorizer: 'none' }); + try { + await pool.get('SELECT 1'); + throw new Error('fatal request unexpectedly resolved'); + } catch (error) { + if (error?.fatal !== true) throw error; + } + setImmediate(() => { + pool.close().then( + () => { + process.stderr.write('fatal cleanup unexpectedly resolved'); + process.exit(3); + }, + (error) => { + if (error?.message !== 'synthetic native close failure') { + process.stderr.write(error?.stack || String(error)); + process.exit(4); + return; + } + process.send('survived', () => process.exit(0)); + }, + ); + }); + })().catch((error) => { + process.stderr.write(error.stack || error.message); + process.exit(2); + }); + `; + const child = spawn( + process.execPath, + ["--unhandled-rejections=throw", "-r", "tsx/cjs", "-e", childScript], + { + cwd: root, + stdio: ["ignore", "ignore", "pipe", "ipc"], + }, + ); + let stderr = ""; + let message: unknown; + child.stderr!.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); + child.once("message", (value) => (message = value)); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + + expect({ exitCode, message, stderr }).toEqual({ + exitCode: 0, + message: "survived", + stderr: "", + }); + }); + test("Symbol.asyncDispose closes idempotently", async () => { const pool = await DatabasePool.open(":memory:", { authorizer: "none" }); await pool[Symbol.asyncDispose](); diff --git a/test/fixtures/test-extension/test_extension.c b/test/fixtures/test-extension/test_extension.c index 645c190..d56c6f5 100644 --- a/test/fixtures/test-extension/test_extension.c +++ b/test/fixtures/test-extension/test_extension.c @@ -10,6 +10,8 @@ SQLITE_EXTENSION_INIT1 #include +static sqlite3_stmt *held_statement = 0; + /* Custom function that returns the extension version */ static void test_extension_version(sqlite3_context *context, int argc, sqlite3_value **argv) { @@ -80,6 +82,46 @@ static void test_extension_reverse(sqlite3_context *context, int argc, sqlite3_result_text(context, (const char *)output, byte_len, sqlite3_free); } +/* Retain a statement so close tests can exercise SQLite's busy/zombie path. */ +static void test_extension_hold_statement(sqlite3_context *context, int argc, + sqlite3_value **argv) { + sqlite3 *db; + int rc; + (void)argc; + (void)argv; + if (held_statement != 0) { + sqlite3_result_error(context, "a test statement is already held", -1); + return; + } + db = sqlite3_context_db_handle(context); + rc = sqlite3_prepare_v2(db, "SELECT 1", -1, &held_statement, 0); + if (rc != SQLITE_OK) { + held_statement = 0; + sqlite3_result_error_code(context, rc); + return; + } + sqlite3_result_int(context, 1); +} + +/* Release a statement retained by another connection in this process. */ +static void test_extension_release_statement(sqlite3_context *context, int argc, + sqlite3_value **argv) { + int rc; + (void)argc; + (void)argv; + if (held_statement == 0) { + sqlite3_result_int(context, 0); + return; + } + rc = sqlite3_finalize(held_statement); + held_statement = 0; + if (rc != SQLITE_OK) { + sqlite3_result_error_code(context, rc); + return; + } + sqlite3_result_int(context, 1); +} + /* Extension entry point */ #ifdef _WIN32 __declspec(dllexport) @@ -105,6 +147,18 @@ int sqlite3_testextension_init(sqlite3 *db, char **pzErrMsg, rc = sqlite3_create_function(db, "test_extension_reverse", 1, SQLITE_UTF8 | SQLITE_DETERMINISTIC, 0, test_extension_reverse, 0, 0); + if (rc != SQLITE_OK) + return rc; + + rc = sqlite3_create_function(db, "test_extension_hold_statement", 0, + SQLITE_UTF8, 0, test_extension_hold_statement, 0, + 0); + if (rc != SQLITE_OK) + return rc; + + rc = sqlite3_create_function(db, "test_extension_release_statement", 0, + SQLITE_UTF8, 0, test_extension_release_statement, + 0, 0); return rc; } From 1d42a3f57e9f6e39d9554a1bc3f18ef4e091a99b Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 20:25:05 -0700 Subject: [PATCH 6/7] fix(valgrind-test): preserve leak reporting under pipefail Parse the final leak summary without a failing grep aborting strict-mode execution. --- scripts/valgrind-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/valgrind-test.sh b/scripts/valgrind-test.sh index 8d22b6b..034c92b 100755 --- a/scripts/valgrind-test.sh +++ b/scripts/valgrind-test.sh @@ -67,8 +67,8 @@ VALGRIND_OPTS="--leak-check=full --show-leak-kinds=definite,indirect,possible -- echo "Running valgrind tests..." if valgrind $VALGRIND_OPTS "$NODE_BIN" "$TSX_CLI" "$VALGRIND_TEST" 2>&1 | tee "$ROOT_DIR/valgrind.log"; then # Extract leak counts from the LEAK SUMMARY - DEFINITELY_LOST=$(grep "definitely lost:" "$ROOT_DIR/valgrind.log" | sed -E 's/.*definitely lost: ([0-9,]+) bytes.*/\1/' | tr -d ',') - INDIRECTLY_LOST=$(grep "indirectly lost:" "$ROOT_DIR/valgrind.log" | sed -E 's/.*indirectly lost: ([0-9,]+) bytes.*/\1/' | tr -d ',') + DEFINITELY_LOST=$(sed -nE 's/.*definitely lost: ([0-9,]+) bytes.*/\1/p' "$ROOT_DIR/valgrind.log" | tail -n 1 | tr -d ',') + INDIRECTLY_LOST=$(sed -nE 's/.*indirectly lost: ([0-9,]+) bytes.*/\1/p' "$ROOT_DIR/valgrind.log" | tail -n 1 | tr -d ',') # Debug output echo "Definitely lost: ${DEFINITELY_LOST:-0} bytes" From 569038f974f37936e0d1ebfa424b847f965a0696 Mon Sep 17 00:00:00 2001 From: Matthew McEachen Date: Mon, 10 Aug 2026 20:31:20 -0700 Subject: [PATCH 7/7] fix(experimental): eliminate security lint warnings Preserve safe handling of arbitrary parameter names while avoiding dynamic property access and a constructor-guard false positive. --- src/experimental.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/experimental.ts b/src/experimental.ts index db85bea..02ef5b1 100644 --- a/src/experimental.ts +++ b/src/experimental.ts @@ -205,11 +205,13 @@ function snapshotParams( throw invalidArgument(`The ${label} parameters must be a plain object.`); } const copied: Record = Object.create(null); - for (const key of Object.keys(params)) { - copied[key] = snapshotValue( - (params as Record)[key], - `${label}.${key}`, - ); + for (const [key, value] of Object.entries(params)) { + Object.defineProperty(copied, key, { + configurable: true, + enumerable: true, + value: snapshotValue(value, `${label}.${key}`), + writable: true, + }); } return copied; } @@ -250,11 +252,10 @@ function snapshotOperation( } function booleanOption( - options: Record, + value: unknown, name: string, defaultValue: boolean, ): boolean { - const value = options[name]; if (value === undefined) return defaultValue; if (typeof value !== "boolean") { throw invalidArgument(`The "options.${name}" argument must be a boolean.`); @@ -308,9 +309,13 @@ function normalizeOptions(options: unknown): NormalizedOptions { return { connections: connections as number, authorizer, - readBigInts: booleanOption(input, "readBigInts", false), - returnArrays: booleanOption(input, "returnArrays", false), - allowExtension: booleanOption(input, "allowExtension", false), + readBigInts: booleanOption(input["readBigInts"], "readBigInts", false), + returnArrays: booleanOption(input["returnArrays"], "returnArrays", false), + allowExtension: booleanOption( + input["allowExtension"], + "allowExtension", + false, + ), connectionSetup: setup.map((operation, index) => snapshotOperation(operation, index, true), ), @@ -364,8 +369,8 @@ export class DatabasePool { #rejectClose?: (reason?: unknown) => void; #nativeCloseStarted = false; - private constructor(token?: symbol, connections: NativeConnection[] = []) { - if (token !== constructorToken) throw new TypeError("Illegal constructor"); + private constructor(guard?: symbol, connections: NativeConnection[] = []) { + if (guard !== constructorToken) throw new TypeError("Illegal constructor"); this.#connections = connections; this.#idle = [...connections]; }