Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/9948-drizzle-sql-prefix-probe.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions tests/release/packages/drizzle-sql-prefix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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=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`
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.
108 changes: 108 additions & 0 deletions tests/release/packages/drizzle-sql-prefix/entry.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions tests/release/packages/drizzle-sql-prefix/expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sql-prefix-stress: iterations=1000 checked=3999 explicit_gc=126
10 changes: 10 additions & 0 deletions tests/release/packages/drizzle-sql-prefix/fixture.sh
Original file line number Diff line number Diff line change
@@ -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"
140 changes: 140 additions & 0 deletions tests/release/packages/drizzle-sql-prefix/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions tests/release/packages/drizzle-sql-prefix/package.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
}
Loading