From 5ecf8827426be101650595cdb577f3c686f18e20 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:44:24 -0400 Subject: [PATCH 1/3] test(drizzle): stress SQL and parameter prefixes across collection --- changelog.d/9935-drizzle-sql-prefix-probe.md | 3 + .../packages/drizzle-sql-prefix/README.md | 58 ++++++++ .../packages/drizzle-sql-prefix/entry.ts | 108 ++++++++++++++ .../packages/drizzle-sql-prefix/expected.txt | 1 + .../packages/drizzle-sql-prefix/fixture.sh | 10 ++ .../drizzle-sql-prefix/package-lock.json | 140 ++++++++++++++++++ .../packages/drizzle-sql-prefix/package.json | 12 ++ 7 files changed, 332 insertions(+) create mode 100644 changelog.d/9935-drizzle-sql-prefix-probe.md create mode 100644 tests/release/packages/drizzle-sql-prefix/README.md create mode 100644 tests/release/packages/drizzle-sql-prefix/entry.ts create mode 100644 tests/release/packages/drizzle-sql-prefix/expected.txt create mode 100755 tests/release/packages/drizzle-sql-prefix/fixture.sh create mode 100644 tests/release/packages/drizzle-sql-prefix/package-lock.json create mode 100644 tests/release/packages/drizzle-sql-prefix/package.json diff --git a/changelog.d/9935-drizzle-sql-prefix-probe.md b/changelog.d/9935-drizzle-sql-prefix-probe.md new file mode 100644 index 0000000000..e820b310a7 --- /dev/null +++ b/changelog.d/9935-drizzle-sql-prefix-probe.md @@ -0,0 +1,3 @@ +Added a database-free Drizzle SQL-prefix stress fixture with exact SQL and +parameter assertions, wide chunk arrays, and explicit GC windows. It provides +investigation coverage for #9935 without claiming the production issue is fixed. diff --git a/tests/release/packages/drizzle-sql-prefix/README.md b/tests/release/packages/drizzle-sql-prefix/README.md new file mode 100644 index 0000000000..f256aeb2fe --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/README.md @@ -0,0 +1,58 @@ +# SQL prefix stress probe (#9935) + +This fixture uses the reported `drizzle-orm@0.44.7` query-construction code, +without a database, driver, credentials, or network requests. It checks the +exact SQL text and every parameter for the reported four-predicate SELECT and +a 40-extra-predicate variant that repeatedly grows the chunk/parameter arrays. +It retains previous query results and collects between constructing the head +and appending the tail, and after materializing SQL. + +This is an investigation probe. Passing does **not** establish that the rare +Linux production failure is fixed, or exclude a driver/transaction/async path. +Failing gives a smaller boundary to investigate before involving MySQL. + +Install and check against the Node version in `.node-version`: + +```sh +cd tests/release/packages/drizzle-sql-prefix +npm ci --ignore-scripts +node --expose-gc --experimental-strip-types entry.ts > node-out.txt +diff -u expected.txt node-out.txt +``` + +Run the usual release fixture using a compiler and static runtime built from +the same source tree: + +```sh +PERRY_BIN=/absolute/path/to/perry bash fixture.sh +``` + +For a standalone stress run, compile once and execute the same binary under +both normal collection and forced moving collection: + +```sh +"$PERRY_BIN" compile entry.ts -o out +PERRY_SQL_PREFIX_ITERATIONS=10000 ./out +PERRY_SQL_PREFIX_ITERATIONS=10000 PERRY_GC_DIAG=1 \ + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + ./out > forced.out 2> forced.log +``` + +For additional collection windows *inside* Drizzle's loops, use a scheduled +run (loop polls must be present in the compiled binary): + +```sh +PERRY_SQL_PREFIX_ITERATIONS=1000 PERRY_GC_DIAG=1 \ + PERRY_GC_SCHEDULE_SEED=9935 PERRY_GC_SCHEDULE_RATE=0.05 \ + PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 \ + ./out > scheduled.out 2> scheduled.log +``` + +Record exit status, the exact build commit/platform, seed, stdout, and stderr. +Use an external timeout for unattended runs. An explicit `gc()` count only +proves that the fixture called `gc`; a moving-GC result also needs runtime +evidence that copying/evacuation actually ran. Inspect `[gc-copy-minor] ran` +and `[gc-fromspace-protect] retired_set` records before claiming that coverage. +If a verifier fails before a SQL assertion, preserve that diagnostic separately; +it is not proof that SQL lost its prefix. Issue #9942's hang/leak may be related, +but this fixture does not assume that connection. diff --git a/tests/release/packages/drizzle-sql-prefix/entry.ts b/tests/release/packages/drizzle-sql-prefix/entry.ts new file mode 100644 index 0000000000..349b8a0112 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/entry.ts @@ -0,0 +1,108 @@ +// #9935: check Drizzle's SQL/parameter prefixes before they reach a driver. +// This is an investigation probe, not a demonstrated reproduction of the +// production failure. Keep both the original four predicates and a wider +// variant that forces the SQL chunk/parameter arrays to grow repeatedly. +import { and, asc, eq, isNotNull, lte } from "drizzle-orm"; +import { datetime, int, mysqlTable, QueryBuilder, varchar } from "drizzle-orm/mysql-core"; + +declare function gc(): void; + +const iterations = Number(process.env.PERRY_SQL_PREFIX_ITERATIONS ?? "1000"); +if (!Number.isInteger(iterations) || iterations < 1 || iterations > 1000000) { + throw new Error("PERRY_SQL_PREFIX_ITERATIONS must be an integer from 1 to 1000000"); +} +if (typeof gc !== "function") { + throw new Error("explicit GC is required; use node --expose-gc for the oracle"); +} + +const auctions = mysqlTable("auctions", { + id: int("id").primaryKey(), + status: varchar("status", { length: 32 }), + format: varchar("format", { length: 32 }), + endsAt: datetime("endsAt"), +}); +const builder = new QueryBuilder(); +const at = new Date("2026-09-07T12:00:00.000Z"); +const expectedDate = "2026-09-07 12:00:00.000"; +const prefix = "select `id` from `auctions` where ("; +const baseConditions = "`auctions`.`status` = ? and `auctions`.`format` = ? and `auctions`.`endsAt` is not null and `auctions`.`endsAt` <= ?"; +const suffix = ") order by `auctions`.`endsAt` asc limit ?"; +type Query = { sql: string; params: unknown[] }; + +function check(query: Query, expectedSql: string, expectedParams: unknown[], context: string) { + if (query.sql !== expectedSql) { + throw new Error(context + ": SQL mismatch\nexpected: " + expectedSql + "\nactual: " + query.sql); + } + if (!Array.isArray(query.params) || query.params.length !== expectedParams.length) { + throw new Error(context + ": parameter length mismatch, expected " + expectedParams.length); + } + for (let index = 0; index < expectedParams.length; index++) { + if (query.params[index] !== expectedParams[index]) { + throw new Error(context + ": parameter " + index + " mismatch, expected " + expectedParams[index] + ", actual " + query.params[index]); + } + } +} + +// Fixed expected structure; changing iteration values prevent a cached answer +// or a result from an earlier query from satisfying the assertions. +const width = 40; +let wideConditions = baseConditions; +for (let index = 0; index < width; index++) { + wideConditions += " and `auctions`.`id` = ?"; +} +let checked = 0; +let collections = 0; +let previous: Query | undefined; +let previousParams: unknown[] = []; +for (let iteration = 0; iteration < iterations; iteration++) { + const status = "live-" + iteration; + const format = "auction-" + iteration; + const limit = 50 + iteration % 7; + const originalParams: unknown[] = [status, format, expectedDate, limit]; + const original = builder.select({ id: auctions.id }).from(auctions).where(and( + eq(auctions.status, status), + eq(auctions.format, format), + isNotNull(auctions.endsAt), + lte(auctions.endsAt, at), + )); + if (iteration % 16 === 0) { + // Keep the already-built head alive while the collector runs, before + // the orderBy/limit tail is appended (the reported missing-head shape). + gc(); + collections++; + } + const query = original.orderBy(asc(auctions.endsAt)).limit(limit).toSQL(); + check(query, prefix + baseConditions + suffix, originalParams, "original iteration " + iteration); + checked++; + + const conditions = [ + eq(auctions.status, status), eq(auctions.format, format), + isNotNull(auctions.endsAt), lte(auctions.endsAt, at), + ]; + const wideParams: unknown[] = [status, format, expectedDate]; + for (let index = 0; index < width; index++) { + const value = iteration * width + index; + conditions.push(eq(auctions.id, value)); + wideParams.push(value); + } + wideParams.push(limit); + const wide = builder.select({ id: auctions.id }).from(auctions) + .where(and(...conditions)).orderBy(asc(auctions.endsAt)).limit(limit).toSQL(); + if (iteration % 16 === 0) { + gc(); + collections++; + } + check(wide, prefix + wideConditions + suffix, wideParams, "wide iteration " + iteration); + check(query, prefix + baseConditions + suffix, originalParams, "retained current " + iteration); + checked += 2; + if (previous !== undefined) { + check(previous, prefix + wideConditions + suffix, previousParams, "retained previous " + iteration); + checked++; + } + previous = wide; + previousParams = wideParams; +} +if (checked !== 4 * iterations - 1 || collections === 0) { + throw new Error("the stress probe did not exercise every assertion and collection window"); +} +console.log("sql-prefix-stress: iterations=" + iterations + " checked=" + checked + " explicit_gc=" + collections); diff --git a/tests/release/packages/drizzle-sql-prefix/expected.txt b/tests/release/packages/drizzle-sql-prefix/expected.txt new file mode 100644 index 0000000000..15d68af1c0 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/expected.txt @@ -0,0 +1 @@ +sql-prefix-stress: iterations=1000 checked=3999 explicit_gc=126 diff --git a/tests/release/packages/drizzle-sql-prefix/fixture.sh b/tests/release/packages/drizzle-sql-prefix/fixture.sh new file mode 100755 index 0000000000..278d31455a --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/fixture.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +source ../_fixture_lib.sh + +# No database or mysql2 connection is needed. Keep the package version pinned +# to the production report; compiler/runtime linking remains the harness's job. +fixture_setup "drizzle-sql-prefix" +export PERRY_SQL_PREFIX_ITERATIONS=1000 +fixture_compile_run_diff "drizzle-sql-prefix" diff --git a/tests/release/packages/drizzle-sql-prefix/package-lock.json b/tests/release/packages/drizzle-sql-prefix/package-lock.json new file mode 100644 index 0000000000..44209f245e --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/package-lock.json @@ -0,0 +1,140 @@ +{ + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "dependencies": { + "drizzle-orm": "0.44.7" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.7", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", + "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + } + } +} diff --git a/tests/release/packages/drizzle-sql-prefix/package.json b/tests/release/packages/drizzle-sql-prefix/package.json new file mode 100644 index 0000000000..c059b0f2d3 --- /dev/null +++ b/tests/release/packages/drizzle-sql-prefix/package.json @@ -0,0 +1,12 @@ +{ + "name": "perry-release-fixture-drizzle-sql-prefix", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Database-free SQL and parameter prefix stress probe for issue #9935.", + "dependencies": { "drizzle-orm": "0.44.7" }, + "perry": { + "compilePackages": ["drizzle-orm"], + "allow": { "compilePackages": ["drizzle-orm"] } + } +} From e3d11d9350254710dcc942c814bd415ec191c315 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:45:28 -0400 Subject: [PATCH 2/3] docs: key SQL prefix changeset to PR 9948 --- ...izzle-sql-prefix-probe.md => 9948-drizzle-sql-prefix-probe.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9935-drizzle-sql-prefix-probe.md => 9948-drizzle-sql-prefix-probe.md} (100%) diff --git a/changelog.d/9935-drizzle-sql-prefix-probe.md b/changelog.d/9948-drizzle-sql-prefix-probe.md similarity index 100% rename from changelog.d/9935-drizzle-sql-prefix-probe.md rename to changelog.d/9948-drizzle-sql-prefix-probe.md From f8a33e212145acfd970624a031cebe51ea2bbc80 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Mon, 7 Sep 2026 00:47:53 -0400 Subject: [PATCH 3/3] docs: use a bounded scheduled-GC probe example --- tests/release/packages/drizzle-sql-prefix/README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/release/packages/drizzle-sql-prefix/README.md b/tests/release/packages/drizzle-sql-prefix/README.md index f256aeb2fe..32e3cbf1a8 100644 --- a/tests/release/packages/drizzle-sql-prefix/README.md +++ b/tests/release/packages/drizzle-sql-prefix/README.md @@ -42,17 +42,22 @@ For additional collection windows *inside* Drizzle's loops, use a scheduled run (loop polls must be present in the compiled binary): ```sh -PERRY_SQL_PREFIX_ITERATIONS=1000 PERRY_GC_DIAG=1 \ +PERRY_SQL_PREFIX_ITERATIONS=10 PERRY_GC_DIAG=1 \ PERRY_GC_SCHEDULE_SEED=9935 PERRY_GC_SCHEDULE_RATE=0.05 \ PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_PROTECT_FROMSPACE=1 \ ./out > scheduled.out 2> scheduled.log ``` +Start with the small scheduled run: removing allocation pacing can produce +thousands of collections in only ten iterations. Scale it separately from the +ordinary 1,000-iteration acceptance run. + Record exit status, the exact build commit/platform, seed, stdout, and stderr. Use an external timeout for unattended runs. An explicit `gc()` count only proves that the fixture called `gc`; a moving-GC result also needs runtime evidence that copying/evacuation actually ran. Inspect `[gc-copy-minor] ran` -and `[gc-fromspace-protect] retired_set` records before claiming that coverage. +records and `[gc-fromspace-protect]` lines containing `retired_set=` before +claiming that coverage. If a verifier fails before a SQL assertion, preserve that diagnostic separately; it is not proof that SQL lost its prefix. Issue #9942's hang/leak may be related, but this fixture does not assume that connection.