Skip to content

Make AutoTimestampEventListener temporary disabling thread-safe (8.0.x)#15954

Merged
jdaugherty merged 4 commits into
apache:8.0.xfrom
codeconsole:fix/autotimestamp-thread-safety-8.0.x
Jul 10, 2026
Merged

Make AutoTimestampEventListener temporary disabling thread-safe (8.0.x)#15954
jdaugherty merged 4 commits into
apache:8.0.xfrom
codeconsole:fix/autotimestamp-thread-safety-8.0.x

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Fixes #14510 on 8.0.x. Identical to the 7.1.x port #15953 (this class is byte-identical on 7.1.x through 8.0.x — the cherry-pick applied with zero conflicts); see also the original 7.0.x fix #15952.

The bug

withoutTimestamps, withoutDateCreated and withoutLastUpdated disabled timestamping by mutating the listener-wide entity maps (capturing each entry and setting it to Optional.empty(), then restoring). That state is JVM-global, so overlapping windows on concurrent threads corrupt each other:

  1. Thread A enters: captures ON, sets OFF
  2. Thread B enters: captures OFF, sets OFF
  3. Thread A exits: restores ON
  4. Thread B's save now runs with timestamping — its historical dateCreated/lastUpdated values are silently clobbered

Reproduction: https://github.com/codeconsole/grails-autotimestamp-bug

A second facet: there was no try/finally, so an exception inside the closure left timestamping permanently disabled for the whole JVM.

The fix

The shared metadata maps are no longer mutated. Suppression is tracked per thread: the property-name getters consult a ThreadLocal holding an all-entities nesting depth plus the set of entity names disabled by per-class scopes. Each scope only re-enables the names it added, so nested and overlapping scopes restore correctly; try/finally guarantees restoration when the closure throws; the ThreadLocal is removed when empty so nothing is retained on pooled threads.

Public API, protected fields and single-thread semantics are unchanged (binary and source compatible). The CreatedBy/UpdatedBy auditing maps are untouched — no withoutXxx API covers them.

Merge coordination

Once this and #15953 are both merged, the routine 7.1.x → 7.2.x → 8.0.x cascade merges see identical content on both sides and resolve cleanly. The only conflicted hop is 7.0.x → 7.1.x after #15952, which resolves as keep-7.1.x's-listener.

Behavioral note

Disabling is now scoped to the calling thread. Threads spawned inside the closure are no longer affected — under the old global behavior they were (by accident). Code relying on that must call withoutTimestamps on each thread. The auto-timestamping guide now documents the thread scoping. Worth a mention in the release notes.

Tests

The spec exercises the listener through the public beforeInsert/beforeUpdate API, with tests that reproduce the bug (verified red on the unfixed listener on 7.0.x and 7.1.x, whose suppression code is identical to 8.0.x's):

All 248 tests in grails-datamapping-core pass on the 8.0.x toolchain (JDK 25/Groovy 5), as do the timestamp specs in grails-datamapping-core-test, grails-data-hibernate5-core and grails-test-suite-uber; Checkstyle and CodeNarc are clean.

Fixes apache#14510. The withoutTimestamps, withoutDateCreated and
withoutLastUpdated methods disabled timestamping by mutating the
listener-wide entity maps (capturing each entry and setting it to
Optional.empty()), which is JVM-global state. Overlapping windows on
concurrent threads corrupted each other: a thread leaving its window
re-enabled timestamping for every other thread still inside one, so
historical dateCreated/lastUpdated values were silently overwritten.
An exception inside the closure also left timestamping permanently
disabled because the previous state was restored without try/finally.

Suppression is now tracked per thread: the property-name getters
consult a ThreadLocal holding an all-entities nesting depth plus the
set of entity names disabled by per-class scopes. Each scope only
re-enables the names it added, so nested and overlapping scopes
restore correctly, and try/finally guarantees restoration when the
closure throws. The shared metadata maps are no longer mutated. The
CreatedBy/UpdatedBy auditing maps are untouched, as no withoutXxx
API covers them.

Behavioral note: disabling is now scoped to the calling thread;
threads spawned inside the closure are no longer affected. The GORM
auto-timestamping guide documents this.

The spec now exercises the listener through beforeInsert/beforeUpdate
and covers cross-thread isolation, overlapping windows, nested and
overlapping same-class scopes, exception restoration and concurrent
use.

Forward-port of the 7.0.x fix (apache#15952) to 8.0.x, identical to the
7.1.x port (apache#15953), as this class is identical on 7.1.x through 8.0.x.
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 49.6071%. Comparing base (c317eef) to head (43e59b4).

Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #15954        +/-   ##
==================================================
+ Coverage     49.5558%   49.6071%   +0.0514%     
- Complexity      16930      16951        +21     
==================================================
  Files            1999       1999                
  Lines           93753      93791        +38     
  Branches        16419      16421         +2     
==================================================
+ Hits            46460      46527        +67     
+ Misses          40131      40092        -39     
- Partials         7162       7172        +10     
Files with missing lines Coverage Δ
...astore/gorm/events/AutoTimestampEventListener.java 86.8778% <100.0000%> (+21.3041%) ⬆️

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty

Copy link
Copy Markdown
Contributor

@codeconsole I pushed some improvements (there were missing docs, dead code, and multi-threading could be broken by your change so I documented how to adjust the code)

@jdaugherty jdaugherty requested a review from jamesfredley July 10, 2026 15:26
@borinquenkid borinquenkid self-requested a review July 10, 2026 15:58

@borinquenkid borinquenkid left a comment

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.

the codecov flagged branches not covered by modifications. Can you execute them?

@codeconsole codeconsole requested review from matrei and sbglasius July 10, 2026 16:19
Two additions to the spec:

- Registering auditor properties through persistentEntityAdded and
  asserting CreatedBy/LastModifiedBy population on insert and update,
  which executes the storeAuditorAvailability body.

- Capturing a suppression snapshot inside a window opened with an
  empty class list, where the thread-local state is present but
  empty, which takes the isEmpty() branch of both TimestampSuppression
  constructor ternaries. Also pins that an empty class list disables
  nothing and that the resulting snapshot suppresses nothing.
@codeconsole

Copy link
Copy Markdown
Contributor Author

@borinquenkid done in 43e59b4 — two tests added:

  • The 2 missing lines were storeAuditorAvailability's body, which nothing in this module's spec executed. There's now a test registering @CreatedBy/@LastModifiedBy properties via persistentEntityAdded() and asserting the auditor is populated on insert/update.
  • The 2 partial branches were the isEmpty() legs of the TimestampSuppression constructor ternaries, reachable only by capturing inside a window opened with an empty class list — now tested, along with the (previously unpinned) behavior that an empty class list disables nothing.

Verified locally with JaCoCo: both lines fully executed, 4/4 branches covered on both ternaries. Codecov should report 100% patch coverage on the next run.

@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks @jdaugherty — reviewed, LGTM. Good catch on the grails-data-hibernate7 doc tree; I'd only updated hibernate5.

One semantic worth stating for the record: withTimestampSuppression replaces the thread's active suppression rather than merging (an empty snapshot temporarily re-enables timestamping inside an enclosing window). Javadoc and tests confirm it's intentional — fine by me.

Ready to merge from my side.

@borinquenkid borinquenkid left a comment

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.

LGTM

@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 43e59b4
▶️ Tests: 8396 executed
⚪️ Checks: 59/59 completed


Learn more about TestLens at testlens.app.

@jdaugherty jdaugherty merged commit 81aa7c2 into apache:8.0.x Jul 10, 2026
107 of 108 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

GRAILS 6+ BUG: autoTimestampEventListener.without* Results in Unexpected Behavior Across All Threads

3 participants