Skip to content

fix(adapter-pg): serialize queries within a transaction - #29468

Open
matingathani wants to merge 7 commits into
prisma:v7from
matingathani:fix/adapter-pg-concurrent-transaction-queries
Open

fix(adapter-pg): serialize queries within a transaction#29468
matingathani wants to merge 7 commits into
prisma:v7from
matingathani:fix/adapter-pg-concurrent-transaction-queries

Conversation

@matingathani

Copy link
Copy Markdown
Contributor

Problem

When a update (or any write operation) is executed with include relations, the query plan produces a join node whose children are fetched via Promise.all. Each child eventually calls queryRaw on context.queryable. Inside a transaction, context.queryable is a PgTransaction backed by a single pg.PoolClient — not a pool. Dispatching concurrent client.query() calls on a single pg Client triggers:

DeprecationWarning: Calling client.query() when the client is already executing
a query is deprecated and will be removed in pg@9.0. Use async/await or an
external async flow control mechanism instead.

This becomes a hard error in pg@9.0. Users are already hitting it in Prisma 7.5 (since pg 8.20.0 introduced the warning). The only current workaround is to downgrade pg to 8.18.

Reported in #29407.

Root cause

query-interpreter.ts, case 'join' (line ~243):

const children = await Promise.all(
  node.args.children.map(async (joinExpr) => ({
    joinExpr,
    childRecords: (await this.interpretNode(joinExpr.child, context)).value,
  })),
)

Promise.all fires all child queries concurrently. Fine when context.queryable is a Pool (each query gets its own connection), but not when it is a Transaction (single connection).

Fix

Detect whether context.queryable is a Transaction via the commit property (present on the Transaction interface from @prisma/driver-adapter-utils). When inside a transaction, replace Promise.all with a sequential for...of loop. When using a pool the existing parallel behaviour is unchanged.

function isTransaction(queryable: SqlQueryable): queryable is Transaction {
  return 'commit' in queryable
}

Test plan

  • update with multiple include relations inside $transaction([...]) no longer emits the pg deprecation warning
  • update with multiple include relations outside a transaction continues to fire relation queries in parallel (no performance regression)
  • Existing tests pass

Fixes #29407

Copilot AI review requested due to automatic review settings April 15, 2026 02:25
@CLAassistant

CLAassistant commented Apr 15, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Added PostgreSQL TIMETZ array type parsing and serialized PgTransaction query execution with a mutex; also added the async-mutex runtime dependency.

Changes

Cohort / File(s) Summary
TIMETZ Array Type Support
packages/adapter-pg/src/conversion.ts
Added TIMETZ_ARRAY OID entry, mapped it to ColumnTypeEnum.TimeArray in fieldToColumnType, and registered customParsers[ArrayColumnType.TIMETZ_ARRAY] = normalize_array(normalize_timez).
Concurrency Control (PgTransaction)
packages/adapter-pg/src/pg.ts
Added a Mutex field to PgTransaction, changed PgQueryable.performIO visibility to protected, and wrapped PgTransaction.performIO with #mutex.runExclusive(...) to serialize queries on a single client.
Dependency
packages/adapter-pg/package.json
Added runtime dependency async-mutex@0.5.0.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement the mutex-based serialization approach in PgTransaction.performIO to prevent concurrent client.query() calls, directly addressing #29407's requirement to serialize performIO calls on the single pg Client.
Out of Scope Changes check ✅ Passed All changes are within scope: adding TIMETZ_ARRAY support to conversion.ts, adding async-mutex dependency, and implementing mutex in PgTransaction are all aligned with fixing concurrent pg queries in transactions.
Title check ✅ Passed The title clearly matches the main change: serializing queries within transactions in the pg adapter.
Description check ✅ Passed The description is directly related to the changeset and accurately explains the transaction serialization fix.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a runtime concurrency issue in client-engine-runtime where join relation fetches were executed concurrently inside transactions, triggering the pg deprecation warning (and future pg@9 error) caused by overlapping client.query() calls on a single transaction connection.

Changes:

  • Detect when the interpreter is executing with a transactional SqlQueryable.
  • Run join children fetches sequentially inside transactions while keeping the parallel Promise.all behavior outside transactions.
  • Add an isTransaction type guard to support the conditional behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/client-engine-runtime/src/interpreter/query-interpreter.ts Outdated
Comment thread packages/client-engine-runtime/src/interpreter/query-interpreter.ts Outdated
@jacek-prisma

Copy link
Copy Markdown
Contributor

Since this is a pg specific issue, I think it might be better to address this in the pg adapter, for example by wrapping the client with a mutex like we do in some other adapters

@matingathani

Copy link
Copy Markdown
Contributor Author

Thanks for the direction — agreed, the adapter is the right place for this.

I looked at how adapter-planetscale and adapter-mssql handle it: both override performIO in their Transaction subclass and gate each call behind a #mutex = new Mutex() from async-mutex. The PgTransaction class in adapter-pg/src/pg.ts already extends PgQueryable<TransactionClient> and overrides commit/rollback, so the same pattern fits naturally — just add the mutex field and override performIO to acquire it before delegating to super.performIO.

That approach is strictly better than what I did in the runtime: the runtime doesn't need to know whether a queryable can handle concurrent calls, and keeping it pg-specific means other adapters are unaffected. I'll update the PR to revert the query-interpreter.ts change and apply the mutex in PgTransaction instead.

When `update` (or any write) with `include` relations is executed inside a
transaction, the `join` node in the query interpreter fires a `Promise.all`
over all child relation fetches. Each child eventually calls `queryRaw` on
`context.queryable`, which — inside a transaction — is a single `pg.PoolClient`
(not a pool). Concurrent `client.query()` calls on a single pg Client trigger:

  "Calling client.query() when the client is already executing a query is
  deprecated and will be removed in pg@9.0."

This becomes a hard error in pg@9.0.

Fix: detect whether `context.queryable` is a `Transaction` (via the `commit`
property on the `Transaction` interface) and, if so, fetch the join children
sequentially instead of in parallel. When using a connection pool the existing
parallel behaviour is preserved.

Fixes prisma#29407
@matingathani
matingathani force-pushed the fix/adapter-pg-concurrent-transaction-queries branch from dee0259 to b19be18 Compare April 16, 2026 20:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/adapter-pg/src/pg.ts (1)

138-159: 🧹 Nitpick | 🔵 Trivial

Mutex-based serialization correctly addresses the root cause.

Moving the fix from the runtime (query-interpreter.ts) into the adapter is the right layering: the runtime stays agnostic of connection concurrency semantics, and other adapters (which already handle this with their own mutex, e.g. PlanetScale) remain unaffected. The try/finally guarantees release() runs even when super.performIO throws via onError, so the mutex cannot be permanently held after a failed query.

One optional simplification: async-mutex exposes runExclusive which removes the need for the explicit acquire/try/finally boilerplate:

♻️ Optional refactor using runExclusive
-  protected async performIO(query: SqlQuery): Promise<pg.QueryArrayResult<any>> {
-    const release = await this.#mutex.acquire()
-    try {
-      return await super.performIO(query)
-    } finally {
-      release()
-    }
-  }
+  protected async performIO(query: SqlQuery): Promise<pg.QueryArrayResult<any>> {
+    return this.#mutex.runExclusive(() => super.performIO(query))
+  }

Note: unlike PlanetScaleTransaction.performIO, there's intentionally no catch here because super.performIO (in PgQueryable) already funnels errors through this.onError, which throws a DriverAdapterError. Re-catching would double-wrap the error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/adapter-pg/src/pg.ts` around lines 138 - 159, The current
PgTransaction.performIO uses explicit acquire/release on the `#mutex` which is
correct but can be simplified: replace the acquire/try/finally pattern in
PgTransaction.performIO with async-mutex's runExclusive to serialize calls,
i.e., call this.#mutex.runExclusive(() => super.performIO(query)) so you still
serialize access on the pg.PoolClient while removing manual release boilerplate;
keep the behavior that errors are not re-caught (super.performIO in PgQueryable
still funnels errors through this.onError).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/adapter-pg/src/pg.ts`:
- Around line 138-159: The current PgTransaction.performIO uses explicit
acquire/release on the `#mutex` which is correct but can be simplified: replace
the acquire/try/finally pattern in PgTransaction.performIO with async-mutex's
runExclusive to serialize calls, i.e., call this.#mutex.runExclusive(() =>
super.performIO(query)) so you still serialize access on the pg.PoolClient while
removing manual release boilerplate; keep the behavior that errors are not
re-caught (super.performIO in PgQueryable still funnels errors through
this.onError).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b66f5fb0-95ed-40a4-956d-23ea12472b1c

📥 Commits

Reviewing files that changed from the base of the PR and between dee0259 and b19be18.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • packages/adapter-pg/package.json
  • packages/adapter-pg/src/conversion.ts
  • packages/adapter-pg/src/pg.ts

@matingathani

Copy link
Copy Markdown
Contributor Author

@jacek-prisma could you approve the CI run when you get a chance? The workflows are blocked waiting for maintainer approval.

matingathani added a commit to matingathani/prisma that referenced this pull request Apr 17, 2026
Replace the manual acquire/try/finally/release pattern in
PgTransaction.performIO with async-mutex's built-in `runExclusive`, which
handles acquire and release internally.  Semantically identical to the
previous implementation — serialises all performIO calls on the underlying
pg.PoolClient — but removes boilerplate and avoids any risk of a missed
release on unexpected throws.

Addresses CodeRabbit suggestion on PR prisma#29468.
@matingathani

Copy link
Copy Markdown
Contributor Author

Addressed in the latest commit (7765742) — replaced the manual acquire()/try/finally/release() pattern with runExclusive(() => super.performIO(query)) as suggested. Serialisation behaviour is identical; runExclusive handles acquire and release internally, removing the boilerplate.

@jacek-prisma

Copy link
Copy Markdown
Contributor

Looks good, but I think the 4a9f3df commit is unrelated and shouldn't be in this PR

@matingathani

Copy link
Copy Markdown
Contributor Author

Good catch — that commit was unintentionally included. I'll squash it out in the next push.

pg.PoolClient does not support concurrent client.query() calls on a
single connection. Inside a transaction all relation-join child queries
were fired via Promise.all, hitting this limitation and triggering the
pg deprecation warning (hard error in pg@9.0).

Move the fix to the adapter layer, matching the pattern already used in
adapter-mssql and adapter-planetscale: PgTransaction overrides performIO
and gates each call behind a Mutex from async-mutex, so concurrent
callers are serialised at the connection level without touching the
runtime.

Revert the query-interpreter.ts change from the previous commit; the
runtime no longer needs to be aware of whether a queryable is a
transaction.

Fixes prisma#29407
Replace the manual acquire/try/finally/release pattern in
PgTransaction.performIO with async-mutex's built-in `runExclusive`, which
handles acquire and release internally.  Semantically identical to the
previous implementation — serialises all performIO calls on the underlying
pg.PoolClient — but removes boilerplate and avoids any risk of a missed
release on unexpected throws.

Addresses CodeRabbit suggestion on PR prisma#29468.
@matingathani
matingathani force-pushed the fix/adapter-pg-concurrent-transaction-queries branch from 7765742 to 0be9563 Compare May 20, 2026 04:11
@matingathani

Copy link
Copy Markdown
Contributor Author

Done — removed the unrelated commit. The branch now only contains the mutex-based serialization fix.

@tensordreams
tensordreams changed the base branch from main to v7 July 21, 2026 12:07
@tensordreams
tensordreams changed the base branch from v7 to main July 21, 2026 15:12
@tensordreams tensordreams changed the title fix(client-engine-runtime): serialize join children inside transactions to avoid concurrent pg queries fix(adapter-pg): serialize queries within a transaction Jul 23, 2026
@tensordreams

Copy link
Copy Markdown
Contributor

Merged latest main and normalized the lockfile: the branch had carried unrelated lockfile churn (TypeScript/turbo/globals/engines-version downgrades and a stray tslib bump). pnpm-lock.yaml now differs from main by only the async-mutex@0.5.0 entry in the packages/adapter-pg importer block.

The mutex is scoped to PgTransaction only — the standard pooled client (PrismaPgAdapter) is unaffected — and runExclusive releases the lock even when a query throws. Added test coverage for both serialization and mutex-release-on-error. pnpm --filter @prisma/adapter-pg test passes (53 tests), prettier and eslint clean on the changed files.

@leoamato10

Copy link
Copy Markdown

We're hitting this too, and can add two data points that widen the scope of the bug.

1. It's not limited to update with multiple includes — batch $transaction triggers it as well.

This fires the warning on every call:

const [courses, totalCount] = await prisma.$transaction([
  prisma.course.findMany({
    where,
    include: {
      teacher: { select: { name: true } },
      schedules: true,
      coursePriceAdjustment: {   // nested include with per-parent take
        include: { adjustment: true },
        orderBy: { adjustment: { appliedAt: "desc" } },
        take: 1,
      },
    },
    skip,
    take: pageSize,
  }),
  prisma.course.count({ where }),
]);

Captured with node --trace-deprecation:

DeprecationWarning: Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0.
    at Client.query (node_modules/pg/lib/client.js:715:7)
    at PgTransaction.performIO (@prisma/adapter-pg/dist/index.mjs)
    at PgTransaction.queryRaw (@prisma/adapter-pg/dist/index.mjs)
    at <query interpreter> (@prisma/client/runtime/client.js)
    at Array.map (<anonymous>)
    at e.interpretNode (@prisma/client/runtime/client.js)

The Array.map inside interpretNode dispatches the relation sub-queries concurrently onto the single pg Client held by PgTransaction, exactly as the OP suspected. Since paginated findMany + count inside a batch transaction is a very common pattern, this likely affects far more users than the update-with-includes case in the original report.

2. The "downgrade pg" workaround doesn't work. The deprecation warning is not new in pg 8.20 — we reproduced it on pg@8.19.0 as well (same message, with the old "asycn" typo). Pinning pg only trades warning text; the deprecated internal queueing is still what keeps things working, and that's what goes away in pg@9.

Confirming the fix in #29468 works: we applied an equivalent local patch via pnpm patch — serializing performIO on PgTransaction with a promise chain (same idea as the mutex in the PR, and as what adapter-mssql / adapter-planetscale already do):

// PgTransaction
constructor(...) { ...; this.queryChain = Promise.resolve(); }
async performIO(query) {
  const result = this.queryChain.then(() => super.performIO(query));
  this.queryChain = result.then(() => {}, () => {});
  return result;
}

With this in place the warning is gone across our full app (verified with --trace-deprecation while exercising transactions with nested includes, batch transactions, and interactive transactions). No perf downside in practice — Postgres executes queries on a single connection sequentially anyway, so pg was already queueing them internally.

Environment: prisma@7.9.0, @prisma/adapter-pg@7.9.0, pg@8.22.0 (also repro'd on 8.19.0), Node 24, PostgreSQL 17.

It would be great to see #29468 land — happy to test a dev release.

@tensordreams
tensordreams changed the base branch from main to v7 July 28, 2026 08:52
Comment on lines +136 to +138
// pg.PoolClient does not support concurrent queries on the same connection,
// so we serialize all performIO calls with a mutex.
#mutex = new Mutex()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move the mutex to the base PgQueryable and do the serialization logic there instead of overriding performIO here.

As the comment says, pg.PoolClient doesn't allow this generally, doesn't matter if it's within a transaction or not. Batch transactions are just how you happen to be able to observe the issue right now in practice but the issue is more general.

@gadcam

gadcam commented Aug 11, 2026

Copy link
Copy Markdown

Hey @matingathani & @tensordreams
Thanks for the work on this, and sorry to step on your toes: this is also showing up in my logs and this lessen trust in my DB operations since I upgraded to the latest version.
So I opened #29979 implementing @aqrln's suggestion (serialization in the base PgQueryable, with a carve-out so the pool path stays parallel) plus the regression tests requested in review.
If you'd rather carry this PR forward yourself, I'm happy to close mine or contribute the tests here instead.

One data point supporting the approach: the same fix, per-client serialization with the pool unaffected, already shipped in the next-gen driver on main in #29839

gadcam added a commit to gadcam/prisma that referenced this pull request Aug 13, 2026
A pg.PoolClient is a single connection and does not support concurrent
queries (deprecated in pg@8, an error in pg@9), but the query interpreter
loads sibling relations concurrently, so any query with 2+ relations
inside a transaction triggered the deprecation warning. Serialize
performIO in PgQueryable for single-connection clients, leaving the
pool path parallel.

Supersedes prisma#29468, fixes prisma#29407.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Camille Barneaud <1693643+gadcam@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

8 participants