Skip to content

Commit c407bce

Browse files
authored
feat: expectation pattern support (#2941)
Signed-off-by: Attila Mészáros <[email protected]>
1 parent a08d4bc commit c407bce

File tree

14 files changed

+1026
-1
lines changed

14 files changed

+1026
-1
lines changed

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,63 @@ In this mode:
258258
- you cannot use managed dependent resources since those manage the finalizers and other logic related to the normal
259259
execution mode.
260260

261-
See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources;
261+
See also [sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/finalizerhandling) for selectively adding finalizers for resources;
262+
263+
### Expectations
264+
265+
Expectations are a pattern to ensure that, during reconciliation, your secondary resources are in a certain state.
266+
For a more detailed explanation see [this blogpost](https://ahmet.im/blog/controller-pitfalls/#expectations-pattern).
267+
You can find framework support for this pattern in [`io.javaoperatorsdk.operator.processing.expectation`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/)
268+
package. See also related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java).
269+
Note that this feature is marked as `@Experimental`, since based on feedback the API might be improved / changed, but we intend
270+
to support it, later also might be integrated to Dependent Resources and/or Workflows.
271+
272+
The idea is the nutshell, is that you can track your expectations in the expectation manager in the reconciler
273+
which has an API that covers the common use cases.
274+
275+
The following sample is the simplified version of the integration test that implements the logic that creates a
276+
deployment and sets status message if there are the target three replicas ready:
277+
278+
```java
279+
public class ExpectationReconciler implements Reconciler<ExpectationCustomResource> {
280+
281+
// some code is omitted
282+
283+
private final ExpectationManager<ExpectationCustomResource> expectationManager =
284+
new ExpectationManager<>();
285+
286+
@Override
287+
public UpdateControl<ExpectationCustomResource> reconcile(
288+
ExpectationCustomResource primary, Context<ExpectationCustomResource> context) {
289+
290+
// exiting asap if there is an expectation that is not timed out neither fulfilled yet
291+
if (expectationManager.ongoingExpectationPresent(primary, context)) {
292+
return UpdateControl.noUpdate();
293+
}
294+
295+
var deployment = context.getSecondaryResource(Deployment.class);
296+
if (deployment.isEmpty()) {
297+
createDeployment(primary, context);
298+
expectationManager.setExpectation(
299+
primary, Duration.ofSeconds(timeout), deploymentReadyExpectation(context));
300+
return UpdateControl.noUpdate();
301+
} else {
302+
// checks if the expectation if it is fulfilled, and also removes it.
303+
//In your logic, you might add a next expectation based on your workflow.
304+
// Expectations have a name, so you can easily distinguish them if there is more of them.
305+
var res = expectationManager.checkExpectation("deploymentReadyExpectation",primary, context);
306+
if (res.isFulfilled()) {
307+
return pathchStatusWithMessage(primary, DEPLOYMENT_READY);
308+
} else if (res.isTimedOut()) {
309+
// you might add some other timeout handling here
310+
return pathchStatusWithMessage(primary, DEPLOYMENT_TIMEOUT);
311+
}
312+
}
313+
return UpdateControl.noUpdate();
314+
315+
}
316+
}
317+
```
318+
319+
320+

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
@Retention(RetentionPolicy.SOURCE)
3030
@Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD, ElementType.PACKAGE})
3131
public @interface Experimental {
32+
/**
33+
* Message for experimental features that we intend to keep and maintain, but the API might change
34+
* usually, based on user feedback.
35+
*/
36+
String API_MIGHT_CHANGE = "API might change, usually based on feedback";
3237

3338
/**
3439
* Describes why the annotated element is experimental.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.processing.expectation;
17+
18+
import java.util.function.BiPredicate;
19+
20+
import io.fabric8.kubernetes.api.model.HasMetadata;
21+
import io.javaoperatorsdk.operator.api.reconciler.Context;
22+
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
23+
24+
import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
25+
26+
/**
27+
* Expectation is basically a named predicate, that has access to the reconciliation context.
28+
* Therefore, access to all caches, so can check current state of all the relevant resources. Name
29+
* is used to distinguish in reconciliation what is the actual expectation for which we wait for.
30+
*/
31+
@Experimental(API_MIGHT_CHANGE)
32+
public interface Expectation<P extends HasMetadata> {
33+
34+
String UNNAMED = "unnamed";
35+
36+
boolean isFulfilled(P primary, Context<P> context);
37+
38+
default String name() {
39+
return UNNAMED;
40+
}
41+
42+
static <P extends HasMetadata> Expectation<P> createExpectation(
43+
String name, BiPredicate<P, Context<P>> predicate) {
44+
return new Expectation<>() {
45+
@Override
46+
public String name() {
47+
return name;
48+
}
49+
50+
@Override
51+
public boolean isFulfilled(P primary, Context<P> context) {
52+
return predicate.test(primary, context);
53+
}
54+
};
55+
}
56+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.processing.expectation;
17+
18+
import java.time.Duration;
19+
import java.time.LocalDateTime;
20+
import java.util.Optional;
21+
import java.util.concurrent.ConcurrentHashMap;
22+
23+
import io.fabric8.kubernetes.api.model.HasMetadata;
24+
import io.javaoperatorsdk.operator.api.reconciler.Context;
25+
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
26+
import io.javaoperatorsdk.operator.processing.event.ResourceID;
27+
28+
import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
29+
30+
@Experimental(API_MIGHT_CHANGE)
31+
public class ExpectationManager<P extends HasMetadata> {
32+
33+
protected final ConcurrentHashMap<ResourceID, RegisteredExpectation<P>> registeredExpectations =
34+
new ConcurrentHashMap<>();
35+
36+
/**
37+
* Checks if the expectation holds, if not sets the expectation with the given timeout.
38+
*
39+
* @return false, if the expectation is already fulfilled, therefore, not registered. Returns true
40+
* if expectation is not met and set with a timeout.
41+
*/
42+
public boolean checkAndSetExpectation(
43+
P primary, Context<P> context, Duration timeout, Expectation<P> expectation) {
44+
var fulfilled = expectation.isFulfilled(primary, context);
45+
if (fulfilled) {
46+
return false;
47+
} else {
48+
setExpectation(primary, timeout, expectation);
49+
return true;
50+
}
51+
}
52+
53+
/**
54+
* Sets a target expectation with given timeout.
55+
*
56+
* @param primary resource
57+
* @param timeout of expectation
58+
* @param expectation to set
59+
*/
60+
// we might consider in the future to throw an exception if an expectation is already set
61+
public void setExpectation(P primary, Duration timeout, Expectation<P> expectation) {
62+
registeredExpectations.put(
63+
ResourceID.fromResource(primary),
64+
new RegisteredExpectation<>(LocalDateTime.now(), timeout, expectation));
65+
}
66+
67+
/**
68+
* Checks on expectation with provided name. Return the expectation result. If the result of
69+
* expectation is fulfilled, the expectation is automatically removed;
70+
*/
71+
public ExpectationResult<P> checkExpectation(
72+
String expectationName, P primary, Context<P> context) {
73+
var resourceID = ResourceID.fromResource(primary);
74+
var exp = registeredExpectations.get(ResourceID.fromResource(primary));
75+
if (exp != null && expectationName.equals(exp.expectation().name())) {
76+
return checkExpectation(exp, resourceID, primary, context);
77+
} else {
78+
return checkExpectation(null, resourceID, primary, context);
79+
}
80+
}
81+
82+
/**
83+
* Checks if actual expectation is fulfilled. Return the expectation result. If the result of
84+
* expectation is fulfilled, the expectation is automatically removed;
85+
*/
86+
public ExpectationResult<P> checkExpectation(P primary, Context<P> context) {
87+
var resourceID = ResourceID.fromResource(primary);
88+
var exp = registeredExpectations.get(ResourceID.fromResource(primary));
89+
return checkExpectation(exp, resourceID, primary, context);
90+
}
91+
92+
private ExpectationResult<P> checkExpectation(
93+
RegisteredExpectation<P> exp, ResourceID resourceID, P primary, Context<P> context) {
94+
if (exp == null) {
95+
return new ExpectationResult<>(null, null);
96+
}
97+
if (exp.expectation().isFulfilled(primary, context)) {
98+
registeredExpectations.remove(resourceID);
99+
return new ExpectationResult<>(exp.expectation(), ExpectationStatus.FULFILLED);
100+
} else if (exp.isTimedOut()) {
101+
// we don't remove the expectation so user knows about it's state
102+
return new ExpectationResult<>(exp.expectation(), ExpectationStatus.TIMED_OUT);
103+
} else {
104+
return new ExpectationResult<>(exp.expectation(), ExpectationStatus.NOT_YET_FULFILLED);
105+
}
106+
}
107+
108+
/*
109+
* Returns true if there is an expectation for the primary resource, but it is not yet fulfilled
110+
* neither timed out.
111+
* The intention behind this is that you can exit reconciliation early with a simple check
112+
* if true.
113+
* */
114+
public boolean ongoingExpectationPresent(P primary, Context<P> context) {
115+
var exp = registeredExpectations.get(ResourceID.fromResource(primary));
116+
if (exp == null) {
117+
return false;
118+
}
119+
return !exp.isTimedOut() && !exp.expectation().isFulfilled(primary, context);
120+
}
121+
122+
public boolean isExpectationPresent(P primary) {
123+
return registeredExpectations.containsKey(ResourceID.fromResource(primary));
124+
}
125+
126+
public boolean isExpectationPresent(String name, P primary) {
127+
var exp = registeredExpectations.get(ResourceID.fromResource(primary));
128+
return exp != null && name.equals(exp.expectation().name());
129+
}
130+
131+
public Optional<Expectation<P>> getExpectation(P primary) {
132+
var regExp = registeredExpectations.get(ResourceID.fromResource(primary));
133+
return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation);
134+
}
135+
136+
public Optional<String> getExpectationName(P primary) {
137+
return getExpectation(primary).map(Expectation::name);
138+
}
139+
140+
public void removeExpectation(P primary) {
141+
registeredExpectations.remove(ResourceID.fromResource(primary));
142+
}
143+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.processing.expectation;
17+
18+
import io.fabric8.kubernetes.api.model.HasMetadata;
19+
20+
public record ExpectationResult<P extends HasMetadata>(
21+
Expectation<P> expectation, ExpectationStatus status) {
22+
23+
public boolean isFulfilled() {
24+
return status == ExpectationStatus.FULFILLED;
25+
}
26+
27+
public boolean isTimedOut() {
28+
return status == ExpectationStatus.TIMED_OUT;
29+
}
30+
31+
public boolean isExpectationPresent() {
32+
return expectation != null;
33+
}
34+
35+
public boolean isNotPresentOrFulfilled() {
36+
return !isExpectationPresent() || isFulfilled();
37+
}
38+
39+
public String name() {
40+
return expectation.name();
41+
}
42+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.processing.expectation;
17+
18+
public enum ExpectationStatus {
19+
FULFILLED,
20+
NOT_YET_FULFILLED,
21+
TIMED_OUT
22+
}

0 commit comments

Comments
 (0)