Repository navigation
Bind a fresh instance before resetting a rebound bean - #1774
sharanggupta wants to merge 6 commits into
Conversation
Rebinding reset the live bean to its class-level defaults before binding it again, so concurrent readers could observe a nested object as null or a scalar as its unbound default between the two steps. A fresh instance is now bound from the Environment first, through the ConfigurationPropertiesBindingPostProcessor so that no other post-processor or lifecycle callback runs for it, and its values are then copied into the live bean. Readers never see unbound state, removed properties still revert to their defaults, and a failed binding leaves the live bean untouched. See spring-cloudgh-1727 Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
Binding the fresh instance after destroying the live bean left the bean destroyed and never re-initialized when the binding failed. The fresh instance is now created, autowired and bound first, so a failing binding leaves the live bean as it was, and the rebinder's vocabulary no longer talks about class-level defaults where it copies the freshly bound state. See spring-cloudgh-1727 Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
Following review: a getter-only collection or map is brought to the contents of the fresh instance without being emptied first (equal contents are left alone, new elements are added before the previous ones are removed), the reset is skipped when no ConfigurationPropertiesBindingPostProcessor is available instead of copying class-level defaults onto the live bean, and the autowiring of the fresh instance is explained in place. Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
| * otherwise the new elements are added before the previous ones are removed. | ||
| */ | ||
| @SuppressWarnings({ "unchecked", "rawtypes" }) | ||
| private void replaceContents(Object bean, String propertyName, Collection collection, @Nullable Object freshValue) { |
There was a problem hiding this comment.
I think replaceContents is undone right after it runs. initializeBean runs ConfigurationPropertiesBindingPostProcessor on the live bean again, and Boot's CollectionBinder.merge does clear() followed by addAll() on a getter-only collection.
I checked this by swapping tags in the tests for a list that counts clear() calls, and measuring the count across a single rebind(). It was 1 both when nothing changed and when test.tags[0] changed. So a concurrent reader of a getter-only List or Set still sees it empty on every rebind, including no-change refreshes. The map test only passes because MapBinder.merge just does putAll.
Could we avoid the second bind emptying the list? One option is to skip the in-place bind when the fresh instance was already bound, since the copy has already brought the live bean to the bound state. Another is to bind in a way that doesn't call merge on the live collection.
|
|
||
| @Test | ||
| @DirtiesContext | ||
| public void rebindBringsGetterOnlyListToNewContents() { |
There was a problem hiding this comment.
These list tests only check final contents, so they would pass even with the empty window. Could you add a TrackingList like TrackingMap and assert it sees no clear() during a rebind, for both a changed and an unchanged rebind? Remember to take a baseline first, because the initial startup bind also calls clear().
A short stress test would also help: a reader thread looping over getTags() while another thread calls rebind(). It is the one thing that proves the issue is closed.
| } | ||
|
|
||
| /** | ||
| * Bind the given instance from the {@link Environment} as if it were the bean with |
There was a problem hiding this comment.
Looks like the javadoc is in the wrong place
| resetProperties(bean, freshInstance, Collections.newSetFromMap(new IdentityHashMap<>()), 0); | ||
| // Autowired so that an injected dependency is copied over as the same instance | ||
| // rather than nulled and re-injected on the live bean afterwards. | ||
| appContext.getAutowireCapableBeanFactory().autowireBean(freshInstance); |
There was a problem hiding this comment.
Constructing the throwaway instance already happened before this PR, but autowiring it is new. If an @Autowired method or setter registers this with a collaborator, such as a listener, a registry or a metrics gauge, each rebind would leak a second instance that is never destroyed. Refreshes with no changed keys make this per-cycle.
Is autowiring needed for correctness? The comment says it keeps injected dependencies identical when copied. If so, could we copy only the bound properties
Following review: the live bean's state now comes from the bound fresh instance only. After the copy the bean is destroyed, re-autowired and re-initialized through its post-processors, InitializingBean and init methods, but not through ConfigurationPropertiesBindingPostProcessor, whose in-place bind cleared and refilled every getter-only collection on every rebind. A list is updated element by element so that a reader iterating over it is not disturbed unless its size changes. The fresh instance is no longer autowired; the copy skips injected setters and fields instead, so a dependency is neither nulled nor re-registered through a throwaway instance. Tests cover an unchanged, a changed, a shortened and a removed list (no clear() after startup), a reader thread iterating during fifty rebinds, and an @Autowired setter kept across a rebind. Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
|
Thanks for the thorough check, @ryanjbaxter; you were right on all counts, and the measurement in particular. Pushed as follow-up commits:
|
….0.x Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>
Fixes gh-1727
ConfigurationPropertiesRebinderreset the live bean to its class-level defaults and only re-bound it afterwards, so between the two steps every concurrent reader saw unbound state:nullfor a nested object populated only by binding,0for a boundint.The rebinder now binds a fresh instance first, through
ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitializationonly, and copies its values into the live bean. Readers can still see a mix of previous and new values during the copy, as before gh-1680, but never unbound state; removed properties still revert to their defaults because the fresh instance only carries what theEnvironmentbinds; and a rebind whose binding fails leaves the live bean exactly as it was, since the binding happens before the bean is destroyed.Following the review notes on the issue:
CollectionBinder.mergeduring the subsequent bind; that part is Spring Boot's and predates this change.)ConfigurationPropertiesBindingPostProcessoris available, the reset is skipped instead of copying class-level defaults onto the live bean.ConfigurationPropertiesRebinderTransientStateIntegrationTestscovers the nested object, the scalar, the failing rebind, and the getter-only map and list cases; thespring-cloud-contextmodule suite passes.