Skip to content

Commit 99aba6f

Browse files
authored
feat: resource operation support multiple update modes (#3491)
### Summary This reworks `ResourceOperations` into the primary, idiomatic API for create/update/patch operations that keep the informer cache read-after-write consistent **and** control how the resulting own event is handled. Every operation comes in a default flavor and an `Options`-taking flavor, so users can tune caching vs. own-event filtering per call. Own-event filtering is only correct when the framework can distinguish an own write from a concurrent third-party write. This PR makes that requirement explicit through a small strategy model (`Options` → `Mode`) and a pluggable `Matcher` framework with sensible per-operation defaults. ### `Options` / `Mode` - **`matchAndFilter(matcher)` / `matchAndFilterWithDefaultMatcher(updateType)`** — compare desired vs. actual (cached) state; skip the write entirely if they already match, otherwise write and filter the own event. This is the **default** for the update/patch methods and the most efficient option (filters *and* avoids a needless API call). - **`filterWithOptimisticLocking()`** — filter the own event; the write **must** use optimistic locking (a resourceVersion set), otherwise an `IllegalArgumentException` is thrown. Guarantees a concurrent change is rejected server-side rather than silently filtered out. - **`cacheOnly()`** — only cache the response (read-after-write consistency), no own-event filtering. - **`forceFilterEvents()`** — always filter (mostly internal; safe only when correctness is otherwise guaranteed). ### Matcher framework - New `Matcher` interface plus default matchers for every `UpdateType` (`UPDATE`, `SSA`, `JSON_PATCH`, `JSON_MERGE_PATCH`, and their `*_STATUS` variants), backed by `MatcherUtils`. - `GenericKubernetesResourceMatcher.matchStatus(...)` added to match only the `/status` subtree (tolerating server-added fields by default). ### Framework integration - **Finalizer handling** (`ReconciliationDispatcher`): the finalizer is now added with `cacheOnly` (its own event is no longer filtered), replacing the previous filter + `INSTANT_RESCHEDULE` so the finalizer-add event itself drives the follow-up reconcile. - **Primary `UpdateControl` writes** now use `cacheOnly`, trading one extra self-triggered reconcile for correctness (a concurrent spec change during a status patch can no longer be absorbed by the own-event filter). - **`KubernetesDependentResource`** create/SSA now use `forceFilterEvents()`. - `AbstractExternalDependentResource` persists explicit state through `resourceOperations().create(...)`. ### Tests & docs - New ITs covering the concurrency and read-after-write edge cases: `ResourceOperationsIT`, `SecondaryResourceOperationsIT`, `SpecChangeDuringStatusPatchIT`, `OwnSsaStatusUpdateIT`, plus expanded `ResourceOperationsTest`, `PatchMatchersTest`, `StatusMatchersTest`. - Adjusted existing tests to the new event cadence (e.g. `SubResourceUpdateIT`, `WorkflowMultipleActivationIT` now snapshot reconcile counts only after they stabilize). - Docs updated: `reconciler.md`, `v5-5-migration.md`, `v5-5-release.md`, and the read-after-write blog post. ### Notes / follow-ups - The `Options` API is marked `@Experimental` (API may change). - Default matchers are heuristics — reconcilers relying on them should be tested against their concrete resources, or supply a custom `Matcher`. Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent 542e8d9 commit 99aba6f

53 files changed

Lines changed: 3566 additions & 347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
name: Integration tests (${{ inputs.java-version }}, ${{ inputs.kube-version }}, ${{ inputs.http-client }})
2828
runs-on: ubuntu-latest
2929
continue-on-error: ${{ inputs.experimental }}
30-
timeout-minutes: 40
30+
timeout-minutes: 120
3131
steps:
3232
- uses: actions/checkout@v7
3333
with:

docs/content/en/blog/news/read-after-write-consistency.md

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ public UpdateControl<WebPage> reconcile(WebPage webPage, Context<WebPage> contex
3030
}
3131
```
3232

33-
In addition to that, the framework will automatically filter events for your own updates,
34-
so they don't trigger the reconciliation again.
33+
In addition to that, the framework will provide facilities to filter out
34+
events for own updates so they don't trigger the reconciliation again.
3535

3636
{{% alert color=success %}}
3737
**This should significantly simplify controller development, and will make reconciliation
@@ -180,12 +180,6 @@ From this point the idea of the algorithm is very simple:
180180
the one in the TRC. If yes, evict the resource from the TRC.
181181
3. When the controller reads a resource from cache, it checks the TRC first, then falls back to the Informer's cache.
182182

183-
The actual filtering of events for our own writes is more nuanced than a simple
184-
"evict on RV ≥ TRC version" rule — it is driven by a per-resource state machine
185-
that tracks in-flight writes and the events received around them. See
186-
[Filtering events for our own updates](#filtering-events-for-our-own-updates) below.
187-
188-
189183
```mermaid
190184
sequenceDiagram
191185
box rgba(50,108,229,0.1)
@@ -226,10 +220,31 @@ sequenceDiagram
226220
When we update a resource, the informer will eventually propagate an event that would trigger a reconciliation.
227221
In most cases, however, this is not desirable. Since we already have the up-to-date resource at that point,
228222
we want to be notified only when the change originates outside our reconciler.
229-
Therefore, in addition to caching the resource, we filter out events caused by our own updates.
223+
Therefore, in addition to caching the resource, provide utilities to so you can optimize the update/patch
224+
operations to filter out those events.
225+
226+
See [ResourceOperations](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java#L49)
227+
for details.
228+
229+
```java
230+
public UpdateControl<WebPage> reconcile(WebPage webPage, Context<WebPage> context) {
231+
232+
ConfigMap managedConfigMap = prepareConfigMap(webPage);
233+
234+
// resource operation in this case will resource only if
235+
// it does not match the actual, and will filter our the related event
236+
context.resourceOperations().serverSideApply(managedConfigMap);
237+
238+
// UpdateControl.patchStatus would only cache the resource to
239+
// filter out events too you have to use resourceOperations.
240+
context.resourceOperations().serverSideApplyPrimaryStatus(alterStatusObject(webPage));
241+
242+
return UpdateControl.noUpdate();
243+
}
244+
```
230245

231-
Note that the implementation of this is relatively complex: while performing the update, we record all the
232-
events received in the meantime and decide whether to propagate them further once the update request completes.
246+
Note that the implementation of this is relatively complex and has some caveats:
247+
while performing the update, we record all the events received in the meantime and decide whether to propagate them further once the update request completes.
233248

234249
This way, we significantly reduce the number of reconciliations, making the whole process much more efficient.
235250

docs/content/en/docs/documentation/reconciler.md

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,42 @@ supports stronger guarantees, both for primary and secondary resources. If this
192192
they would otherwise look like own echoes, since the relist may have
193193
hidden events.
194194

195+
#### Requesting event filtering and its correctness requirements
196+
197+
Own-event filtering is only safe if the framework can tell an own write apart from a concurrent
198+
third-party write. This requires **either**:
199+
200+
- a *matcher* to be provided (so a filtered event can be confirmed to reflect the desired state we
201+
just wrote), **or**
202+
- the update to be done using *optimistic locking* (a resource version set on the written resource),
203+
so a conflicting concurrent change is rejected by the API server rather than silently swallowed.
204+
205+
`ResourceOperations` methods accept an `Options` argument to select the behavior; each maps to a
206+
`Mode`:
207+
208+
- `Options.matchAndFilter(matcher)` / `Options.matchAndFilterWithDefaultMatcher(updateType)` — compare
209+
the desired state to the actual (cached) state; if they already match, skip the write entirely,
210+
otherwise write and filter the own event. This is the **default** for the server-side apply and
211+
patch (JSON Patch / JSON Merge Patch) methods, and generally the most efficient option: it filters
212+
the own event *and* avoids a request to the API server when nothing changed. Default matchers are
213+
provided for every operation type, but they are heuristics — a workflow relying on them should be
214+
tested against the concrete resources it manages, or a custom matcher supplied.
215+
- `Options.filterWithOptimisticLocking()` — filter the own event; the write must use optimistic
216+
locking (a resource version set on the written resource), otherwise an `IllegalArgumentException`
217+
is thrown. Requiring optimistic locking guarantees a concurrent third-party change is rejected by
218+
the API server rather than being silently filtered out. The overloads taking a matcher /
219+
`updateType` additionally skip the write when the desired state already matches. This match +
220+
optimistic locking combination is the **default** for the PUT `update` / `updatePrimary` /
221+
`updatePrimaryStatus` methods (a full PUT should not clobber a concurrent change).
222+
- `Options.cacheOnly()` — only cache the response (read-cache-after-write consistency), no own-event
223+
filtering.
224+
- `Options.forceFilterEvents()` — always filter, regardless of optimistic locking. Only safe when
225+
correctness is otherwise guaranteed (mostly for internal usage); a concurrent external update in
226+
the filter window may otherwise be missed until the next resync.
227+
228+
See the [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
229+
and `ResourceOperations.Options` documentation for details.
230+
195231

196232
In order to benefit from these stronger guarantees, use [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
197233
from the context of the reconciliation:
@@ -208,20 +244,26 @@ public UpdateControl<WebPage> reconcile(WebPage webPage, Context<WebPage> contex
208244
var upToDateResource = context.getSecondaryResource(ConfigMap.class);
209245

210246
makeStatusChanges(webPage);
211-
212-
// built in update methods by default use this feature
247+
// patches the status and caches the response (does not filter the own event by default, see below)
213248
return UpdateControl.patchStatus(webPage);
214249
}
215250
```
216251

217-
`UpdateControl` and `ErrorStatusUpdateControl` by default use this functionality, but you can also update your primary resource at any time during the reconciliation using `ResourceOperations`:
252+
{{% alert title="UpdateControl and event filtering" %}}
253+
Since v5.5, returning an `UpdateControl` (or `ErrorStatusUpdateControl`) updates the resource and
254+
keeps the cache read-after-write consistent, but by default it **no longer filters the own event**
255+
the resulting update may an additional reconciliation (which should be idempotent). To also filter the own
256+
event, perform the update through `ResourceOperations` instead and return `UpdateControl.noUpdate()`.
257+
{{% /alert %}}
258+
259+
You can update your primary resource at any time during the reconciliation using `ResourceOperations`:
218260

219261
```java
220262

221263
public UpdateControl<WebPage> reconcile(WebPage webPage, Context<WebPage> context) {
222264

223265
makeStatusChanges(webPage);
224-
// this is equivalent to UpdateControl.patchStatus(webpage)
266+
// updates the status, filters the own event and skips the write if nothing changed
225267
context.resourceOperations().serverSideApplyPrimaryStatus(webPage);
226268
return UpdateControl.noUpdate();
227269
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
title: Migrating from v5.4 to v5.5
3+
description: Migrating from v5.4 to v5.5
4+
---
5+
6+
## No breaking API changes
7+
8+
v5.5 does **not** contain breaking API changes: existing code compiles and continues to work without
9+
modification. There is, however, one **behavioral** change around own-event filtering that you should
10+
be aware of (see below).
11+
12+
## `UpdateControl` no longer filters own events by default
13+
14+
In previous versions, updating the primary resource (or its status) by returning an `UpdateControl`
15+
from the reconciler would filter out the event caused by that own update, so the write did not
16+
trigger an additional reconciliation. Unfortunately, in some edge cases this could lead an
17+
incorrect behavior, thus filtering out events which should be propagated.
18+
19+
More precisely in case where for example the spec part of the primary resource was updated, while
20+
controller patched the status, but that patch status was a no-op operation. Note that
21+
these no-op operations are causing issue, which should not be done in first place.
22+
From 5.5 we provide methods in `ResourceOperations` which allow only operations which are
23+
correct.
24+
25+
From v5.5, `UpdateControl` **no longer filters these own events by default**. Returning an
26+
`UpdateControl` still updates the resource and keeps the cache read-after-write consistent, but the
27+
resulting update event is now delivered like any other event, which may cause an additional
28+
reconciliation.
29+
30+
This is safe (reconciliations are expected to be idempotent), but if you relied on the previous
31+
filtering — for example to avoid an extra reconciliation after a status update — use
32+
[`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java)
33+
directly, which does filter own events.
34+
35+
### Using `ResourceOperations` instead
36+
37+
`ResourceOperations` is available from the reconciliation `Context` via
38+
`context.resourceOperations()`. Instead of returning an `UpdateControl`, perform the update through
39+
it and return `UpdateControl.noUpdate()`:
40+
41+
```java
42+
// before (v5.4): the own status update event was filtered
43+
@Override
44+
public UpdateControl<MyResource> reconcile(MyResource resource, Context<MyResource> context) {
45+
resource.setStatus(new MyStatus().setReady(true));
46+
return UpdateControl.patchStatus(resource);
47+
}
48+
```
49+
50+
```java
51+
// after (v5.5): filter the own event explicitly via ResourceOperations
52+
@Override
53+
public UpdateControl<MyResource> reconcile(MyResource resource, Context<MyResource> context) {
54+
resource.setStatus(new MyStatus().setReady(true));
55+
context.resourceOperations().serverSideApplyPrimaryStatus(resource);
56+
return UpdateControl.noUpdate();
57+
}
58+
```
59+
60+
`ResourceOperations` covers every update/patch strategy (server-side apply, update, JSON Patch, JSON
61+
Merge Patch) for both the whole resource and the status subresource, as well as their primary
62+
variants. By default these operations match the desired state against the actual (cached) state
63+
before writing and filter the own event, so they only issue a request to the Kubernetes API server
64+
when something actually changed.
65+
66+
> **Note**: Safe own-event filtering requires either a matcher (used by default) or the update to be
67+
> done with optimistic locking. See the `ResourceOperations` and `ResourceOperations.Options`
68+
> documentation for the available modes, the correctness requirements, and the caveats of the
69+
> default matchers.

0 commit comments

Comments
 (0)