fix: preserve full transaction attribute state in CustomizableRollbackTransactionAttribute copy constructors - #16063
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes lossy copy-constructor behavior in CustomizableRollbackTransactionAttribute so that rule-bearing TransactionAttribute/TransactionDefinition inputs preserve rollback rules and attribute metadata (qualifier/labels/descriptor/timeoutString), and so copying does not mutate the source object. This aligns programmatic transaction APIs (e.g., withTransaction, withNewTransaction, GrailsTransactionTemplate) with the caller-provided rollback semantics.
Changes:
- Reworked
CustomizableRollbackTransactionAttributecopy constructors to preserve full attribute state (including rollback rules, qualifier/labels, descriptor/timeoutString, and Grails-specific fields). - Fixed Java trace logging placeholders and guarded the remaining trace call.
- Added unit + integration-style Spock specs to validate deep-copy behavior and end-to-end commit/rollback outcomes via public APIs.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttribute.java | Makes copy construction non-lossy and non-mutating; preserves Spring attribute state + Grails custom fields; fixes trace logging. |
| grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/transactions/CustomizableRollbackTransactionAttributeSpec.groovy | Adds comprehensive unit coverage for copy semantics, defensive copies, and rollback rule behavior. |
| grails-datamapping-core/src/test/groovy/grails/gorm/transactions/TransactionRollbackRulePropagationSpec.groovy | Verifies rollback-rule propagation through public programmatic transaction APIs using a real DataSourceTransactionManager. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The TestLens failures for Fix: #16067 (replaces the |
|
The CI failures on this PR (
Both assert on a This branch depends on #16067 ( |
…kTransactionAttribute copy constructors
The RuleBasedTransactionAttribute and CustomizableRollbackTransactionAttribute
overloads now delegate to Spring's own copy constructor (super(other)),
which snapshots the rule list from the field without invoking the
source's lazy getRollbackRules() (which would mutate the source by
assigning a new list into it). The TransactionDefinition and
TransactionAttribute overloads recover the dynamic type and snapshot
rules through a temporary Spring copy, so the source is never mutated
on any path.
All paths now explicitly carry the attribute-level state that
Spring 7's DefaultTransactionAttribute copy constructor does not:
descriptor, timeoutString, qualifier, and labels (defensively copied,
since setLabels stores the given reference), plus connection and
inheritRollbackOnly.
Also fixes GString-style placeholders ("$ex", "$winner") in trace
logging that never interpolated in this .java source, and guards the
remaining trace call behind isTraceEnabled().
Covered by CustomizableRollbackTransactionAttributeSpec (copy
independence and state preservation for every constructor dispatch
path) and TransactionRollbackRulePropagationSpec (behavior through
GrailsTransactionTemplate and DefaultTransactionService, verifying
NoRollbackRuleAttribute rules survive the conversion).
Split out of the GormRegistry consolidation per review on apache#15779.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4042a87 to
a1594a5
Compare
✅ All tests passed ✅🏷️ Commit: a1594a5 Learn more about TestLens at testlens.app. |
fix: preserve full transaction attribute state in CustomizableRollbackTransactionAttribute copy constructors
Split out of the GormRegistry consolidation per review discussion on #15779 (this class's copy semantics are an independent bug fix and deserve their own review).
Problem
The copy constructors of
CustomizableRollbackTransactionAttributewere lossy:TransactionAttribute/TransactionDefinitionoverloads copied only the fiveTransactionDefinitionfields (propagation, isolation, timeout, readOnly, name) — rollback rules, qualifier, labels, descriptor, andtimeoutStringwere silently dropped. In practice this meant aNoRollbackRuleAttributeconfigured on an attribute passed intoGrailsTransactionTemplateorTransactionService.withTransaction/withNewTransactionwas ignored: every exception rolled back regardless.RuleBasedTransactionAttributeoverload copied almost nothing — not even the definition fields — because it never calledsuper(other).copyFromhelper also mutated the source object: callinggetRollbackRules()on aRuleBasedTransactionAttributelazily installs a new list into it, andsetLabels(other.getLabels())aliased the label collection between source and copy..javafile used GString-style placeholders ("$ex","$winner") that never interpolate in Java.Fix
RuleBasedTransactionAttribute/CustomizableRollbackTransactionAttributeoverloads now delegate to Spring's own copy constructor viasuper(other), which snapshots the rule list from the field (never the lazily-materializing getter).TransactionDefinition/TransactionAttributeoverloads copy the definition fields and then recover the dynamic type: rules are snapshotted through a temporaryRuleBasedTransactionAttributecopy (so the source is never mutated), and attribute-level state is copied explicitly.descriptor,timeoutString, andqualifierare carried over explicitly — as of Spring Framework 7,DefaultTransactionAttribute(TransactionAttribute)copies only theTransactionDefinitionfields, so these would otherwise be lost.labelsgets a defensive copy (setLabelsstores the given reference).connectionandinheritRollbackOnlysurvive every copy path when the source is aCustomizableRollbackTransactionAttribute.isTraceEnabled().Behavior change (release-note material)
@Transactional/@ReadOnly/@Rollbacksemantics are unchanged — the AST transform builds the attribute directly and never goes through these copy constructors.For programmatic API users: an application that passes its own rule-bearing
TransactionAttribute(e.g. aRuleBasedTransactionAttributewithNoRollbackRuleAttributes) intoDomainClass.withTransaction(definition),TransactionService.withTransaction(definition), orwithNewTransaction(definition)will now have those rules honored: an exception matching a no-rollback rule commits the transaction (the exception still propagates). Previously the rules were silently dropped and every exception rolled back. The change is one-directional — no scenario turns a commit into a rollback; with no matching rule the class still rolls back on every exception, checked or unchecked, as documented.Suggested upgrade note:
Tests
CustomizableRollbackTransactionAttributeSpec(13 tests): deep-copy independence of rule lists and labels in both directions, non-mutation of the source's internal rule list, preservation of every definition- and attribute-level property across all four constructor dispatch paths (including statically-dispatchedTransactionDefinition/TransactionAttributeentry points via@CompileStatichelpers), and the rollback-on-everything default.TransactionRollbackRulePropagationSpec(4 tests): behavior through the public APIs —GrailsTransactionTemplate.executeandDefaultTransactionService.withNewTransactionagainst a real H2DataSourceTransactionManager— asserting commit-vs-rollback outcomes andREQUIRES_NEWpropagation.All fix-specific assertions fail against the previous implementation (verified — the tests are not vacuous).
Known follow-up (out of scope)
grails.gorm.transactions.GrailsTransactionAttribute(grails-datamapping-core) andorg.grails.transaction.GrailsTransactionAttribute(grails-core) contain the same lossy copy-constructor pattern on theirTransactionAttribute/TransactionDefinitionoverloads. Fixing them belongs in a separate change with its own tests.🤖 Generated with Claude Code