From 4499a66c5905be0f9e0ba5c3c21ca5239b58010b Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 11:05:52 +1200 Subject: [PATCH 1/8] Draft proposal for Reconfiguration Trigger SPI Defines a pluggable SPI for triggering KafkaProxy.reconfigure(), building on the trigger responsibilities established during the Proposal 083 review. Covers the SPI interfaces, configuration model, lifecycle, and the full trigger responsibility contract. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/000-reconfiguration-trigger-spi.md | 417 +++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 proposals/000-reconfiguration-trigger-spi.md diff --git a/proposals/000-reconfiguration-trigger-spi.md b/proposals/000-reconfiguration-trigger-spi.md new file mode 100644 index 00000000..98d25d52 --- /dev/null +++ b/proposals/000-reconfiguration-trigger-spi.md @@ -0,0 +1,417 @@ +# 000 - Reconfiguration Trigger SPI + +**Builds on:** [Proposal 083 — Changing Active Proxy Configuration](https://github.com/kroxylicious/design/blob/main/proposals/083-hot-reload-feature.md) + +This proposal defines a pluggable Service Provider Interface (SPI) for triggering `KafkaProxy.reconfigure()`. Trigger implementations are discovered via `ServiceLoader`, configured in the proxy's YAML configuration, and are responsible for sourcing new configuration and driving the reconfiguration lifecycle. The SPI formalises the trigger responsibilities established during the design of Proposal 083 and provides the extension point that allows different deployment models — standalone, Kubernetes, embedded — to use different reconfiguration strategies without proxy changes. + +## Current situation + +Proposal 083 delivered `KafkaProxy.reconfigure(Configuration)` — the core mechanism for applying configuration changes to a running proxy without a full restart. The method accepts a complete `Configuration`, detects what changed, and converges the running state to match. + +However, nothing calls `reconfigure()` today. The standalone binary (`kroxylicious-app`) has no way to apply configuration changes at runtime. Operators who embed the proxy can call `reconfigure()` directly from their own code, but the project-shipped binary needs a trigger mechanism to make hot reload usable. + +During the Proposal 083 review, several trigger mechanisms were discussed — file watchers, HTTP endpoints, and operator callbacks — but all were explicitly deferred to keep that proposal focused on the reconfiguration machinery itself. The discussion also established that triggers carry significant responsibility: configuration sourcing, static validation, failure policy, rollback, concurrency handling, debouncing, and configuration persistence. These responsibilities need a formal contract. + +## Motivation + +- **The shipped binary needs hot reload.** Without a trigger mechanism, `kroxylicious-app` cannot use the reconfiguration capability that Proposal 083 introduced. Configuration changes still require a full process restart. + +- **Different deployments need different triggers.** A bare-metal deployment watching a config file has different requirements from a Kubernetes operator reconciling a CRD, which has different requirements from a custom control plane using an HTTP API. The trigger mechanism must be pluggable. + +- **Trigger authors need a contract.** Proposal 083 pushed substantial responsibility onto triggers — failure policy, rollback, concurrency handling — but that responsibility is currently documented only in PR comments. A formal SPI with documented responsibilities makes it possible for third parties to write correct trigger implementations. + +## Proposal + +### SPI overview + +The trigger SPI consists of three interfaces: + +- **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. +- **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. +- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()` and configuration parsing utilities. + +A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. + +### `ReconfigurationTrigger` + +```java +/** + * A reconfiguration trigger watches for configuration changes and drives + * {@link ReconfigurationTriggerContext#reconfigure(Configuration)} when a + * change is detected. + * + *

Lifecycle

+ *

A trigger instance is created by its {@link ReconfigurationTriggerFactory} + * and has proxy-level lifecycle: one instance exists per proxy, and it lives + * for the lifetime of the proxy process. + * + *

    + *
  • {@link #start()} is called after the proxy has completed startup and + * is serving traffic. The trigger should begin watching for changes.
  • + *
  • {@link #close()} is called before proxy shutdown begins. The trigger + * should stop watching, release resources, and return promptly. Any + * in-flight {@code reconfigure()} call will complete independently.
  • + *
+ * + *

Threading

+ *

{@code start()} and {@code close()} are called on the proxy's main thread. + * The trigger is free to create its own threads (e.g. a file watcher thread, an + * HTTP server thread) but must manage their lifecycle. {@code reconfigure()} is + * thread-safe and may be called from any thread. + * + *

Trigger responsibilities

+ *

See the "Trigger responsibilities" section of this proposal for the full + * contract that trigger implementations must follow. + */ +public interface ReconfigurationTrigger extends Closeable { + + /** + * Start watching for configuration changes. + * + *

Called once, after the proxy has completed startup. The trigger should + * begin watching for changes and call + * {@link ReconfigurationTriggerContext#reconfigure(Configuration)} when a + * change is detected. This method should return promptly — long-running + * work (file watching, HTTP listening) should happen on background threads + * managed by the trigger. + * + * @throws Exception if the trigger cannot start (e.g. cannot open a watch + * on the configuration file, cannot bind an HTTP port) + */ + void start() throws Exception; + + /** + * Stop watching and release resources. Called before proxy shutdown. + */ + @Override + void close(); +} +``` + +### `ReconfigurationTriggerFactory` + +```java +/** + * Factory for creating {@link ReconfigurationTrigger} instances. Discovered + * via {@link java.util.ServiceLoader}. + * + *

Each factory declares the type of its configuration object via + * {@link #configType()}. The runtime deserialises the trigger-specific + * configuration from the proxy's YAML configuration and passes it to + * {@link #create(ReconfigurationTriggerContext, Object)}. + * + * @param the trigger-specific configuration type. Must be deserializable + * from YAML by Jackson. + */ +public interface ReconfigurationTriggerFactory { + + /** + * Creates a new trigger instance. + * + *

The trigger is not yet active — the caller will invoke + * {@link ReconfigurationTrigger#start()} after this method returns. + * + * @param context provides access to reconfigure() and configuration parsing + * @param config the trigger-specific configuration, deserialized from YAML + * @return a new trigger instance, ready to be started + * @throws Exception if the trigger cannot be constructed (e.g. invalid + * configuration values) + */ + ReconfigurationTrigger create(ReconfigurationTriggerContext context, C config) throws Exception; + + /** + * Returns the type of the trigger-specific configuration object. + * The runtime uses this to deserialize the {@code config:} section of the + * trigger's YAML configuration block. + * + * @return the configuration class + */ + Class configType(); +} +``` + +### `ReconfigurationTriggerContext` + +```java +/** + * Runtime context provided to a {@link ReconfigurationTrigger}. This is the + * trigger's view of the proxy — triggers never interact with + * {@code KafkaProxy} directly. + * + *

The context provides two categories of capability: + *

    + *
  • Reconfiguration — {@link #reconfigure(Configuration)} drives + * the proxy to converge to a new configuration.
  • + *
  • Configuration parsing — {@link #parseConfiguration(Path)} + * provides a convenience for file-based triggers that need to convert + * a YAML file into a {@link Configuration} object.
  • + *
+ * + *

The context is thread-safe. All methods may be called from any thread. + */ +public interface ReconfigurationTriggerContext { + + /** + * Apply a new configuration to the running proxy. Delegates to + * {@link KafkaProxy#reconfigure(Configuration)} — see that method's + * Javadoc (Proposal 083) for the full contract including error reporting, + * concurrency control, and scope limitations. + * + * @param newConfig the desired end-state configuration; must be non-null + * and statically valid + * @return a future that completes with a {@link ReconfigureResult} + * describing any per-component failures, or completes + * exceptionally on catastrophic failure or input rejection + */ + CompletableFuture reconfigure(Configuration newConfig); + + /** + * Parse a YAML configuration file into a {@link Configuration} object. + * + *

This is a convenience method for file-based triggers. It performs + * the same parsing and static validation that the proxy performs at + * startup. Triggers that source configuration from non-file sources + * (e.g. an HTTP request body, a CRD spec) may construct + * {@link Configuration} objects directly instead. + * + * @param configFile path to the YAML configuration file + * @return the parsed configuration + * @throws ConfigurationException if the file cannot be read or contains + * invalid configuration + */ + Configuration parseConfiguration(Path configFile); + + /** + * Returns the path to the configuration file the proxy was started with. + * + *

File-based triggers typically watch this path for changes. The path + * is the same one passed to the proxy at startup and does not change + * during the proxy's lifetime. + * + * @return the startup configuration file path + */ + Path configFilePath(); +} +``` + +### Configuration model + +Triggers are selected and configured via a top-level `reconfigurationTrigger` section in the proxy's YAML configuration: + +```yaml +reconfigurationTrigger: + type: FileWatcher # ServiceLoader type name + config: # trigger-specific configuration + debounceInterval: PT1S # implementation-specific settings +``` + +This follows the same `type` + `config` pattern used by filters, routers, and other Kroxylicious plugins. + +When the `reconfigurationTrigger` section is absent, no trigger is created and the proxy operates as today — configuration changes require a restart. + +The `reconfigurationTrigger` section is **static configuration**: it is not hot-reloadable. Changing the trigger type or its configuration requires a proxy restart. This is consistent with Proposal 083's scope limitations — `reconfigure()` applies only virtual-cluster and filter configuration; other sections (including the trigger section) are out of scope and will cause `reconfigure()` to reject the configuration with `OutOfScopeChangeException` if they differ. + +### Trigger responsibilities + +Proposal 083 defined `KafkaProxy.reconfigure()` as a minimal operation that reports outcomes without taking policy action. This was a deliberate design choice — it pushes failure handling, rollback, and operational policy onto the caller. For triggers, "the caller" is the trigger implementation. The following responsibilities form the contract that trigger implementations must satisfy. + +#### Configuration sourcing + +The trigger is responsible for obtaining and delivering a new `Configuration` to `reconfigure()`. How the configuration is sourced — watching a file, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `ReconfigurationTriggerContext` provides `parseConfiguration(Path)` as a convenience for file-based triggers, but triggers may construct `Configuration` objects from any source. + +#### Static validation + +Static validation (schema conformance, required fields, field-value ranges, internal consistency) must be performed on the new configuration **before** calling `reconfigure()`. This is the caller's responsibility per Proposal 083's contract. `parseConfiguration(Path)` performs static validation as part of parsing; triggers that construct `Configuration` objects directly must ensure equivalent validation. + +#### Failure policy + +The proxy does not act on `ReconfigureResult.errors()`. The trigger expresses its failure policy via `whenComplete()` on the returned future. Three canonical patterns are defined in Proposal 083: + +- **Shut down on any failure** — call `proxy.shutdown()` if `errors()` is non-empty +- **Best-effort** — log failures, take no proxy-level action; surviving VCs continue serving +- **Rollback on failure** — call `reconfigure(oldConfig)` when `errors()` is non-empty + +The choice between these (or a custom policy) is the trigger's decision, typically determined at deployment time by the trigger's configuration or hardcoded by the trigger implementation. + +#### Previous configuration tracking + +Triggers that support rollback must maintain their own record of the previous known-good configuration. The proxy does not expose a getter for its running configuration. Triggers typically have a natural source-of-truth for this: a previous file snapshot, a ConfigMap revision, an HTTP request history. + +#### Concurrency handling + +`reconfigure()` rejects concurrent calls with `ConcurrentReconfigureException` (the future completes exceptionally). The trigger **must not** treat this as a real failure: + +- **Do not shut down** — the proxy is healthy; another reconfiguration is in flight. +- **Do not roll back** — rolling back would undo the other reconfiguration's changes. +- **Do retry** — typically after a short delay, with the most recent desired configuration. + +The recommended discrimination is `ex instanceof ConcurrentReconfigureException` — respond with retry or no-op rather than a destructive policy. + +#### Out-of-scope change handling + +`reconfigure()` rejects configurations that differ in out-of-scope sections with `OutOfScopeChangeException` (the future completes exceptionally). Like `ConcurrentReconfigureException`, this means the proxy did not change state. The trigger should log the rejection and **not** apply destructive policies (shutdown, rollback). + +#### Debouncing + +Since concurrent `reconfigure()` calls are rejected rather than queued, triggers that may receive rapid configuration changes (e.g. a file watcher receiving multiple filesystem events during an atomic file replacement) must debounce internally. The pattern is: absorb events for a short window, then call `reconfigure()` with the latest configuration. The debounce interval is a trigger-specific configuration concern. + +#### Configuration persistence + +Whether to persist the applied configuration to disk is a trigger concern. A Kubernetes operator owns configuration state via CRD and does not want the proxy overwriting files. A bare-metal file watcher may not need persistence because the file is already the source of truth. A custom trigger may persist to a database. The proxy takes no action on configuration persistence. + +#### Change detection (optimisation) + +Triggers may perform their own change detection to avoid unnecessary `reconfigure()` calls. For example, a Kubernetes operator might compare ConfigMap checksums to skip no-op reconciliation loops. The proxy performs its own change detection internally (it will not restart unaffected virtual clusters), so trigger-level detection is an optimisation, not a correctness requirement. + +### Trigger lifecycle + +The trigger lifecycle is tied to the proxy's lifecycle: + +``` +Proxy startup + │ + ├── Parse proxy configuration (including reconfigurationTrigger section) + ├── Discover ReconfigurationTriggerFactory via ServiceLoader + ├── Deserialize trigger-specific config + ├── Call factory.create(context, config) → ReconfigurationTrigger + │ + ├── Proxy completes startup (VCs serving) + │ + ├── Call trigger.start() + │ │ + │ ├── Success: trigger is active, watching for changes + │ └── Failure: log warning, proxy continues without hot reload + │ + ├── ... proxy running, trigger calling reconfigure() as needed ... + │ + ├── Proxy shutdown initiated + │ + ├── Call trigger.close() + │ (trigger stops watching, releases resources) + │ + └── Proxy completes shutdown +``` + +**Startup ordering.** The trigger is started *after* the proxy has completed startup and all virtual clusters are serving. This is why the `ReconfigurationTrigger` interface separates construction (`create()`) from activation (`start()`): the factory creates the trigger during proxy initialisation, but the trigger must not begin watching for changes — or call `reconfigure()` — until the proxy is ready. Without this separation, a file watcher trigger could detect the existing configuration file immediately on construction and attempt a `reconfigure()` before the proxy has loaded its initial configuration, which would throw `IllegalStateException` per Proposal 083. + +**Failure to start.** If the trigger's `start()` method throws, the proxy logs a warning and continues running without hot reload capability. This is a pragmatic choice: the proxy is functional and serving traffic; the operator can diagnose the trigger failure and restart the proxy if hot reload is required. Failing the entire proxy startup because a trigger couldn't start would be disproportionate. + +**Shutdown ordering.** The trigger is closed *before* the proxy begins its shutdown sequence. This prevents the trigger from attempting a `reconfigure()` call while the proxy is shutting down. Any `reconfigure()` call already in flight will complete independently — the proxy handles the `IllegalStateException` case per Proposal 083. + +**In-flight reconfiguration at shutdown.** If a trigger-initiated `reconfigure()` is in progress when the proxy receives a shutdown signal, the proxy waits for the reconfiguration to complete before proceeding with shutdown. The trigger's `close()` is called after the reconfiguration completes. + +### Example: File watcher trigger + +To illustrate the SPI in use, here is a sketch of how a file watcher trigger would be structured. This is not a specification for a file watcher — that is an implementation concern — but demonstrates that the SPI is sufficient for the most common trigger pattern. + +```java +public class FileWatcherTriggerFactory + implements ReconfigurationTriggerFactory { + + @Override + public ReconfigurationTrigger create( + ReconfigurationTriggerContext context, + FileWatcherConfig config) { + return new FileWatcherTrigger(context, config); + } + + @Override + public Class configType() { + return FileWatcherConfig.class; + } +} + +// Registered in META-INF/services/...ReconfigurationTriggerFactory +``` + +```java +class FileWatcherTrigger implements ReconfigurationTrigger { + + private final ReconfigurationTriggerContext context; + private final FileWatcherConfig config; + private WatchService watchService; + private Thread watchThread; + + // ... constructor ... + + @Override + public void start() throws Exception { + Path watchPath = context.configFilePath(); + watchService = FileSystems.getDefault().newWatchService(); + // register watch on parent directory (handles K8s ConfigMap symlinks) + watchPath.getParent().register(watchService, ENTRY_MODIFY, ENTRY_CREATE); + + watchThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + // wait for events, debounce, then: + try { + Configuration newConfig = context.parseConfiguration(watchPath); + context.reconfigure(newConfig) + .whenComplete((result, ex) -> { + if (ex instanceof ConcurrentReconfigureException) { + // retry later + return; + } + if (ex != null) { + LOG.error("Reconfigure failed", ex); + return; + } + for (var error : result.errors()) { + LOG.error("Component failed: {}", + error.humanReadableIdentifier(), + error.cause()); + } + }); + } catch (ConfigurationException e) { + LOG.error("Failed to parse configuration", e); + } + } + }); + watchThread.setDaemon(true); + watchThread.start(); + } + + @Override + public void close() { + watchThread.interrupt(); + watchService.close(); + } +} +``` + +This example demonstrates: +- The trigger manages its own threads +- `parseConfiguration()` provides file parsing without reimplementation +- `whenComplete()` implements failure policy (best-effort in this case) +- `ConcurrentReconfigureException` is handled with retry semantics +- The trigger watches `configFilePath()` by default + +## Affected projects + +- **kroxylicious-runtime** (`kroxylicious-api` module) — the SPI interfaces (`ReconfigurationTrigger`, `ReconfigurationTriggerFactory`, `ReconfigurationTriggerContext`) are added as public API. +- **kroxylicious-runtime** (runtime module) — implements `ReconfigurationTriggerContext`, integrates trigger lifecycle with `KafkaProxy` startup and shutdown, and performs ServiceLoader discovery. +- **kroxylicious-app** — configures a trigger (initially a file watcher, shipped as a separate module) for the standalone binary. +- **kroxylicious-operator** — not directly affected. The operator embeds the proxy and calls `reconfigure()` directly; it does not use the trigger SPI. If a future operator design prefers to delegate to an in-proxy trigger, it can configure one via the SPI. + +## Compatibility + +- **Additive.** No existing behaviour changes. A proxy with no `reconfigurationTrigger` configuration operates identically to today. +- **Proposal 083 unchanged.** The `KafkaProxy.reconfigure()` contract, `ReconfigureResult`, `ReconfigureError`, concurrency control, and scope limitations are unchanged. +- **Configuration format.** The `reconfigurationTrigger` section is new; its absence is a no-op. Because it is an out-of-scope section for `reconfigure()`, any change to it in a new configuration will be rejected with `OutOfScopeChangeException` — which is the correct behaviour (trigger changes require a restart). +- **Plugin convention.** The `type` + `config` pattern and `ServiceLoader` discovery follow established Kroxylicious conventions and do not introduce new mechanisms. + +## Rejected alternatives + +- **Per-call `ReloadOptions`**: An earlier Proposal 083 iteration proposed a `ReloadOptions` parameter on each `reconfigure()` call carrying failure policy (rollback/terminate) and persistence settings. Rejected because failure policy is a deployment-time decision that should not vary between invocations. A trigger that hardcodes "best-effort" and another that hardcodes "rollback" should not be able to vary their behaviour per call — that creates inconsistency. The `whenComplete()` pattern achieves the same expressiveness without a per-call parameter. + +- **`VirtualClusterLifecycleObserver`**: An earlier Proposal 083 iteration proposed a push-based observer injected at `KafkaProxy` construction time, notified of every lifecycle transition. While valuable for a future control-plane integration, it is a broader mechanism than triggers need and was deferred to avoid coupling it with the trigger SPI. The `whenComplete()` pattern on `reconfigure()` is sufficient for the failure-handling use case. + +- **Multiple simultaneous triggers**: Considered allowing multiple triggers to be active (e.g. both a file watcher and an HTTP endpoint). Rejected because `reconfigure()` only allows one reconfiguration at a time (`ConcurrentReconfigureException`), and multiple triggers racing to reconfigure would create unpredictable behaviour. If a deployment needs both file-based and HTTP-based triggering, a single trigger implementation can support both input mechanisms internally. + +- **Trigger signals "config changed" without providing `Configuration`**: An alternative where the trigger simply signals "reload" and the runtime re-reads and parses the configuration file. Simpler for file-based triggers but does not support non-file configuration sources (HTTP request bodies, CRD specs, programmatically generated configuration). The `parseConfiguration(Path)` convenience method on `ReconfigurationTriggerContext` gives file-based triggers the same simplicity while preserving flexibility. + +- **Hardcoded trigger in `kroxylicious-app`**: Instead of an SPI, wire a file watcher directly into the standalone binary. Rejected because it forces users who need a different trigger mechanism (HTTP, custom control plane) to embed the proxy rather than just providing a different trigger on the classpath. The SPI cost is small and the extensibility value is high. + +- **Proxy-managed configuration persistence**: An earlier design had the proxy persist the applied configuration to disk after a successful `reconfigure()`. Rejected because persistence requirements vary by deployment: a Kubernetes operator owns state via CRD and does not want the proxy overwriting files; a bare-metal deployment may want file persistence; a custom control plane may persist to a database. This is a trigger concern, not a proxy concern. + +- **Trigger configuration in `updateStrategy` or `configurationReload` YAML block**: Earlier iterations of Proposal 083 proposed YAML-level configuration for failure policy and rollback behaviour. Rejected in favour of caller-side policy via `whenComplete()` — the proxy reports outcomes and takes no policy action. The only YAML configuration for triggers is the `reconfigurationTrigger` section that selects and configures the trigger implementation. From 523dd4fc5f0d4040bf6dfba1b608eec9c28ebaf4 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 11:16:17 +1200 Subject: [PATCH 2/8] Rename proposal to use PR number 117 Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- ...ration-trigger-spi.md => 117-reconfiguration-trigger-spi.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename proposals/{000-reconfiguration-trigger-spi.md => 117-reconfiguration-trigger-spi.md} (99%) diff --git a/proposals/000-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md similarity index 99% rename from proposals/000-reconfiguration-trigger-spi.md rename to proposals/117-reconfiguration-trigger-spi.md index 98d25d52..242aad68 100644 --- a/proposals/000-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -1,4 +1,4 @@ -# 000 - Reconfiguration Trigger SPI +# 117 - Reconfiguration Trigger SPI **Builds on:** [Proposal 083 — Changing Active Proxy Configuration](https://github.com/kroxylicious/design/blob/main/proposals/083-hot-reload-feature.md) From d400d18728e5968c52462b2cfc032a99c61e31f2 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 11:21:26 +1200 Subject: [PATCH 3/8] Make ReconfigurationTriggerContext source-agnostic Replace file-specific parseConfiguration(Path) with source-agnostic parseConfiguration(InputStream). Add validateConfiguration() for pre-flight validation before applying. Keep configFilePath() with clearer rationale as the path the proxy was booted from. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 84 ++++++++++++++------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index 242aad68..af0fc9dc 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -28,7 +28,7 @@ The trigger SPI consists of three interfaces: - **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. - **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. -- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()` and configuration parsing utilities. +- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, source-agnostic configuration parsing, pre-flight validation, and the proxy's startup configuration path. A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. @@ -138,13 +138,16 @@ public interface ReconfigurationTriggerFactory { * trigger's view of the proxy — triggers never interact with * {@code KafkaProxy} directly. * - *

The context provides two categories of capability: + *

The context provides three categories of capability: *

    *
  • Reconfiguration — {@link #reconfigure(Configuration)} drives * the proxy to converge to a new configuration.
  • - *
  • Configuration parsing — {@link #parseConfiguration(Path)} - * provides a convenience for file-based triggers that need to convert - * a YAML file into a {@link Configuration} object.
  • + *
  • Configuration handling — {@link #parseConfiguration(InputStream)} + * and {@link #validateConfiguration(Configuration)} allow triggers to + * parse and pre-validate configuration from any source before applying + * it.
  • + *
  • Startup context — {@link #configFilePath()} provides the path + * the proxy was originally started with.
  • *
* *

The context is thread-safe. All methods may be called from any thread. @@ -166,27 +169,54 @@ public interface ReconfigurationTriggerContext { CompletableFuture reconfigure(Configuration newConfig); /** - * Parse a YAML configuration file into a {@link Configuration} object. + * Parse a YAML configuration stream into a {@link Configuration} object. * - *

This is a convenience method for file-based triggers. It performs - * the same parsing and static validation that the proxy performs at - * startup. Triggers that source configuration from non-file sources - * (e.g. an HTTP request body, a CRD spec) may construct - * {@link Configuration} objects directly instead. + *

Uses the same parser and static validation rules that the proxy + * applies at startup, ensuring consistency regardless of how the trigger + * sources configuration. Triggers may obtain the stream from any source: + * a file ({@link java.nio.file.Files#newInputStream}), an HTTP request + * body, an in-memory buffer, etc. * - * @param configFile path to the YAML configuration file - * @return the parsed configuration - * @throws ConfigurationException if the file cannot be read or contains + * @param configurationYaml the YAML configuration as a stream + * @return the parsed and statically validated configuration + * @throws ConfigurationException if the stream cannot be read or contains * invalid configuration */ - Configuration parseConfiguration(Path configFile); + Configuration parseConfiguration(InputStream configurationYaml); + + /** + * Validate a {@link Configuration} against the proxy's current running + * state without applying it. Performs the same pre-flight checks that + * {@link #reconfigure(Configuration)} would perform before beginning + * any state-changing work — in particular, detecting out-of-scope + * changes that would cause {@code reconfigure()} to reject the + * configuration. + * + *

A trigger can use this to implement a two-phase workflow: + * validate first, then apply only if validation passes. This catches + * problems before any virtual cluster experiences downtime. + * + *

A successful validation does not guarantee that a subsequent + * {@code reconfigure()} call will succeed — runtime conditions (port + * availability, upstream reachability) may change between validation + * and application. But it does guarantee that the configuration will + * not be rejected for structural or scope reasons. + * + * @param config the configuration to validate + * @throws OutOfScopeChangeException if the configuration differs from + * the running configuration in an out-of-scope section + * @throws ConfigurationException if the configuration fails validation + */ + void validateConfiguration(Configuration config); /** * Returns the path to the configuration file the proxy was started with. * - *

File-based triggers typically watch this path for changes. The path - * is the same one passed to the proxy at startup and does not change - * during the proxy's lifetime. + *

This is the file path that was passed to the proxy at startup. It + * does not change during the proxy's lifetime. Triggers may use it as a + * default watch target, as a baseline for change detection, or to locate + * configuration relative to the proxy's working directory. Triggers that + * source configuration from non-file origins may ignore it. * * @return the startup configuration file path */ @@ -217,11 +247,11 @@ Proposal 083 defined `KafkaProxy.reconfigure()` as a minimal operation that repo #### Configuration sourcing -The trigger is responsible for obtaining and delivering a new `Configuration` to `reconfigure()`. How the configuration is sourced — watching a file, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `ReconfigurationTriggerContext` provides `parseConfiguration(Path)` as a convenience for file-based triggers, but triggers may construct `Configuration` objects from any source. +The trigger is responsible for obtaining and delivering a new `Configuration` to `reconfigure()`. How the configuration is sourced — watching a file, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `ReconfigurationTriggerContext` provides `parseConfiguration(InputStream)` so triggers can parse YAML from any source (file, HTTP body, in-memory buffer) using the same parser and validation rules the proxy applies at startup. Triggers may also construct `Configuration` objects programmatically if their source is not YAML. #### Static validation -Static validation (schema conformance, required fields, field-value ranges, internal consistency) must be performed on the new configuration **before** calling `reconfigure()`. This is the caller's responsibility per Proposal 083's contract. `parseConfiguration(Path)` performs static validation as part of parsing; triggers that construct `Configuration` objects directly must ensure equivalent validation. +Static validation (schema conformance, required fields, field-value ranges, internal consistency) must be performed on the new configuration **before** calling `reconfigure()`. This is the caller's responsibility per Proposal 083's contract. `parseConfiguration(InputStream)` performs static validation as part of parsing. Triggers that construct `Configuration` objects directly must ensure equivalent validation. Additionally, `validateConfiguration(Configuration)` allows triggers to check for out-of-scope changes and other pre-flight failures before committing to a reconfiguration that would disrupt traffic — enabling a validate-then-apply workflow. #### Failure policy @@ -344,8 +374,9 @@ class FileWatcherTrigger implements ReconfigurationTrigger { watchThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // wait for events, debounce, then: - try { - Configuration newConfig = context.parseConfiguration(watchPath); + try (InputStream in = Files.newInputStream(watchPath)) { + Configuration newConfig = context.parseConfiguration(in); + context.validateConfiguration(newConfig); context.reconfigure(newConfig) .whenComplete((result, ex) -> { if (ex instanceof ConcurrentReconfigureException) { @@ -363,7 +394,7 @@ class FileWatcherTrigger implements ReconfigurationTrigger { } }); } catch (ConfigurationException e) { - LOG.error("Failed to parse configuration", e); + LOG.error("Failed to parse or validate configuration", e); } } }); @@ -381,10 +412,11 @@ class FileWatcherTrigger implements ReconfigurationTrigger { This example demonstrates: - The trigger manages its own threads -- `parseConfiguration()` provides file parsing without reimplementation +- `parseConfiguration(InputStream)` parses from any source — here a file, but equally an HTTP body or in-memory buffer +- `validateConfiguration()` catches out-of-scope changes before any VC experiences downtime - `whenComplete()` implements failure policy (best-effort in this case) - `ConcurrentReconfigureException` is handled with retry semantics -- The trigger watches `configFilePath()` by default +- The trigger uses `configFilePath()` as its default watch target ## Affected projects @@ -408,7 +440,7 @@ This example demonstrates: - **Multiple simultaneous triggers**: Considered allowing multiple triggers to be active (e.g. both a file watcher and an HTTP endpoint). Rejected because `reconfigure()` only allows one reconfiguration at a time (`ConcurrentReconfigureException`), and multiple triggers racing to reconfigure would create unpredictable behaviour. If a deployment needs both file-based and HTTP-based triggering, a single trigger implementation can support both input mechanisms internally. -- **Trigger signals "config changed" without providing `Configuration`**: An alternative where the trigger simply signals "reload" and the runtime re-reads and parses the configuration file. Simpler for file-based triggers but does not support non-file configuration sources (HTTP request bodies, CRD specs, programmatically generated configuration). The `parseConfiguration(Path)` convenience method on `ReconfigurationTriggerContext` gives file-based triggers the same simplicity while preserving flexibility. +- **Trigger signals "config changed" without providing `Configuration`**: An alternative where the trigger simply signals "reload" and the runtime re-reads and parses the configuration file. Simpler for file-based triggers but does not support non-file configuration sources (HTTP request bodies, CRD specs, programmatically generated configuration). The `parseConfiguration(InputStream)` method on `ReconfigurationTriggerContext` gives file-based triggers the same simplicity (open a stream, call parse) while preserving flexibility for other sources. - **Hardcoded trigger in `kroxylicious-app`**: Instead of an SPI, wire a file watcher directly into the standalone binary. Rejected because it forces users who need a different trigger mechanism (HTTP, custom control plane) to embed the proxy rather than just providing a different trigger on the classpath. The SPI cost is small and the extensibility value is high. From 5ea61d79d75f427925a1256ee0e678e86a6d5ebb Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 11:47:14 +1200 Subject: [PATCH 4/8] Add shutdown() to ReconfigurationTriggerContext Triggers implementing failure policies (shut down on any failure, last-resort after failed rollback) need the ability to initiate proxy shutdown. Without this, the canonical patterns from Proposal 083 cannot be expressed through the SPI. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index af0fc9dc..07185032 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -28,7 +28,7 @@ The trigger SPI consists of three interfaces: - **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. - **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. -- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, source-agnostic configuration parsing, pre-flight validation, and the proxy's startup configuration path. +- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, source-agnostic configuration parsing, pre-flight validation, and the proxy's startup configuration path. A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. @@ -138,10 +138,13 @@ public interface ReconfigurationTriggerFactory { * trigger's view of the proxy — triggers never interact with * {@code KafkaProxy} directly. * - *

The context provides three categories of capability: + *

The context provides four categories of capability: *

    *
  • Reconfiguration — {@link #reconfigure(Configuration)} drives * the proxy to converge to a new configuration.
  • + *
  • Proxy lifecycle — {@link #shutdown()} initiates an orderly + * proxy shutdown, enabling failure policies that terminate the proxy + * on unrecoverable errors.
  • *
  • Configuration handling — {@link #parseConfiguration(InputStream)} * and {@link #validateConfiguration(Configuration)} allow triggers to * parse and pre-validate configuration from any source before applying @@ -168,6 +171,20 @@ public interface ReconfigurationTriggerContext { */ CompletableFuture reconfigure(Configuration newConfig); + /** + * Initiate an orderly shutdown of the proxy. + * + *

    Triggers use this to implement failure policies that terminate the + * proxy on unrecoverable errors — for example, shutting down when + * {@code reconfigure()} returns non-empty {@code errors()}, or as a + * last resort when a rollback attempt itself fails. + * + *

    This method returns immediately; the actual shutdown proceeds + * asynchronously. The trigger's {@link ReconfigurationTrigger#close()} + * method will be called as part of the shutdown sequence. + */ + void shutdown(); + /** * Parse a YAML configuration stream into a {@link Configuration} object. * From b378232d0ec0d98fac54d455c9646f48b2dc74ed Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 11:59:08 +1200 Subject: [PATCH 5/8] Adopt Proposal 096's Snapshot as the trigger-to-runtime contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Configuration with Snapshot as the type triggers provide to reconfigure(). This decouples triggers from the configuration format: triggers produce a source-agnostic Snapshot, the runtime handles parsing internally. parseConfiguration() is removed — parsing is no longer a trigger concern. The Snapshot abstraction is promoted from Proposal 096's internal runtime type to public API, ensuring the trigger SPI won't need to change when multi-file configuration lands. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 121 +++++++++---------- 1 file changed, 57 insertions(+), 64 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index 07185032..262c04ce 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -28,7 +28,7 @@ The trigger SPI consists of three interfaces: - **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. - **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. -- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, source-agnostic configuration parsing, pre-flight validation, and the proxy's startup configuration path. +- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, pre-flight validation, and the proxy's startup configuration path. Triggers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic abstraction that decouples the trigger from the configuration format. A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. @@ -37,7 +37,7 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt ```java /** * A reconfiguration trigger watches for configuration changes and drives - * {@link ReconfigurationTriggerContext#reconfigure(Configuration)} when a + * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} when a * change is detected. * *

    Lifecycle

    @@ -130,6 +130,14 @@ public interface ReconfigurationTriggerFactory { } ``` +### `Snapshot` + +Triggers deliver configuration to the runtime as a `Snapshot` — a source-agnostic representation of the proxy's desired configuration state. This type is adopted from [Proposal 096 — Reworking proxy configuration](https://github.com/kroxylicious/design/pull/96), where it is described as an internal runtime abstraction. This proposal promotes `Snapshot` to public API so that triggers can produce configuration from any source without coupling to the configuration format. + +For the current single-file configuration model, a `Snapshot` wraps a single YAML string. When Proposal 096's multi-file configuration lands, the same `Snapshot` interface supports `proxy.yaml` + `plugins.d/` directory trees, Kubernetes-backed configurations, and in-memory representations — without any change to the trigger SPI. + +The `Snapshot` interface is defined in Proposal 096. This proposal does not redefine it; it adopts it as-is. + ### `ReconfigurationTriggerContext` ```java @@ -138,19 +146,18 @@ public interface ReconfigurationTriggerFactory { * trigger's view of the proxy — triggers never interact with * {@code KafkaProxy} directly. * - *

    The context provides four categories of capability: + *

    The context provides three categories of capability: *

      - *
    • Reconfiguration — {@link #reconfigure(Configuration)} drives - * the proxy to converge to a new configuration.
    • + *
    • Reconfiguration — {@link #reconfigure(Snapshot)} drives + * the proxy to converge to a new configuration. The trigger provides + * a {@link Snapshot} representing the desired state; the runtime + * handles parsing and change detection internally.
    • *
    • Proxy lifecycle — {@link #shutdown()} initiates an orderly * proxy shutdown, enabling failure policies that terminate the proxy * on unrecoverable errors.
    • - *
    • Configuration handling — {@link #parseConfiguration(InputStream)} - * and {@link #validateConfiguration(Configuration)} allow triggers to - * parse and pre-validate configuration from any source before applying - * it.
    • - *
    • Startup context — {@link #configFilePath()} provides the path - * the proxy was originally started with.
    • + *
    • Validation — {@link #validate(Snapshot)} allows triggers to + * pre-validate a snapshot before applying it, catching structural and + * scope errors before any virtual cluster experiences downtime.
    • *
    * *

    The context is thread-safe. All methods may be called from any thread. @@ -158,18 +165,18 @@ public interface ReconfigurationTriggerFactory { public interface ReconfigurationTriggerContext { /** - * Apply a new configuration to the running proxy. Delegates to - * {@link KafkaProxy#reconfigure(Configuration)} — see that method's - * Javadoc (Proposal 083) for the full contract including error reporting, - * concurrency control, and scope limitations. + * Apply a new configuration to the running proxy. The runtime parses the + * snapshot, detects what changed, and converges the running state to match. + * See {@link KafkaProxy#reconfigure} (Proposal 083) for the full contract + * including error reporting, concurrency control, and scope limitations. * - * @param newConfig the desired end-state configuration; must be non-null - * and statically valid + * @param newConfig a snapshot representing the desired end-state + * configuration * @return a future that completes with a {@link ReconfigureResult} * describing any per-component failures, or completes * exceptionally on catastrophic failure or input rejection */ - CompletableFuture reconfigure(Configuration newConfig); + CompletableFuture reconfigure(Snapshot newConfig); /** * Initiate an orderly shutdown of the proxy. @@ -186,28 +193,11 @@ public interface ReconfigurationTriggerContext { void shutdown(); /** - * Parse a YAML configuration stream into a {@link Configuration} object. - * - *

    Uses the same parser and static validation rules that the proxy - * applies at startup, ensuring consistency regardless of how the trigger - * sources configuration. Triggers may obtain the stream from any source: - * a file ({@link java.nio.file.Files#newInputStream}), an HTTP request - * body, an in-memory buffer, etc. - * - * @param configurationYaml the YAML configuration as a stream - * @return the parsed and statically validated configuration - * @throws ConfigurationException if the stream cannot be read or contains - * invalid configuration - */ - Configuration parseConfiguration(InputStream configurationYaml); - - /** - * Validate a {@link Configuration} against the proxy's current running - * state without applying it. Performs the same pre-flight checks that - * {@link #reconfigure(Configuration)} would perform before beginning - * any state-changing work — in particular, detecting out-of-scope - * changes that would cause {@code reconfigure()} to reject the - * configuration. + * Validate a snapshot against the proxy's current running state without + * applying it. Performs the same pre-flight checks that + * {@link #reconfigure(Snapshot)} would perform before beginning any + * state-changing work — parsing, static validation, and detection of + * out-of-scope changes. * *

    A trigger can use this to implement a two-phase workflow: * validate first, then apply only if validation passes. This catches @@ -216,26 +206,28 @@ public interface ReconfigurationTriggerContext { *

    A successful validation does not guarantee that a subsequent * {@code reconfigure()} call will succeed — runtime conditions (port * availability, upstream reachability) may change between validation - * and application. But it does guarantee that the configuration will - * not be rejected for structural or scope reasons. + * and application. But it does guarantee that the snapshot will not be + * rejected for structural or scope reasons. * - * @param config the configuration to validate + * @param config the snapshot to validate * @throws OutOfScopeChangeException if the configuration differs from * the running configuration in an out-of-scope section - * @throws ConfigurationException if the configuration fails validation + * @throws ConfigurationException if the snapshot cannot be parsed or + * fails validation */ - void validateConfiguration(Configuration config); + void validate(Snapshot config); /** - * Returns the path to the configuration file the proxy was started with. + * Returns the path to the configuration file (or directory) the proxy + * was started with. * - *

    This is the file path that was passed to the proxy at startup. It - * does not change during the proxy's lifetime. Triggers may use it as a - * default watch target, as a baseline for change detection, or to locate - * configuration relative to the proxy's working directory. Triggers that - * source configuration from non-file origins may ignore it. + *

    This is the path that was passed to the proxy at startup. It does + * not change during the proxy's lifetime. Triggers may use it as a + * default watch target, as a baseline for change detection, or to + * construct a new {@link Snapshot} from the same location. Triggers + * that source configuration from non-filesystem origins may ignore it. * - * @return the startup configuration file path + * @return the startup configuration path */ Path configFilePath(); } @@ -264,11 +256,11 @@ Proposal 083 defined `KafkaProxy.reconfigure()` as a minimal operation that repo #### Configuration sourcing -The trigger is responsible for obtaining and delivering a new `Configuration` to `reconfigure()`. How the configuration is sourced — watching a file, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `ReconfigurationTriggerContext` provides `parseConfiguration(InputStream)` so triggers can parse YAML from any source (file, HTTP body, in-memory buffer) using the same parser and validation rules the proxy applies at startup. Triggers may also construct `Configuration` objects programmatically if their source is not YAML. +The trigger is responsible for obtaining and delivering a new `Snapshot` to `reconfigure()`. How the snapshot is produced — watching a filesystem directory, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `Snapshot` abstraction (adopted from Proposal 096) decouples the trigger from the configuration format: a file watcher produces a filesystem-backed snapshot, an operator produces a Kubernetes-backed snapshot, and so on. The runtime handles parsing and validation internally. -#### Static validation +#### Validation -Static validation (schema conformance, required fields, field-value ranges, internal consistency) must be performed on the new configuration **before** calling `reconfigure()`. This is the caller's responsibility per Proposal 083's contract. `parseConfiguration(InputStream)` performs static validation as part of parsing. Triggers that construct `Configuration` objects directly must ensure equivalent validation. Additionally, `validateConfiguration(Configuration)` allows triggers to check for out-of-scope changes and other pre-flight failures before committing to a reconfiguration that would disrupt traffic — enabling a validate-then-apply workflow. +Triggers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing, static validation, and out-of-scope change detection — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. #### Failure policy @@ -282,7 +274,7 @@ The choice between these (or a custom policy) is the trigger's decision, typical #### Previous configuration tracking -Triggers that support rollback must maintain their own record of the previous known-good configuration. The proxy does not expose a getter for its running configuration. Triggers typically have a natural source-of-truth for this: a previous file snapshot, a ConfigMap revision, an HTTP request history. +Triggers that support rollback must maintain their own record of the previous known-good `Snapshot`. The proxy does not expose a getter for its running configuration. Triggers typically have a natural source-of-truth for this: a previous filesystem snapshot, a ConfigMap revision, an HTTP request history. #### Concurrency handling @@ -391,10 +383,10 @@ class FileWatcherTrigger implements ReconfigurationTrigger { watchThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // wait for events, debounce, then: - try (InputStream in = Files.newInputStream(watchPath)) { - Configuration newConfig = context.parseConfiguration(in); - context.validateConfiguration(newConfig); - context.reconfigure(newConfig) + try { + Snapshot snapshot = Snapshot.fromPath(watchPath); + context.validate(snapshot); + context.reconfigure(snapshot) .whenComplete((result, ex) -> { if (ex instanceof ConcurrentReconfigureException) { // retry later @@ -411,7 +403,7 @@ class FileWatcherTrigger implements ReconfigurationTrigger { } }); } catch (ConfigurationException e) { - LOG.error("Failed to parse or validate configuration", e); + LOG.error("Failed to validate configuration", e); } } }); @@ -429,8 +421,8 @@ class FileWatcherTrigger implements ReconfigurationTrigger { This example demonstrates: - The trigger manages its own threads -- `parseConfiguration(InputStream)` parses from any source — here a file, but equally an HTTP body or in-memory buffer -- `validateConfiguration()` catches out-of-scope changes before any VC experiences downtime +- The trigger produces a `Snapshot` from the filesystem — other triggers would produce snapshots from other sources +- `validate()` catches structural and scope errors before any VC experiences downtime - `whenComplete()` implements failure policy (best-effort in this case) - `ConcurrentReconfigureException` is handled with retry semantics - The trigger uses `configFilePath()` as its default watch target @@ -448,6 +440,7 @@ This example demonstrates: - **Proposal 083 unchanged.** The `KafkaProxy.reconfigure()` contract, `ReconfigureResult`, `ReconfigureError`, concurrency control, and scope limitations are unchanged. - **Configuration format.** The `reconfigurationTrigger` section is new; its absence is a no-op. Because it is an out-of-scope section for `reconfigure()`, any change to it in a new configuration will be rejected with `OutOfScopeChangeException` — which is the correct behaviour (trigger changes require a restart). - **Plugin convention.** The `type` + `config` pattern and `ServiceLoader` discovery follow established Kroxylicious conventions and do not introduce new mechanisms. +- **Proposal 096 (configuration rework).** This proposal adopts Proposal 096's `Snapshot` abstraction as the type triggers provide to `reconfigure()`. For the current single-file configuration model, `Snapshot` wraps a single YAML string. When Proposal 096's multi-file configuration lands, triggers produce multi-file snapshots through the same interface — no trigger SPI changes required. ## Rejected alternatives @@ -457,7 +450,7 @@ This example demonstrates: - **Multiple simultaneous triggers**: Considered allowing multiple triggers to be active (e.g. both a file watcher and an HTTP endpoint). Rejected because `reconfigure()` only allows one reconfiguration at a time (`ConcurrentReconfigureException`), and multiple triggers racing to reconfigure would create unpredictable behaviour. If a deployment needs both file-based and HTTP-based triggering, a single trigger implementation can support both input mechanisms internally. -- **Trigger signals "config changed" without providing `Configuration`**: An alternative where the trigger simply signals "reload" and the runtime re-reads and parses the configuration file. Simpler for file-based triggers but does not support non-file configuration sources (HTTP request bodies, CRD specs, programmatically generated configuration). The `parseConfiguration(InputStream)` method on `ReconfigurationTriggerContext` gives file-based triggers the same simplicity (open a stream, call parse) while preserving flexibility for other sources. +- **Trigger signals "config changed" without providing a `Snapshot`**: An alternative where the trigger simply signals "reload" and the runtime re-reads the configuration from its startup path. Simpler for the file watcher case but does not support non-filesystem configuration sources (HTTP request bodies, CRD specs, in-memory representations). The `Snapshot` abstraction gives file-based triggers comparable simplicity (`Snapshot.fromPath(...)`) while supporting any source. - **Hardcoded trigger in `kroxylicious-app`**: Instead of an SPI, wire a file watcher directly into the standalone binary. Rejected because it forces users who need a different trigger mechanism (HTTP, custom control plane) to embed the proxy rather than just providing a different trigger on the classpath. The SPI cost is small and the extensibility value is high. From 7d8df2b4b1facfb7b17033d142ac77af0964f72b Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 13:04:19 +1200 Subject: [PATCH 6/8] Introduce bootstrap/reloadable configuration split The trigger is now the sole source of reloadable configuration, including the initial load at startup. The proxy starts in an empty state (bootstrap infrastructure only) and the trigger's first reconfigure() call brings virtual clusters to life. This eliminates the two-path inconsistency where the constructor loaded initial config and triggers handled subsequent changes. Key changes: - Logical split between bootstrap (static) and reloadable (via Snapshot) - start() performs initial load synchronously, then sets up watching - Failure to start exits the proxy (no VCs = not useful) - OutOfScopeChangeException no longer relevant for trigger-driven reconfig - New rejected alternative: runtime-loads-initial model Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 195 +++++++++++-------- 1 file changed, 117 insertions(+), 78 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index 262c04ce..1bd5b8a6 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -2,7 +2,7 @@ **Builds on:** [Proposal 083 — Changing Active Proxy Configuration](https://github.com/kroxylicious/design/blob/main/proposals/083-hot-reload-feature.md) -This proposal defines a pluggable Service Provider Interface (SPI) for triggering `KafkaProxy.reconfigure()`. Trigger implementations are discovered via `ServiceLoader`, configured in the proxy's YAML configuration, and are responsible for sourcing new configuration and driving the reconfiguration lifecycle. The SPI formalises the trigger responsibilities established during the design of Proposal 083 and provides the extension point that allows different deployment models — standalone, Kubernetes, embedded — to use different reconfiguration strategies without proxy changes. +This proposal defines a pluggable Service Provider Interface (SPI) for triggering `KafkaProxy.reconfigure()`. Trigger implementations are discovered via `ServiceLoader`, configured in the proxy's bootstrap configuration, and are the sole source of reloadable configuration — including the initial load at startup. The proposal introduces a logical split between **bootstrap configuration** (static settings read once at startup) and **reloadable configuration** (virtual clusters, filters, and plugins delivered via `Snapshot`). The SPI formalises the trigger responsibilities established during the design of Proposal 083 and provides the extension point that allows different deployment models — standalone, Kubernetes, embedded — to use different reconfiguration strategies without proxy changes. ## Current situation @@ -28,7 +28,7 @@ The trigger SPI consists of three interfaces: - **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. - **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. -- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, pre-flight validation, and the proxy's startup configuration path. Triggers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic abstraction that decouples the trigger from the configuration format. +- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, and pre-flight validation. Triggers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic representation of the reloadable state that decouples the trigger from the configuration format. A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. @@ -36,9 +36,10 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt ```java /** - * A reconfiguration trigger watches for configuration changes and drives - * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} when a - * change is detected. + * A reconfiguration trigger is the sole source of reloadable configuration + * for the proxy. It performs the initial configuration load and then watches + * for subsequent changes, driving + * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} in both cases. * *

    Lifecycle

    *

    A trigger instance is created by its {@link ReconfigurationTriggerFactory} @@ -46,8 +47,12 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt * for the lifetime of the proxy process. * *

      - *
    • {@link #start()} is called after the proxy has completed startup and - * is serving traffic. The trigger should begin watching for changes.
    • + *
    • {@link #start()} is called after the proxy has completed its bootstrap + * (management endpoints, metrics) but before any virtual clusters exist. + * The trigger must perform the initial configuration load — reading its + * source, constructing a {@link Snapshot}, and calling + * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} — before + * setting up background watching for subsequent changes.
    • *
    • {@link #close()} is called before proxy shutdown begins. The trigger * should stop watching, release resources, and return promptly. Any * in-flight {@code reconfigure()} call will complete independently.
    • @@ -55,9 +60,10 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt * *

      Threading

      *

      {@code start()} and {@code close()} are called on the proxy's main thread. - * The trigger is free to create its own threads (e.g. a file watcher thread, an - * HTTP server thread) but must manage their lifecycle. {@code reconfigure()} is - * thread-safe and may be called from any thread. + * The initial configuration load within {@code start()} happens synchronously + * on the calling thread. Subsequent change detection (file watching, HTTP + * listening) should happen on background threads managed by the trigger. + * {@code reconfigure()} is thread-safe and may be called from any thread. * *

      Trigger responsibilities

      *

      See the "Trigger responsibilities" section of this proposal for the full @@ -66,17 +72,25 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt public interface ReconfigurationTrigger extends Closeable { /** - * Start watching for configuration changes. + * Perform the initial configuration load and begin watching for changes. * - *

      Called once, after the proxy has completed startup. The trigger should - * begin watching for changes and call - * {@link ReconfigurationTriggerContext#reconfigure(Configuration)} when a - * change is detected. This method should return promptly — long-running - * work (file watching, HTTP listening) should happen on background threads - * managed by the trigger. + *

      Called once, after the proxy has completed its bootstrap. The proxy + * has no virtual clusters at this point — the trigger must load the + * current configuration from its source, construct a {@link Snapshot}, + * and call {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} + * to bring virtual clusters to life. Only after the initial load + * succeeds should the trigger set up background watching for subsequent + * changes. * - * @throws Exception if the trigger cannot start (e.g. cannot open a watch - * on the configuration file, cannot bind an HTTP port) + *

      The initial load is synchronous: this method should not return + * until the first {@code reconfigure()} call has completed. Background + * watching for subsequent changes should be set up before returning. + * + * @throws Exception if the trigger cannot start (e.g. cannot read the + * configuration source, cannot open a watch on the configuration + * file, cannot bind an HTTP port). If this method throws, the + * proxy will exit — an empty proxy with no virtual clusters is + * not useful. */ void start() throws Exception; @@ -97,7 +111,7 @@ public interface ReconfigurationTrigger extends Closeable { * *

      Each factory declares the type of its configuration object via * {@link #configType()}. The runtime deserialises the trigger-specific - * configuration from the proxy's YAML configuration and passes it to + * configuration from the proxy's bootstrap configuration and passes it to * {@link #create(ReconfigurationTriggerContext, Object)}. * * @param the trigger-specific configuration type. Must be deserializable @@ -122,7 +136,7 @@ public interface ReconfigurationTriggerFactory { /** * Returns the type of the trigger-specific configuration object. * The runtime uses this to deserialize the {@code config:} section of the - * trigger's YAML configuration block. + * trigger's bootstrap configuration block. * * @return the configuration class */ @@ -132,9 +146,11 @@ public interface ReconfigurationTriggerFactory { ### `Snapshot` -Triggers deliver configuration to the runtime as a `Snapshot` — a source-agnostic representation of the proxy's desired configuration state. This type is adopted from [Proposal 096 — Reworking proxy configuration](https://github.com/kroxylicious/design/pull/96), where it is described as an internal runtime abstraction. This proposal promotes `Snapshot` to public API so that triggers can produce configuration from any source without coupling to the configuration format. +Triggers deliver configuration to the runtime as a `Snapshot` — a source-agnostic representation of the proxy's desired **reloadable** state (virtual clusters, filters, plugin instances). A `Snapshot` does not include bootstrap configuration (management, metrics, trigger settings) — that is read once at startup from the bootstrap file and is not the trigger's concern. + +The `Snapshot` type is adopted from [Proposal 096 — Reworking proxy configuration](https://github.com/kroxylicious/design/pull/96), where it is described as an internal runtime abstraction. This proposal promotes `Snapshot` to public API so that triggers can produce configuration from any source without coupling to the configuration format. -For the current single-file configuration model, a `Snapshot` wraps a single YAML string. When Proposal 096's multi-file configuration lands, the same `Snapshot` interface supports `proxy.yaml` + `plugins.d/` directory trees, Kubernetes-backed configurations, and in-memory representations — without any change to the trigger SPI. +For the current configuration model, a `Snapshot` wraps the reloadable portions of the proxy's YAML. As the configuration format evolves, the same `Snapshot` interface can support richer representations — multi-file layouts, Kubernetes-backed configurations, in-memory representations — without any change to the trigger SPI. The `Snapshot` interface is defined in Proposal 096. This proposal does not redefine it; it adopts it as-is. @@ -150,14 +166,14 @@ The `Snapshot` interface is defined in Proposal 096. This proposal does not rede *

        *
      • Reconfiguration — {@link #reconfigure(Snapshot)} drives * the proxy to converge to a new configuration. The trigger provides - * a {@link Snapshot} representing the desired state; the runtime - * handles parsing and change detection internally.
      • + * a {@link Snapshot} representing the desired reloadable state; the + * runtime handles parsing and change detection internally. *
      • Proxy lifecycle — {@link #shutdown()} initiates an orderly * proxy shutdown, enabling failure policies that terminate the proxy * on unrecoverable errors.
      • *
      • Validation — {@link #validate(Snapshot)} allows triggers to - * pre-validate a snapshot before applying it, catching structural and - * scope errors before any virtual cluster experiences downtime.
      • + * pre-validate a snapshot before applying it, catching structural + * errors before any virtual cluster experiences downtime. *
      * *

      The context is thread-safe. All methods may be called from any thread. @@ -168,10 +184,15 @@ public interface ReconfigurationTriggerContext { * Apply a new configuration to the running proxy. The runtime parses the * snapshot, detects what changed, and converges the running state to match. * See {@link KafkaProxy#reconfigure} (Proposal 083) for the full contract - * including error reporting, concurrency control, and scope limitations. + * including error reporting and concurrency control. * - * @param newConfig a snapshot representing the desired end-state - * configuration + *

      This method handles both the initial load (when no virtual clusters + * exist) and subsequent reconfigurations. The trigger calls it in both + * cases — the runtime handles the "from nothing to something" case + * naturally. + * + * @param newConfig a snapshot representing the desired reloadable + * configuration (virtual clusters, filters, plugins) * @return a future that completes with a {@link ReconfigureResult} * describing any per-component failures, or completes * exceptionally on catastrophic failure or input rejection @@ -193,11 +214,9 @@ public interface ReconfigurationTriggerContext { void shutdown(); /** - * Validate a snapshot against the proxy's current running state without - * applying it. Performs the same pre-flight checks that - * {@link #reconfigure(Snapshot)} would perform before beginning any - * state-changing work — parsing, static validation, and detection of - * out-of-scope changes. + * Validate a snapshot without applying it. Performs the same pre-flight + * checks that {@link #reconfigure(Snapshot)} would perform before + * beginning any state-changing work — parsing and static validation. * *

      A trigger can use this to implement a two-phase workflow: * validate first, then apply only if validation passes. This catches @@ -207,25 +226,21 @@ public interface ReconfigurationTriggerContext { * {@code reconfigure()} call will succeed — runtime conditions (port * availability, upstream reachability) may change between validation * and application. But it does guarantee that the snapshot will not be - * rejected for structural or scope reasons. + * rejected for structural reasons. * * @param config the snapshot to validate - * @throws OutOfScopeChangeException if the configuration differs from - * the running configuration in an out-of-scope section * @throws ConfigurationException if the snapshot cannot be parsed or * fails validation */ void validate(Snapshot config); /** - * Returns the path to the configuration file (or directory) the proxy - * was started with. + * Returns the path that was passed to the proxy at startup. * - *

      This is the path that was passed to the proxy at startup. It does - * not change during the proxy's lifetime. Triggers may use it as a - * default watch target, as a baseline for change detection, or to - * construct a new {@link Snapshot} from the same location. Triggers - * that source configuration from non-filesystem origins may ignore it. + *

      This path does not change during the proxy's lifetime. File-based + * triggers may use it as a default location for their configuration + * source. Triggers that source configuration from non-filesystem + * origins may ignore it. * * @return the startup configuration path */ @@ -233,22 +248,30 @@ public interface ReconfigurationTriggerContext { } ``` -### Configuration model +### Bootstrap and reloadable configuration + +This proposal introduces a logical split in proxy configuration: + +- **Bootstrap configuration** — static settings read once at startup: management endpoints, metrics, admin, and the trigger selection and configuration. These cannot change without a process restart. + +- **Reloadable configuration** — virtual clusters, filters, and plugin instances. This is the configuration that changes at runtime. It is always delivered as a `Snapshot` via `reconfigure()`, and the trigger is the sole source — including for the initial load at startup. + +This split formalises what Proposal 083 established implicitly: `reconfigure()` only applies virtual-cluster and filter configuration, and rejects changes to management, metrics, or admin sections. Rather than detecting out-of-scope changes in a monolithic configuration and rejecting them, the split separates the concerns logically. Snapshots contain only reloadable state, so there is nothing out-of-scope to detect. + +How the logical split manifests on disk — whether bootstrap and reloadable configuration live in the same file, separate files, or separate directories — is a configuration format concern outside the scope of this proposal. What matters for the trigger SPI is the contract: the trigger produces `Snapshot` objects containing reloadable state, and the runtime handles bootstrap configuration independently. -Triggers are selected and configured via a top-level `reconfigurationTrigger` section in the proxy's YAML configuration: +The trigger is selected and configured within the bootstrap configuration. This follows the same `type` + `config` pattern used by filters, routers, and other Kroxylicious plugins: ```yaml reconfigurationTrigger: - type: FileWatcher # ServiceLoader type name - config: # trigger-specific configuration - debounceInterval: PT1S # implementation-specific settings + type: FileWatcher + config: + debounceInterval: PT1S ``` -This follows the same `type` + `config` pattern used by filters, routers, and other Kroxylicious plugins. +When the `reconfigurationTrigger` section is absent, no trigger is created and the proxy operates as today — configuration is loaded at startup and changes require a restart. -When the `reconfigurationTrigger` section is absent, no trigger is created and the proxy operates as today — configuration changes require a restart. - -The `reconfigurationTrigger` section is **static configuration**: it is not hot-reloadable. Changing the trigger type or its configuration requires a proxy restart. This is consistent with Proposal 083's scope limitations — `reconfigure()` applies only virtual-cluster and filter configuration; other sections (including the trigger section) are out of scope and will cause `reconfigure()` to reject the configuration with `OutOfScopeChangeException` if they differ. +The bootstrap/reloadable split has a direct consequence for the trigger lifecycle: because the trigger is the sole source of reloadable configuration, the proxy starts in an **empty state** — bootstrap infrastructure (management endpoints, metrics) is running but no virtual clusters exist. The trigger's first `reconfigure()` call brings up the virtual clusters. This is described in detail in the [Trigger lifecycle](#trigger-lifecycle) section. ### Trigger responsibilities @@ -260,13 +283,13 @@ The trigger is responsible for obtaining and delivering a new `Snapshot` to `rec #### Validation -Triggers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing, static validation, and out-of-scope change detection — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. +Triggers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing and static validation — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. #### Failure policy The proxy does not act on `ReconfigureResult.errors()`. The trigger expresses its failure policy via `whenComplete()` on the returned future. Three canonical patterns are defined in Proposal 083: -- **Shut down on any failure** — call `proxy.shutdown()` if `errors()` is non-empty +- **Shut down on any failure** — call `context.shutdown()` if `errors()` is non-empty - **Best-effort** — log failures, take no proxy-level action; surviving VCs continue serving - **Rollback on failure** — call `reconfigure(oldConfig)` when `errors()` is non-empty @@ -288,7 +311,7 @@ The recommended discrimination is `ex instanceof ConcurrentReconfigureException` #### Out-of-scope change handling -`reconfigure()` rejects configurations that differ in out-of-scope sections with `OutOfScopeChangeException` (the future completes exceptionally). Like `ConcurrentReconfigureException`, this means the proxy did not change state. The trigger should log the rejection and **not** apply destructive policies (shutdown, rollback). +Because the bootstrap/reloadable split separates static configuration from reloadable state structurally, `OutOfScopeChangeException` is not expected in trigger-driven reconfiguration — the `Snapshot` contains only reloadable state and there is nothing out-of-scope to detect. However, trigger implementations should handle unexpected exceptions from `reconfigure()` defensively: log the rejection and **not** apply destructive policies (shutdown, rollback). #### Debouncing @@ -304,22 +327,26 @@ Triggers may perform their own change detection to avoid unnecessary `reconfigur ### Trigger lifecycle -The trigger lifecycle is tied to the proxy's lifecycle: +The trigger lifecycle is tied to the proxy's lifecycle. Because the trigger is the sole source of reloadable configuration, the proxy starts in an empty state — bootstrap infrastructure only — and the trigger's first `reconfigure()` call brings virtual clusters to life. ``` Proxy startup │ - ├── Parse proxy configuration (including reconfigurationTrigger section) + ├── Parse bootstrap configuration (management, trigger selection) + ├── Start bootstrap infrastructure (management endpoints, metrics) ├── Discover ReconfigurationTriggerFactory via ServiceLoader - ├── Deserialize trigger-specific config + ├── Deserialize trigger-specific config from bootstrap ├── Call factory.create(context, config) → ReconfigurationTrigger │ - ├── Proxy completes startup (VCs serving) - │ ├── Call trigger.start() │ │ - │ ├── Success: trigger is active, watching for changes - │ └── Failure: log warning, proxy continues without hot reload + │ ├── Trigger reads current config from its source + │ ├── Trigger calls context.reconfigure(snapshot) ← initial load + │ ├── VCs come up + │ ├── Trigger sets up background watching for changes + │ │ + │ ├── Start success: trigger is active, VCs serving + │ └── Start failure: proxy has no VCs, proxy exits │ ├── ... proxy running, trigger calling reconfigure() as needed ... │ @@ -331,9 +358,11 @@ Proxy startup └── Proxy completes shutdown ``` -**Startup ordering.** The trigger is started *after* the proxy has completed startup and all virtual clusters are serving. This is why the `ReconfigurationTrigger` interface separates construction (`create()`) from activation (`start()`): the factory creates the trigger during proxy initialisation, but the trigger must not begin watching for changes — or call `reconfigure()` — until the proxy is ready. Without this separation, a file watcher trigger could detect the existing configuration file immediately on construction and attempt a `reconfigure()` before the proxy has loaded its initial configuration, which would throw `IllegalStateException` per Proposal 083. +**Initial load within `start()`.** The trigger performs the initial configuration load synchronously within `start()`: it reads its source, constructs a `Snapshot`, and calls `context.reconfigure(snapshot)`. This first `reconfigure()` call creates all virtual clusters and brings the proxy to a serving state. Only after the initial load succeeds does the trigger set up background watching for subsequent changes. This means `start()` blocks for the duration of the initial load — which is acceptable because the proxy cannot serve traffic until the first configuration is applied. Subsequent changes happen asynchronously on the trigger's own threads. + +**Why separate `create()` from `start()`.** The `ReconfigurationTriggerFactory` creates the trigger during proxy initialisation, but `start()` is called separately so that the proxy can complete its own bootstrap (management endpoints, metrics) before the trigger begins loading configuration. This also allows the runtime to handle trigger creation failures differently from trigger start failures. -**Failure to start.** If the trigger's `start()` method throws, the proxy logs a warning and continues running without hot reload capability. This is a pragmatic choice: the proxy is functional and serving traffic; the operator can diagnose the trigger failure and restart the proxy if hot reload is required. Failing the entire proxy startup because a trigger couldn't start would be disproportionate. +**Failure to start.** If the trigger's `start()` method throws, the proxy has no virtual clusters and cannot serve traffic. The proxy should exit — an empty proxy with no VCs is not useful, and the operator needs to diagnose the trigger failure. This is a deliberate difference from the trigger-optional model: when a trigger is configured, the proxy depends on it for all reloadable configuration, so a trigger failure is a proxy failure. **Shutdown ordering.** The trigger is closed *before* the proxy begins its shutdown sequence. This prevents the trigger from attempting a `reconfigure()` call while the proxy is shutting down. Any `reconfigure()` call already in flight will complete independently — the proxy handles the `IllegalStateException` case per Proposal 083. @@ -375,18 +404,25 @@ class FileWatcherTrigger implements ReconfigurationTrigger { @Override public void start() throws Exception { - Path watchPath = context.configFilePath(); + Path watchPath = config.watchPath() != null + ? config.watchPath() + : context.configFilePath(); + + // Initial load — synchronous, brings up VCs for the first time + Snapshot snapshot = Snapshot.fromPath(watchPath); + context.reconfigure(snapshot).join(); + + // Begin watching for subsequent changes watchService = FileSystems.getDefault().newWatchService(); - // register watch on parent directory (handles K8s ConfigMap symlinks) watchPath.getParent().register(watchService, ENTRY_MODIFY, ENTRY_CREATE); watchThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // wait for events, debounce, then: try { - Snapshot snapshot = Snapshot.fromPath(watchPath); - context.validate(snapshot); - context.reconfigure(snapshot) + Snapshot newSnapshot = Snapshot.fromPath(watchPath); + context.validate(newSnapshot); + context.reconfigure(newSnapshot) .whenComplete((result, ex) -> { if (ex instanceof ConcurrentReconfigureException) { // retry later @@ -420,12 +456,13 @@ class FileWatcherTrigger implements ReconfigurationTrigger { ``` This example demonstrates: -- The trigger manages its own threads +- `start()` performs the initial load synchronously, then sets up background watching +- The initial `reconfigure()` call creates all virtual clusters — same path as subsequent changes - The trigger produces a `Snapshot` from the filesystem — other triggers would produce snapshots from other sources -- `validate()` catches structural and scope errors before any VC experiences downtime +- `validate()` catches structural errors before any VC experiences downtime - `whenComplete()` implements failure policy (best-effort in this case) - `ConcurrentReconfigureException` is handled with retry semantics -- The trigger uses `configFilePath()` as its default watch target +- The trigger uses its factory config for the watch path, falling back to `configFilePath()` ## Affected projects @@ -436,11 +473,11 @@ This example demonstrates: ## Compatibility -- **Additive.** No existing behaviour changes. A proxy with no `reconfigurationTrigger` configuration operates identically to today. -- **Proposal 083 unchanged.** The `KafkaProxy.reconfigure()` contract, `ReconfigureResult`, `ReconfigureError`, concurrency control, and scope limitations are unchanged. -- **Configuration format.** The `reconfigurationTrigger` section is new; its absence is a no-op. Because it is an out-of-scope section for `reconfigure()`, any change to it in a new configuration will be rejected with `OutOfScopeChangeException` — which is the correct behaviour (trigger changes require a restart). +- **Additive.** No existing behaviour changes. A proxy with no `reconfigurationTrigger` configuration operates identically to today — configuration is loaded from a single file at startup and changes require a restart. +- **Proposal 083 extension.** This proposal extends Proposal 083's `reconfigure()` contract to handle the "from nothing to something" case — where `reconfigure()` is called with no virtual clusters running. This is a natural extension: Proposal 083's remove/replace/add flow already handles the "add" case; when there is no prior state, there is nothing to remove or replace and everything is an add. The `ReconfigureResult`, `ReconfigureError`, and concurrency control contracts are unchanged. `OutOfScopeChangeException` remains part of the Proposal 083 contract for embedded callers who provide a full `Configuration` directly, but is not relevant for trigger-driven reconfiguration because `Snapshot` contains only reloadable state. +- **Configuration format.** The `reconfigurationTrigger` section is new; its absence means no trigger — the proxy loads configuration at startup as today. - **Plugin convention.** The `type` + `config` pattern and `ServiceLoader` discovery follow established Kroxylicious conventions and do not introduce new mechanisms. -- **Proposal 096 (configuration rework).** This proposal adopts Proposal 096's `Snapshot` abstraction as the type triggers provide to `reconfigure()`. For the current single-file configuration model, `Snapshot` wraps a single YAML string. When Proposal 096's multi-file configuration lands, triggers produce multi-file snapshots through the same interface — no trigger SPI changes required. +- **Proposal 096 (configuration rework).** This proposal adopts Proposal 096's `Snapshot` abstraction as the type triggers provide to `reconfigure()`. The bootstrap/reloadable split established here is a simpler foundation than Proposal 096's full multi-file structure, but the two are compatible: Proposal 096's ideas — per-plugin versioning, dependency tracking, richer `Snapshot` implementations — can evolve on top of this split. This proposal does not commit to Proposal 096's specific filesystem layout (e.g. `plugins.d/` keyed by plugin interface FQCN). ## Rejected alternatives @@ -457,3 +494,5 @@ This example demonstrates: - **Proxy-managed configuration persistence**: An earlier design had the proxy persist the applied configuration to disk after a successful `reconfigure()`. Rejected because persistence requirements vary by deployment: a Kubernetes operator owns state via CRD and does not want the proxy overwriting files; a bare-metal deployment may want file persistence; a custom control plane may persist to a database. This is a trigger concern, not a proxy concern. - **Trigger configuration in `updateStrategy` or `configurationReload` YAML block**: Earlier iterations of Proposal 083 proposed YAML-level configuration for failure policy and rollback behaviour. Rejected in favour of caller-side policy via `whenComplete()` — the proxy reports outcomes and takes no policy action. The only YAML configuration for triggers is the `reconfigurationTrigger` section that selects and configures the trigger implementation. + +- **Runtime loads initial configuration, trigger handles subsequent changes**: An earlier version of this proposal had the proxy load its initial configuration directly at startup (via the constructor, as in Proposal 083), with the trigger only responsible for detecting and applying subsequent changes. Rejected because it creates two code paths for configuration loading: one in the runtime (startup) and one in the trigger (reload). The unified model — where the trigger is the sole source of reloadable configuration, including the initial load — is simpler, eliminates the two-path inconsistency, and gives the trigger control over initial validation and failure policy from the start. From 4cc5c874c34f11af49d7dabbd9958622f7ddc5cc Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Thu, 25 Jun 2026 17:29:27 +1200 Subject: [PATCH 7/8] Rename to VirtualClusterConfigController, adopt config bucket names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename ReconfigurationTrigger → VirtualClusterConfigController throughout, borrowing from the Kubernetes controller pattern: a control loop that watches desired state, detects drift, and reconciles. Rename bootstrap/reloadable config split to process configuration vs virtual cluster configuration. Add rejected alternative for separate ConfigurationSource and controller plugins — the coupling between source and controller is deployment-specific and no single SPI boundary works for all cases (file watcher shares a path, HTTP PUT body *is* the source, partial config needs merging with current state). Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 260 ++++++++++--------- 1 file changed, 132 insertions(+), 128 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index 1bd5b8a6..23799284 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -1,59 +1,61 @@ -# 117 - Reconfiguration Trigger SPI +# 117 - Virtual Cluster Config Controller SPI **Builds on:** [Proposal 083 — Changing Active Proxy Configuration](https://github.com/kroxylicious/design/blob/main/proposals/083-hot-reload-feature.md) -This proposal defines a pluggable Service Provider Interface (SPI) for triggering `KafkaProxy.reconfigure()`. Trigger implementations are discovered via `ServiceLoader`, configured in the proxy's bootstrap configuration, and are the sole source of reloadable configuration — including the initial load at startup. The proposal introduces a logical split between **bootstrap configuration** (static settings read once at startup) and **reloadable configuration** (virtual clusters, filters, and plugins delivered via `Snapshot`). The SPI formalises the trigger responsibilities established during the design of Proposal 083 and provides the extension point that allows different deployment models — standalone, Kubernetes, embedded — to use different reconfiguration strategies without proxy changes. +This proposal defines a pluggable Service Provider Interface (SPI) for controlling virtual cluster configuration — sourcing it, detecting changes, and driving `KafkaProxy.reconfigure()`. Controller implementations are discovered via `ServiceLoader`, configured in the proxy's process configuration, and are the sole source of virtual cluster configuration — including the initial load at startup. The proposal introduces a logical split between **process configuration** (static settings read once at startup) and **virtual cluster configuration** (virtual clusters, filters, and plugins delivered via `Snapshot`). The SPI formalises the controller responsibilities established during the design of Proposal 083 and provides the extension point that allows different deployment models — standalone, Kubernetes, embedded — to use different reconfiguration strategies without proxy changes. ## Current situation Proposal 083 delivered `KafkaProxy.reconfigure(Configuration)` — the core mechanism for applying configuration changes to a running proxy without a full restart. The method accepts a complete `Configuration`, detects what changed, and converges the running state to match. -However, nothing calls `reconfigure()` today. The standalone binary (`kroxylicious-app`) has no way to apply configuration changes at runtime. Operators who embed the proxy can call `reconfigure()` directly from their own code, but the project-shipped binary needs a trigger mechanism to make hot reload usable. +However, nothing calls `reconfigure()` today. The standalone binary (`kroxylicious-app`) has no way to apply configuration changes at runtime. Operators who embed the proxy can call `reconfigure()` directly from their own code, but the project-shipped binary needs a controller mechanism to make hot reload usable. -During the Proposal 083 review, several trigger mechanisms were discussed — file watchers, HTTP endpoints, and operator callbacks — but all were explicitly deferred to keep that proposal focused on the reconfiguration machinery itself. The discussion also established that triggers carry significant responsibility: configuration sourcing, static validation, failure policy, rollback, concurrency handling, debouncing, and configuration persistence. These responsibilities need a formal contract. +During the Proposal 083 review, several mechanisms were discussed — file watchers, HTTP endpoints, and operator callbacks — but all were explicitly deferred to keep that proposal focused on the reconfiguration machinery itself. The discussion also established that the controller of virtual cluster configuration carries significant responsibility: configuration sourcing, static validation, failure policy, rollback, concurrency handling, debouncing, and configuration persistence. These responsibilities need a formal contract. ## Motivation -- **The shipped binary needs hot reload.** Without a trigger mechanism, `kroxylicious-app` cannot use the reconfiguration capability that Proposal 083 introduced. Configuration changes still require a full process restart. +- **The shipped binary needs hot reload.** Without a controller mechanism, `kroxylicious-app` cannot use the reconfiguration capability that Proposal 083 introduced. Configuration changes still require a full process restart. -- **Different deployments need different triggers.** A bare-metal deployment watching a config file has different requirements from a Kubernetes operator reconciling a CRD, which has different requirements from a custom control plane using an HTTP API. The trigger mechanism must be pluggable. +- **Different deployments need different controllers.** A bare-metal deployment watching a config file has different requirements from a Kubernetes operator reconciling a CRD, which has different requirements from a custom control plane using an HTTP API. The controller mechanism must be pluggable. -- **Trigger authors need a contract.** Proposal 083 pushed substantial responsibility onto triggers — failure policy, rollback, concurrency handling — but that responsibility is currently documented only in PR comments. A formal SPI with documented responsibilities makes it possible for third parties to write correct trigger implementations. +- **Controller authors need a contract.** Proposal 083 pushed substantial responsibility onto the caller of `reconfigure()` — failure policy, rollback, concurrency handling — but that responsibility is currently documented only in PR comments. A formal SPI with documented responsibilities makes it possible for third parties to write correct controller implementations. ## Proposal ### SPI overview -The trigger SPI consists of three interfaces: +The controller SPI consists of three interfaces: -- **`ReconfigurationTrigger`** — the trigger implementation itself, created by the factory, responsible for watching for configuration changes and calling `reconfigure()`. -- **`ReconfigurationTriggerFactory`** — discovered via `ServiceLoader`, responsible for creating a `ReconfigurationTrigger` from its typed configuration. -- **`ReconfigurationTriggerContext`** — provided by the runtime, gives the trigger access to `reconfigure()`, `shutdown()`, and pre-flight validation. Triggers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic representation of the reloadable state that decouples the trigger from the configuration format. +- **`VirtualClusterConfigController`** — the controller implementation itself, created by the factory. It sources virtual cluster configuration, detects changes, and drives `reconfigure()`. +- **`VirtualClusterConfigControllerFactory`** — discovered via `ServiceLoader`, responsible for creating a `VirtualClusterConfigController` from its typed configuration. +- **`VirtualClusterConfigControllerContext`** — provided by the runtime, gives the controller access to `reconfigure()`, `shutdown()`, and pre-flight validation. Controllers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic representation of the virtual cluster configuration that decouples the controller from the configuration format. -A proxy has at most one active trigger. Triggers are not composable (unlike filters in a chain). When no trigger is configured, the proxy operates as it does today — hot reload is not available. +The name "controller" borrows from the Kubernetes controller pattern: a control loop that watches desired state, detects drift, and reconciles. A `VirtualClusterConfigController` does the same — it watches a configuration source, detects changes, and reconciles the proxy's running state to match via `reconfigure()`. The failure handling, retry, and rollback responsibilities are natural parts of this reconciliation loop. -### `ReconfigurationTrigger` +A proxy has at most one active controller. Controllers are not composable (unlike filters in a chain). When no controller is configured, the proxy operates as it does today — hot reload is not available. + +### `VirtualClusterConfigController` ```java /** - * A reconfiguration trigger is the sole source of reloadable configuration - * for the proxy. It performs the initial configuration load and then watches - * for subsequent changes, driving - * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} in both cases. + * A virtual cluster config controller is the sole source of virtual cluster + * configuration for the proxy. It sources configuration, detects changes, + * and drives reconfiguration via + * {@link VirtualClusterConfigControllerContext#reconfigure(Snapshot)} in both cases. * *

      Lifecycle

      - *

      A trigger instance is created by its {@link ReconfigurationTriggerFactory} + *

      A controller instance is created by its {@link VirtualClusterConfigControllerFactory} * and has proxy-level lifecycle: one instance exists per proxy, and it lives * for the lifetime of the proxy process. * *

        *
      • {@link #start()} is called after the proxy has completed its bootstrap * (management endpoints, metrics) but before any virtual clusters exist. - * The trigger must perform the initial configuration load — reading its + * The controller must perform the initial configuration load — reading its * source, constructing a {@link Snapshot}, and calling - * {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} — before + * {@link VirtualClusterConfigControllerContext#reconfigure(Snapshot)} — before * setting up background watching for subsequent changes.
      • - *
      • {@link #close()} is called before proxy shutdown begins. The trigger + *
      • {@link #close()} is called before proxy shutdown begins. The controller * should stop watching, release resources, and return promptly. Any * in-flight {@code reconfigure()} call will complete independently.
      • *
      @@ -62,31 +64,31 @@ A proxy has at most one active trigger. Triggers are not composable (unlike filt *

      {@code start()} and {@code close()} are called on the proxy's main thread. * The initial configuration load within {@code start()} happens synchronously * on the calling thread. Subsequent change detection (file watching, HTTP - * listening) should happen on background threads managed by the trigger. + * listening) should happen on background threads managed by the controller. * {@code reconfigure()} is thread-safe and may be called from any thread. * - *

      Trigger responsibilities

      - *

      See the "Trigger responsibilities" section of this proposal for the full - * contract that trigger implementations must follow. + *

      Controller responsibilities

      + *

      See the "Controller responsibilities" section of this proposal for the full + * contract that controller implementations must follow. */ -public interface ReconfigurationTrigger extends Closeable { +public interface VirtualClusterConfigController extends Closeable { /** * Perform the initial configuration load and begin watching for changes. * *

      Called once, after the proxy has completed its bootstrap. The proxy - * has no virtual clusters at this point — the trigger must load the + * has no virtual clusters at this point — the controller must load the * current configuration from its source, construct a {@link Snapshot}, - * and call {@link ReconfigurationTriggerContext#reconfigure(Snapshot)} + * and call {@link VirtualClusterConfigControllerContext#reconfigure(Snapshot)} * to bring virtual clusters to life. Only after the initial load - * succeeds should the trigger set up background watching for subsequent + * succeeds should the controller set up background watching for subsequent * changes. * *

      The initial load is synchronous: this method should not return * until the first {@code reconfigure()} call has completed. Background * watching for subsequent changes should be set up before returning. * - * @throws Exception if the trigger cannot start (e.g. cannot read the + * @throws Exception if the controller cannot start (e.g. cannot read the * configuration source, cannot open a watch on the configuration * file, cannot bind an HTTP port). If this method throws, the * proxy will exit — an empty proxy with no virtual clusters is @@ -102,41 +104,41 @@ public interface ReconfigurationTrigger extends Closeable { } ``` -### `ReconfigurationTriggerFactory` +### `VirtualClusterConfigControllerFactory` ```java /** - * Factory for creating {@link ReconfigurationTrigger} instances. Discovered + * Factory for creating {@link VirtualClusterConfigController} instances. Discovered * via {@link java.util.ServiceLoader}. * *

      Each factory declares the type of its configuration object via - * {@link #configType()}. The runtime deserialises the trigger-specific - * configuration from the proxy's bootstrap configuration and passes it to - * {@link #create(ReconfigurationTriggerContext, Object)}. + * {@link #configType()}. The runtime deserialises the controller-specific + * configuration from the proxy's process configuration and passes it to + * {@link #create(VirtualClusterConfigControllerContext, Object)}. * - * @param the trigger-specific configuration type. Must be deserializable + * @param the controller-specific configuration type. Must be deserializable * from YAML by Jackson. */ -public interface ReconfigurationTriggerFactory { +public interface VirtualClusterConfigControllerFactory { /** - * Creates a new trigger instance. + * Creates a new controller instance. * - *

      The trigger is not yet active — the caller will invoke - * {@link ReconfigurationTrigger#start()} after this method returns. + *

      The controller is not yet active — the caller will invoke + * {@link VirtualClusterConfigController#start()} after this method returns. * * @param context provides access to reconfigure() and configuration parsing - * @param config the trigger-specific configuration, deserialized from YAML - * @return a new trigger instance, ready to be started - * @throws Exception if the trigger cannot be constructed (e.g. invalid + * @param config the controller-specific configuration, deserialized from YAML + * @return a new controller instance, ready to be started + * @throws Exception if the controller cannot be constructed (e.g. invalid * configuration values) */ - ReconfigurationTrigger create(ReconfigurationTriggerContext context, C config) throws Exception; + VirtualClusterConfigController create(VirtualClusterConfigControllerContext context, C config) throws Exception; /** - * Returns the type of the trigger-specific configuration object. + * Returns the type of the controller-specific configuration object. * The runtime uses this to deserialize the {@code config:} section of the - * trigger's bootstrap configuration block. + * controller's process configuration block. * * @return the configuration class */ @@ -146,39 +148,39 @@ public interface ReconfigurationTriggerFactory { ### `Snapshot` -Triggers deliver configuration to the runtime as a `Snapshot` — a source-agnostic representation of the proxy's desired **reloadable** state (virtual clusters, filters, plugin instances). A `Snapshot` does not include bootstrap configuration (management, metrics, trigger settings) — that is read once at startup from the bootstrap file and is not the trigger's concern. +Controllers deliver configuration to the runtime as a `Snapshot` — a source-agnostic representation of the desired virtual cluster configuration (virtual clusters, filters, plugin instances). A `Snapshot` does not include process configuration (management, metrics, controller settings) — that is read once at startup and is not the controller's concern. -The `Snapshot` type is adopted from [Proposal 096 — Reworking proxy configuration](https://github.com/kroxylicious/design/pull/96), where it is described as an internal runtime abstraction. This proposal promotes `Snapshot` to public API so that triggers can produce configuration from any source without coupling to the configuration format. +The `Snapshot` type is adopted from [Proposal 096 — Reworking proxy configuration](https://github.com/kroxylicious/design/pull/96), where it is described as an internal runtime abstraction. This proposal promotes `Snapshot` to public API so that controllers can produce configuration from any source without coupling to the configuration format. -For the current configuration model, a `Snapshot` wraps the reloadable portions of the proxy's YAML. As the configuration format evolves, the same `Snapshot` interface can support richer representations — multi-file layouts, Kubernetes-backed configurations, in-memory representations — without any change to the trigger SPI. +For the current configuration model, a `Snapshot` wraps the virtual-cluster portions of the proxy's YAML. As the configuration format evolves, the same `Snapshot` interface can support richer representations — multi-file layouts, Kubernetes-backed configurations, in-memory representations — without any change to the controller SPI. The `Snapshot` interface is defined in Proposal 096. This proposal does not redefine it; it adopts it as-is. -### `ReconfigurationTriggerContext` +### `VirtualClusterConfigControllerContext` ```java /** - * Runtime context provided to a {@link ReconfigurationTrigger}. This is the - * trigger's view of the proxy — triggers never interact with + * Runtime context provided to a {@link VirtualClusterConfigController}. This is the + * controller's view of the proxy — controllers never interact with * {@code KafkaProxy} directly. * *

      The context provides three categories of capability: *

        *
      • Reconfiguration — {@link #reconfigure(Snapshot)} drives - * the proxy to converge to a new configuration. The trigger provides - * a {@link Snapshot} representing the desired reloadable state; the + * the proxy to converge to a new configuration. The controller provides + * a {@link Snapshot} representing the desired virtual cluster configuration; the * runtime handles parsing and change detection internally.
      • *
      • Proxy lifecycle — {@link #shutdown()} initiates an orderly * proxy shutdown, enabling failure policies that terminate the proxy * on unrecoverable errors.
      • - *
      • Validation — {@link #validate(Snapshot)} allows triggers to + *
      • Validation — {@link #validate(Snapshot)} allows controllers to * pre-validate a snapshot before applying it, catching structural * errors before any virtual cluster experiences downtime.
      • *
      * *

      The context is thread-safe. All methods may be called from any thread. */ -public interface ReconfigurationTriggerContext { +public interface VirtualClusterConfigControllerContext { /** * Apply a new configuration to the running proxy. The runtime parses the @@ -187,11 +189,11 @@ public interface ReconfigurationTriggerContext { * including error reporting and concurrency control. * *

      This method handles both the initial load (when no virtual clusters - * exist) and subsequent reconfigurations. The trigger calls it in both + * exist) and subsequent reconfigurations. The controller calls it in both * cases — the runtime handles the "from nothing to something" case * naturally. * - * @param newConfig a snapshot representing the desired reloadable + * @param newConfig a snapshot representing the desired virtual cluster * configuration (virtual clusters, filters, plugins) * @return a future that completes with a {@link ReconfigureResult} * describing any per-component failures, or completes @@ -202,13 +204,13 @@ public interface ReconfigurationTriggerContext { /** * Initiate an orderly shutdown of the proxy. * - *

      Triggers use this to implement failure policies that terminate the + *

      Controllers use this to implement failure policies that terminate the * proxy on unrecoverable errors — for example, shutting down when * {@code reconfigure()} returns non-empty {@code errors()}, or as a * last resort when a rollback attempt itself fails. * *

      This method returns immediately; the actual shutdown proceeds - * asynchronously. The trigger's {@link ReconfigurationTrigger#close()} + * asynchronously. The controller's {@link VirtualClusterConfigController#close()} * method will be called as part of the shutdown sequence. */ void shutdown(); @@ -218,7 +220,7 @@ public interface ReconfigurationTriggerContext { * checks that {@link #reconfigure(Snapshot)} would perform before * beginning any state-changing work — parsing and static validation. * - *

      A trigger can use this to implement a two-phase workflow: + *

      A controller can use this to implement a two-phase workflow: * validate first, then apply only if validation passes. This catches * problems before any virtual cluster experiences downtime. * @@ -238,8 +240,8 @@ public interface ReconfigurationTriggerContext { * Returns the path that was passed to the proxy at startup. * *

      This path does not change during the proxy's lifetime. File-based - * triggers may use it as a default location for their configuration - * source. Triggers that source configuration from non-filesystem + * controllers may use it as a default location for their configuration + * source. Controllers that source configuration from non-filesystem * origins may ignore it. * * @return the startup configuration path @@ -248,60 +250,60 @@ public interface ReconfigurationTriggerContext { } ``` -### Bootstrap and reloadable configuration +### Process configuration and virtual cluster configuration This proposal introduces a logical split in proxy configuration: -- **Bootstrap configuration** — static settings read once at startup: management endpoints, metrics, admin, and the trigger selection and configuration. These cannot change without a process restart. +- **Process configuration** — static settings read once at startup: management endpoints, metrics, admin, and the controller selection and configuration. These cannot change without a process restart. Process configuration is everything required to start a live proxy process. -- **Reloadable configuration** — virtual clusters, filters, and plugin instances. This is the configuration that changes at runtime. It is always delivered as a `Snapshot` via `reconfigure()`, and the trigger is the sole source — including for the initial load at startup. +- **Virtual cluster configuration** — virtual clusters, filters, and plugin instances. This is the configuration that changes at runtime — everything required to make the proxy do something users care about. It is always delivered as a `Snapshot` via `reconfigure()`, and the controller is the sole source — including for the initial load at startup. -This split formalises what Proposal 083 established implicitly: `reconfigure()` only applies virtual-cluster and filter configuration, and rejects changes to management, metrics, or admin sections. Rather than detecting out-of-scope changes in a monolithic configuration and rejecting them, the split separates the concerns logically. Snapshots contain only reloadable state, so there is nothing out-of-scope to detect. +This split formalises what Proposal 083 established implicitly: `reconfigure()` only applies virtual-cluster and filter configuration, and rejects changes to management, metrics, or admin sections. Rather than detecting out-of-scope changes in a monolithic configuration and rejecting them, the split separates the concerns logically. Snapshots contain only virtual cluster configuration, so there is nothing out-of-scope to detect. -How the logical split manifests on disk — whether bootstrap and reloadable configuration live in the same file, separate files, or separate directories — is a configuration format concern outside the scope of this proposal. What matters for the trigger SPI is the contract: the trigger produces `Snapshot` objects containing reloadable state, and the runtime handles bootstrap configuration independently. +How the logical split manifests on disk — whether process and virtual cluster configuration live in the same file, separate files, or separate directories — is a configuration format concern outside the scope of this proposal. What matters for the controller SPI is the contract: the controller produces `Snapshot` objects containing virtual cluster configuration, and the runtime handles process configuration independently. -The trigger is selected and configured within the bootstrap configuration. This follows the same `type` + `config` pattern used by filters, routers, and other Kroxylicious plugins: +The controller is selected and configured within the process configuration. This follows the same `type` + `config` pattern used by filters, routers, and other Kroxylicious plugins: ```yaml -reconfigurationTrigger: +virtualClusterConfigController: type: FileWatcher config: - debounceInterval: PT1S + debounceInterval: 1s ``` -When the `reconfigurationTrigger` section is absent, no trigger is created and the proxy operates as today — configuration is loaded at startup and changes require a restart. +When the `virtualClusterConfigController` section is absent, no controller is created and the proxy operates as today — configuration is loaded at startup and changes require a restart. -The bootstrap/reloadable split has a direct consequence for the trigger lifecycle: because the trigger is the sole source of reloadable configuration, the proxy starts in an **empty state** — bootstrap infrastructure (management endpoints, metrics) is running but no virtual clusters exist. The trigger's first `reconfigure()` call brings up the virtual clusters. This is described in detail in the [Trigger lifecycle](#trigger-lifecycle) section. +The process/virtual-cluster split has a direct consequence for the controller lifecycle: because the controller is the sole source of virtual cluster configuration, the proxy starts in an **empty state** — process infrastructure (management endpoints, metrics) is running but no virtual clusters exist. The controller's first `reconfigure()` call brings up the virtual clusters. This is described in detail in the [Controller lifecycle](#controller-lifecycle) section. -### Trigger responsibilities +### Controller responsibilities -Proposal 083 defined `KafkaProxy.reconfigure()` as a minimal operation that reports outcomes without taking policy action. This was a deliberate design choice — it pushes failure handling, rollback, and operational policy onto the caller. For triggers, "the caller" is the trigger implementation. The following responsibilities form the contract that trigger implementations must satisfy. +Proposal 083 defined `KafkaProxy.reconfigure()` as a minimal operation that reports outcomes without taking policy action. This was a deliberate design choice — it pushes failure handling, rollback, and operational policy onto the caller. For controllers, "the caller" is the controller implementation. The following responsibilities form the contract that controller implementations must satisfy. #### Configuration sourcing -The trigger is responsible for obtaining and delivering a new `Snapshot` to `reconfigure()`. How the snapshot is produced — watching a filesystem directory, receiving an HTTP request, responding to a CRD reconciliation — is the trigger's concern. The `Snapshot` abstraction (adopted from Proposal 096) decouples the trigger from the configuration format: a file watcher produces a filesystem-backed snapshot, an operator produces a Kubernetes-backed snapshot, and so on. The runtime handles parsing and validation internally. +The controller is responsible for obtaining and delivering a new `Snapshot` to `reconfigure()`. How the snapshot is produced — watching a filesystem directory, receiving an HTTP request, responding to a CRD reconciliation — is the controller's concern. The `Snapshot` abstraction (adopted from Proposal 096) decouples the controller from the configuration format: a file watcher produces a filesystem-backed snapshot, an operator produces a Kubernetes-backed snapshot, and so on. The runtime handles parsing and validation internally. #### Validation -Triggers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing and static validation — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. +Controllers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing and static validation — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. #### Failure policy -The proxy does not act on `ReconfigureResult.errors()`. The trigger expresses its failure policy via `whenComplete()` on the returned future. Three canonical patterns are defined in Proposal 083: +The proxy does not act on `ReconfigureResult.errors()`. The controller expresses its failure policy via `whenComplete()` on the returned future. Three canonical patterns are defined in Proposal 083: - **Shut down on any failure** — call `context.shutdown()` if `errors()` is non-empty - **Best-effort** — log failures, take no proxy-level action; surviving VCs continue serving - **Rollback on failure** — call `reconfigure(oldConfig)` when `errors()` is non-empty -The choice between these (or a custom policy) is the trigger's decision, typically determined at deployment time by the trigger's configuration or hardcoded by the trigger implementation. +The choice between these (or a custom policy) is the controller's decision, typically determined at deployment time by the controller's configuration or hardcoded by the controller implementation. #### Previous configuration tracking -Triggers that support rollback must maintain their own record of the previous known-good `Snapshot`. The proxy does not expose a getter for its running configuration. Triggers typically have a natural source-of-truth for this: a previous filesystem snapshot, a ConfigMap revision, an HTTP request history. +Controllers that support rollback must maintain their own record of the previous known-good `Snapshot`. The proxy does not expose a getter for its running configuration. Controllers typically have a natural source-of-truth for this: a previous filesystem snapshot, a ConfigMap revision, an HTTP request history. #### Concurrency handling -`reconfigure()` rejects concurrent calls with `ConcurrentReconfigureException` (the future completes exceptionally). The trigger **must not** treat this as a real failure: +`reconfigure()` rejects concurrent calls with `ConcurrentReconfigureException` (the future completes exceptionally). The controller **must not** treat this as a real failure: - **Do not shut down** — the proxy is healthy; another reconfiguration is in flight. - **Do not roll back** — rolling back would undo the other reconfiguration's changes. @@ -311,76 +313,76 @@ The recommended discrimination is `ex instanceof ConcurrentReconfigureException` #### Out-of-scope change handling -Because the bootstrap/reloadable split separates static configuration from reloadable state structurally, `OutOfScopeChangeException` is not expected in trigger-driven reconfiguration — the `Snapshot` contains only reloadable state and there is nothing out-of-scope to detect. However, trigger implementations should handle unexpected exceptions from `reconfigure()` defensively: log the rejection and **not** apply destructive policies (shutdown, rollback). +Because the process/virtual-cluster split separates static configuration from virtual cluster configuration structurally, `OutOfScopeChangeException` is not expected in controller-driven reconfiguration — the `Snapshot` contains only virtual cluster configuration and there is nothing out-of-scope to detect. However, controller implementations should handle unexpected exceptions from `reconfigure()` defensively: log the rejection and **not** apply destructive policies (shutdown, rollback). #### Debouncing -Since concurrent `reconfigure()` calls are rejected rather than queued, triggers that may receive rapid configuration changes (e.g. a file watcher receiving multiple filesystem events during an atomic file replacement) must debounce internally. The pattern is: absorb events for a short window, then call `reconfigure()` with the latest configuration. The debounce interval is a trigger-specific configuration concern. +Since concurrent `reconfigure()` calls are rejected rather than queued, controllers that may receive rapid configuration changes (e.g. a file watcher receiving multiple filesystem events during an atomic file replacement) must debounce internally. The pattern is: absorb events for a short window, then call `reconfigure()` with the latest configuration. The debounce interval is a controller-specific configuration concern. #### Configuration persistence -Whether to persist the applied configuration to disk is a trigger concern. A Kubernetes operator owns configuration state via CRD and does not want the proxy overwriting files. A bare-metal file watcher may not need persistence because the file is already the source of truth. A custom trigger may persist to a database. The proxy takes no action on configuration persistence. +Whether to persist the applied configuration to disk is a controller concern. A Kubernetes operator owns configuration state via CRD and does not want the proxy overwriting files. A bare-metal file watcher may not need persistence because the file is already the source of truth. A custom controller may persist to a database. The proxy takes no action on configuration persistence. #### Change detection (optimisation) -Triggers may perform their own change detection to avoid unnecessary `reconfigure()` calls. For example, a Kubernetes operator might compare ConfigMap checksums to skip no-op reconciliation loops. The proxy performs its own change detection internally (it will not restart unaffected virtual clusters), so trigger-level detection is an optimisation, not a correctness requirement. +Controllers may perform their own change detection to avoid unnecessary `reconfigure()` calls. For example, a Kubernetes operator might compare ConfigMap checksums to skip no-op reconciliation loops. The proxy performs its own change detection internally (it will not restart unaffected virtual clusters), so controller-level detection is an optimisation, not a correctness requirement. -### Trigger lifecycle +### Controller lifecycle -The trigger lifecycle is tied to the proxy's lifecycle. Because the trigger is the sole source of reloadable configuration, the proxy starts in an empty state — bootstrap infrastructure only — and the trigger's first `reconfigure()` call brings virtual clusters to life. +The controller lifecycle is tied to the proxy's lifecycle. Because the controller is the sole source of virtual cluster configuration, the proxy starts in an empty state — process infrastructure only — and the controller's first `reconfigure()` call brings virtual clusters to life. ``` Proxy startup │ - ├── Parse bootstrap configuration (management, trigger selection) - ├── Start bootstrap infrastructure (management endpoints, metrics) - ├── Discover ReconfigurationTriggerFactory via ServiceLoader - ├── Deserialize trigger-specific config from bootstrap - ├── Call factory.create(context, config) → ReconfigurationTrigger + ├── Parse process configuration (management, controller selection) + ├── Start process infrastructure (management endpoints, metrics) + ├── Discover VirtualClusterConfigControllerFactory via ServiceLoader + ├── Deserialize controller-specific config from process configuration + ├── Call factory.create(context, config) → VirtualClusterConfigController │ - ├── Call trigger.start() + ├── Call controller.start() │ │ │ ├── Trigger reads current config from its source │ ├── Trigger calls context.reconfigure(snapshot) ← initial load │ ├── VCs come up │ ├── Trigger sets up background watching for changes │ │ - │ ├── Start success: trigger is active, VCs serving + │ ├── Start success: controller is active, VCs serving │ └── Start failure: proxy has no VCs, proxy exits │ - ├── ... proxy running, trigger calling reconfigure() as needed ... + ├── ... proxy running, controller calling reconfigure() as needed ... │ ├── Proxy shutdown initiated │ - ├── Call trigger.close() - │ (trigger stops watching, releases resources) + ├── Call controller.close() + │ (controller stops watching, releases resources) │ └── Proxy completes shutdown ``` -**Initial load within `start()`.** The trigger performs the initial configuration load synchronously within `start()`: it reads its source, constructs a `Snapshot`, and calls `context.reconfigure(snapshot)`. This first `reconfigure()` call creates all virtual clusters and brings the proxy to a serving state. Only after the initial load succeeds does the trigger set up background watching for subsequent changes. This means `start()` blocks for the duration of the initial load — which is acceptable because the proxy cannot serve traffic until the first configuration is applied. Subsequent changes happen asynchronously on the trigger's own threads. +**Initial load within `start()`.** The controller performs the initial configuration load synchronously within `start()`: it reads its source, constructs a `Snapshot`, and calls `context.reconfigure(snapshot)`. This first `reconfigure()` call creates all virtual clusters and brings the proxy to a serving state. Only after the initial load succeeds does the controller set up background watching for subsequent changes. This means `start()` blocks for the duration of the initial load — which is acceptable because the proxy cannot serve traffic until the first configuration is applied. Subsequent changes happen asynchronously on the controller's own threads. -**Why separate `create()` from `start()`.** The `ReconfigurationTriggerFactory` creates the trigger during proxy initialisation, but `start()` is called separately so that the proxy can complete its own bootstrap (management endpoints, metrics) before the trigger begins loading configuration. This also allows the runtime to handle trigger creation failures differently from trigger start failures. +**Why separate `create()` from `start()`.** The `VirtualClusterConfigControllerFactory` creates the controller during proxy initialisation, but `start()` is called separately so that the proxy can complete its own bootstrap (management endpoints, metrics) before the controller begins loading configuration. This also allows the runtime to handle controller creation failures differently from controller start failures. -**Failure to start.** If the trigger's `start()` method throws, the proxy has no virtual clusters and cannot serve traffic. The proxy should exit — an empty proxy with no VCs is not useful, and the operator needs to diagnose the trigger failure. This is a deliberate difference from the trigger-optional model: when a trigger is configured, the proxy depends on it for all reloadable configuration, so a trigger failure is a proxy failure. +**Failure to start.** If the controller's `start()` method throws, the proxy has no virtual clusters and cannot serve traffic. The proxy should exit — an empty proxy with no VCs is not useful, and the operator needs to diagnose the controller failure. This is a deliberate difference from the controller-optional model: when a controller is configured, the proxy depends on it for all virtual cluster configuration, so a controller failure is a proxy failure. -**Shutdown ordering.** The trigger is closed *before* the proxy begins its shutdown sequence. This prevents the trigger from attempting a `reconfigure()` call while the proxy is shutting down. Any `reconfigure()` call already in flight will complete independently — the proxy handles the `IllegalStateException` case per Proposal 083. +**Shutdown ordering.** The controller is closed *before* the proxy begins its shutdown sequence. This prevents the controller from attempting a `reconfigure()` call while the proxy is shutting down. Any `reconfigure()` call already in flight will complete independently — the proxy handles the `IllegalStateException` case per Proposal 083. -**In-flight reconfiguration at shutdown.** If a trigger-initiated `reconfigure()` is in progress when the proxy receives a shutdown signal, the proxy waits for the reconfiguration to complete before proceeding with shutdown. The trigger's `close()` is called after the reconfiguration completes. +**In-flight reconfiguration at shutdown.** If a controller-initiated `reconfigure()` is in progress when the proxy receives a shutdown signal, the proxy waits for the reconfiguration to complete before proceeding with shutdown. The controller's `close()` is called after the reconfiguration completes. -### Example: File watcher trigger +### Example: File watcher controller -To illustrate the SPI in use, here is a sketch of how a file watcher trigger would be structured. This is not a specification for a file watcher — that is an implementation concern — but demonstrates that the SPI is sufficient for the most common trigger pattern. +To illustrate the SPI in use, here is a sketch of how a file watcher controller would be structured. This is not a specification for a file watcher — that is an implementation concern — but demonstrates that the SPI is sufficient for the most common controller pattern. ```java -public class FileWatcherTriggerFactory - implements ReconfigurationTriggerFactory { +public class FileWatcherControllerFactory + implements VirtualClusterConfigControllerFactory { @Override - public ReconfigurationTrigger create( - ReconfigurationTriggerContext context, + public VirtualClusterConfigController create( + VirtualClusterConfigControllerContext context, FileWatcherConfig config) { - return new FileWatcherTrigger(context, config); + return new FileWatcherController(context, config); } @Override @@ -389,13 +391,13 @@ public class FileWatcherTriggerFactory } } -// Registered in META-INF/services/...ReconfigurationTriggerFactory +// Registered in META-INF/services/...VirtualClusterConfigControllerFactory ``` ```java -class FileWatcherTrigger implements ReconfigurationTrigger { +class FileWatcherController implements VirtualClusterConfigController { - private final ReconfigurationTriggerContext context; + private final VirtualClusterConfigControllerContext context; private final FileWatcherConfig config; private WatchService watchService; private Thread watchThread; @@ -458,41 +460,43 @@ class FileWatcherTrigger implements ReconfigurationTrigger { This example demonstrates: - `start()` performs the initial load synchronously, then sets up background watching - The initial `reconfigure()` call creates all virtual clusters — same path as subsequent changes -- The trigger produces a `Snapshot` from the filesystem — other triggers would produce snapshots from other sources +- The controller produces a `Snapshot` from the filesystem — other controllers would produce snapshots from other sources - `validate()` catches structural errors before any VC experiences downtime - `whenComplete()` implements failure policy (best-effort in this case) - `ConcurrentReconfigureException` is handled with retry semantics -- The trigger uses its factory config for the watch path, falling back to `configFilePath()` +- The controller uses its factory config for the watch path, falling back to `configFilePath()` ## Affected projects -- **kroxylicious-runtime** (`kroxylicious-api` module) — the SPI interfaces (`ReconfigurationTrigger`, `ReconfigurationTriggerFactory`, `ReconfigurationTriggerContext`) are added as public API. -- **kroxylicious-runtime** (runtime module) — implements `ReconfigurationTriggerContext`, integrates trigger lifecycle with `KafkaProxy` startup and shutdown, and performs ServiceLoader discovery. -- **kroxylicious-app** — configures a trigger (initially a file watcher, shipped as a separate module) for the standalone binary. -- **kroxylicious-operator** — not directly affected. The operator embeds the proxy and calls `reconfigure()` directly; it does not use the trigger SPI. If a future operator design prefers to delegate to an in-proxy trigger, it can configure one via the SPI. +- **kroxylicious-runtime** (`kroxylicious-api` module) — the SPI interfaces (`VirtualClusterConfigController`, `VirtualClusterConfigControllerFactory`, `VirtualClusterConfigControllerContext`) are added as public API. +- **kroxylicious-runtime** (runtime module) — implements `VirtualClusterConfigControllerContext`, integrates controller lifecycle with `KafkaProxy` startup and shutdown, and performs ServiceLoader discovery. +- **kroxylicious-app** — configures a controller (initially a file watcher, shipped as a separate module) for the standalone binary. +- **kroxylicious-operator** — not directly affected. The operator embeds the proxy and calls `reconfigure()` directly; it does not use the controller SPI. If a future operator design prefers to delegate to an in-proxy controller, it can configure one via the SPI. ## Compatibility -- **Additive.** No existing behaviour changes. A proxy with no `reconfigurationTrigger` configuration operates identically to today — configuration is loaded from a single file at startup and changes require a restart. -- **Proposal 083 extension.** This proposal extends Proposal 083's `reconfigure()` contract to handle the "from nothing to something" case — where `reconfigure()` is called with no virtual clusters running. This is a natural extension: Proposal 083's remove/replace/add flow already handles the "add" case; when there is no prior state, there is nothing to remove or replace and everything is an add. The `ReconfigureResult`, `ReconfigureError`, and concurrency control contracts are unchanged. `OutOfScopeChangeException` remains part of the Proposal 083 contract for embedded callers who provide a full `Configuration` directly, but is not relevant for trigger-driven reconfiguration because `Snapshot` contains only reloadable state. -- **Configuration format.** The `reconfigurationTrigger` section is new; its absence means no trigger — the proxy loads configuration at startup as today. +- **Additive.** No existing behaviour changes. A proxy with no `virtualClusterConfigController` configuration operates identically to today — configuration is loaded from a single file at startup and changes require a restart. +- **Proposal 083 extension.** This proposal extends Proposal 083's `reconfigure()` contract to handle the "from nothing to something" case — where `reconfigure()` is called with no virtual clusters running. This is a natural extension: Proposal 083's remove/replace/add flow already handles the "add" case; when there is no prior state, there is nothing to remove or replace and everything is an add. The `ReconfigureResult`, `ReconfigureError`, and concurrency control contracts are unchanged. `OutOfScopeChangeException` remains part of the Proposal 083 contract for embedded callers who provide a full `Configuration` directly, but is not relevant for controller-driven reconfiguration because `Snapshot` contains only virtual cluster configuration. +- **Configuration format.** The `virtualClusterConfigController` section is new; its absence means no controller — the proxy loads configuration at startup as today. - **Plugin convention.** The `type` + `config` pattern and `ServiceLoader` discovery follow established Kroxylicious conventions and do not introduce new mechanisms. -- **Proposal 096 (configuration rework).** This proposal adopts Proposal 096's `Snapshot` abstraction as the type triggers provide to `reconfigure()`. The bootstrap/reloadable split established here is a simpler foundation than Proposal 096's full multi-file structure, but the two are compatible: Proposal 096's ideas — per-plugin versioning, dependency tracking, richer `Snapshot` implementations — can evolve on top of this split. This proposal does not commit to Proposal 096's specific filesystem layout (e.g. `plugins.d/` keyed by plugin interface FQCN). +- **Proposal 096 (configuration rework).** This proposal adopts Proposal 096's `Snapshot` abstraction as the type controllers provide to `reconfigure()`. The process/virtual-cluster split established here is a simpler foundation than Proposal 096's full multi-file structure, but the two are compatible: Proposal 096's ideas — per-plugin versioning, dependency tracking, richer `Snapshot` implementations — can evolve on top of this split. This proposal does not commit to Proposal 096's specific filesystem layout (e.g. `plugins.d/` keyed by plugin interface FQCN). ## Rejected alternatives - **Per-call `ReloadOptions`**: An earlier Proposal 083 iteration proposed a `ReloadOptions` parameter on each `reconfigure()` call carrying failure policy (rollback/terminate) and persistence settings. Rejected because failure policy is a deployment-time decision that should not vary between invocations. A trigger that hardcodes "best-effort" and another that hardcodes "rollback" should not be able to vary their behaviour per call — that creates inconsistency. The `whenComplete()` pattern achieves the same expressiveness without a per-call parameter. -- **`VirtualClusterLifecycleObserver`**: An earlier Proposal 083 iteration proposed a push-based observer injected at `KafkaProxy` construction time, notified of every lifecycle transition. While valuable for a future control-plane integration, it is a broader mechanism than triggers need and was deferred to avoid coupling it with the trigger SPI. The `whenComplete()` pattern on `reconfigure()` is sufficient for the failure-handling use case. +- **`VirtualClusterLifecycleObserver`**: An earlier Proposal 083 iteration proposed a push-based observer injected at `KafkaProxy` construction time, notified of every lifecycle transition. While valuable for a future control-plane integration, it is a broader mechanism than controllers need and was deferred to avoid coupling it with the controller SPI. The `whenComplete()` pattern on `reconfigure()` is sufficient for the failure-handling use case. -- **Multiple simultaneous triggers**: Considered allowing multiple triggers to be active (e.g. both a file watcher and an HTTP endpoint). Rejected because `reconfigure()` only allows one reconfiguration at a time (`ConcurrentReconfigureException`), and multiple triggers racing to reconfigure would create unpredictable behaviour. If a deployment needs both file-based and HTTP-based triggering, a single trigger implementation can support both input mechanisms internally. +- **Multiple simultaneous controllers**: Considered allowing multiple controllers to be active (e.g. both a file watcher and an HTTP endpoint). Rejected because `reconfigure()` only allows one reconfiguration at a time (`ConcurrentReconfigureException`), and multiple controllers racing to reconfigure would create unpredictable behaviour. If a deployment needs both file-based and HTTP-based triggering, a single controller implementation can support both input mechanisms internally. -- **Trigger signals "config changed" without providing a `Snapshot`**: An alternative where the trigger simply signals "reload" and the runtime re-reads the configuration from its startup path. Simpler for the file watcher case but does not support non-filesystem configuration sources (HTTP request bodies, CRD specs, in-memory representations). The `Snapshot` abstraction gives file-based triggers comparable simplicity (`Snapshot.fromPath(...)`) while supporting any source. +- **Trigger signals "config changed" without providing a `Snapshot`**: An alternative where the controller simply signals "reload" and the runtime re-reads the configuration from its startup path. Simpler for the file watcher case but does not support non-filesystem configuration sources (HTTP request bodies, CRD specs, in-memory representations). The `Snapshot` abstraction gives file-based controllers comparable simplicity (`Snapshot.fromPath(...)`) while supporting any source. - **Hardcoded trigger in `kroxylicious-app`**: Instead of an SPI, wire a file watcher directly into the standalone binary. Rejected because it forces users who need a different trigger mechanism (HTTP, custom control plane) to embed the proxy rather than just providing a different trigger on the classpath. The SPI cost is small and the extensibility value is high. -- **Proxy-managed configuration persistence**: An earlier design had the proxy persist the applied configuration to disk after a successful `reconfigure()`. Rejected because persistence requirements vary by deployment: a Kubernetes operator owns state via CRD and does not want the proxy overwriting files; a bare-metal deployment may want file persistence; a custom control plane may persist to a database. This is a trigger concern, not a proxy concern. +- **Proxy-managed configuration persistence**: An earlier design had the proxy persist the applied configuration to disk after a successful `reconfigure()`. Rejected because persistence requirements vary by deployment: a Kubernetes operator owns state via CRD and does not want the proxy overwriting files; a bare-metal deployment may want file persistence; a custom control plane may persist to a database. This is a controller concern, not a proxy concern. + +- **Trigger configuration in `updateStrategy` or `configurationReload` YAML block**: Earlier iterations of Proposal 083 proposed YAML-level configuration for failure policy and rollback behaviour. Rejected in favour of caller-side policy via `whenComplete()` — the proxy reports outcomes and takes no policy action. The only YAML configuration for controllers is the `virtualClusterConfigController` section that selects and configures the controller implementation. -- **Trigger configuration in `updateStrategy` or `configurationReload` YAML block**: Earlier iterations of Proposal 083 proposed YAML-level configuration for failure policy and rollback behaviour. Rejected in favour of caller-side policy via `whenComplete()` — the proxy reports outcomes and takes no policy action. The only YAML configuration for triggers is the `reconfigurationTrigger` section that selects and configures the trigger implementation. +- **Runtime loads initial configuration, trigger handles subsequent changes**: An earlier version of this proposal had the proxy load its initial configuration directly at startup (via the constructor, as in Proposal 083), with the controller only responsible for detecting and applying subsequent changes. Rejected because it creates two code paths for configuration loading: one in the runtime (startup) and one in the controller (reload). The unified model — where the controller is the sole source of virtual cluster configuration, including the initial load — is simpler, eliminates the two-path inconsistency, and gives the controller control over initial validation and failure policy from the start. -- **Runtime loads initial configuration, trigger handles subsequent changes**: An earlier version of this proposal had the proxy load its initial configuration directly at startup (via the constructor, as in Proposal 083), with the trigger only responsible for detecting and applying subsequent changes. Rejected because it creates two code paths for configuration loading: one in the runtime (startup) and one in the trigger (reload). The unified model — where the trigger is the sole source of reloadable configuration, including the initial load — is simpler, eliminates the two-path inconsistency, and gives the trigger control over initial validation and failure policy from the start. +- **Separate `ConfigurationSource` and `VirtualClusterConfigController` plugins**: We considered splitting the controller into two independently pluggable SPIs: a `ConfigurationSource` responsible for producing `Snapshot` objects from a backing store, and a `VirtualClusterConfigController` responsible for orchestration (debouncing, validation, failure policy). The motivation was reuse — the same filesystem source could be paired with either a file watcher trigger or an HTTP trigger that accepts `reloadNow` requests. Rejected because the coupling between source and trigger is deployment-specific and no single SPI boundary works for all cases. A file watcher trigger and its source share a path. An HTTP PUT trigger that accepts configuration in the request body *is* the source — there is nothing to load from. A trigger that accepts partial configuration (e.g. config for a single named virtual cluster) needs to merge with the current state, which goes beyond what a passive `load()` method can express. The separation between sourcing and orchestration is a real design concern, and controller implementations may factor these responsibilities internally. But forcing this factoring at the SPI level creates an interface that is either too restrictive for push-based controllers or too leaky for pull-based ones. The single-controller SPI accommodates all deployment models; utility types like `Snapshot.fromPath()` and `Snapshot.fromYaml()` can provide building blocks without constraining the design. From 26a81e80ee2e525338271d9f6969f12cf20c4975 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Fri, 26 Jun 2026 14:25:11 +1200 Subject: [PATCH 8/8] Return ValidationResult from validate(), remove configFilePath() validate() now returns a ValidationResult with errors deduplicated by root cause instead of throwing ConfigurationException. This gives controllers a uniform error-handling pattern (check isValid()) rather than mixing try/catch for validation with whenComplete() for reconfiguration, and prevents cascading errors (e.g. one bad plugin type referenced by N virtual clusters producing N identical errors). configFilePath() is removed from VirtualClusterConfigControllerContext. It was a file-specific method on a source-agnostic interface, left over from before the process/virtual-cluster configuration split. File-based controllers get their path from their own factory config. Assisted-by: Claude Opus 4.6 Signed-off-by: Sam Barker --- proposals/117-reconfiguration-trigger-spi.md | 88 +++++++++----------- 1 file changed, 41 insertions(+), 47 deletions(-) diff --git a/proposals/117-reconfiguration-trigger-spi.md b/proposals/117-reconfiguration-trigger-spi.md index 23799284..efc648d6 100644 --- a/proposals/117-reconfiguration-trigger-spi.md +++ b/proposals/117-reconfiguration-trigger-spi.md @@ -28,7 +28,7 @@ The controller SPI consists of three interfaces: - **`VirtualClusterConfigController`** — the controller implementation itself, created by the factory. It sources virtual cluster configuration, detects changes, and drives `reconfigure()`. - **`VirtualClusterConfigControllerFactory`** — discovered via `ServiceLoader`, responsible for creating a `VirtualClusterConfigController` from its typed configuration. -- **`VirtualClusterConfigControllerContext`** — provided by the runtime, gives the controller access to `reconfigure()`, `shutdown()`, and pre-flight validation. Controllers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic representation of the virtual cluster configuration that decouples the controller from the configuration format. +- **`VirtualClusterConfigControllerContext`** — provided by the runtime, gives the controller access to `reconfigure()`, `validate()`, and `shutdown()`. Controllers provide configuration as a `Snapshot` (adopted from Proposal 096) — a source-agnostic representation of the virtual cluster configuration that decouples the controller from the configuration format. The name "controller" borrows from the Kubernetes controller pattern: a control loop that watches desired state, detects drift, and reconciles. A `VirtualClusterConfigController` does the same — it watches a configuration source, detects changes, and reconciles the proxy's running state to match via `reconfigure()`. The failure handling, retry, and rollback responsibilities are natural parts of this reconciliation loop. @@ -164,18 +164,19 @@ The `Snapshot` interface is defined in Proposal 096. This proposal does not rede * controller's view of the proxy — controllers never interact with * {@code KafkaProxy} directly. * - *

      The context provides three categories of capability: + *

      The context provides three capabilities: *

        *
      • Reconfiguration — {@link #reconfigure(Snapshot)} drives * the proxy to converge to a new configuration. The controller provides * a {@link Snapshot} representing the desired virtual cluster configuration; the * runtime handles parsing and change detection internally.
      • + *
      • Validation — {@link #validate(Snapshot)} allows controllers to + * pre-validate a snapshot before applying it, catching structural + * errors before any virtual cluster experiences downtime. Returns a + * {@link ValidationResult} with errors deduplicated by root cause.
      • *
      • Proxy lifecycle — {@link #shutdown()} initiates an orderly * proxy shutdown, enabling failure policies that terminate the proxy * on unrecoverable errors.
      • - *
      • Validation — {@link #validate(Snapshot)} allows controllers to - * pre-validate a snapshot before applying it, catching structural - * errors before any virtual cluster experiences downtime.
      • *
      * *

      The context is thread-safe. All methods may be called from any thread. @@ -230,23 +231,18 @@ public interface VirtualClusterConfigControllerContext { * and application. But it does guarantee that the snapshot will not be * rejected for structural reasons. * - * @param config the snapshot to validate - * @throws ConfigurationException if the snapshot cannot be parsed or - * fails validation - */ - void validate(Snapshot config); - - /** - * Returns the path that was passed to the proxy at startup. - * - *

      This path does not change during the proxy's lifetime. File-based - * controllers may use it as a default location for their configuration - * source. Controllers that source configuration from non-filesystem - * origins may ignore it. + *

      The returned {@link ValidationResult} collects all errors found + * during validation, deduplicated by root cause. For example, if a + * single unknown filter type is referenced by multiple virtual clusters, + * the result reports the root cause once rather than once per usage. + * This prevents cascading errors from obscuring the actual problem. * - * @return the startup configuration path + * @param config the snapshot to validate + * @return a {@link ValidationResult} describing any validation errors. + * Call {@link ValidationResult#isValid()} to check whether the + * snapshot passed validation. */ - Path configFilePath(); + ValidationResult validate(Snapshot config); } ``` @@ -285,7 +281,7 @@ The controller is responsible for obtaining and delivering a new `Snapshot` to ` #### Validation -Controllers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing and static validation — without modifying any running state. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. +Controllers can use `validate(Snapshot)` to pre-validate a snapshot before applying it. This performs the same pre-flight checks as `reconfigure()` — parsing and static validation — without modifying any running state. The returned `ValidationResult` collects all errors found during validation, deduplicated by root cause — so a single misconfigured plugin referenced by many virtual clusters produces one error, not one per usage. This enables a validate-then-apply workflow that catches problems before any virtual cluster experiences downtime. #### Failure policy @@ -406,9 +402,7 @@ class FileWatcherController implements VirtualClusterConfigController { @Override public void start() throws Exception { - Path watchPath = config.watchPath() != null - ? config.watchPath() - : context.configFilePath(); + Path watchPath = config.watchPath(); // Initial load — synchronous, brings up VCs for the first time Snapshot snapshot = Snapshot.fromPath(watchPath); @@ -421,28 +415,29 @@ class FileWatcherController implements VirtualClusterConfigController { watchThread = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { // wait for events, debounce, then: - try { - Snapshot newSnapshot = Snapshot.fromPath(watchPath); - context.validate(newSnapshot); - context.reconfigure(newSnapshot) - .whenComplete((result, ex) -> { - if (ex instanceof ConcurrentReconfigureException) { - // retry later - return; - } - if (ex != null) { - LOG.error("Reconfigure failed", ex); - return; - } - for (var error : result.errors()) { - LOG.error("Component failed: {}", - error.humanReadableIdentifier(), - error.cause()); - } - }); - } catch (ConfigurationException e) { - LOG.error("Failed to validate configuration", e); + Snapshot newSnapshot = Snapshot.fromPath(watchPath); + ValidationResult validation = context.validate(newSnapshot); + if (!validation.isValid()) { + LOG.error("Configuration validation failed: {}", + validation.errors()); + continue; } + context.reconfigure(newSnapshot) + .whenComplete((result, ex) -> { + if (ex instanceof ConcurrentReconfigureException) { + // retry later + return; + } + if (ex != null) { + LOG.error("Reconfigure failed", ex); + return; + } + for (var error : result.errors()) { + LOG.error("Component failed: {}", + error.humanReadableIdentifier(), + error.cause()); + } + }); } }); watchThread.setDaemon(true); @@ -461,10 +456,9 @@ This example demonstrates: - `start()` performs the initial load synchronously, then sets up background watching - The initial `reconfigure()` call creates all virtual clusters — same path as subsequent changes - The controller produces a `Snapshot` from the filesystem — other controllers would produce snapshots from other sources -- `validate()` catches structural errors before any VC experiences downtime +- `validate()` returns a `ValidationResult` — the controller checks `isValid()` and logs errors without catching exceptions - `whenComplete()` implements failure policy (best-effort in this case) - `ConcurrentReconfigureException` is handled with retry semantics -- The controller uses its factory config for the watch path, falling back to `configFilePath()` ## Affected projects