Summary
A transaction containing a locking read (SELECT … FOR UPDATE, with or without SKIP LOCKED) intermittently never completes. The promise never settles, no error is raised, and the connection is left idle. Roughly one occurrence per 200 transactions, on an otherwise idle database with no contention — a single process, one connection in use, nothing else touching the table.
Non-locking transactions and non-transactional queries are unaffected, which is what makes this specific rather than "transactions are flaky".
Perry 0.5.1519, MySQL 8, mysql2@3.24.2, drizzle-orm@0.44.7, Linux x86_64.
Reproduction
Four loops, 400 iterations each, each iteration wrapped in an 8s timeout:
const { db } = await connect({ env: { … } }); // drizzle over a mysql2 POOL
await loop("tx + FOR UPDATE SKIP LOCKED", () => db.transaction(async (tx) =>
{ await tx.execute(sql`SELECT id FROM notifications ORDER BY id LIMIT 5 FOR UPDATE SKIP LOCKED`); }));
await loop("tx + FOR UPDATE", () => db.transaction(async (tx) =>
{ await tx.execute(sql`SELECT id FROM notifications ORDER BY id LIMIT 5 FOR UPDATE`); }));
await loop("tx + plain SELECT", () => db.transaction(async (tx) =>
{ await tx.execute(sql`SELECT id FROM notifications ORDER BY id LIMIT 5`); }));
await loop("no tx, plain SELECT", async () =>
{ await db.execute(sql`SELECT id FROM notifications LIMIT 5`); });
| pattern |
node 26.8.1 |
perry 0.5.1519 |
tx + FOR UPDATE SKIP LOCKED |
400/400 |
HUNG at iteration 193 |
tx + FOR UPDATE |
400/400 |
HUNG at iteration 239 |
tx + plain SELECT |
400/400 |
400/400 |
no tx, plain SELECT |
400/400 |
400/400 |
A second, independent run hung the first pattern at iteration 194 — so the rate is consistent rather than a one-off.
The locking read is the only variable. Same pool, same table, same transaction wrapper, same process.
What it looks like when it happens
So the connection has been handed a statement that never comes back, while everything around it stays healthy. That last part is what makes it expensive to find.
Why this is the one that matters for us
Our platform serialises every bid, max-bid, buy-now and auction close with SELECT … FOR UPDATE on the auction row — it is the documented rule the whole auction engine rests on. At roughly 1 in 200 that means one bid in every two hundred hangs forever, and because the surrounding request never completes, the connection is never returned to the pool.
We found it through a scheduler loop that guards against overlapping ticks with an in-flight flag. One wedged tick left that flag set, so every subsequent tick was skipped — the service stayed active, the health endpoint kept answering ok (it runs SELECT 1, which has no transaction and no lock), and auctions silently stopped closing. Fifteen clean cycles, then nothing, with no error anywhere.
Related
Same investigation, same driver: #9330 (a transaction does not pin its pooled connection; the options-object query() form is unsupported) and #9349 (JSON columns read back as null, fixed in #9350). This one is independent of both — it reproduces with the pinning workaround in place and with #9350 applied.
I have a host that reproduces it in about two minutes and can test a patch quickly.
Summary
A transaction containing a locking read (
SELECT … FOR UPDATE, with or withoutSKIP LOCKED) intermittently never completes. The promise never settles, no error is raised, and the connection is left idle. Roughly one occurrence per 200 transactions, on an otherwise idle database with no contention — a single process, one connection in use, nothing else touching the table.Non-locking transactions and non-transactional queries are unaffected, which is what makes this specific rather than "transactions are flaky".
Perry 0.5.1519, MySQL 8,
mysql2@3.24.2,drizzle-orm@0.44.7, Linux x86_64.Reproduction
Four loops, 400 iterations each, each iteration wrapped in an 8s timeout:
FOR UPDATE SKIP LOCKEDFOR UPDATESELECTSELECTA second, independent run hung the first pattern at iteration 194 — so the rate is consistent rather than a one-off.
The locking read is the only variable. Same pool, same table, same transaction wrapper, same process.
What it looks like when it happens
information_schema.innodb_trxis empty — the transaction is not sitting open holding locks, so this is not the lock-wait case and not the same defect as mysql2/drizzle: a transaction does not pin its pooled connection — statements scatter, breaking atomicity #9330.SHOW PROCESSLISTshows no running query.epoll_waitreturns normally, timers still fire, HTTP requests are still served.So the connection has been handed a statement that never comes back, while everything around it stays healthy. That last part is what makes it expensive to find.
Why this is the one that matters for us
Our platform serialises every bid, max-bid, buy-now and auction close with
SELECT … FOR UPDATEon the auction row — it is the documented rule the whole auction engine rests on. At roughly 1 in 200 that means one bid in every two hundred hangs forever, and because the surrounding request never completes, the connection is never returned to the pool.We found it through a scheduler loop that guards against overlapping ticks with an in-flight flag. One wedged tick left that flag set, so every subsequent tick was skipped — the service stayed
active, the health endpoint kept answeringok(it runsSELECT 1, which has no transaction and no lock), and auctions silently stopped closing. Fifteen clean cycles, then nothing, with no error anywhere.Related
Same investigation, same driver: #9330 (a transaction does not pin its pooled connection; the options-object
query()form is unsupported) and #9349 (JSON columns read back as null, fixed in #9350). This one is independent of both — it reproduces with the pinning workaround in place and with #9350 applied.I have a host that reproduces it in about two minutes and can test a patch quickly.