Skip to content

Commit 0a365dc

Browse files
committed
fix: retain recently written external resources missing from a stale update (#3565)
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 a state that a write replaced. Every state replaced since the last update is kept, since the reconciler might write the same resource multiple times in between, and an update created before any of those writes is stale. 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 19629d0 commit 0a365dc

4 files changed

Lines changed: 280 additions & 3 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: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,30 @@ 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+
* The last state of a resource written by the reconciler and every state it replaced since the
88+
* last update. There can be multiple replaced states if the reconciler wrote the resource more
89+
* than once without an update in between; an update created before any of those writes is stale.
90+
* The set is empty if the resource was created, since there was no previous state then.
91+
*/
92+
private record RecentWrite<R>(R written, Set<R> replaced) {}
93+
7094
protected ExternalResourceCachingEventSource(
7195
Class<R> resourceClass, ResourceIDMapper<R, ID> resourceIDMapper) {
7296
this(null, resourceClass, resourceIDMapper);
@@ -86,6 +110,7 @@ protected ExternalResourceCachingEventSource(
86110
}
87111

88112
protected synchronized void handleDelete(ResourceID primaryID) {
113+
unconfirmedWrites.remove(primaryID);
89114
var res = cache.remove(primaryID);
90115
if (res != null && deleteAcceptedByFilter(res.values())) {
91116
getEventHandler().handleEvent(new Event(primaryID));
@@ -105,6 +130,13 @@ protected synchronized void handleDelete(ResourceID primaryID, Set<ID> resourceI
105130
if (!isRunning()) {
106131
return;
107132
}
133+
var unconfirmed = unconfirmedWrites.get(primaryID);
134+
if (unconfirmed != null) {
135+
unconfirmed.keySet().removeAll(resourceIDs);
136+
if (unconfirmed.isEmpty()) {
137+
unconfirmedWrites.remove(primaryID);
138+
}
139+
}
108140
var cachedValues = cache.get(primaryID);
109141
List<R> removedResources =
110142
cachedValues == null
@@ -131,7 +163,16 @@ protected synchronized void handleResources(ResourceID primaryID, Set<R> newReso
131163

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

@@ -148,6 +189,7 @@ protected synchronized void handleResources(
148189
}
149190
var newResourcesMap =
150191
newResources.stream().collect(Collectors.toMap(resourceIDMapper::idFor, r -> r));
192+
retainUnconfirmedWrites(primaryID, newResourcesMap);
151193
cache.put(primaryID, newResourcesMap);
152194
if (propagateEvent
153195
&& !newResourcesMap.equals(cachedResources)
@@ -156,6 +198,34 @@ && acceptedByFiler(cachedResources, newResourcesMap)) {
156198
}
157199
}
158200

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

161231
var addedResources = new HashMap<>(newResourcesMap);
@@ -229,6 +299,7 @@ public synchronized void handleRecentResourceCreate(ResourceID primaryID, R reso
229299
} else {
230300
actualValues.computeIfAbsent(resourceId, r -> resource);
231301
}
302+
markUnconfirmedWrite(primaryID, resourceId, resource, null);
232303
}
233304

234305
@Override
@@ -240,10 +311,34 @@ public synchronized void handleRecentResourceUpdate(
240311
R actualResource = actualValues.get(resourceId);
241312
if (actualResource != null && actualResource.equals(previousVersionOfResource)) {
242313
actualValues.put(resourceId, resource);
314+
markUnconfirmedWrite(primaryID, resourceId, resource, previousVersionOfResource);
243315
}
244316
}
245317
}
246318

319+
/**
320+
* Marks the written resource as not confirmed yet by an update, keeping the states replaced by
321+
* previous writes of the same resource. Without those, an update created before an earlier write
322+
* would not be recognized as stale, and the last write would be lost from the cache.
323+
*
324+
* @param replaced the state the write replaced, {@code null} if the resource was created
325+
* @see #unconfirmedWrites
326+
*/
327+
private void markUnconfirmedWrite(ResourceID primaryID, ID resourceId, R written, R replaced) {
328+
unconfirmedWrites
329+
.computeIfAbsent(primaryID, id -> new HashMap<>())
330+
.compute(
331+
resourceId,
332+
(id, previousWrite) -> {
333+
Set<R> replacedStates =
334+
previousWrite == null ? new HashSet<>() : new HashSet<>(previousWrite.replaced());
335+
if (replaced != null) {
336+
replacedStates.add(replaced);
337+
}
338+
return new RecentWrite<>(written, replacedStates);
339+
});
340+
}
341+
247342
@Override
248343
public Set<R> getSecondaryResources(P primary) {
249344
return getSecondaryResources(ResourceID.fromResource(primary));

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

Lines changed: 162 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,167 @@ 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+
@Test
313+
void retainsResourceUpdatedTwiceIfUpdateContainsTheStateBeforeBothWrites() {
314+
source.handleResources(primaryID1(), Set.of(testResource1()));
315+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
316+
source.handleRecentResourceUpdate(
317+
primaryID1(), changedTwiceTestResource1(), changedTestResource1());
318+
319+
// the update was created before both writes, thus contains the state before the first one
320+
source.handleResources(primaryID1(), Set.of(testResource1()));
321+
322+
assertThat(source.getSecondaryResources(primaryID1()))
323+
.containsExactly(changedTwiceTestResource1());
324+
// no event for the retained resource, only the initial add event
325+
verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
326+
}
327+
328+
@Test
329+
void retainsResourceUpdatedTwiceIfUpdateContainsTheStateBetweenTheWrites() {
330+
source.handleResources(primaryID1(), Set.of(testResource1()));
331+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
332+
source.handleRecentResourceUpdate(
333+
primaryID1(), changedTwiceTestResource1(), changedTestResource1());
334+
335+
// the update was created between the two writes, thus contains the intermediate state
336+
source.handleResources(primaryID1(), Set.of(changedTestResource1()));
337+
338+
assertThat(source.getSecondaryResources(primaryID1()))
339+
.containsExactly(changedTwiceTestResource1());
340+
verify(eventHandler, times(1)).handleEvent(new Event(primaryID1()));
341+
}
342+
343+
@Test
344+
void retainsRecentlyCreatedAndThenUpdatedResourceMissingFromUpdate() {
345+
source.handleRecentResourceCreate(primaryID1(), testResource1());
346+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
347+
348+
// the update was created before both writes, thus does not contain the resource yet
349+
source.handleResources(primaryID1(), Set.of(testResource2()));
350+
351+
assertThat(source.getSecondaryResources(primaryID1()))
352+
.containsExactlyInAnyOrder(changedTestResource1(), testResource2());
353+
}
354+
355+
@Test
356+
void doesNotRetainResourceUpdatedTwiceIfChangedOutsideOfTheReconciler() {
357+
var externallyChanged = testResource1().setValue("externallyChangedValue");
358+
source.handleResources(primaryID1(), Set.of(testResource1()));
359+
source.handleRecentResourceUpdate(primaryID1(), changedTestResource1(), testResource1());
360+
source.handleRecentResourceUpdate(
361+
primaryID1(), changedTwiceTestResource1(), changedTestResource1());
362+
363+
source.handleResources(primaryID1(), Set.of(externallyChanged));
364+
365+
assertThat(source.getSecondaryResources(primaryID1())).containsExactly(externallyChanged);
366+
}
367+
368+
private static SampleExternalResource changedTestResource1() {
369+
return testResource1().setValue("changedValue");
370+
}
371+
372+
private static SampleExternalResource changedTwiceTestResource1() {
373+
return testResource1().setValue("changedValueAgain");
374+
}
375+
214376
@Test
215377
void getSecondaryResourcesReturnsASnapshotNotALiveView() {
216378
source.handleResources(primaryID1(), Set.of(testResource1()));

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