Skip to content

fix(qc): don't wrap single-statement plans in a transaction - #5840

Merged
aqrln merged 3 commits into
prisma:mainfrom
gordyf:fix/single-statement-transaction-unwrap
Jul 27, 2026
Merged

fix(qc): don't wrap single-statement plans in a transaction#5840
aqrln merged 3 commits into
prisma:mainfrom
gordyf:fix/single-statement-transaction-unwrap

Conversation

@gordyf

@gordyf gordyf commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Fixes prisma/prisma#29748. Supersedes prisma/prisma#29759, following the feedback there that this belongs in Expression::simplify rather than in the query interpreter.

Problem

The query graphs for updateMany and createMany report needs_transaction() == true, so their plans get wrapped in a Transaction expression even though they translate to exactly one SQL statement. A single statement is atomic on its own, so the wrapper only adds BEGIN/COMMIT round-trips — and it makes both operations fail hard on driver adapters without transaction support (e.g. PrismaNeonHttp), while the structurally identical deleteMany executes its single DELETE directly:

Error: Transactions are not supported in HTTP mode
    at PrismaNeonHttpAdapter.startTransaction

Solution

  • Expression::simplify now unwraps Transaction(expr) when expr contains at most one statement node (Query/Execute). The new max_statement_count helper computes an upper bound over the expression tree, taking the maximum over mutually exclusive If branches and treating nested Transaction expressions as unbounded.
  • translate() wraps the root in Transaction before the simplify pass (previously after), so the new simplification can see the wrapper. Plans with two or more statements keep the transaction exactly as before.

Note on chunking: a single chunkable statement (e.g. a large createMany exceeding the bind-parameter limit) may still be split into multiple queries by the client at runtime, and those chunks now run without a transaction. This matches how chunked deleteMany and read queries — which are never wrapped in a transaction — behave today; a comment in simplify documents this.

Tests

  • Added an update-many case to the query translation snapshot tests (there was none).
  • Updated create-many and create-many-and-return snapshots: the transaction wrapper around their single statement is gone; multi-statement snapshots (nested writes, m2m, etc.) are unchanged.
  • Verified end-to-end by building the query compiler Wasm from this branch, linking it into prisma/prisma (unmodified main), and running a generated client against a driver adapter whose startTransaction rejects: createMany/updateMany now succeed, and explicit $transaction([...]) still fails with the adapter's error as documented.

🤖 Generated with Claude Code

The query graph for updateMany and createMany is marked as requiring a
transaction, so their plans were wrapped in a Transaction expression
even though they translate to exactly one SQL statement. A single
statement is atomic on its own, so the wrapper only added BEGIN/COMMIT
round-trips — and made these operations fail on driver adapters without
transaction support (e.g. Neon over HTTP), while the structurally
identical deleteMany executed its single DELETE directly.

Unwrap Transaction expressions in Expression::simplify when the inner
expression contains at most one statement node. To let the new
simplification see the wrapper, translate() now wraps the root before
the simplify pass instead of after it.

Fixes prisma/prisma#29748

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 22, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0814451b-3a79-465f-9745-dbc56e92414e

📥 Commits

Reviewing files that changed from the base of the PR and between 9b85a55 and c9ffb72.

📒 Files selected for processing (1)
  • query-compiler/query-compiler/src/expression.rs

Summary by CodeRabbit

  • Performance

    • Reduced unnecessary transaction overhead by removing transaction wrappers for plans that evaluate to zero or one database statement, while preserving atomic single-statement behavior.
  • Bug Fixes

    • Improved transaction handling in the generated query plans, ensuring simplification occurs after transaction wrapping for more consistent behavior across conditional and composite expressions.
  • Tests

    • Added/updated fixtures for an updateMany operation on the user model filtered by email and setting their role to administrator.

Walkthrough

The query compiler now calculates an upper bound for database statement nodes across expression trees. Transaction expressions are simplified to Unit when their inner plans contain at most one statement. Root translation now applies transaction wrapping before final simplification. The update-many.json fixture defines a User updateMany request with an email filter, an ADMIN role update, and scalar selection.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: skipping transactions for single-statement plans.
Description check ✅ Passed The description is directly related to the query-compiler transaction simplification changes.
Linked Issues check ✅ Passed The PR addresses #29748 by unwrapping single-statement Transaction plans and preserving multi-statement transactions.
Out of Scope Changes check ✅ Passed The changes shown are scoped to the transaction fix and matching snapshot test updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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.

coderabbitai[bot]
coderabbitai Bot previously requested changes Jul 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@query-compiler/query-compiler/src/expression.rs`:
- Around line 262-267: Preserve transactions for chunkable Execute operations by
representing their statement count as potentially multiple or unbounded, or by
propagating explicit chunkability metadata. In
query-compiler/query-compiler/src/expression.rs lines 262-267, update
max_statement_count for Execute; in lines 209-220, remove the transaction only
when the execution-level bound—not merely the AST-node count—proves exactly one
statement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a054c1ce-8e58-4ab3-931c-c36eee4342dd

📥 Commits

Reviewing files that changed from the base of the PR and between e922089 and 9b85a55.

⛔ Files ignored due to path filters (3)
  • query-compiler/query-compiler/tests/snapshots/queries__queries@create-many-and-return.json.snap is excluded by !**/*.snap
  • query-compiler/query-compiler/tests/snapshots/queries__queries@create-many.json.snap is excluded by !**/*.snap
  • query-compiler/query-compiler/tests/snapshots/queries__queries@update-many.json.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • query-compiler/query-compiler/src/expression.rs
  • query-compiler/query-compiler/src/translate.rs
  • query-compiler/query-compiler/tests/data/update-many.json

Comment thread query-compiler/query-compiler/src/expression.rs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 11 untouched benchmarks
⏩ 11 skipped benchmarks1


Comparing gordyf:fix/single-statement-transaction-unwrap (acf5a38) with main (059a7b2)

Open in CodSpeed

Footnotes

  1. 11 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@aqrln

aqrln commented Jul 23, 2026

Copy link
Copy Markdown
Member

Sorry for the broken CI @gordyf, it looks like some recent changes in the main repo might have broken it. I'll have a look.

@aqrln aqrln added this to the 7.10.0 milestone Jul 27, 2026
@aqrln
aqrln merged commit 9d90ce2 into prisma:main Jul 27, 2026
92 checks passed
aqrln pushed a commit to prisma/prisma that referenced this pull request Jul 27, 2026
…29771)

Part of #29748. Implements the query interpreter side of
[the approach agreed in
prisma/prisma-engines#5840](prisma/prisma-engines#5840 (comment)):
the query compiler stops wrapping plans in a transaction just because
they contain a chunkable statement, and instead the interpreter starts a
transaction at runtime iff the statement was actually split into
multiple queries.

## Problem

Whether a chunkable statement splits cannot be decided at plan
compilation time: it depends on the number of bound values (hidden
behind a placeholder) and on the adapter's `maxBindValues`, both only
known at runtime. Today the chunks of a split statement run without any
transaction for `deleteMany` and reads, which:

- leaves earlier chunks committed if a later chunk fails (partial
write), and
- lets concurrent readers observe a half-applied `deleteMany`, and
violates repeatable-read semantics for chunked reads on e.g. MySQL.

Once prisma/prisma-engines#5840 lands, single-statement
`createMany`/`updateMany` plans lose their compile-time `transaction`
wrapper too (so they work on adapters without transaction support),
which extends the same gap to their chunked executions.

## Solution

`QueryInterpreter` now wraps the rendered queries of a `query` or
`execute` node in an internal transaction when:

- rendering produced **more than one** chunk, and
- the plan is not already executing inside a transaction — neither an
interactive transaction (`transactionManager: { enabled: false }`) nor
an enclosing `transaction` plan node (tracked via a new
`insideTransaction` context flag).

Single-chunk statements keep executing directly on the current
queryable, so the common case is unchanged and keeps working on adapters
without transaction support (e.g. Neon HTTP). A multi-chunk statement on
such an adapter will now fail up front instead of applying a partial,
non-atomic write.

This fix stands on its own (it fixes chunked `deleteMany` and chunked
reads on current `main`) and complements prisma/prisma-engines#5840
without depending on it.

## Tests

Added unit tests in `query-interpreter.test.ts` covering: chunked
`execute` and `query` run inside a transaction and commit; rollback when
a later chunk fails; no transaction for single-chunk statements; no
nested transaction when inside a `transaction` plan node or an
interactive transaction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexey Orlenko's AI Agent <robot@aqrln.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query compiler wraps single-statement updateMany/createMany in an internal transaction, breaking transaction-less adapters (PrismaNeonHttp)

3 participants