From 3799bfa3f053e768160c300242851d89abef1b4c Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:01:05 +0700 Subject: [PATCH 1/3] Jitter StatusPlugin report interval between min/max delay StatusPlugin.start() scheduled status reports via scheduleAtFixedRate at a hardcoded 30s interval, with no way to configure it and no jitter, so every instance in a fleet reports on the same cadence. The twin decision-log issue (#78) and this SDK's BundleDownloader already establish the chained random-delay pattern for spreading load across instances. Add min_delay_seconds/max_delay_seconds to Config.StatusConfig (defaulting to 30/30, preserving the previous fixed interval when unconfigured) and wire them through StatusPlugin -> Status, mirroring DecisionLogsConfig. Replace scheduleAtFixedRate with scheduleNextReport, mirroring BundleDownloader.scheduleNextPoll and DecisionLogPlugin.scheduleNextFlush: each report re-schedules itself with a uniformly random delay in [min, max], swallowing exceptions from reportStatus() (which already logs internally) so the chain keeps running, and stopping cleanly on RejectedExecutionException after shutdown. The immediate report on startup is preserved via a separate zero-delay schedule() call before the chain starts, matching BundleDownloader.startPolling()'s immediate-download-then-poll structure - so there's no regression in time-to-first-report. When max_delay_seconds is unset, it now defaults to 2x min_delay_seconds, consistent with #78's DecisionLogPlugin fallback. Also switch the plugin's scheduler to BundleDownloader.newPollScheduler(...), matching BundlePlugin, DiscoveryPlugin, and the DecisionLogPlugin fix in #78. Note: I checked OPA Go's current status plugin source and it has no standalone min_delay_seconds/max_delay_seconds of its own - status reports there are triggered by the bundle/discovery plugin's own polling rather than an independent timer - so there is no canonical upstream number to mirror for the default interval. I kept this SDK's existing 30s default unchanged for unconfigured setups (no behavior change), while adding full jitter support once a wider [min, max] range is configured, in the spirit of this issue and its #78 twin. Fixes https://github.com/open-policy-agent/java-opa-sdk/issues/80 Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- .../open_policy_agent/opa/config/Config.java | 28 ++++ .../opa/plugins/StatusPlugin.java | 88 ++++++++++++- .../opa/config/ConfigTest.java | 2 + .../opa/plugins/StatusPluginTest.java | 120 +++++++++++++++++- 4 files changed, 232 insertions(+), 6 deletions(-) diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java index fe943e0f..23f376f7 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java @@ -455,6 +455,12 @@ public static class StatusConfig { @JsonProperty("resource") private String resource = "/status"; + @JsonProperty("min_delay_seconds") + private Integer minDelaySeconds = 30; + + @JsonProperty("max_delay_seconds") + private Integer maxDelaySeconds = 30; + public Boolean getConsole() { return console; } @@ -482,6 +488,24 @@ public StatusConfig setResource(String resource) { return this; } + public Integer getMinDelaySeconds() { + return minDelaySeconds; + } + + public StatusConfig setMinDelaySeconds(Integer minDelaySeconds) { + this.minDelaySeconds = minDelaySeconds; + return this; + } + + public Integer getMaxDelaySeconds() { + return maxDelaySeconds; + } + + public StatusConfig setMaxDelaySeconds(Integer maxDelaySeconds) { + this.maxDelaySeconds = maxDelaySeconds; + return this; + } + @Override public String toString() { return "StatusConfig{" @@ -493,6 +517,10 @@ public String toString() { + ", resource='" + resource + '\'' + + ", minDelaySeconds=" + + minDelaySeconds + + ", maxDelaySeconds=" + + maxDelaySeconds + '}'; } } diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java index 86066ff3..4928e34b 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java @@ -5,8 +5,9 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; -import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import io.github.open_policy_agent.opa.bundle.Bundle; import io.github.open_policy_agent.opa.config.Config; @@ -48,6 +49,18 @@ public Set validate(PluginManager manager) { } } + // Validate delay settings + if (statusConfig.getMinDelaySeconds() != null && statusConfig.getMaxDelaySeconds() != null) { + if (statusConfig.getMinDelaySeconds() > statusConfig.getMaxDelaySeconds()) { + errors.add( + "Status min_delay_seconds (" + + statusConfig.getMinDelaySeconds() + + ") cannot be greater than max_delay_seconds (" + + statusConfig.getMaxDelaySeconds() + + ")"); + } + } + return errors; } @@ -55,7 +68,7 @@ public Set validate(PluginManager manager) { public Plugin initialize(PluginManager manager) { StatusPlugin plugin = new StatusPlugin(); plugin.manager = manager; - plugin.scheduler = Executors.newScheduledThreadPool(1); + plugin.scheduler = BundleDownloader.newPollScheduler("opa-status-scheduler"); Config.StatusConfig statusConfig = manager.getConfig().getStatus(); if (statusConfig != null) { @@ -63,7 +76,9 @@ public Plugin initialize(PluginManager manager) { new Status(manager, manager.getLogger()) .setConsole(statusConfig.getConsole()) .setService(statusConfig.getService()) - .setResource(statusConfig.getResource()); + .setResource(statusConfig.getResource()) + .setMinDelaySeconds(statusConfig.getMinDelaySeconds()) + .setMaxDelaySeconds(statusConfig.getMaxDelaySeconds()); } return plugin; @@ -76,12 +91,55 @@ public void start() { return; } - // Report status every 30 seconds (matches OPA default) - scheduler.scheduleAtFixedRate(() -> status.reportStatus(), 0, 30, TimeUnit.SECONDS); + // Get report interval bounds (default: 30 seconds, matching OPA's previous fixed interval; + // OPA Go's status plugin has no standalone min/max delay of its own today - reports are + // triggered by the bundle/discovery plugin's polling - so there's no upstream number to + // mirror here beyond the interval this SDK already used). If only a min is configured, + // default the max to twice the min so the jitter window stays sensible. + int minDelaySeconds = + (status.getMinDelaySeconds() != null) ? status.getMinDelaySeconds() : 30; + int maxDelaySeconds = + (status.getMaxDelaySeconds() != null) ? status.getMaxDelaySeconds() : minDelaySeconds * 2; + + // Report immediately on startup (matches previous behavior), then continue with a jittered + // chained schedule for subsequent reports - mirrors BundleDownloader.startPolling(), which + // downloads immediately before starting its own chained poll. + scheduler.schedule(() -> status.reportStatus(), 0, TimeUnit.SECONDS); + scheduleNextReport(minDelaySeconds, maxDelaySeconds); manager.updatePluginStatus("status", PluginManager.Status.OK); } + // Re-schedules the next status report with a uniformly random delay in [minDelay, maxDelay], + // mirroring BundleDownloader.scheduleNextPoll and DecisionLogPlugin.scheduleNextFlush. + // ScheduledExecutorService has no built-in jitter, so the task chains itself. + // RejectedExecutionException after a shutdown breaks the chain cleanly. + private void scheduleNextReport(int minDelay, int maxDelay) { + long delay = + minDelay >= maxDelay + ? minDelay + : ThreadLocalRandom.current().nextLong(minDelay, (long) maxDelay + 1); + try { + scheduler.schedule( + () -> { + try { + status.reportStatus(); + } catch (Exception e) { + // reportStatus() handles its own logging; swallow so the chain keeps reporting. + // Only Exception is caught here — Errors (OOM, etc.) propagate and let the + // executor's uncaught-exception handler tear down the pool, which is the right + // outcome for unrecoverable conditions. + } finally { + scheduleNextReport(minDelay, maxDelay); + } + }, + delay, + TimeUnit.SECONDS); + } catch (RejectedExecutionException stopped) { + // Scheduler was shut down; let the chain end. + } + } + @Override public void stop() { if (scheduler != null) { @@ -109,6 +167,8 @@ public static class Status { private Boolean console; private String service; private String resource; + private Integer minDelaySeconds; + private Integer maxDelaySeconds; private Status(PluginManager manager, Logger logger) { this.manager = manager; @@ -142,6 +202,24 @@ public Status setResource(String resource) { return this; } + public Integer getMinDelaySeconds() { + return minDelaySeconds; + } + + public Status setMinDelaySeconds(Integer minDelaySeconds) { + this.minDelaySeconds = minDelaySeconds; + return this; + } + + public Integer getMaxDelaySeconds() { + return maxDelaySeconds; + } + + public Status setMaxDelaySeconds(Integer maxDelaySeconds) { + this.maxDelaySeconds = maxDelaySeconds; + return this; + } + /** Collect and report current status. */ void reportStatus() { try { diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java index d64be422..b3804bfd 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java @@ -293,6 +293,8 @@ void config_statusDefaults() { assertFalse(status.getConsole()); assertEquals("/status", status.getResource()); + assertEquals(30, status.getMinDelaySeconds()); + assertEquals(30, status.getMaxDelaySeconds()); } @Test diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java index 5f17b338..eb8b460c 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java @@ -114,6 +114,55 @@ void validate_statusConsoleOnly_returnsNoErrors() { assertTrue(errors.isEmpty()); } + @Test + void validate_delaySecondsInvalid_returnsError() { + Config.StatusConfig status = + new Config.StatusConfig() + .setService("test-service") + .setMinDelaySeconds(60) + .setMaxDelaySeconds(30); // min > max is invalid + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + Set errors = plugin.validate(manager); + + assertFalse(errors.isEmpty()); + assertTrue( + errors.stream() + .anyMatch(e -> e.contains("min_delay_seconds") && e.contains("max_delay_seconds"))); + } + + @Test + void validate_delaySecondsEqual_returnsNoErrors() { + Config.StatusConfig status = + new Config.StatusConfig() + .setService("test-service") + .setMinDelaySeconds(30) + .setMaxDelaySeconds(30); + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + Set errors = plugin.validate(manager); + + assertTrue(errors.isEmpty()); + } + @Test void initialize_noStatusConfigured_returnsPlugin() { manager = @@ -173,6 +222,63 @@ void start_setsStatusOk() { assertEquals(PluginManager.Status.OK, manager.getPluginStatus("status")); } + @Test + void start_periodicReport_usesJitteredChainedSchedule() throws Exception { + // min == max makes the jittered delay deterministic (always 1s), keeping the test fast and + // non-flaky while still exercising the chained re-scheduling in scheduleNextReport. + Config.StatusConfig status = + new Config.StatusConfig().setConsole(true).setMinDelaySeconds(1).setMaxDelaySeconds(1); + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + plugin = (StatusPlugin) plugin.initialize(manager); + plugin.start(); + + // Immediate report at t=0, plus chained reports at ~t=1s and ~t=2s. + Thread.sleep(2500); + + // Three reports ~1s apart proves scheduleNextReport re-chains itself instead of firing once + // (which the old immediate-only schedule() call would not do). + verify(mockLogger, atLeast(3)).info(eq("Status: %s"), anyString()); + } + + @Test + void start_onlyMinDelayConfigured_defaultsMaxToTwiceMin() throws Exception { + Config.StatusConfig status = + new Config.StatusConfig() + .setConsole(true) + .setMinDelaySeconds(1) + .setMaxDelaySeconds(null); // only min provided + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + plugin = (StatusPlugin) plugin.initialize(manager); + plugin.start(); + + // With max unset, the chained report falls back to 2x min (2s here). Together with the + // immediate report at t=0, that guarantees at least 2 reports by t=2.5s. If the fallback + // instead used an unrelated large default, only the immediate report would land in time. + Thread.sleep(2500); + + verify(mockLogger, atLeast(2)).info(eq("Status: %s"), anyString()); + } + @Test void configDefaults_consoleIsFalse() { Config.StatusConfig status = new Config.StatusConfig(); @@ -181,17 +287,29 @@ void configDefaults_consoleIsFalse() { assertEquals("/status", status.getResource()); } + @Test + void configDefaults_delaySecondsAreCorrect() { + Config.StatusConfig status = new Config.StatusConfig(); + + assertEquals(30, status.getMinDelaySeconds()); + assertEquals(30, status.getMaxDelaySeconds()); + } + @Test void configBuilder_setsAllFields() { Config.StatusConfig status = new Config.StatusConfig() .setService("test-service") .setConsole(true) - .setResource("/custom/status"); + .setResource("/custom/status") + .setMinDelaySeconds(60) + .setMaxDelaySeconds(120); assertEquals("test-service", status.getService()); assertTrue(status.getConsole()); assertEquals("/custom/status", status.getResource()); + assertEquals(60, status.getMinDelaySeconds()); + assertEquals(120, status.getMaxDelaySeconds()); } @Test From 7597903ea1d1f3c1ee3886dcc34ca76ba7c4ee83 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:55:55 +0700 Subject: [PATCH 2/3] fix(status): default StatusConfig delay fields to null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jackson only invokes setters for keys present in config, so a non-null field default made the start()-time unset-max → 2*min fallback unreachable via YAML. Mirror ReportingConfig: leave min/max null on the config bean and apply 30 / 2*min in StatusPlugin.start(). Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- .../io/github/open_policy_agent/opa/config/Config.java | 4 ++-- .../github/open_policy_agent/opa/config/ConfigTest.java | 4 ++-- .../open_policy_agent/opa/plugins/StatusPluginTest.java | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java index 23f376f7..ed96d4ec 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/config/Config.java @@ -456,10 +456,10 @@ public static class StatusConfig { private String resource = "/status"; @JsonProperty("min_delay_seconds") - private Integer minDelaySeconds = 30; + private Integer minDelaySeconds; @JsonProperty("max_delay_seconds") - private Integer maxDelaySeconds = 30; + private Integer maxDelaySeconds; public Boolean getConsole() { return console; diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java index b3804bfd..c287527a 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/config/ConfigTest.java @@ -293,8 +293,8 @@ void config_statusDefaults() { assertFalse(status.getConsole()); assertEquals("/status", status.getResource()); - assertEquals(30, status.getMinDelaySeconds()); - assertEquals(30, status.getMaxDelaySeconds()); + assertNull(status.getMinDelaySeconds()); + assertNull(status.getMaxDelaySeconds()); } @Test diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java index eb8b460c..bbe01adc 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java @@ -255,8 +255,7 @@ void start_onlyMinDelayConfigured_defaultsMaxToTwiceMin() throws Exception { Config.StatusConfig status = new Config.StatusConfig() .setConsole(true) - .setMinDelaySeconds(1) - .setMaxDelaySeconds(null); // only min provided + .setMinDelaySeconds(1); // max omitted → start() uses 2 * min config.setStatus(status); manager = @@ -291,8 +290,10 @@ void configDefaults_consoleIsFalse() { void configDefaults_delaySecondsAreCorrect() { Config.StatusConfig status = new Config.StatusConfig(); - assertEquals(30, status.getMinDelaySeconds()); - assertEquals(30, status.getMaxDelaySeconds()); + // Unset in config (null) so Jackson-omitted keys keep the start()-time defaults + // (min=30, max=2*min) reachable — same pattern as ReportingConfig. + assertNull(status.getMinDelaySeconds()); + assertNull(status.getMaxDelaySeconds()); } @Test From f3fe89d8218428b0d2779e45d056a97b407e7157 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:15:43 +0700 Subject: [PATCH 3/3] fix(status): reject negative delays and honor max-only config Mirror BundleDownloader.validatePolling's >= 0 guards so a negative min_delay_seconds cannot busy-loop the status scheduler. When only max_delay_seconds is set below the 30s default min, default min to min(30, max) so the configured max is not silently ignored. Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com> Co-authored-by: Cursor --- .../opa/plugins/StatusPlugin.java | 53 +++++++++----- .../opa/plugins/StatusPluginTest.java | 70 +++++++++++++++++++ 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java index 4928e34b..a2d16447 100644 --- a/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java +++ b/opa-services/src/main/java/io/github/open_policy_agent/opa/plugins/StatusPlugin.java @@ -49,16 +49,22 @@ public Set validate(PluginManager manager) { } } - // Validate delay settings - if (statusConfig.getMinDelaySeconds() != null && statusConfig.getMaxDelaySeconds() != null) { - if (statusConfig.getMinDelaySeconds() > statusConfig.getMaxDelaySeconds()) { - errors.add( - "Status min_delay_seconds (" - + statusConfig.getMinDelaySeconds() - + ") cannot be greater than max_delay_seconds (" - + statusConfig.getMaxDelaySeconds() - + ")"); - } + // Validate delay settings (mirrors BundleDownloader.validatePolling) + Integer min = statusConfig.getMinDelaySeconds(); + Integer max = statusConfig.getMaxDelaySeconds(); + if (min != null && min < 0) { + errors.add("Status min_delay_seconds must be >= 0"); + } + if (max != null && max < 0) { + errors.add("Status max_delay_seconds must be >= 0"); + } + if (min != null && max != null && min >= 0 && max >= 0 && min > max) { + errors.add( + "Status min_delay_seconds (" + + min + + ") cannot be greater than max_delay_seconds (" + + max + + ")"); } return errors; @@ -94,12 +100,27 @@ public void start() { // Get report interval bounds (default: 30 seconds, matching OPA's previous fixed interval; // OPA Go's status plugin has no standalone min/max delay of its own today - reports are // triggered by the bundle/discovery plugin's polling - so there's no upstream number to - // mirror here beyond the interval this SDK already used). If only a min is configured, - // default the max to twice the min so the jitter window stays sensible. - int minDelaySeconds = - (status.getMinDelaySeconds() != null) ? status.getMinDelaySeconds() : 30; - int maxDelaySeconds = - (status.getMaxDelaySeconds() != null) ? status.getMaxDelaySeconds() : minDelaySeconds * 2; + // mirror here beyond the interval this SDK already used). + // - only min set → max defaults to 2 * min + // - only max set → min defaults to min(30, max) so a max below 30 is not silently ignored + // - neither set → min=30, max=60 + Integer configuredMin = status.getMinDelaySeconds(); + Integer configuredMax = status.getMaxDelaySeconds(); + int minDelaySeconds; + int maxDelaySeconds; + if (configuredMin != null && configuredMax != null) { + minDelaySeconds = configuredMin; + maxDelaySeconds = configuredMax; + } else if (configuredMin != null) { + minDelaySeconds = configuredMin; + maxDelaySeconds = configuredMin * 2; + } else if (configuredMax != null) { + maxDelaySeconds = configuredMax; + minDelaySeconds = Math.min(30, configuredMax); + } else { + minDelaySeconds = 30; + maxDelaySeconds = 60; + } // Report immediately on startup (matches previous behavior), then continue with a jittered // chained schedule for subsequent reports - mirrors BundleDownloader.startPolling(), which diff --git a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java index bbe01adc..6688d45e 100644 --- a/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java +++ b/opa-services/src/test/java/io/github/open_policy_agent/opa/plugins/StatusPluginTest.java @@ -163,6 +163,50 @@ void validate_delaySecondsEqual_returnsNoErrors() { assertTrue(errors.isEmpty()); } + @Test + void validate_negativeDelaySeconds_returnsError() { + Config.StatusConfig status = + new Config.StatusConfig() + .setService("test-service") + .setMinDelaySeconds(-1) + .setMaxDelaySeconds(-5); + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + Set errors = plugin.validate(manager); + + assertTrue(errors.stream().anyMatch(e -> e.contains("min_delay_seconds must be >= 0"))); + assertTrue(errors.stream().anyMatch(e -> e.contains("max_delay_seconds must be >= 0"))); + } + + @Test + void validate_onlyMaxDelayNegative_returnsError() { + Config.StatusConfig status = + new Config.StatusConfig().setService("test-service").setMaxDelaySeconds(-1); + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + Set errors = plugin.validate(manager); + + assertTrue(errors.stream().anyMatch(e -> e.contains("max_delay_seconds must be >= 0"))); + } + @Test void initialize_noStatusConfigured_returnsPlugin() { manager = @@ -278,6 +322,32 @@ void start_onlyMinDelayConfigured_defaultsMaxToTwiceMin() throws Exception { verify(mockLogger, atLeast(2)).info(eq("Status: %s"), anyString()); } + @Test + void start_onlyMaxDelayBelowDefaultMin_defaultsMinToMax() throws Exception { + // max-only with max < 30: previously min defaulted to 30, so scheduleNextReport used + // min>=max → always 30s and silently ignored the configured max. Now min = min(30, max). + Config.StatusConfig status = + new Config.StatusConfig().setConsole(true).setMaxDelaySeconds(1); + config.setStatus(status); + + manager = + new PluginManager.Builder() + .withId("test-opa") + .withStore(store) + .withConfig(config) + .withLogger(mockLogger) + .build(); + + StatusPlugin plugin = new StatusPlugin(); + plugin = (StatusPlugin) plugin.initialize(manager); + plugin.start(); + + Thread.sleep(2500); + + // Immediate + chained at ~1s and ~2s proves the max was honored (not replaced by 30s). + verify(mockLogger, atLeast(3)).info(eq("Status: %s"), anyString()); + } + @Test void configDefaults_consoleIsFalse() { Config.StatusConfig status = new Config.StatusConfig();