diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml
index 75c1ebd50d..bcd6c2e743 100644
--- a/bootstrapper-maven-plugin/pom.xml
+++ b/bootstrapper-maven-plugin/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTbootstrapper
diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml
index 8ae3911352..be70ab9a2e 100644
--- a/caffeine-bounded-cache-support/pom.xml
+++ b/caffeine-bounded-cache-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTcaffeine-bounded-cache-support
diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
index 538c52c00c..3ac1e88e71 100644
--- a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
+++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
@@ -531,6 +531,34 @@ samples [here](https://github.com/java-operator-sdk/java-operator-sdk/tree/main/
in [related integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java)
.
+### Adding Common Metadata to All Managed Resources (Desired State Aspects)
+
+Operators often need to mark every resource they manage in a uniform way, for example with a
+`app.kubernetes.io/managed-by` label, so that these resources can easily be identified, selected or
+garbage-collected later on. Instead of repeating that logic in every `desired()` implementation, a
+`DesiredStateAspect` can be registered once, at the operator level, and is then applied to the
+desired state of every Kubernetes dependent resource managed by the operator:
+
+```java
+Operator operator = new Operator(overrider -> overrider
+ .withDesiredStateAspects(List.of(
+ (desired, dependentResource, context) -> desired.getMetadata().getLabels()
+ .put("app.kubernetes.io/managed-by", "my-operator"))));
+```
+
+Aspects are applied, in registration order, right after the desired state has been computed and
+before the desired state is matched against the actual resource, created or updated. As a
+consequence, the metadata added by an aspect is part of the desired state proper: if it is removed
+from the actual resource, or if the aspect itself changes, the associated secondary resources are
+updated accordingly on the next reconciliation.
+
+Since the desired state is computed at most once per reconciliation and cached in the `Context`,
+aspects are called at most once per dependent resource and reconciliation. They are only called for
+dependent resources whose desired state is a `HasMetadata`, meaning that external (non-Kubernetes)
+dependent resources are left untouched. Implementations are expected to modify the provided desired
+state in place and need to be thread-safe as they can be called concurrently for different primary
+resources.
+
## "Read-only" Dependent Resources vs. Event Source
See Integration test for a read-only
diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md
index e7aea6b065..4b890407e4 100644
--- a/docs/content/en/docs/documentation/eventing.md
+++ b/docs/content/en/docs/documentation/eventing.md
@@ -348,6 +348,30 @@ See
also [CaffeineBoundedItemStores](https://github.com/operator-framework/java-operator-sdk/blob/main/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java)
for more details.
+### Removing the Namespace Index
+
+Informers keep an index from namespace to the resources cached for it. JOSDK never reads that index,
+only the ones registered explicitly through `IndexerResourceCache.addIndexers(..)`, so it can be
+removed to save an entry per cached resource:
+
+```java
+@ControllerConfiguration(informer = @Informer(withoutNamespaceIndex = true))
+public class MyReconciler implements Reconciler { }
+```
+
+The same option is available on `InformerEventSourceConfiguration.Builder` for event sources, and on
+`InformerConfiguration.Builder`.
+
+This matters most for informers that cache a large number of resources, and in particular together
+with a custom [item store](#bounded-caches-for-informers) that keeps only a reduced form of each
+resource: the index is keyed independently of what the store does with the resource itself, so
+shrinking what is cached does not shrink the index.
+
+Note that this takes part in the informer pool identity described below: an event source that
+removes the index does not share an informer with one that keeps it. If two event sources watch the
+same resource type and only one of them sets the option, they end up with two informers, and two
+caches of that resource type, which can cost far more memory than the index ever did.
+
### Sharing Informers Between Controllers (Informer Pool)
{{% alert title="Experimental" color="warning" %}}
@@ -374,7 +398,10 @@ Two event sources share an informer when their effective informer configuration
- the resource type (or the group/version/kind for generic resources),
- the watched namespace,
- the label, field and shard selectors,
-- the configured [item store](#bounded-caches-for-informers).
+- the configured [item store](#bounded-caches-for-informers),
+- whether the [namespace index is removed](#removing-the-namespace-index): it cannot be present for
+ one event source and absent for another on one shared informer, so event sources that disagree on
+ it are backed by separate informers.
The `informerListLimit` is intentionally *not* part of this identity: if two otherwise-equivalent
event sources request a different list limit, the existing informer is reused (a warning is logged
diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md
index cdfb1b7fdb..20b3ca2003 100644
--- a/docs/content/en/docs/documentation/operations/configuration.md
+++ b/docs/content/en/docs/documentation/operations/configuration.md
@@ -23,6 +23,47 @@ Operator operator = new Operator( override -> override
.withLeaderElectionConfiguration(new LeaderElectionConfiguration("bar", "barNS")));
```
+### Virtual Threads
+
+Reconciliation is mostly about blocking: talking to the Kubernetes API server or to external
+systems. Virtual threads make such blocking calls much cheaper than platform threads, and the
+framework can be switched over to them with a single flag:
+
+```java
+Operator operator = new Operator(override -> override.withUseVirtualThreads(true));
+```
+
+When enabled, reconciliations, dependent resource workflows and the framework's internal
+housekeeping (starting the informers, for example) all run on virtual threads.
+
+Enabling virtual threads does **not** remove the concurrency limits, parallelism is configured
+exactly as before: `withConcurrentReconciliationThreads(int)` still caps how many reconciliations
+run at the same time and `withConcurrentWorkflowExecutorThreads(int)` how many dependent resources
+of a workflow are processed concurrently. Only the threads backing those limits change. Since
+virtual threads are cheap, these limits can usually be raised significantly compared to what is
+reasonable with platform threads.
+
+Two things to keep in mind:
+
+- Virtual threads are officially supported on **Java 25 or later**. They exist from Java 21 on and
+ the flag does enable them there, but before Java 25 a virtual thread pins its carrier thread
+ while it is inside a `synchronized` block, which can starve the carrier pool. [JEP
+ 491](https://openjdk.org/jeps/491), delivered in Java 25, removed that pinning, so this is the
+ baseline the framework supports. On a JVM without virtual threads at all (below Java 21) a
+ warning is logged and platform threads are used instead, so the same configuration works on any
+ supported Java version.
+- A custom `ExecutorService` provided through `withExecutorService(...)` or
+ `withWorkflowExecutorService(...)` is always used as is, the flag has no effect on it.
+- The Kubernetes client the framework creates when none is provided also switches its internal
+ task executor (used to dispatch informer events to their handlers and to deliver watch events)
+ to virtual threads. A client you provide through `withKubernetesClient(...)` is used as is: to
+ get the same behavior, configure it yourself, e.g. with
+ `new KubernetesClientBuilder().withTaskExecutor(Executors.newVirtualThreadPerTaskExecutor())`
+ (the client doesn't shut down an executor passed that way, use `withTaskExecutorSupplier(...)`
+ if it should be shut down when the client is closed).
+ Either way, the blocking calls your reconciler makes through the client work well on virtual
+ threads without any client-side change.
+
## Reconciler-Level Configuration
While reconcilers are typically configured using the `@ControllerConfiguration` annotation, you can also override configuration at runtime when registering the reconciler with the operator. You can either:
@@ -265,6 +306,7 @@ All operator-level keys are prefixed with `josdk.`.
|---|---|---|
| `josdk.check-crd` | `Boolean` | Validate CRDs against local model on startup |
| `josdk.close-client-on-stop` | `Boolean` | Close the Kubernetes client when the operator stops |
+| `josdk.use-virtual-threads` | `Boolean` | Run the framework's concurrent work on virtual threads (officially supported on Java 25+ at runtime) |
| `josdk.use-ssa-to-patch-primary-resource` | `Boolean` | Use Server-Side Apply to patch the primary resource |
| `josdk.clone-secondary-resources-when-getting-from-cache` | `Boolean` | Clone secondary resources on cache reads |
@@ -356,6 +398,7 @@ All controller-level keys are prefixed with `josdk.controller..
| `josdk.controller..informer.label-selector` | `String` | Label selector for the primary resource informer (alias for `label-selector`) |
| `josdk.controller..informer.shard-selector` | `String` | Shard selector for the primary resource informer (alias for `shard-selector`) |
| `josdk.controller..informer.list-limit` | `Long` | Page size for paginated informer list requests; omit for no pagination |
+| `josdk.controller..informer.without-namespace-index` | `Boolean` | Removes the namespace index the informer maintains; defaults to `false`. See [Removing the Namespace Index](../eventing#removing-the-namespace-index) |
#### Retry
diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml
index be864bff8e..ae3c4d0be1 100644
--- a/micrometer-support/pom.xml
+++ b/micrometer-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTmicrometer-support
diff --git a/migration/pom.xml b/migration/pom.xml
index bdbc8fbc4f..cf5143c925 100644
--- a/migration/pom.xml
+++ b/migration/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTmigration
diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml
index 5adbefd8d8..0f974400b1 100644
--- a/operator-framework-bom/pom.xml
+++ b/operator-framework-bom/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkoperator-framework-bom
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTpomOperator SDK - Bill of MaterialsJava SDK for implementing Kubernetes operators
diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml
index b0ef5cac6f..e0c6031670 100644
--- a/operator-framework-core/pom.xml
+++ b/operator-framework-core/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOT../pom.xml
@@ -128,7 +128,7 @@
${git-commit-id-maven-plugin.version}true
- ${project.build.outputDirectory}/version.properties
+ ${project.build.outputDirectory}/operator-sdk-version.properties^git.build.time$^git.commit.id.(abbrev|full)$
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
index db1b9a5fa5..1d750bdc96 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java
@@ -16,10 +16,10 @@
package io.javaoperatorsdk.operator.api.config;
import java.time.Duration;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.function.Consumer;
import org.slf4j.Logger;
@@ -41,6 +41,7 @@
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
@@ -153,6 +154,12 @@ public R clone(R object) {
* io.javaoperatorsdk.operator.Operator#Operator(Consumer)}, passing your custom instance with
* {@link ConfigurationServiceOverrider#withKubernetesClient(KubernetesClient)}.
*
+ *
When {@link #useVirtualThreads()} is enabled (and supported by the JVM), the default client
+ * runs its internal asynchronous tasks, such as dispatching informer events to their handlers and
+ * delivering watch events, on virtual threads instead of its default cached platform thread pool.
+ * A client provided by overriding this method or through {@link
+ * ConfigurationServiceOverrider#withKubernetesClient(KubernetesClient)} is used as is.
+ *
*
NOTE: It is strongly suggested that implementors override this method since the
* default implementation creates a new {@link KubernetesClient} instance each time this method is
* called.
@@ -161,13 +168,17 @@ public R clone(R object) {
* @since 4.4.0
*/
default KubernetesClient getKubernetesClient() {
- return new KubernetesClientBuilder()
- .withConfig(
- new ConfigBuilder(Config.autoConfigure(null))
- .withMaxConcurrentRequests(DEFAULT_MAX_CONCURRENT_REQUEST)
- .build())
- .withKubernetesSerialization(new KubernetesSerialization())
- .build();
+ final var builder =
+ new KubernetesClientBuilder()
+ .withConfig(
+ new ConfigBuilder(Config.autoConfigure(null))
+ .withMaxConcurrentRequests(DEFAULT_MAX_CONCURRENT_REQUEST)
+ .build())
+ .withKubernetesSerialization(new KubernetesSerialization());
+ if (VirtualThreads.shouldUse(useVirtualThreads())) {
+ builder.withTaskExecutorSupplier(VirtualThreads.newKubernetesClientTaskExecutorSupplier());
+ }
+ return builder.build();
}
/**
@@ -201,6 +212,11 @@ default boolean checkCRDAndValidateLocalModel() {
* The number of threads the operator can spin out to dispatch reconciliation requests to
* reconcilers with the default executors
*
+ *
This is a concurrency limit and applies regardless of whether the default executor is backed
+ * by platform or by virtual threads, see {@link #useVirtualThreads()}: with virtual threads it
+ * caps how many reconciliations run at the same time rather than the size of a thread pool. Since
+ * virtual threads are cheap, the limit can be set considerably higher when they are enabled.
+ *
* @return the number of concurrent reconciliation threads
*/
default int concurrentReconciliationThreads() {
@@ -211,6 +227,12 @@ default int concurrentReconciliationThreads() {
* Number of threads the operator can spin out to be used in the workflows with the default
* executor.
*
+ *
This is a concurrency limit and applies regardless of whether the default executor is backed
+ * by platform or by virtual threads, see {@link #useVirtualThreads()}: with virtual threads it
+ * caps how many dependent resources are processed at the same time rather than the size of a
+ * thread pool. Since virtual threads are cheap, the limit can be set considerably higher when
+ * they are enabled.
+ *
* @return the maximum number of concurrent workflow threads
*/
default int concurrentWorkflowExecutorThreads() {
@@ -226,6 +248,42 @@ default Metrics getMetrics() {
return Metrics.NOOP;
}
+ /**
+ * Whether the framework should run the tasks it executes concurrently — reconciliations,
+ * dependent workflows and internal housekeeping such as starting the informers — on virtual
+ * threads instead of platform threads.
+ *
+ *
Virtual threads make blocking operations, which is essentially all a reconciler does while
+ * talking to the Kubernetes API server or to external systems, much cheaper. Enabling them does
+ * not lift the configured concurrency limits: {@link #concurrentReconciliationThreads()}
+ * and {@link #concurrentWorkflowExecutorThreads()} still cap how many reconciliations,
+ * respectively dependent resources, are processed at the same time, they just aren't backed by a
+ * pool of platform threads anymore. Since virtual threads are cheap, those limits can be set
+ * considerably higher than what would be reasonable for platform threads.
+ *
+ *
Officially supported on Java 25 or later. Virtual threads exist as of Java 21 and are used
+ * there as well, but before Java 25 a virtual thread pins its carrier thread while inside a
+ * {@code synchronized} block, which JEP 491 removed in
+ * Java 25. On a JVM without virtual threads at all, a warning is logged and platform threads are
+ * used, so that the same configuration works regardless of the Java version the operator runs on.
+ *
+ *
Note that this only affects the executors created by the framework: a custom {@link
+ * ExecutorService} provided through {@link #getExecutorService()} or {@link
+ * #getWorkflowExecutorService()} is used as is. The same goes for the {@link KubernetesClient}:
+ * the default client created by {@link #getKubernetesClient()} then also runs its internal
+ * asynchronous tasks (informer event dispatching, watch event delivery) on virtual threads, while
+ * a custom client, e.g. provided through {@link
+ * ConfigurationServiceOverrider#withKubernetesClient(KubernetesClient)}, keeps whatever task
+ * executor it was built with. The blocking calls a reconciler performs through the client don't
+ * need any of this: they already park the calling virtual thread rather than its carrier.
+ *
+ * @return {@code true} to use virtual threads, {@code false} (default) to use platform threads
+ * @since 5.7.0
+ */
+ default boolean useVirtualThreads() {
+ return false;
+ }
+
/**
* Override to provide a custom {@link ExecutorService} implementation to change how threads
* handle concurrent reconciliations
@@ -234,7 +292,8 @@ default Metrics getMetrics() {
* processing
*/
default ExecutorService getExecutorService() {
- return Executors.newFixedThreadPool(concurrentReconciliationThreads());
+ return ExecutorServiceManager.newBoundedExecutorService(
+ concurrentReconciliationThreads(), useVirtualThreads());
}
/**
@@ -244,7 +303,8 @@ default ExecutorService getExecutorService() {
* @return the {@link ExecutorService} implementation to use for dependent workflow processing
*/
default ExecutorService getWorkflowExecutorService() {
- return Executors.newFixedThreadPool(concurrentWorkflowExecutorThreads());
+ return ExecutorServiceManager.newBoundedExecutorService(
+ concurrentWorkflowExecutorThreads(), useVirtualThreads());
}
/**
@@ -539,4 +599,18 @@ default InformerPool informerPool() {
pool.setConfigurationService(this);
return pool;
}
+
+ /**
+ * Retrieves the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
+ * dependent resources managed by the operator. Aspects are applied in the order in which they are
+ * returned, right after the desired state has been computed, and are typically used to add common
+ * metadata (such as a label identifying the operator managing the resource) to all the resources
+ * the operator creates or updates.
+ *
+ * @return the list of aspects to apply to computed desired states, empty by default
+ * @since 5.6.0
+ */
+ default List desiredStateAspects() {
+ return List.of();
+ }
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
index 18a2e3fc38..0f4f74bf37 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java
@@ -16,6 +16,8 @@
package io.javaoperatorsdk.operator.api.config;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@@ -31,6 +33,7 @@
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
@SuppressWarnings({"unused", "UnusedReturnValue"})
@@ -47,6 +50,7 @@ public class ConfigurationServiceOverrider {
private KubernetesClient client;
private ExecutorService executorService;
private ExecutorService workflowExecutorService;
+ private Boolean useVirtualThreads;
private LeaderElectionConfiguration leaderElectionConfiguration;
private String clusterScopedEventNamespace;
private EventRecorder eventRecorder;
@@ -59,6 +63,7 @@ public class ConfigurationServiceOverrider {
private Boolean useSSAToPatchPrimaryResource;
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
private InformerPool informerPool;
+ private List desiredStateAspects;
@SuppressWarnings("rawtypes")
private DependentResourceFactory dependentResourceFactory;
@@ -115,6 +120,19 @@ public ConfigurationServiceOverrider withWorkflowExecutorService(
return this;
}
+ /**
+ * Makes the framework run the tasks it executes concurrently on virtual threads instead of
+ * platform threads. Officially supported on Java 25 or later, see {@link
+ * ConfigurationService#useVirtualThreads()} for the details.
+ *
+ * @param useVirtualThreads {@code true} to use virtual threads
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ */
+ public ConfigurationServiceOverrider withUseVirtualThreads(boolean useVirtualThreads) {
+ this.useVirtualThreads = useVirtualThreads;
+ return this;
+ }
+
/**
* Replaces the default {@link KubernetesClient} instance by the specified one. This is the
* preferred mechanism to configure which client will be used to access the cluster.
@@ -229,6 +247,38 @@ public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool)
return this;
}
+ /**
+ * Replaces the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
+ * dependent resources managed by the operator by the specified ones.
+ *
+ * @param desiredStateAspects the aspects to apply, in the order in which they should be applied
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ * @since 5.6.0
+ */
+ public ConfigurationServiceOverrider withDesiredStateAspects(
+ List desiredStateAspects) {
+ this.desiredStateAspects = new ArrayList<>(desiredStateAspects);
+ return this;
+ }
+
+ /**
+ * Appends the specified {@link DesiredStateAspect}s to the already configured ones, which are the
+ * ones configured on the overridden {@link ConfigurationService} unless {@link
+ * #withDesiredStateAspects(List)} was called on this overrider first.
+ *
+ * @param desiredStateAspects the aspects to append, in the order in which they should be applied
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ * @since 5.6.0
+ */
+ public ConfigurationServiceOverrider addDesiredStateAspects(
+ DesiredStateAspect... desiredStateAspects) {
+ if (this.desiredStateAspects == null) {
+ this.desiredStateAspects = new ArrayList<>(original.desiredStateAspects());
+ }
+ this.desiredStateAspects.addAll(List.of(desiredStateAspects));
+ return this;
+ }
+
public ConfigurationService build() {
return new BaseConfigurationService(original.getVersion(), cloner, client) {
@Override
@@ -286,6 +336,11 @@ public boolean closeClientOnStop() {
return overriddenValueOrDefault(closeClientOnStop, ConfigurationService::closeClientOnStop);
}
+ @Override
+ public boolean useVirtualThreads() {
+ return overriddenValueOrDefault(useVirtualThreads, ConfigurationService::useVirtualThreads);
+ }
+
@Override
public ExecutorService getExecutorService() {
if (executorService != null) {
@@ -383,6 +438,12 @@ public synchronized InformerPool informerPool() {
informerPool.setConfigurationService(this);
return informerPool;
}
+
+ @Override
+ public List desiredStateAspects() {
+ return overriddenValueOrDefault(
+ desiredStateAspects, ConfigurationService::desiredStateAspects);
+ }
};
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java
index 1c1e03c870..0a32bbdcaf 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java
@@ -210,6 +210,18 @@ public ControllerConfigurationOverrider withInformerListLimit(Long informerLi
return this;
}
+ /**
+ * Whether to remove the namespace index the underlying informer maintains by default. Note that
+ * event sources that disagree on this setting do not share an informer.
+ *
+ * @param withoutNamespaceIndex true to remove the namespace index, false (the default) to keep it
+ * @see io.javaoperatorsdk.operator.api.config.informer.Informer#withoutNamespaceIndex()
+ */
+ public ControllerConfigurationOverrider withoutNamespaceIndex(boolean withoutNamespaceIndex) {
+ config.withoutNamespaceIndex(withoutNamespaceIndex);
+ return this;
+ }
+
public ControllerConfigurationOverrider replacingNamedDependentResourceConfig(
String name, Object dependentResourceConfig) {
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java
index cdcafcaa46..342e07fb9f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java
@@ -49,6 +49,37 @@ public class ExecutorServiceManager {
start(configurationService);
}
+ /**
+ * Creates the executor service used to run a bounded number of tasks concurrently, either backed
+ * by virtual threads or by a fixed size pool of platform threads. The concurrency limit is
+ * enforced in both cases.
+ *
+ * @param maxConcurrency the maximal number of tasks executed at the same time
+ * @param useVirtualThreads whether virtual threads should be used, see {@link
+ * ConfigurationService#useVirtualThreads()}
+ * @return the created {@link ExecutorService}
+ */
+ public static ExecutorService newBoundedExecutorService(
+ int maxConcurrency, boolean useVirtualThreads) {
+ return VirtualThreads.shouldUse(useVirtualThreads)
+ ? VirtualThreads.newBoundedVirtualThreadExecutor(maxConcurrency)
+ : Executors.newFixedThreadPool(maxConcurrency);
+ }
+
+ /**
+ * Creates the executor service used to run an unbounded number of tasks concurrently, either
+ * backed by virtual threads or by a cached pool of platform threads.
+ *
+ * @param useVirtualThreads whether virtual threads should be used, see {@link
+ * ConfigurationService#useVirtualThreads()}
+ * @return the created {@link ExecutorService}
+ */
+ public static ExecutorService newUnboundedExecutorService(boolean useVirtualThreads) {
+ return VirtualThreads.shouldUse(useVirtualThreads)
+ ? VirtualThreads.newVirtualThreadPerTaskExecutor()
+ : Executors.newCachedThreadPool();
+ }
+
/**
* Uses cachingExecutorService from this manager. Use this only for tasks, that don't have dynamic
* nature, in sense that won't grow with the number of inputs (thus kubernetes resources)
@@ -135,7 +166,10 @@ public ScheduledExecutorService scheduledExecutorService() {
public synchronized void start(ConfigurationService configurationService) {
if (!started) {
this.configurationService = configurationService; // used to lazy init workflow executor
- this.cachingExecutorService = Executors.newCachedThreadPool();
+ this.cachingExecutorService =
+ newUnboundedExecutorService(configurationService.useVirtualThreads());
+ // stays on platform threads even when virtual threads are requested: there is no virtual
+ // thread backed ScheduledExecutorService in the JDK, see VirtualThreads
this.scheduledExecutorService = Executors.newScheduledThreadPool(0);
this.executor = new InstrumentedExecutorService(configurationService.getExecutorService());
started = true;
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java
index 6ad4928c86..4613d9645d 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java
@@ -42,6 +42,8 @@ public class Utils {
public static final String GENERIC_PARAMETER_TYPE_ERROR_PREFIX =
"Couldn't retrieve generic parameter type from ";
+ public static final String VERSION_PROPERTIES_FILE_NAME = "operator-sdk-version.properties";
+
public static final Version VERSION = loadFromProperties();
/**
@@ -52,7 +54,9 @@ public class Utils {
*/
private static Version loadFromProperties() {
final var is =
- Thread.currentThread().getContextClassLoader().getResourceAsStream("version.properties");
+ Thread.currentThread()
+ .getContextClassLoader()
+ .getResourceAsStream(VERSION_PROPERTIES_FILE_NAME);
final var properties = new Properties();
if (is != null) {
@@ -62,7 +66,9 @@ private static Version loadFromProperties() {
log.warn("Couldn't load version information: {}", e.getMessage());
}
} else {
- log.warn("Couldn't find version.properties file. Default version information will be used.");
+ log.warn(
+ "Couldn't find {} file. Default version information will be used.",
+ VERSION_PROPERTIES_FILE_NAME);
}
Date builtTime;
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/VirtualThreads.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/VirtualThreads.java
new file mode 100644
index 0000000000..7c7b8edc2b
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/VirtualThreads.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright Java Operator SDK Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.javaoperatorsdk.operator.api.config;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.fabric8.kubernetes.client.KubernetesClientBuilder.ExecutorSupplier;
+import io.javaoperatorsdk.operator.OperatorException;
+
+/**
+ * Creates the virtual thread based executors used when {@link
+ * ConfigurationService#useVirtualThreads()} is enabled.
+ *
+ *
The SDK is compiled for Java 17, in which virtual threads don't exist yet, so {@code
+ * Executors.newVirtualThreadPerTaskExecutor()} is looked up reflectively and is only available when
+ * the operator actually runs on Java 21 or later.
+ *
+ *
There is intentionally no scheduled variant here: the JDK provides no virtual thread backed
+ * {@link java.util.concurrent.ScheduledExecutorService}, scheduling was deliberately left out of
+ * virtual threads (the Loom runtime itself uses a platform thread scheduler to unpark timed out
+ * virtual threads). Running scheduled tasks on virtual threads would mean keeping a platform thread
+ * scheduler purely for the timing and handing each fired task off to a virtual thread executor,
+ * which the SDK doesn't do, so {@link ExecutorServiceManager#scheduledExecutorService()} is always
+ * backed by platform threads.
+ */
+final class VirtualThreads {
+
+ private static final Logger log = LoggerFactory.getLogger(VirtualThreads.class);
+
+ private static final MethodHandle NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = lookupFactoryMethod();
+ private static final AtomicBoolean UNSUPPORTED_WARNING_LOGGED = new AtomicBoolean();
+
+ private VirtualThreads() {}
+
+ private static MethodHandle lookupFactoryMethod() {
+ try {
+ return MethodHandles.publicLookup()
+ .findStatic(
+ Executors.class,
+ "newVirtualThreadPerTaskExecutor",
+ MethodType.methodType(ExecutorService.class));
+ } catch (NoSuchMethodException | IllegalAccessException e) {
+ log.debug("Virtual threads are not available on this JVM", e);
+ return null;
+ }
+ }
+
+ /** Whether the JVM the operator runs on supports virtual threads, i.e. is Java 21 or later. */
+ static boolean isSupported() {
+ return NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR != null;
+ }
+
+ /**
+ * Whether virtual threads should effectively be used, i.e. they were requested through {@link
+ * ConfigurationService#useVirtualThreads()} and the JVM supports them. Requesting them
+ * on a JVM that doesn't support them is only warned about, so that the same configuration can be
+ * used regardless of the Java version the operator ends up running on, the only consequence being
+ * that platform threads are used instead. Concurrency limits are enforced either way.
+ */
+ static boolean shouldUse(boolean requested) {
+ if (!requested || isSupported()) {
+ return requested;
+ }
+ if (UNSUPPORTED_WARNING_LOGGED.compareAndSet(false, true)) {
+ log.warn(
+ "Virtual threads were requested but are not supported by the JVM in use (Java {}, Java 21"
+ + " or later is required, Java 25 or later is officially supported). Falling back to"
+ + " platform threads.",
+ Runtime.version().feature());
+ }
+ return false;
+ }
+
+ /** An unbounded executor starting a new virtual thread for each submitted task. */
+ static ExecutorService newVirtualThreadPerTaskExecutor() {
+ if (!isSupported()) {
+ throw new OperatorException(
+ "Virtual threads are not supported by the JVM in use, Java 21 or later is required"
+ + " (Java 25 or later is officially supported)");
+ }
+ try {
+ return (ExecutorService) NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR.invokeExact();
+ } catch (Throwable e) {
+ throw new OperatorException("Couldn't create a virtual thread per task executor", e);
+ }
+ }
+
+ /**
+ * Supplies the task executor of the default {@link io.fabric8.kubernetes.client.KubernetesClient}
+ * created by {@link ConfigurationService#getKubernetesClient()}: an unbounded virtual thread
+ * executor, replacing the client's default cached platform thread pool, that is shut down when
+ * the client is closed.
+ */
+ static ExecutorSupplier newKubernetesClientTaskExecutorSupplier() {
+ return new ExecutorSupplier() {
+ @Override
+ public Executor get() {
+ return newVirtualThreadPerTaskExecutor();
+ }
+
+ @Override
+ public void onClose(Executor executor) {
+ ((ExecutorService) executor).shutdownNow();
+ }
+ };
+ }
+
+ /**
+ * A virtual thread based executor executing at most {@code maxConcurrency} tasks at the same
+ * time, the equivalent of a fixed size platform thread pool.
+ */
+ static ExecutorService newBoundedVirtualThreadExecutor(int maxConcurrency) {
+ return new BoundedExecutorService(newVirtualThreadPerTaskExecutor(), maxConcurrency);
+ }
+
+ /**
+ * Limits how many of the tasks submitted to the wrapped executor run at the same time.
+ *
+ *
A thread is started for each task as soon as it is submitted, the task then waits for a
+ * permit before it actually runs. This only makes sense with virtual threads, which are cheap
+ * enough to be parked in large numbers, and has the property that submitting a task never blocks
+ * the submitting thread, just like queuing it on a fixed size platform thread pool wouldn't.
+ */
+ private static final class BoundedExecutorService extends AbstractExecutorService {
+
+ private final ExecutorService delegate;
+ private final Semaphore permits;
+
+ private BoundedExecutorService(ExecutorService delegate, int maxConcurrency) {
+ this.delegate = delegate;
+ this.permits = new Semaphore(maxConcurrency, true);
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ delegate.execute(
+ () -> {
+ try {
+ permits.acquire();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // shutdownNow interrupted us before the task even started: cancel it so that whoever
+ // waits on the associated future isn't left hanging
+ if (command instanceof Future) {
+ ((Future>) command).cancel(false);
+ }
+ return;
+ }
+ try {
+ command.run();
+ } finally {
+ permits.release();
+ }
+ });
+ }
+
+ @Override
+ public void shutdown() {
+ delegate.shutdown();
+ }
+
+ @Override
+ public List shutdownNow() {
+ return delegate.shutdownNow();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return delegate.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return delegate.isTerminated();
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
+ return delegate.awaitTermination(timeout, unit);
+ }
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
index 04f97902d3..69999ef04f 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java
@@ -31,6 +31,7 @@
import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_COMPARABLE_RESOURCE_VERSION;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_GHOST_RESOURCE_CHECK_INTERVAL_MILLIS;
+import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_WITHOUT_NAMESPACE_INDEX;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_LONG_VALUE_SET;
import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_VALUE_SET;
@@ -153,6 +154,23 @@
*/
boolean comparableResourceVersions() default DEFAULT_COMPARABLE_RESOURCE_VERSION;
+ /**
+ * Whether to remove the namespace index that the underlying informer maintains by default.
+ *
+ *
The framework never reads that index, it only reads the indexes registered through {@link
+ * io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache#addIndexers}, so
+ * dropping it saves an entry per cached resource. It is worth setting when the informer caches a
+ * large number of resources and nothing looks them up by namespace, in particular together with a
+ * custom {@link #itemStore()} that keeps only a reduced form of each resource.
+ *
+ *
Note that this makes the informer a distinct one for pooling purposes: event sources that
+ * disagree on this setting do not share an informer, because the index cannot be present for one
+ * of them and absent for the other.
+ *
+ * @since 5.7.0
+ */
+ boolean withoutNamespaceIndex() default DEFAULT_WITHOUT_NAMESPACE_INDEX;
+
/**
* @deprecated Ghost resource checking is now triggered by the informer's onList callback. This
* setting is no longer used.
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
index 9fe25c999d..106f5898c5 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java
@@ -58,6 +58,7 @@ public class InformerConfiguration {
private Long informerListLimit;
private FieldSelector fieldSelector;
private Boolean comparableResourceVersions;
+ private Boolean withoutNamespaceIndex;
protected InformerConfiguration(
Class resourceClass,
@@ -75,6 +76,7 @@ protected InformerConfiguration(
Long informerListLimit,
FieldSelector fieldSelector,
Boolean comparableResourceVersions,
+ Boolean withoutNamespaceIndex,
// TODO for removal in major release
Duration ghostResourceCacheCheckInterval) {
this(resourceClass, resourceGroupVersionKind);
@@ -91,6 +93,7 @@ protected InformerConfiguration(
this.informerListLimit = informerListLimit;
this.fieldSelector = fieldSelector;
this.comparableResourceVersions = comparableResourceVersions;
+ this.withoutNamespaceIndex = withoutNamespaceIndex;
}
private InformerConfiguration(Class resourceClass, GroupVersionKind resourceGroupVersionKind) {
@@ -140,6 +143,7 @@ public static InformerConfiguration.Builder builder(
original.informerListLimit,
original.fieldSelector,
original.comparableResourceVersions,
+ original.withoutNamespaceIndex,
null)
.builder;
}
@@ -332,6 +336,15 @@ public boolean isComparableResourceVersions() {
return comparableResourceVersions;
}
+ /**
+ * Whether the namespace index the underlying informer maintains by default is removed.
+ *
+ * @see Informer#withoutNamespaceIndex()
+ */
+ public boolean isWithoutNamespaceIndex() {
+ return withoutNamespaceIndex;
+ }
+
@SuppressWarnings("UnusedReturnValue")
public class Builder {
@@ -349,6 +362,9 @@ public InformerConfiguration buildForController() {
if (comparableResourceVersions == null) {
comparableResourceVersions = DEFAULT_COMPARABLE_RESOURCE_VERSION;
}
+ if (withoutNamespaceIndex == null) {
+ withoutNamespaceIndex = DEFAULT_WITHOUT_NAMESPACE_INDEX;
+ }
return InformerConfiguration.this;
}
@@ -364,6 +380,9 @@ public InformerConfiguration build() {
if (comparableResourceVersions == null) {
comparableResourceVersions = DEFAULT_COMPARABLE_RESOURCE_VERSION;
}
+ if (withoutNamespaceIndex == null) {
+ withoutNamespaceIndex = DEFAULT_WITHOUT_NAMESPACE_INDEX;
+ }
return InformerConfiguration.this;
}
@@ -417,6 +436,7 @@ public InformerConfiguration.Builder initFromAnnotation(
.map(f -> new FieldSelector.Field(f.path(), f.value(), f.negated()))
.toList()));
withComparableResourceVersions(informerConfig.comparableResourceVersions());
+ withoutNamespaceIndex(informerConfig.withoutNamespaceIndex());
}
return this;
}
@@ -533,6 +553,16 @@ private static boolean isEmpty(FieldSelector fieldSelector) {
|| fieldSelector.getFields().isEmpty();
}
+ /**
+ * Whether to remove the namespace index the underlying informer maintains by default.
+ *
+ * @see Informer#withoutNamespaceIndex()
+ */
+ public Builder withoutNamespaceIndex(boolean withoutNamespaceIndex) {
+ InformerConfiguration.this.withoutNamespaceIndex = withoutNamespaceIndex;
+ return this;
+ }
+
public Builder withComparableResourceVersions(boolean comparableResourceVersions) {
InformerConfiguration.this.comparableResourceVersions = comparableResourceVersions;
return this;
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
index 9bd6f84d06..d40469de4c 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java
@@ -292,6 +292,11 @@ public Builder withComparableResourceVersion(boolean comparableResourceVersio
return this;
}
+ public Builder withoutNamespaceIndex(boolean withoutNamespaceIndex) {
+ config.withoutNamespaceIndex(withoutNamespaceIndex);
+ return this;
+ }
+
@Deprecated(forRemoval = true)
public Builder withGhostResourceCacheCheckInterval(
Duration ghostResourceCacheCheckInterval) {
@@ -316,6 +321,8 @@ public void updateFrom(InformerConfiguration informerConfig) {
.withOnDeleteFilter(informerConfig.getOnDeleteFilter())
.withGenericFilter(informerConfig.getGenericFilter())
.withInformerListLimit(informerConfig.getInformerListLimit())
+ .withComparableResourceVersions(informerConfig.isComparableResourceVersions())
+ .withoutNamespaceIndex(informerConfig.isWithoutNamespaceIndex())
.withFieldSelector(informerConfig.getFieldSelector());
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
index 2e7023623d..67764b7146 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
@@ -24,6 +24,7 @@
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;
import java.util.Objects;
+import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,10 +49,11 @@
* can be overridden, see {@link
* io.javaoperatorsdk.operator.api.config.ConfigurationService#clusterScopedEventNamespace()}.
*
- *
Events are named deterministically, after the object they are about plus a hash of everything
- * that identifies the event, so that recording the same event again resolves to the event already
- * recorded for it. Repeat occurrences are then counted on that event rather than recorded as copies
- * of it, see {@link DefaultEventSink}.
+ *
By default, events are named deterministically, after the object they are about plus a hash of
+ * everything that identifies the event, so that recording the same event again resolves to the
+ * event already recorded for it. Repeat occurrences are then counted on that event rather than
+ * recorded as copies of it, see {@link DefaultEventSink}. How events aggregate, how they are named
+ * and whether they carry an owner reference can be configured, see {@link #builder(EventSink)}.
*/
public class DefaultEventRecorder implements EventRecorder {
@@ -73,10 +75,79 @@ public class DefaultEventRecorder implements EventRecorder {
private static final int IDENTITY_HASH_LENGTH = 32;
+ /** What the API server accepts as an object name, see RFC 1123 on DNS subdomains. */
+ private static final Pattern RFC_1123_SUBDOMAIN =
+ Pattern.compile("[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*");
+
private final EventSink sink;
+ private final EventNamingStrategy namingStrategy;
+ private final EventKeyStrategy keyStrategy;
+ private final boolean ownerReference;
public DefaultEventRecorder(EventSink sink) {
+ this(sink, EventNamingStrategy.none(), EventKeyStrategy.none(), false);
+ }
+
+ private DefaultEventRecorder(
+ EventSink sink,
+ EventNamingStrategy namingStrategy,
+ EventKeyStrategy keyStrategy,
+ boolean ownerReference) {
this.sink = sink;
+ this.namingStrategy = namingStrategy;
+ this.keyStrategy = keyStrategy;
+ this.ownerReference = ownerReference;
+ }
+
+ public static Builder builder(EventSink sink) {
+ return new Builder(sink);
+ }
+
+ /** Builder for {@link DefaultEventRecorder}. */
+ public static final class Builder {
+
+ private final EventSink sink;
+ private EventNamingStrategy namingStrategy = EventNamingStrategy.none();
+ private EventKeyStrategy keyStrategy = EventKeyStrategy.none();
+ private boolean ownerReference = false;
+
+ private Builder(EventSink sink) {
+ this.sink = Objects.requireNonNull(sink, "sink must not be null");
+ }
+
+ /** The strategy naming recorded events, see {@link EventNamingStrategy}. */
+ public Builder namingStrategy(EventNamingStrategy namingStrategy) {
+ this.namingStrategy =
+ Objects.requireNonNull(namingStrategy, "namingStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * The strategy deriving the default aggregation key of records that do not set one, see {@link
+ * EventKeyStrategy}. The key is ignored for events whose name is set by the record or resolved
+ * by the naming strategy, see {@link EventNamingStrategy}.
+ */
+ public Builder keyStrategy(EventKeyStrategy keyStrategy) {
+ this.keyStrategy = Objects.requireNonNull(keyStrategy, "keyStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * When set, recorded events carry an {@code ownerReference} to the object they are about. The
+ * reference expresses ownership for tooling that reads it; note that the Kubernetes garbage
+ * collector ignores events, so it does not cause cascade deletion, events expire through the
+ * event TTL either way. Records can override this per event via {@link
+ * EventRecord.Builder#ownedByRegarding(boolean)}. The reference is only set when the object
+ * already has a uid.
+ */
+ public Builder ownerReference(boolean ownerReference) {
+ this.ownerReference = ownerReference;
+ return this;
+ }
+
+ public DefaultEventRecorder build() {
+ return new DefaultEventRecorder(sink, namingStrategy, keyStrategy, ownerReference);
+ }
}
/**
@@ -111,14 +182,17 @@ private static String resolve() {
public void record(EventRecord event, Context> context) {
Objects.requireNonNull(context, "the context of the reconciliation must not be null");
Objects.requireNonNull(event, "event must not be null");
+ Event assembled = null;
try {
- sink.emit(toEvent(context, event), context);
+ assembled = toEvent(context, event);
+ sink.emit(assembled, context);
} catch (Exception e) {
// recording an event must never break the caller: a controller that fails to reconcile
// because it could not write an event is strictly worse than one that records nothing
log.warn(
- "Could not record {} event with reason {} for resource {} in namespace {}",
+ "Could not record {} event named {} with reason {} for resource {} in namespace {}",
event.type(),
+ assembled != null ? assembled.getMetadata().getName() : "unknown",
event.reason(),
context.getPrimaryResource().getMetadata().getName(),
context.getPrimaryResource().getMetadata().getNamespace(),
@@ -164,6 +238,23 @@ protected Event toEvent(Context> context, EventRecord record) {
.withNewSource()
.withComponent(record.reportingComponent().orElse(controllerName))
.endSource();
+ boolean ownedByRegarding = record.ownedByRegarding().orElse(ownerReference);
+ if (ownedByRegarding && regarding.getMetadata().getUid() == null) {
+ log.debug(
+ "Not setting the owner reference on the event about {}: the object has no uid yet",
+ regarding.getMetadata().getName());
+ }
+ if (ownedByRegarding && regarding.getMetadata().getUid() != null) {
+ builder
+ .editMetadata()
+ .addNewOwnerReference()
+ .withApiVersion(regarding.getApiVersion())
+ .withKind(regarding.getKind())
+ .withName(regarding.getMetadata().getName())
+ .withUid(regarding.getMetadata().getUid())
+ .endOwnerReference()
+ .endMetadata();
+ }
record.action().ifPresent(builder::withAction);
return builder.build();
}
@@ -181,17 +272,56 @@ private String eventNamespace(HasMetadata regarding, Context> context) {
CLUSTER_SCOPED_EVENT_NAMESPACE);
}
+ private String eventName(HasMetadata regarding, EventRecord record, String reportingController) {
+ return record
+ .name()
+ .filter(name -> !name.isBlank())
+ .or(() -> namingStrategy.nameFor(regarding, record).filter(name -> !name.isBlank()))
+ .map(DefaultEventRecorder::truncateToMaxNameLength)
+ .filter(DefaultEventRecorder::isValidEventName)
+ .orElseGet(() -> identityHashName(regarding, record, reportingController));
+ }
+
+ private static boolean isValidEventName(String name) {
+ if (RFC_1123_SUBDOMAIN.matcher(name).matches()) {
+ return true;
+ }
+ log.warn(
+ "Falling back to the default event name: {} is not a valid RFC 1123 DNS subdomain", name);
+ return false;
+ }
+
+ private static String truncateToMaxNameLength(String name) {
+ if (name.length() <= MAX_NAME_LENGTH) {
+ return name;
+ }
+ var truncated = name.substring(0, MAX_NAME_LENGTH);
+ while (truncated.endsWith("-") || truncated.endsWith(".")) {
+ truncated = truncated.substring(0, truncated.length() - 1);
+ }
+ log.warn(
+ "Truncated the name of event {} to {} to stay within the Kubernetes name limit",
+ name,
+ truncated);
+ return truncated;
+ }
+
/**
* Names events {@code