Skip to content

perf(orm): avoid unnecessary pre-mutation read and transactions#2484

Open
ymc9 wants to merge 1 commit intodevfrom
refactor/avoid-tx
Open

perf(orm): avoid unnecessary pre-mutation read and transactions#2484
ymc9 wants to merge 1 commit intodevfrom
refactor/avoid-tx

Conversation

@ymc9
Copy link
Member

@ymc9 ymc9 commented Mar 14, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced atomicity guarantees for nested and delegate model operations during create, update, and delete workflows.
  • Bug Fixes

    • Improved timestamp handling in PostgreSQL.
    • Better transaction and rollback management for complex relational operations.
    • More consistent return value semantics for update operations.
  • Tests

    • Added comprehensive atomicity test suite covering nested and delegate model scenarios.
    • Various test cleanup and improvements.

@claude
Copy link
Contributor

claude bot commented Mar 14, 2026

Claude finished @ymc9's task —— View job


PR Review: Performance Optimization for ORM Transactions

Todo List:

  • Read repository guidelines (CLAUDE.md)
  • Analyze core ORM operation changes (base.ts, create.ts, update.ts, delete.ts)
  • Review PostgreSQL dialect modifications
  • Examine new atomicity test coverage
  • Check for potential data consistency issues
  • Review transaction handling changes
  • Provide comprehensive feedback on code quality and security

Summary

This PR implements an excellent performance optimization that conditionally avoids unnecessary transactions and pre-mutation reads in the ORM layer. The approach is well-designed and maintains data consistency while significantly reducing overhead for simple operations.

Strengths

Smart Transaction Management

  • Excellent Design: safeTransactionIf() only creates transactions when needed, reducing overhead for simple operations
  • Maintains Safety: Complex operations (nested creates/updates, delegate models) still properly use transactions
  • Proper Nesting: Already handles nested transactions correctly with kysely.isTransaction check

Intelligent Read-Back Detection

The mutationNeedsReadBack() logic (packages/orm/src/client/crud/operations/base.ts:2577) is comprehensive:

  • ✅ Correctly handles policy enforcement requirements
  • ✅ Database dialect capabilities (supportsReturning)
  • ✅ Relation field selections (include operations)
  • ✅ Computed field requirements
  • ✅ Polymorphic model support

Comprehensive Test Coverage

The new atomicity tests (tests/e2e/orm/client-api/atomicity.test.ts) provide excellent coverage:

  • ✅ Nested create rollback scenarios
  • ✅ Deep nesting validation
  • ✅ Update with nested operations
  • ✅ Multiple failure points testing

Clean Architecture

  • ✅ Well-separated concerns with dedicated methods for each operation type
  • ✅ Consistent patterns across create/update/delete operations
  • ✅ Proper inheritance and code reuse

🔍 Code Quality Observations

Minor Improvement Opportunities

  1. TODO Comment in Create Operation (packages/orm/src/client/crud/operations/create.ts:32):

    // TODO: avoid using transaction for simple create

    This TODO appears to be partially addressed by this PR, consider updating or removing it.

  2. Method Complexity: The mutationNeedsReadBack method is quite long (58 lines). While functional, consider breaking it into smaller helper methods for better maintainability.

  3. Duplicate Logic: The nested operation detection methods (needsNestedCreate, needsNestedUpdate, needsNestedDelete) have similar patterns for delegate model checking. Consider extracting this into a shared helper.

🛡️ Security & Data Consistency

Excellent Safety Measures

  • Transaction Atomicity: Maintains ACID properties for operations that need them
  • Policy Enforcement: Always uses read-back when policies are enabled
  • Cascade Operations: Properly handles delegate model operations with transactions
  • Error Handling: Comprehensive error handling with proper rollback behavior

No Security Concerns Identified

  • ✅ No SQL injection risks introduced
  • ✅ No privilege escalation possibilities
  • ✅ Maintains existing access control patterns

🚀 Performance Impact

Expected Improvements

  • Significant reduction in unnecessary transaction overhead for simple operations
  • Reduced database load from eliminated pre-mutation reads
  • Better scalability under high load scenarios
  • Maintained safety for complex operations

No Performance Regressions

The conditional logic adds minimal overhead while providing substantial gains for the common case of simple mutations.

🧪 Test Coverage Assessment

The atomicity tests are comprehensive and cover critical scenarios:

  • ✅ Single and nested operation failures
  • ✅ Deep nesting with multiple failure points
  • ✅ Proper rollback verification
  • ✅ Data integrity validation after failures

Final Recommendation: ✅ APPROVED

This is an excellent performance optimization that:

  1. Maintains data integrity and transaction semantics
  2. Provides significant performance benefits for common operations
  3. Has comprehensive test coverage for edge cases
  4. Follows clean architecture principles
  5. Introduces no security vulnerabilities

The implementation demonstrates deep understanding of database transaction semantics and ORM optimization patterns. The conditional transaction approach is exactly what you'd want in a production ORM.


@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

📝 Walkthrough

Walkthrough

Introduces lazy-loading for update operations with improved return semantics, adds nested operation detection for conditional transaction wrapping across create/update/delete handlers, refactors PostgreSQL timezone parsing, implements hook-aware transaction management in the query executor, removes a public API getter from PolicyHandler, and adds comprehensive atomicity test coverage.

Changes

Cohort / File(s) Summary
ORM Operation Handlers
packages/orm/src/client/crud/operations/base.ts, packages/orm/src/client/crud/operations/create.ts, packages/orm/src/client/crud/operations/update.ts, packages/orm/src/client/crud/operations/delete.ts
Introduces lazy-loading via loadThisEntity for updates, adds needsNested* helpers to detect nested creates/updates/deletes and relation fields, replaces unconditional safeTransaction with safeTransactionIf for conditional wrapping, refines post-update return semantics (boolean success path when no fields requested), enhances read-back field selection to use idFields for dialects lacking RETURNING support, and adds protected safeTransactionIf utility method for optional transaction wrapping.
Core Infrastructure
packages/orm/src/client/crud/dialects/postgresql.ts, packages/orm/src/client/executor/zenstack-query-executor.ts
Centralizes PostgreSQL timezone adjustment via dedicated fixTimezone helper for TIMESTAMP and TIMESTAMP ARRAY parsing with fallback resilience, introduces hasOnKyselyHooks getter to detect plugin hooks, and gates transaction wrapping in mutations on presence of Kysely hooks or after-mutation hooks via needEnsureTx flag.
Policy Plugin
packages/plugins/policy/src/policy-handler.ts
Removes public kysely getter from PolicyHandler class.
Test Suite
tests/e2e/orm/client-api/atomicity.test.ts, tests/e2e/orm/client-api/delegate.test.ts, tests/e2e/orm/client-api/type-coverage.test.ts, tests/e2e/orm/client-api/update.test.ts, tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts, tests/e2e/orm/policy/migrated/nested-to-many.test.ts, tests/e2e/orm/policy/now-function.test.ts, tests/e2e/tsconfig.json
Adds comprehensive atomicity test suite covering nested/delegate operations and rollback scenarios, simplifies delegate test setup and imports, removes provider guard from array-type test, adds semicolons to update test statements, removes debug logging, reorders relation includes in policy tests, updates policy test to pass explicit createdAt value, and expands tsconfig include pattern.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 With lazy loads and nested threads,
Transactions wrap where safety treads,
Atomicity guards each create and delete,
From base to leaf, the logic's complete!
Hop forward bold, your data's secure—
Nested mutations now endure! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the changes: optimizing performance by eliminating unnecessary pre-mutation reads and transactions through conditional transaction wrapping and lazy-loading patterns.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/avoid-tx
📝 Coding Plan
  • Generate coding plan for human review comments

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
tests/e2e/orm/policy/now-function.test.ts (1)

100-123: ⚠️ Potential issue | 🟡 Minor

Test intent and assertion path are now misaligned.

Line 122 passes an explicit createdAt, so this case no longer verifies the “default now()” behavior described by the test name/comment (Line 100 and Line 120). Please either rename/update the description or add a separate assertion that omits createdAt to keep default-path coverage explicit.

Suggested minimal wording fix
-it('combines now() default with auth in create policy', async () => {
+it('combines now() with auth in create policy', async () => {
@@
-        // authenticated user with auto-filled createdAt - allowed
+        // authenticated user with explicit createdAt <= now() - allowed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/orm/policy/now-function.test.ts` around lines 100 - 123, The test
currently passes an explicit createdAt which prevents verifying Log.createdAt
`@default`(now()) behavior; update the test that uses createPolicyTestClient and
db.$setAuth({ id: 1 }).log.create to include an additional assertion that omits
createdAt (e.g. call db.$setAuth({ id: 1 }).log.create({ data: { message: 'test'
} }) and assert it resolves and that the returned createdAt is recent), or
alternatively rename the test to reflect that it supplies createdAt — but prefer
adding the separate no-createdAt assertion to keep default(now()) coverage for
the Log model and @@allow('create', createdAt <= now() && auth() != null)
policy.
🧹 Nitpick comments (1)
tests/e2e/orm/client-api/atomicity.test.ts (1)

28-37: Consider tightening scenario-specific failure assertions.

For tests explicitly targeting unique-constraint rollback (e.g., Line 28 and similar cases in this file), .rejects.toThrow() is broad and may pass on unrelated errors. Prefer asserting duplicate/constraint semantics (code or message pattern) to keep these tests high-signal.

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

In `@tests/e2e/orm/client-api/atomicity.test.ts` around lines 28 - 37, The test
currently uses a broad .rejects.toThrow() for the client.post.create call in
atomicity.test.ts; narrow this to assert the unique-constraint failure
specifically by checking the error code/message instead (e.g., assert Prisma's
unique-constraint error like code "P2002" or a message regex such as /Unique
constraint failed|duplicate/i). Update the expectation around
client.post.create({ data: { title: 'Post1', author: { create: { email:
'u1@test.com' } } } }) to assert the specific duplicate/constraint semantics
(for example using .rejects.toMatchObject({ code: 'P2002' }) or
.rejects.toThrow(/unique|P2002/i/regexp/), depending on how errors are surfaced
in this test harness) so the test only passes for the intended
rollback/constraint scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/crud/operations/delete.ts`:
- Around line 65-68: needsNestedDelete currently returns true only when
modelDef.baseModel is set, but it must also detect when
BaseOperationHandler.delete() will recurse via processDelegateRelationDelete();
update needsNestedDelete() to return true if the model has any
delegate/cascading relations (e.g., check modelDef.relations or
modelDef.delegateRelations for entries with delegate=true or onDelete ===
'cascade') so that runDelete and runDeleteMany keep these cases inside the
nested/transactional path; reference needsNestedDelete(),
BaseOperationHandler.delete(), processDelegateRelationDelete(), runDelete and
runDeleteMany when making this change.

In `@packages/orm/src/client/executor/zenstack-query-executor.ts`:
- Around line 142-155: The auto-transaction gating variable needEnsureTx
currently considers only this.hasOnKyselyHooks and
this.hasEntityMutationPluginsWithAfterMutationHooks; include plugins that define
before-entity-mutation hooks so those hooks run inside the ensured transaction
as well. Change the definition of needEnsureTx to also OR in the before-mutation
flag (e.g. this.hasEntityMutationPluginsWithBeforeMutationHooks) so the
subsequent mutation branch (in provideConnection / isMutationNode /
driver.isTransactionConnection) will start a transaction when
beforeEntityMutation hooks are present.

---

Outside diff comments:
In `@tests/e2e/orm/policy/now-function.test.ts`:
- Around line 100-123: The test currently passes an explicit createdAt which
prevents verifying Log.createdAt `@default`(now()) behavior; update the test that
uses createPolicyTestClient and db.$setAuth({ id: 1 }).log.create to include an
additional assertion that omits createdAt (e.g. call db.$setAuth({ id: 1
}).log.create({ data: { message: 'test' } }) and assert it resolves and that the
returned createdAt is recent), or alternatively rename the test to reflect that
it supplies createdAt — but prefer adding the separate no-createdAt assertion to
keep default(now()) coverage for the Log model and @@allow('create', createdAt
<= now() && auth() != null) policy.

---

Nitpick comments:
In `@tests/e2e/orm/client-api/atomicity.test.ts`:
- Around line 28-37: The test currently uses a broad .rejects.toThrow() for the
client.post.create call in atomicity.test.ts; narrow this to assert the
unique-constraint failure specifically by checking the error code/message
instead (e.g., assert Prisma's unique-constraint error like code "P2002" or a
message regex such as /Unique constraint failed|duplicate/i). Update the
expectation around client.post.create({ data: { title: 'Post1', author: {
create: { email: 'u1@test.com' } } } }) to assert the specific
duplicate/constraint semantics (for example using .rejects.toMatchObject({ code:
'P2002' }) or .rejects.toThrow(/unique|P2002/i/regexp/), depending on how errors
are surfaced in this test harness) so the test only passes for the intended
rollback/constraint scenario.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bc21a6ee-604f-482a-835e-1b39f51a7539

📥 Commits

Reviewing files that changed from the base of the PR and between 7363096 and ccc960d.

📒 Files selected for processing (15)
  • packages/orm/src/client/crud/dialects/postgresql.ts
  • packages/orm/src/client/crud/operations/base.ts
  • packages/orm/src/client/crud/operations/create.ts
  • packages/orm/src/client/crud/operations/delete.ts
  • packages/orm/src/client/crud/operations/update.ts
  • packages/orm/src/client/executor/zenstack-query-executor.ts
  • packages/plugins/policy/src/policy-handler.ts
  • tests/e2e/orm/client-api/atomicity.test.ts
  • tests/e2e/orm/client-api/delegate.test.ts
  • tests/e2e/orm/client-api/type-coverage.test.ts
  • tests/e2e/orm/client-api/update.test.ts
  • tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts
  • tests/e2e/orm/policy/migrated/nested-to-many.test.ts
  • tests/e2e/orm/policy/now-function.test.ts
  • tests/e2e/tsconfig.json
💤 Files with no reviewable changes (3)
  • tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts
  • packages/plugins/policy/src/policy-handler.ts
  • tests/e2e/orm/client-api/type-coverage.test.ts

Comment on lines +65 to +68
private needsNestedDelete() {
const modelDef = this.requireModel(this.model);
return !!modelDef.baseModel;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Treat delegate-cascade deletes as nested work too.

Line 67 only checks baseModel, but BaseOperationHandler.delete() can also recurse through processDelegateRelationDelete() before issuing the final delete. In that case runDelete and runDeleteMany now skip the transaction and can commit the recursive delete even if the last statement fails.

🛠️ Suggested fix
     private needsNestedDelete() {
         const modelDef = this.requireModel(this.model);
-        return !!modelDef.baseModel;
+        if (modelDef.baseModel) {
+            return true;
+        }
+
+        return Object.values(modelDef.fields).some((fieldDef) => {
+            if (!fieldDef.relation?.opposite) {
+                return false;
+            }
+
+            const oppositeModelDef = this.getModel(fieldDef.type);
+            if (!oppositeModelDef?.baseModel) {
+                return false;
+            }
+
+            const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite);
+            return oppositeRelation.relation?.onDelete === 'Cascade';
+        });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/crud/operations/delete.ts` around lines 65 - 68,
needsNestedDelete currently returns true only when modelDef.baseModel is set,
but it must also detect when BaseOperationHandler.delete() will recurse via
processDelegateRelationDelete(); update needsNestedDelete() to return true if
the model has any delegate/cascading relations (e.g., check modelDef.relations
or modelDef.delegateRelations for entries with delegate=true or onDelete ===
'cascade') so that runDelete and runDeleteMany keep these cases inside the
nested/transactional path; reference needsNestedDelete(),
BaseOperationHandler.delete(), processDelegateRelationDelete(), runDelete and
runDeleteMany when making this change.

Comment on lines +142 to +155
// needs to ensure transaction if we:
// - have plugins with Kysely hooks, as they may spawn more queries (check: should creating tx be plugin's responsibility?)
// - have entity mutation plugins that consume post-mutation entities
const needEnsureTx = this.hasOnKyselyHooks || this.hasEntityMutationPluginsWithAfterMutationHooks;

const result = await this.provideConnection(async (connection) => {
let startedTx = false;
try {
// mutations are wrapped in tx if not already in one
if (this.isMutationNode(compiledQuery.query) && !this.driver.isTransactionConnection(connection)) {
if (
this.isMutationNode(compiledQuery.query) &&
!this.driver.isTransactionConnection(connection) &&
needEnsureTx
) {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Include beforeEntityMutation hooks in needEnsureTx.

Line 145 now skips the auto-transaction when a plugin only has beforeEntityMutation. Those hooks still run later with a connection-scoped client, so any writes they perform can commit independently if the main mutation then fails or rolls back. That opens a partial-commit path for mutation hooks.

🛠️ Suggested fix
+    private get hasEntityMutationHooks() {
+        return (this.client.$options.plugins ?? []).some(
+            (plugin) =>
+                plugin.onEntityMutation?.beforeEntityMutation || plugin.onEntityMutation?.afterEntityMutation,
+        );
+    }
+
     override async executeQuery(compiledQuery: CompiledQuery) {
         // proceed with the query with kysely interceptors
         // if the query is a raw query, we need to carry over the parameters
         const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined;
 
         // needs to ensure transaction if we:
         // - have plugins with Kysely hooks, as they may spawn more queries (check: should creating tx be plugin's responsibility?)
         // - have entity mutation plugins that consume post-mutation entities
-        const needEnsureTx = this.hasOnKyselyHooks || this.hasEntityMutationPluginsWithAfterMutationHooks;
+        const needEnsureTx = this.hasOnKyselyHooks || this.hasEntityMutationHooks;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// needs to ensure transaction if we:
// - have plugins with Kysely hooks, as they may spawn more queries (check: should creating tx be plugin's responsibility?)
// - have entity mutation plugins that consume post-mutation entities
const needEnsureTx = this.hasOnKyselyHooks || this.hasEntityMutationPluginsWithAfterMutationHooks;
const result = await this.provideConnection(async (connection) => {
let startedTx = false;
try {
// mutations are wrapped in tx if not already in one
if (this.isMutationNode(compiledQuery.query) && !this.driver.isTransactionConnection(connection)) {
if (
this.isMutationNode(compiledQuery.query) &&
!this.driver.isTransactionConnection(connection) &&
needEnsureTx
) {
// needs to ensure transaction if we:
// - have plugins with Kysely hooks, as they may spawn more queries (check: should creating tx be plugin's responsibility?)
// - have entity mutation plugins that consume post-mutation entities
const needEnsureTx = this.hasOnKyselyHooks || this.hasEntityMutationHooks;
const result = await this.provideConnection(async (connection) => {
let startedTx = false;
try {
// mutations are wrapped in tx if not already in one
if (
this.isMutationNode(compiledQuery.query) &&
!this.driver.isTransactionConnection(connection) &&
needEnsureTx
) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/orm/src/client/executor/zenstack-query-executor.ts` around lines 142
- 155, The auto-transaction gating variable needEnsureTx currently considers
only this.hasOnKyselyHooks and
this.hasEntityMutationPluginsWithAfterMutationHooks; include plugins that define
before-entity-mutation hooks so those hooks run inside the ensured transaction
as well. Change the definition of needEnsureTx to also OR in the before-mutation
flag (e.g. this.hasEntityMutationPluginsWithBeforeMutationHooks) so the
subsequent mutation branch (in provideConnection / isMutationNode /
driver.isTransactionConnection) will start a transaction when
beforeEntityMutation hooks are present.

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.

1 participant