Skip to content

Commit 35c64d6

Browse files
committed
fix: retain recently written external resources missing from a stale update
An update of the whole resource set of a primary (a poll result or a received event) might have been created before the reconciler wrote a resource, thus not containing it yet. Since such updates are handled as the full actual state, the write was lost from the cache, and the next reconciliation created a duplicate of an already created resource or repeated an already executed update. Writes are now marked as unconfirmed and retained for the next update if it either does not contain the resource at all - the expected case for a create - or still contains the state that the write replaced. Any other state is treated as a change made outside of the reconciler and accepted as actual. Marks are dropped on the first update, so a resource really deleted or changed meanwhile is not retained indefinitely. Also guards handleRecentResourceUpdate against a missing cache entry, and resolves the actual resources from the state resources in the external state bulk dependent integration test, which is the recommended approach for resources that take longer to become visible.
1 parent eba57e1 commit 35c64d6

4 files changed

Lines changed: 201 additions & 4 deletions

File tree

docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,17 @@ also be created, one per dependent resource.
480480
See [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent)
481481
as a sample.
482482

483+
Note that an external resource and the state resource referencing it cannot be created atomically:
484+
the external resource has to be created first, since its identifier is what gets stored in the
485+
state. If the resources are fetched based on the state - which is usually the case, since the
486+
identifier is only known from the state - a poll happening in between the two steps cannot see the
487+
new external resource yet. JOSDK keeps such a recently created resource in the cache for the next
488+
update to avoid creating a duplicate of it, but for a resource that takes longer to become visible,
489+
it is recommended to resolve the actual resources from the state resources in
490+
`BulkDependentResource.getSecondaryResources`, as done in the integration test above. The state
491+
resources are managed by an `InformerEventSource`, thus are always up-to-date regarding the
492+
operator's own changes.
493+
483494
## GenericKubernetesResource based Dependent Resources
484495

485496
In rare circumstances resource handling where there is no class representation or just typeless handling might be

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,28 @@ public abstract class ExternalResourceCachingEventSource<R, P extends HasMetadat
6767

6868
protected Map<ResourceID, Map<ID, R>> cache = new ConcurrentHashMap<>();
6969

70+
/**
71+
* The resources written by the reconciler ({@link #handleRecentResourceCreate(ResourceID,
72+
* Object)} and {@link #handleRecentResourceUpdate(ResourceID, Object, Object)}) that were not
73+
* seen yet in a subsequent update of the whole resource set of a primary. Such an update might
74+
* have been created (polled or received) before the resource was actually written, thus not
75+
* containing the new state yet. Since these updates are handled as the full actual state, the
76+
* write would be lost from the cache; the next reconciliation would then create a duplicate of an
77+
* already created resource, or repeat an already executed update. Note that a mark is dropped on
78+
* the first update, so a resource really deleted or changed in the meantime is not retained
79+
* indefinitely.
80+
*
81+
* @see #retainUnconfirmedWrites(ResourceID, Map)
82+
*/
83+
private final Map<ResourceID, Map<ID, RecentWrite<R>>> unconfirmedWrites =
84+
new ConcurrentHashMap<>();
85+
86+
/**
87+
* A resource written by the reconciler and the state it replaced, which is {@code null} in case
88+
* the resource was created.
89+
*/
90+
private record RecentWrite<R>(R written, R replaced) {}
91+
7092
protected ExternalResourceCachingEventSource(
7193
Class<R> resourceClass, ResourceIDMapper<R, ID> resourceIDMapper) {
7294
this(null, resourceClass, resourceIDMapper);
@@ -86,6 +108,7 @@ protected ExternalResourceCachingEventSource(
86108
}
87109

88110
protected synchronized void handleDelete(ResourceID primaryID) {
111+
unconfirmedWrites.remove(primaryID);
89112
var res = cache.remove(primaryID);
90113
if (res != null && deleteAcceptedByFilter(res.values())) {
91114
getEventHandler().handleEvent(new Event(primaryID));
@@ -105,6 +128,13 @@ protected synchronized void handleDelete(ResourceID primaryID, Set<ID> resourceI
105128
if (!isRunning()) {
106129
return;
107130
}
131+
var unconfirmed = unconfirmedWrites.get(primaryID);
132+
if (unconfirmed != null) {
133+
unconfirmed.keySet().removeAll(resourceIDs);
134+
if (unconfirmed.isEmpty()) {
135+
unconfirmedWrites.remove(primaryID);
136+
}
137+
}
108138
var cachedValues = cache.get(primaryID);
109139
List<R> removedResources =
110140
cachedValues == null
@@ -131,7 +161,16 @@ protected synchronized void handleResources(ResourceID primaryID, Set<R> newReso
131161

132162
protected synchronized void handleResources(Map<ResourceID, Set<R>> allNewResources) {
133163
var toDelete = cache.keySet().stream().filter(k -> !allNewResources.containsKey(k)).toList();
134-
toDelete.forEach(this::handleDelete);
164+
toDelete.forEach(
165+
primaryID -> {
166+
if (unconfirmedWrites.containsKey(primaryID)) {
167+
// handled as an empty update, so that a recently written resource, that this update
168+
// could not see yet, is not removed from the cache
169+
handleResources(primaryID, Collections.emptySet());
170+
} else {
171+
handleDelete(primaryID);
172+
}
173+
});
135174
allNewResources.forEach(this::handleResources);
136175
}
137176

@@ -148,6 +187,7 @@ protected synchronized void handleResources(
148187
}
149188
var newResourcesMap =
150189
newResources.stream().collect(Collectors.toMap(resourceIDMapper::idFor, r -> r));
190+
retainUnconfirmedWrites(primaryID, newResourcesMap);
151191
cache.put(primaryID, newResourcesMap);
152192
if (propagateEvent
153193
&& !newResourcesMap.equals(cachedResources)
@@ -156,6 +196,34 @@ && acceptedByFiler(cachedResources, newResourcesMap)) {
156196
}
157197
}
158198

199+
/**
200+
* Keeps the resources written since the received update was created, thus missing from it. An
201+
* update is considered stale for a written resource if it does not contain it at all - which is
202+
* the expected case for a create - or if it still contains the state that the write replaced. Any
203+
* other state is a change that happened outside of the reconciler, so it is accepted as the
204+
* actual state.
205+
*
206+
* @see #unconfirmedWrites
207+
*/
208+
private void retainUnconfirmedWrites(ResourceID primaryID, Map<ID, R> newResourcesMap) {
209+
var unconfirmed = unconfirmedWrites.remove(primaryID);
210+
if (unconfirmed == null) {
211+
return;
212+
}
213+
unconfirmed.forEach(
214+
(id, write) -> {
215+
var newResource = newResourcesMap.get(id);
216+
if (newResource == null || newResource.equals(write.replaced())) {
217+
log.debug(
218+
"Retaining recently written resource missing from the update. Primary ID: {},"
219+
+ " resource ID: {}",
220+
primaryID,
221+
id);
222+
newResourcesMap.put(id, write.written());
223+
}
224+
});
225+
}
226+
159227
private boolean acceptedByFiler(Map<ID, R> cachedResourceMap, Map<ID, R> newResourcesMap) {
160228

161229
var addedResources = new HashMap<>(newResourcesMap);
@@ -217,6 +285,7 @@ public synchronized void handleRecentResourceCreate(ResourceID primaryID, R reso
217285
} else {
218286
actualValues.computeIfAbsent(resourceId, r -> resource);
219287
}
288+
markUnconfirmedWrite(primaryID, resourceId, new RecentWrite<>(resource, null));
220289
}
221290

222291
@Override
@@ -226,12 +295,18 @@ public synchronized void handleRecentResourceUpdate(
226295
if (actualValues != null) {
227296
var resourceId = resourceIDMapper.idFor(resource);
228297
R actualResource = actualValues.get(resourceId);
229-
if (actualResource.equals(previousVersionOfResource)) {
298+
if (actualResource != null && actualResource.equals(previousVersionOfResource)) {
230299
actualValues.put(resourceId, resource);
300+
markUnconfirmedWrite(
301+
primaryID, resourceId, new RecentWrite<>(resource, previousVersionOfResource));
231302
}
232303
}
233304
}
234305

306+
private void markUnconfirmedWrite(ResourceID primaryID, ID resourceId, RecentWrite<R> write) {
307+
unconfirmedWrites.computeIfAbsent(primaryID, id -> new HashMap<>()).put(resourceId, write);
308+
}
309+
235310
@Override
236311
public Set<R> getSecondaryResources(P primary) {
237312
return getSecondaryResources(ResourceID.fromResource(primary));

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.javaoperatorsdk.operator.processing.event.source;
1717

18+
import java.util.Map;
1819
import java.util.Set;
1920

2021
import org.junit.jupiter.api.BeforeEach;
@@ -211,6 +212,107 @@ void genericFilteringEvents() {
211212
verify(eventHandler, times(0)).handleEvent(any());
212213
}
213214

215+
@Test
216+
void retainsRecentlyCreatedResourceMissingFromUpdate() {
217+
source.handleResources(primaryID1(), Set.of(testResource1()));
218+
source.handleRecentResourceCreate(primaryID1(), testResource2());
219+
220+
// the update was created before the resource, thus does not contain it yet
221+
source.handleResources(primaryID1(), Set.of(testResource1()));
222+
223+
assertThat(source.getSecondaryResources(primaryID1()))
224+
.containsExactlyInAnyOrder(testResource1(), testResource2());
225+
// no event for the retained resource, only the initial add event
226+
verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
227+
}
228+
229+
@Test
230+
void retainsRecentlyCreatedResourceOnlyForASingleUpdate() {
231+
source.handleResources(primaryID1(), Set.of(testResource1()));
232+
source.handleRecentResourceCreate(primaryID1(), testResource2());
233+
source.handleResources(primaryID1(), Set.of(testResource1()));
234+
235+
// this update is created after the resource, so it is really deleted meanwhile
236+
source.handleResources(primaryID1(), Set.of(testResource1()));
237+
238+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
239+
verify(eventHandler, times(2)).handleEvent(new Event(primaryID1()));
240+
}
241+
242+
@Test
243+
void doesNotRetainRecentlyCreatedResourceDeletedBeforeTheUpdate() {
244+
source.handleRecentResourceCreate(primaryID1(), testResource2());
245+
source.handleDelete(primaryID1(), testResource2());
246+
247+
source.handleResources(primaryID1(), Set.of(testResource1()));
248+
249+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
250+
}
251+
252+
@Test
253+
void retainsRecentlyCreatedResourceMissingFromWholeCacheUpdate() {
254+
source.handleRecentResourceCreate(primaryID1(), testResource1());
255+
256+
source.handleResources(Map.of());
257+
258+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
259+
260+
source.handleResources(Map.of());
261+
262+
assertThat(source.getSecondaryResources(primaryID1())).isEmpty();
263+
}
264+
265+
@Test
266+
void retainsRecentlyUpdatedResourceMissingFromUpdate() {
267+
source.handleResources(primaryID1(), Set.of(testResource1()));
268+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
269+
270+
// the update was created before the resource was updated, thus still contains the old state
271+
source.handleResources(primaryID1(), Set.of(testResource1()));
272+
273+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(changedTestResource1());
274+
// no event for the retained resource, only the initial add event
275+
verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
276+
}
277+
278+
@Test
279+
void retainsRecentlyUpdatedResourceOnlyForASingleUpdate() {
280+
source.handleResources(primaryID1(), Set.of(testResource1()));
281+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
282+
source.handleResources(primaryID1(), Set.of(testResource1()));
283+
284+
// this update is created after the resource was updated, so it was really changed meanwhile
285+
source.handleResources(primaryID1(), Set.of(testResource1()));
286+
287+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1());
288+
verify(eventHandler, times(2)).handleEvent(new Event(primaryID1()));
289+
}
290+
291+
@Test
292+
void doesNotRetainRecentlyUpdatedResourceChangedOutsideOfTheReconciler() {
293+
var externallyChanged = testResource1().setValue("externallyChangedValue");
294+
source.handleResources(primaryID1(), Set.of(testResource1()));
295+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
296+
297+
source.handleResources(primaryID1(), Set.of(externallyChanged));
298+
299+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(externallyChanged);
300+
}
301+
302+
@Test
303+
void retainsRecentlyUpdatedResourceInWholeCacheUpdate() {
304+
source.handleResources(primaryID1(), Set.of(testResource1()));
305+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
306+
307+
source.handleResources(Map.of(primaryID1(), Set.of(testResource1())));
308+
309+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(changedTestResource1());
310+
}
311+
312+
private static SampleExternalResource changedTestResource1() {
313+
return testResource1().setValue("changedValue");
314+
}
315+
214316
public static class TestExternalCachingEventSource
215317
extends ExternalResourceCachingEventSource<SampleExternalResource, HasMetadata, String> {
216318
public TestExternalCachingEventSource() {

operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,21 @@ public Map<String, ExternalResource> desiredResources(
135135
return res;
136136
}
137137

138+
/**
139+
* Resolves the actual resources from the persisted state instead of the polled cache. An external
140+
* resource and the state referencing it cannot be created atomically, so a poll happening in
141+
* between replaces the cached resources with the ones it can already see, dropping the freshly
142+
* created one. The next reconciliation would then create a duplicate external resource that no
143+
* state references anymore, thus is leaked. The state itself is read-after-write consistent,
144+
* since it is managed through an {@link
145+
* io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}.
146+
*/
138147
@Override
139148
public Map<String, ExternalResource> getSecondaryResources(
140149
ExternalStateBulkDependentCustomResource primary,
141150
Context<ExternalStateBulkDependentCustomResource> context) {
142-
var resources = context.getSecondaryResources(ExternalResource.class);
143-
return resources.stream().collect(Collectors.toMap(this::externalResourceIndex, r -> r));
151+
return fetchResources(primary).stream()
152+
.collect(Collectors.toMap(this::externalResourceIndex, r -> r));
144153
}
145154

146155
@Override

0 commit comments

Comments
 (0)