Skip to content

Commit 3bfac63

Browse files
authored
fix: using tombstones to account for rapid deletion (#2317)
closes: #2314 Signed-off-by: Steven Hawkins <[email protected]>
1 parent e5cb5b8 commit 3bfac63

File tree

4 files changed

+125
-28
lines changed

4 files changed

+125
-28
lines changed

Diff for: operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,17 @@ protected ManagedInformerEventSource(
5151

5252
@Override
5353
public void onAdd(R resource) {
54-
temporaryResourceCache.onEvent(resource, false);
54+
temporaryResourceCache.onAddOrUpdateEvent(resource);
5555
}
5656

5757
@Override
5858
public void onUpdate(R oldObj, R newObj) {
59-
temporaryResourceCache.onEvent(newObj, false);
59+
temporaryResourceCache.onAddOrUpdateEvent(newObj);
6060
}
6161

6262
@Override
6363
public void onDelete(R obj, boolean deletedFinalStateUnknown) {
64-
temporaryResourceCache.onEvent(obj, deletedFinalStateUnknown);
64+
temporaryResourceCache.onDeleteEvent(obj, deletedFinalStateUnknown);
6565
}
6666

6767
protected InformerManager<R, C> manager() {

Diff for: operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java

+74-20
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package io.javaoperatorsdk.operator.processing.event.source.informer;
22

3-
import java.util.Collections;
43
import java.util.LinkedHashMap;
54
import java.util.Map;
65
import java.util.Optional;
7-
import java.util.Set;
86
import java.util.concurrent.ConcurrentHashMap;
97

108
import org.slf4j.Logger;
@@ -18,8 +16,8 @@
1816
/**
1917
* <p>
2018
* Temporal cache is used to solve the problem for {@link KubernetesDependentResource} that is, when
21-
* a create or update is executed the subsequent getResource opeeration might not return the
22-
* up-to-date resource from informer cache, since it is not received yet by webhook.
19+
* a create or update is executed the subsequent getResource operation might not return the
20+
* up-to-date resource from informer cache, since it is not received yet.
2321
* </p>
2422
* <p>
2523
* The idea of the solution is, that since an update (for create is simpler) was done successfully,
@@ -36,31 +34,78 @@
3634
*/
3735
public class TemporaryResourceCache<T extends HasMetadata> {
3836

37+
static class ExpirationCache<K> {
38+
private final LinkedHashMap<K, Long> cache;
39+
private final int ttlMs;
40+
41+
public ExpirationCache(int maxEntries, int ttlMs) {
42+
this.ttlMs = ttlMs;
43+
this.cache = new LinkedHashMap<>() {
44+
@Override
45+
protected boolean removeEldestEntry(Map.Entry<K, Long> eldest) {
46+
return size() > maxEntries;
47+
}
48+
};
49+
}
50+
51+
public void add(K key) {
52+
clean();
53+
cache.putIfAbsent(key, System.currentTimeMillis());
54+
}
55+
56+
public boolean contains(K key) {
57+
clean();
58+
return cache.get(key) != null;
59+
}
60+
61+
void clean() {
62+
if (!cache.isEmpty()) {
63+
long currentTimeMillis = System.currentTimeMillis();
64+
var iter = cache.entrySet().iterator();
65+
// the order will already be from oldest to newest, clean a fixed number of entries to
66+
// amortize the cost amongst multiple calls
67+
for (int i = 0; i < 10 && iter.hasNext(); i++) {
68+
var entry = iter.next();
69+
if (currentTimeMillis - entry.getValue() > ttlMs) {
70+
iter.remove();
71+
}
72+
}
73+
}
74+
}
75+
}
76+
3977
private static final Logger log = LoggerFactory.getLogger(TemporaryResourceCache.class);
40-
private static final int MAX_RESOURCE_VERSIONS = 256;
4178

4279
private final Map<ResourceID, T> cache = new ConcurrentHashMap<>();
80+
81+
// keep up to the last million deletions for up to 10 minutes
82+
private final ExpirationCache<String> tombstones = new ExpirationCache<>(1000000, 1200000);
4383
private final ManagedInformerEventSource<T, ?, ?> managedInformerEventSource;
4484
private final boolean parseResourceVersions;
45-
private final Set<String> knownResourceVersions;
85+
private final ExpirationCache<String> knownResourceVersions;
4686

4787
public TemporaryResourceCache(ManagedInformerEventSource<T, ?, ?> managedInformerEventSource,
4888
boolean parseResourceVersions) {
4989
this.managedInformerEventSource = managedInformerEventSource;
5090
this.parseResourceVersions = parseResourceVersions;
5191
if (parseResourceVersions) {
52-
knownResourceVersions = Collections.newSetFromMap(new LinkedHashMap<String, Boolean>() {
53-
@Override
54-
protected boolean removeEldestEntry(java.util.Map.Entry<String, Boolean> eldest) {
55-
return size() >= MAX_RESOURCE_VERSIONS;
56-
}
57-
});
92+
// keep up to the 50000 add/updates for up to 5 minutes
93+
knownResourceVersions = new ExpirationCache<>(50000, 600000);
5894
} else {
5995
knownResourceVersions = null;
6096
}
6197
}
6298

63-
public synchronized void onEvent(T resource, boolean unknownState) {
99+
public synchronized void onDeleteEvent(T resource, boolean unknownState) {
100+
tombstones.add(resource.getMetadata().getUid());
101+
onEvent(resource, unknownState);
102+
}
103+
104+
public synchronized void onAddOrUpdateEvent(T resource) {
105+
onEvent(resource, false);
106+
}
107+
108+
synchronized void onEvent(T resource, boolean unknownState) {
64109
cache.computeIfPresent(ResourceID.fromResource(resource),
65110
(id, cached) -> (unknownState || !isLaterResourceVersion(id, cached, resource)) ? null
66111
: cached);
@@ -84,20 +129,33 @@ public synchronized void putResource(T newResource, String previousResourceVersi
84129
var cachedResource = getResourceFromCache(resourceId)
85130
.orElse(managedInformerEventSource.get(resourceId).orElse(null));
86131

87-
if ((previousResourceVersion == null && cachedResource == null)
132+
boolean moveAhead = false;
133+
if (previousResourceVersion == null && cachedResource == null) {
134+
if (tombstones.contains(newResource.getMetadata().getUid())) {
135+
log.debug(
136+
"Won't resurrect uid {} for resource id: {}",
137+
newResource.getMetadata().getUid(), resourceId);
138+
return;
139+
}
140+
// we can skip further checks as this is a simple add and there's no previous entry to
141+
// consider
142+
moveAhead = true;
143+
}
144+
145+
if (moveAhead
88146
|| (cachedResource != null
89147
&& (cachedResource.getMetadata().getResourceVersion().equals(previousResourceVersion))
90148
|| isLaterResourceVersion(resourceId, newResource, cachedResource))) {
91149
log.debug(
92150
"Temporarily moving ahead to target version {} for resource id: {}",
93151
newResource.getMetadata().getResourceVersion(), resourceId);
94-
putToCache(newResource, resourceId);
152+
cache.put(resourceId, newResource);
95153
} else if (cache.remove(resourceId) != null) {
96154
log.debug("Removed an obsolete resource from cache for id: {}", resourceId);
97155
}
98156
}
99157

100-
public boolean isKnownResourceVersion(T resource) {
158+
public synchronized boolean isKnownResourceVersion(T resource) {
101159
return knownResourceVersions != null
102160
&& knownResourceVersions.contains(resource.getMetadata().getResourceVersion());
103161
}
@@ -123,10 +181,6 @@ private boolean isLaterResourceVersion(ResourceID resourceId, T newResource, T c
123181
return false;
124182
}
125183

126-
private void putToCache(T resource, ResourceID resourceID) {
127-
cache.put(resourceID == null ? ResourceID.fromResource(resource) : resourceID, resource);
128-
}
129-
130184
public synchronized Optional<T> getResourceFromCache(ResourceID resourceID) {
131185
return Optional.ofNullable(cache.get(resourceID));
132186
}

Diff for: operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() {
120120
informerEventSource.onUpdate(cachedDeployment, testDeployment());
121121

122122
verify(eventHandlerMock, times(1)).handleEvent(any());
123-
verify(temporaryResourceCacheMock, times(1)).onEvent(testDeployment(), false);
123+
verify(temporaryResourceCacheMock, times(1)).onAddOrUpdateEvent(testDeployment());
124124
}
125125

126126
@Test

Diff for: operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java

+47-4
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import java.util.Map;
44
import java.util.Optional;
5+
import java.util.concurrent.TimeUnit;
56

7+
import org.awaitility.Awaitility;
68
import org.junit.jupiter.api.BeforeEach;
79
import org.junit.jupiter.api.Test;
810

911
import io.fabric8.kubernetes.api.model.ConfigMap;
1012
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
1113
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
1214
import io.javaoperatorsdk.operator.processing.event.ResourceID;
15+
import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.ExpirationCache;
1316

1417
import static org.assertj.core.api.Assertions.assertThat;
1518
import static org.mockito.ArgumentMatchers.any;
@@ -81,7 +84,7 @@ void addOperationNotAddsTheResourceIfInformerCacheNotEmpty() {
8184
void removesResourceFromCache() {
8285
ConfigMap testResource = propagateTestResourceToCache();
8386

84-
temporaryResourceCache.onEvent(testResource(), false);
87+
temporaryResourceCache.onAddOrUpdateEvent(testResource());
8588

8689
assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)))
8790
.isNotPresent();
@@ -96,20 +99,59 @@ void resourceVersionParsing() {
9699
ConfigMap testResource = propagateTestResourceToCache();
97100

98101
// an event with a newer version will not remove
99-
temporaryResourceCache.onEvent(new ConfigMapBuilder(testResource).editMetadata()
100-
.withResourceVersion("1").endMetadata().build(), false);
102+
temporaryResourceCache.onAddOrUpdateEvent(new ConfigMapBuilder(testResource).editMetadata()
103+
.withResourceVersion("1").endMetadata().build());
101104

102105
assertThat(temporaryResourceCache.isKnownResourceVersion(testResource)).isTrue();
103106
assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)))
104107
.isPresent();
105108

106109
// anything else will remove
107-
temporaryResourceCache.onEvent(testResource(), false);
110+
temporaryResourceCache.onAddOrUpdateEvent(testResource());
108111

109112
assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)))
110113
.isNotPresent();
111114
}
112115

116+
@Test
117+
void rapidDeletion() {
118+
var testResource = testResource();
119+
120+
temporaryResourceCache.onAddOrUpdateEvent(testResource);
121+
temporaryResourceCache.onDeleteEvent(new ConfigMapBuilder(testResource).editMetadata()
122+
.withResourceVersion("3").endMetadata().build(), false);
123+
temporaryResourceCache.putAddedResource(testResource);
124+
125+
assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)))
126+
.isEmpty();
127+
}
128+
129+
@Test
130+
void expirationCacheMax() {
131+
ExpirationCache<Integer> cache = new ExpirationCache<>(2, Integer.MAX_VALUE);
132+
133+
cache.add(1);
134+
cache.add(2);
135+
cache.add(3);
136+
137+
assertThat(cache.contains(1)).isFalse();
138+
assertThat(cache.contains(2)).isTrue();
139+
assertThat(cache.contains(3)).isTrue();
140+
}
141+
142+
@Test
143+
void expirationCacheTtl() {
144+
ExpirationCache<Integer> cache = new ExpirationCache<>(2, 1);
145+
146+
cache.add(1);
147+
cache.add(2);
148+
149+
Awaitility.await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> {
150+
assertThat(cache.contains(1)).isFalse();
151+
assertThat(cache.contains(2)).isFalse();
152+
});
153+
}
154+
113155
private ConfigMap propagateTestResourceToCache() {
114156
var testResource = testResource();
115157
when(informerEventSource.get(any())).thenReturn(Optional.empty());
@@ -127,6 +169,7 @@ ConfigMap testResource() {
127169
configMap.getMetadata().setName("test");
128170
configMap.getMetadata().setNamespace("default");
129171
configMap.getMetadata().setResourceVersion(RESOURCE_VERSION);
172+
configMap.getMetadata().setUid("test-uid");
130173
return configMap;
131174
}
132175

0 commit comments

Comments
 (0)