Skip to content

Bind a fresh instance before resetting a rebound bean - #1774

Open
sharanggupta wants to merge 6 commits into
spring-cloud:5.0.xfrom
sharanggupta:gh-1727-rebind-fresh-instance-5.0.x
Open

sharanggupta wants to merge 6 commits into
spring-cloud:5.0.xfrom
sharanggupta:gh-1727-rebind-fresh-instance-5.0.x

Conversation

@sharanggupta

Copy link
Copy Markdown

Fixes gh-1727

ConfigurationPropertiesRebinder reset 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: null for a nested object populated only by binding, 0 for a bound int.

The rebinder now binds a fresh instance first, through ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization only, 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 the Environment binds; 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:

  • Getter-only collections and maps are no longer cleared and refilled. Equal contents are left untouched; otherwise a list gets the new elements appended before the previous ones are removed, and a set or map drops only the removed elements or keys before the fresh ones are added, so a reader never sees an empty collection. (A getter-only collection the binder itself binds is still cleared and refilled by CollectionBinder.merge during the subsequent bind; that part is Spring Boot's and predates this change.)
  • When no ConfigurationPropertiesBindingPostProcessor is available, the reset is skipped instead of copying class-level defaults onto the live bean.
  • The fresh instance is autowired on purpose: an injected dependency is then copied over as the same instance, instead of being nulled by the copy and re-injected a moment later. The previous code already instantiated a fresh instance for every rebind, so the extra cost is the autowiring only.

ConfigurationPropertiesRebinderTransientStateIntegrationTests covers the nested object, the scalar, the failing rebind, and the getter-only map and list cases; the spring-cloud-context module suite passes.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@sharanggupta

Copy link
Copy Markdown
Author

Thanks for the thorough check, @ryanjbaxter; you were right on all counts, and the measurement in particular. Pushed as follow-up commits:

  1. The live bean is no longer bound in place a second time. After the copy it is still destroyed, re-autowired and re-initialized (the other post-processors, InitializingBean and init methods run as before, which ConfigurationPropertiesRebinderLifecycleIntegrationTests relies on), but ConfigurationPropertiesBindingPostProcessor is left out, since the state already comes from the bound fresh instance. That removes the clear() from CollectionBinder.merge. A bean that cannot be reset from a fresh instance (no default constructor) is still bound in place as before. A list is now updated element by element, so a reader iterating over it is only disturbed when the size changes.
  2. TrackingList is back with a startup baseline: no clear() for an unchanged, a changed, a shortened and a removed list, plus a stress test where a reader thread iterates over getTags() while fifty rebinds alternate a value; it never sees an empty or partial list.
  3. Fixed.
  4. The fresh instance is no longer autowired. The copy instead skips properties whose setter or backing field is an injection point (@Autowired, @Value, @Inject, @Resource), so a dependency is neither nulled nor registered through a throwaway; a test keeps an @Autowired setter across a rebind.

….0.x

Signed-off-by: Sharang Gupta <sharang@sharanggupta.dev>

This branch has not been deployed

No deployments
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.

3 participants