Skip to content

mysql2 + drizzle: transactions deterministically hang after ~195, independent of rows, time, pool size and locking #9356

Description

@proggeramlug

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions