Skip to content

Make AutoTimestampEventListener temporary disabling thread-safe#15952

Closed
codeconsole wants to merge 1 commit into
apache:7.0.xfrom
codeconsole:fix/autotimestamp-thread-safety
Closed

Make AutoTimestampEventListener temporary disabling thread-safe#15952
codeconsole wants to merge 1 commit into
apache:7.0.xfrom
codeconsole:fix/autotimestamp-thread-safety

Conversation

@codeconsole

Copy link
Copy Markdown
Contributor

Fixes #14510

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).

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 was rewritten to exercise the listener through the public beforeInsert/beforeUpdate API and extended with tests that reproduce the bug — written first and verified to fail on the old implementation:

All 218 tests in grails-datamapping-core pass, 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.

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.
@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: b0d7fe4
▶️ Tests: 45871 executed
⚪️ Checks: 51/51 completed


Learn more about TestLens at testlens.app.

@jdaugherty

Copy link
Copy Markdown
Contributor

This has been like this for years and at this point it does revert a behavior so I think this shouldn't go in 7.0

@jdaugherty jdaugherty closed this Jul 10, 2026
jdaugherty pushed a commit that referenced this pull request Jul 10, 2026
Fixes #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 (#15952) to 8.0.x, identical to the
7.1.x port (#15953), as this class is identical on 7.1.x through 8.0.x.
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.

2 participants