Skip to content

Make GenericScope proxy locking conditional on ScopeCache - #1730

Open
akenra wants to merge 1 commit into
spring-cloud:mainfrom
akenra:feat/gh-630
Open

Make GenericScope proxy locking conditional on ScopeCache#1730
akenra wants to merge 1 commit into
spring-cloud:mainfrom
akenra:feat/gh-630

Conversation

@akenra

@akenra akenra commented Aug 25, 2026

Copy link
Copy Markdown

Context

GenericScope guards every proxied invocation with a read lock keyed by the bean name, and destroy() takes the matching write lock. Those locks live in a single map on the scope instance, so all scope instances sharing one GenericScope contend - even when the configured ScopeCache confines instances to a single thread (e.g. ThreadLocalScopeCache). For message-listener-style usage (one thread-local scope per consumer thread), a single instance's teardown write-locks the bean name for every thread.

The analysis in #630 was endorsed by @dsyer back in 2020:

The analysis looks good. Do we have a pull request?

Six years later - here is that pull request. Nothing was ever merged in between; the locking code is unchanged since the original 2017 commit referenced in the issue.

What this PR does

  1. New ScopeCache.requiresLocking() (default boolean, returns true) documenting the contract: caches whose instances are confined to a single thread return false. Concurrency semantics belong to the cache implementation - the reporter's instinct - without contorting the storage-only contract into an invocation-guard.
  2. ThreadLocalScopeCache overrides it to false, and GenericScope's LockedScopedProxyFactoryBean skips read-lock acquisition entirely when the cache does not require locking. The UndeclaredThrowableException when using @RefreshScope in @Service with checked Exceptions #349 UndeclaredThrowableException unwrap is preserved on both paths; destruction-side write locks are intentionally left untouched (uncontended when no readers exist, keeps the diff minimal).
  3. Bonus contention fix in the same spirit: BeanLifecycleWrapper synchronized on this.name - a shared, typically-interned String - so independent scope instances creating same-named beans serialized against each other during lazy creation and destruction. The monitor is now the wrapper instance itself, which is exactly the state being guarded. Single-wrapper-per-name-per-cache holds via the existing putIfAbsent semantics of both in-tree caches.

Backward compatibility

  • Default behavior is unchanged by construction: StandardScopeCache (and therefore RefreshScope) never override requiresLocking(), so every refresh path keeps identical locking. All existing RefreshScope*Tests pass unmodified.
  • Adding a default method preserves binary compatibility for already-compiled custom ScopeCache implementations.
  • No framework-internal code calls setScopeCache(...) with a non-default cache today; the affected surface is custom scopes like the reporter's.

Testing

  • New GenericScopeLockingTests (first direct test coverage this area has had): contract defaults, proxy invocation consults locks for StandardScopeCache / skips for ThreadLocalScopeCache, and cross-instance creation of same-named beans proceeds without blocking. Tests went red before the fix (compile gap + behavioral failures on the old GenericScope) and green after.
  • Full spring-cloud-context suite: 232 tests, no failures from this change. (One pre-existing, unrelated error in EncryptorFactoryTests.testWithRsaPrivateKey reproduces identically on clean main; it is environment-related PEM parsing and untouched here.)

Fixes #630

Notes for reviewers

  • Happy to split the BeanLifecycleWrapper monitor-granularity fix into a separate commit/PR if preferred.
  • Pre-existing nits observed but deliberately not touched here: non-volatile callback field written outside synchronization, and a potential NPE in destroy(name) if the lock map lacks an entry.

Scoped proxies created by GenericScope guard every invocation with a read
lock keyed by the bean name, and destruction takes the matching write lock.
Those locks are shared by all instances using the same GenericScope even
when the configured ScopeCache confines instances to a single thread,
causing heavy contention for thread-local scopes (spring-cloudgh-630).

Introduce ScopeCache.requiresLocking() (default true), have
ThreadLocalScopeCache return false, and skip proxy locking when the cache
does not require it. Also replace the bean-name String monitor inside
BeanLifecycleWrapper with the wrapper instance so independent scope
instances no longer contend on shared strings during lazy creation and
destruction.

Default behavior for StandardScopeCache and RefreshScope is unchanged.

Fixes spring-cloudgh-630

Signed-off-by: akenra <37288280+akenra@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GenericScope with ThreadLocalScopeCache heavy concurrency contention on ReadWriteLock

2 participants