Repository navigation
Description
Summary
Since spring-cloud-context 5.0.2 (resetBeanToDefaults, #1680), a rebind empties the live
@ConfigurationProperties singleton before repopulating it. For a bean with nested property
objects, the observable intermediate state is not merely a stale or new scalar — it is null, so a
concurrent reader does not read a wrong value, it throws NullPointerException.
This is the same window PR #1709 reported and #1721 partially addressed (writer/writer only). #1709
was closed with the observation acknowledged; this report adds the failure mode it did not cover — a
hard failure rather than a wrong value — and evidence of how often it is hit in a framework where
every service has nested properties read on request threads.
Environment
- spring-cloud-context 5.0.2 and 5.0.3 (trains 2025.1.2, 2025.1.3); Spring Boot 4.0.8. Not present in
4.3.x/4.2.x.
What happens
@Bean
@ConfigurationProperties(prefix = "my.service")
MyProperties myProperties() { return new MyProperties(); }
public class MyProperties {
private Timeout timeout = null; // populated only by binding
public Timeout getTimeout() { return timeout; }
// ...
}resetProperties walks the BeanWrapper descriptors and, for every writable property, does
target.setPropertyValue(name, defaultsWrapper.getPropertyValue(name)). For timeout that is
setTimeout(null). Between resetBeanToDefaults(bean) and the binding inside
initializeBean(bean, name), every reader of myProperties.getTimeout().getX() throws NPE.
spring.cloud.refresh.never-reset-nested-types does not help: it controls recursion into a non-null
nested value, not the top level setTimeout(null).
Reproduction
rebind-reset-repro.zip is a self-contained project — Spring Boot 4.0.8 with
spring-cloud-context as the only other dependency, no Spring Cloud Config, no Consul, no
@RefreshScope. The test is also attached on its own as RebindEmptiesNestedPropertiesTest.java.txt,
and the three runs below as test-results.txt.
mvn test # 5.0.3 (default) - both tests fail
mvn test -Dspring-cloud-context.version=5.0.2 # both tests fail
mvn test -Dspring-cloud-context.version=5.0.1 # both tests pass
spring-cloud-context |
nestedPropertyIsReadableThroughoutARebind |
concurrentReadersSurviveRepeatedRebinds |
|---|---|---|
| 5.0.1 | passes | passes, 0 failed reads |
| 5.0.2 | fails, NPE | fails, 10,942,734 of 19,659,911 reads |
| 5.0.3 | fails, NPE | fails, 11,072,765 of 20,753,865 reads |
The first test is deterministic rather than a race: a PriorityOrdered BeanPostProcessor reads the bean
from inside the re-bind, ahead of ConfigurationPropertiesBindingPostProcessor, which is where a request
thread lands.
NullPointerException: Cannot invoke "...MyProperties$Timeout.getConnect()"
because the return value of "...MyProperties.getTimeout()" is null
The second is one reader thread against 200 re-binds. A tight reader loop overstates the proportion
compared with real traffic, but it shows the window is wide rather than a few instructions.
Impact and frequency
Measured in a framework used across a fleet of services, where a Consul config watch fires
ContextRefresher.refresh() on a running service:
- In a framework integration test, 5,363 of 2,647,116 reads failed with NPE during a single
ContextRefresher.refresh()- 4 reader threads resolving an HTTP connect timeout through the
properties bean, exactly as request threads do for every outbound call. - In production this is a burst of failed outbound calls or 5xx per config change, not a sustained
outage — but it is one burst per config change, per service, unattended. - It is aggravated by
ContextRefresher.refresh()also callingscope.refreshAll():@RefreshScope
beans that read the properties bean are rebuilt at exactly the moment the properties they read are
reset. An application with an asyncApplicationEventMulticastermakes it near deterministic,
because/refreshreturns before the rebind has finished.
Why @RefreshScope is only a partial answer
@RefreshScope on the properties bean does work — ConfigurationPropertiesBeans skips refresh scoped
beans, so they are replaced rather than rebound — and that is the mitigation adopted here. But it has
to be applied bean by bean, by whoever owns each bean. A framework can scope its own properties beans;
it cannot scope the ones its consuming applications declare. Every application that upgrades to a
2025.1.2+ train silently acquires this exposure in its own configuration classes, with no warning at
build or start time, and the symptom surfaces as an unexplained NPE burst minutes after a config change.
Request
- An open issue tracking the reader visible window (ConfigurationPropertiesRebinder exposes invalid state racily #1709 was a closed PR; Thread safety of @ConfigurationProperties beans when refreshing #750 predates the reset and
is about a different symptom), so the state of it is discoverable. - Consider making the reset opt-in — or at least opt-out per bean without losing rebinding — on 5.0.x,
rather than only in the next major. The reset restores removed properties to defaults, which is
valuable, but it turns a previously non-failing operation into a failing one for any bean with
nested properties, and Reset bean to defaults before rebinding values #1680 changed the behavior for non-proxied beans that Recreate proxied @ConfigurationProperties beans on rebind #1662 had deliberately
left alone for backwards compatibility.
References
- Reset bean to defaults before rebinding values #1680 introduced the reset (5.0.2); Recreate proxied @ConfigurationProperties beans on rebind #1662 was the superseded variant that kept non-proxied beans safe.
- ConfigurationPropertiesRebinder exposes invalid state racily #1709 reported the racy window with a repro; closed 2026-08-10, with the reporter noting on 2026-08-17
that Add a per-bean-name lock around destroy/reset/re-populate/re-initialize steps in rebind #1721 does not fix it. - Add a per-bean-name lock around destroy/reset/re-populate/re-initialize steps in rebind #1721 added per-bean-name rebind locks (5.0.3) — serializes rebinders, not readers.
- Regression: spring-cloud-context 5.0.2 permanently nulls
@Value-injected fields on@ConfigurationPropertiesbeans on the firstEnvironmentChangeEvent. #1716 the permanent@Valuenulling variant, fixed by Autowire beans when rebinding #1720 in 5.0.3. - Thread safety of @ConfigurationProperties beans when refreshing #750 open since 2020, "there never was a guarantee of thread safety refreshing
@ConfigurationProperties".
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
- StatusShow more project fieldsDone
Activity
180254 commentedon Aug 23, 2026
We hit similar (the same?) regression.
What is happening on our production code: A request thread in our service reads an
int parallelExecutionsfrom a@ConfigurationPropertiesbean (annotated@Positive, configured to4) to decide how many worker flows to start. During a rebind it read0, the unbound Java default, a value that is not configured anywhere and that the bean's own validation forbids. The thread started zero workers and then waited forever for them to complete. The pipeline froze permanently.Two additions from our case:
ContextRefresher.refreshEnvironment()publishesEnvironmentChangeEventwith an empty key set and all beans are still rebound, so an application that refreshes on a schedule or on config-store notifications re-runs the reset on every cycle with zero config changes.4, one@ConfigurationPropertiesbean with a single int, one thread loopingrefreshEnvironment()without changing anything, one reader thread): 5.0.1 stays clean; 5.0.2, 5.0.3 and 5.0.4-SNAPSHOT (build 5.0.4-20260823.000329-5) all show roughly 1 in 40 reads observing the unbound0. Reproducer: https://github.com/180254/spring-cloud-commons-issues-1727Bind the fresh instance before destroying the live bean
sharanggupta commentedon Oct 22, 2026
@ryanjbaxter, I looked at the reader/writer window that remains after #1721. The
null/0intermediate state comes from the order of operations: the live bean is reset to its class-level defaults first and only re-bound afterwards, so between the two steps every reader sees unbound state.Binding a fresh instance first and then copying its values into the live bean removes that window without touching the read path: readers can still see a mix of old and new values during the copy (as before #1680), but never
nullfor a nested object or0for a bound scalar. Removed properties still revert to their defaults, because the fresh instance only carries what theEnvironmentbinds, and since the binding happens before the live bean is destroyed, a rebind whose binding fails (for example an unconvertible value) now leaves the live bean exactly as it was. The fresh instance is bound throughConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitializationdirectly, so no other post-processor or lifecycle callback runs for it.Branch with a failing test (
ConfigurationPropertiesRebinderTransientStateIntegrationTests: on main a nested object populated only by binding is set tonull, and anintpasses through0) and the change: main...sharanggupta:spring-cloud-commons:gh-1727-rebind-fresh-instanceThe
spring-cloud-contextsuite passes. Happy to open a PR if this direction fits; the Javadoc note about@RefreshScopefor a consistent view would still apply to mixed old/new reads.ryanjbaxter commentedon Oct 22, 2026
This seems directionally correct. A PR would be great! Just a couple of notes
resetBeanToDefaultstransiently nulls nested@ConfigurationPropertiesobjects, NPEing concurrent request threads #1727 for a different property shape.sharanggupta commentedon Oct 22, 2026
Thanks, @ryanjbaxter. PR #1774 is against
5.0.xand takes the three notes into account:CollectionBinder.mergestill clears and refills a getter-only collection it binds, as it did before; that window is Spring Boot's rather than the rebinder's.ConfigurationPropertiesBindingPostProcessoris available the reset is skipped (debug log) rather than copying defaults onto the live bean.Merge branch '5.0.x' into spring-cloudgh-1727-rebind-fresh-instance-5…
ryanjbaxter commentedon Oct 30, 2026
@180254 and @rsheldon-dealeron would you mind testing the changes in #1781 and see if they address your issue?
180254 commentedon Oct 31, 2026
I built this PR and ran the 180254/spring-cloud-commons-issues-1727 reproducer against it. On 5.0.3 it fails within seconds; with this PR it ran clean for 60 seconds: 48k refresh cycles, 15B reads, 0 bad reads. So the scalar case is fixed.
The reproducer only checks an
int. I picked it because it was the clearest case, and it's the one that froze our app in production. If I read the known limitation in the PR description correctly, collection properties can still be observed empty during a rebind. I must admit that I don’t understand what is supposed to be working here, or why.I wasn't sure whether the way we used the periodic rebind was correct in the first place, so I looked at alternatives:
@RefreshScopeand callRefreshScope.refreshAll()when keys change, so readers get a new instance instead of one being reset in place.I went with the second one, and since then I haven't followed the topic closely.
Thank you for working on this issue.
ryanjbaxter commentedon Nov 2, 2026
@180254 thanks for trying out the fix and confirming it works.
Yes that is true, and the only way to work around this is to reimplement the bean initialization from Spring Boot and that is not something I want to do in Spring Cloud.
Given the difficulties we have had since adding the restore defaults functionality when refreshing beans I am going to be looking into going back to our previous functionallity and make this new functionality opt in for those that want it.
Bind a fresh instance before resetting a rebound bean
Merge pull request #1781 from ryanjbaxter/bind-a-fresh-instance-befor…