Skip to content

Jitter StatusPlugin report interval between min/max delay - #159

Merged
sspaink merged 3 commits into
open-policy-agent:mainfrom
arimu1:fix/80-status-plugin-jitter
Jul 22, 2026
Merged

Jitter StatusPlugin report interval between min/max delay#159
sspaink merged 3 commits into
open-policy-agent:mainfrom
arimu1:fix/80-status-plugin-jitter

Conversation

@arimu1

@arimu1 arimu1 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Problem

StatusPlugin.start() scheduled status reports via scheduleAtFixedRate(...) at a hardcoded 30s interval, with no way to configure it and no jitter. Every instance in a fleet therefore reports on the same cadence. This is the twin of #78 (DecisionLogPlugin), and this SDK's BundleDownloader already implements the chained random-delay pattern for bundle polling.

Approach

Mirrors BundleDownloader.scheduleNextPoll and this repo's own #78 fix for DecisionLogPlugin:

  • Added min_delay_seconds/max_delay_seconds to Config.StatusConfig (previously it only had console and service), defaulting to 30/30 — see note below on why.
  • Added matching minDelaySeconds/maxDelaySeconds fields + getters/setters to the inner StatusPlugin.Status class, and wired them through in initialize().
  • Added a min_delay_seconds <= max_delay_seconds check to validate(), matching the existing pattern in DecisionLogPlugin.validate().
  • Replaced scheduler.scheduleAtFixedRate(...) with a new private scheduleNextReport(minDelay, maxDelay) that re-schedules itself after each report with a uniformly random delay in [minDelay, maxDelay] (ThreadLocalRandom), swallowing Exception from reportStatus() (which already logs internally) so the chain keeps running, and stopping cleanly on RejectedExecutionException after shutdown — same structure as BundleDownloader.scheduleNextPoll and the DecisionLogPlugin.scheduleNextFlush added in Jittered upload interval #78.
  • Preserved the previous immediate report on startup via a separate zero-delay scheduler.schedule(...) call before starting the chain, mirroring BundleDownloader.startPolling()'s "download immediately, then start the chained poll" structure — so there's no regression in time-to-first-report.
  • If max_delay_seconds is unset, it now defaults to 2 * min_delay_seconds, consistent with the fallback added for DecisionLogPlugin in Jittered upload interval #78.
  • Switched the plugin's scheduler construction to BundleDownloader.newPollScheduler("opa-status-scheduler"), matching BundlePlugin, DiscoveryPlugin, and the Jittered upload interval #78 DecisionLogPlugin change.

Note on defaults: I checked OPA Go's current v1/plugins/status/plugin.go and its Config struct has no min_delay_seconds/max_delay_seconds fields at all — status reports there are triggered by the bundle/discovery plugin's own polling (Trigger mode) rather than an independent timer, so there's no canonical upstream number to mirror for a standalone status interval default (this differs from decision logs, where OPA Go does have defaultMinDelaySeconds = 300 / defaultMaxDelaySeconds = 600 in v1/plugins/logs/plugin.go, matching what's already in this SDK's DecisionLogsConfig). Given that, I kept this SDK's existing 30s interval as the default when unconfigured (min = max = 30, deterministic, zero behavior change for existing users), while adding full jitter support once a wider [min, max] range is configured — which is the actual ask of this issue.

Tests

Added to StatusPluginTest:

  • validate_delaySecondsInvalid_returnsError / validate_delaySecondsEqual_returnsNoErrors — validation edge cases for min <= max.
  • configDefaults_delaySecondsAreCorrect — confirms the 30/30 default.
  • configBuilder_setsAllFields — updated to also cover the new delay setters.
  • start_periodicReport_usesJitteredChainedSchedule — sets min == max == 1s for a deterministic interval and verifies at least 3 "Status: %s" console logs (immediate + ~1s + ~2s), proving the schedule re-chains itself.
  • start_onlyMinDelayConfigured_defaultsMaxToTwiceMin — sets only min_delay_seconds = 1 with max_delay_seconds explicitly null, and verifies at least 2 reports within 2.5s, proving the fallback is 2 * min rather than an unrelated default that wouldn't fire in the window.

Also added config_statusDefaults coverage in ConfigTest for the new fields.

Ran locally with JDK 17 / Gradle:

./gradlew :opa-services:test --tests "io.github.open_policy_agent.opa.plugins.StatusPluginTest" --tests "io.github.open_policy_agent.opa.config.ConfigTest"
./gradlew test

StatusPluginTest: 20/20 pass (14 pre-existing + 6 new/updated). ConfigTest: 14/14 pass. Full multi-module ./gradlew test and :opa-services:checkstyleMain also pass with zero violations in the changed files.

Fixes #80
Fixes #80

@arimu1
arimu1 requested a review from a team as a code owner July 19, 2026 11:08
@arimu1
arimu1 force-pushed the fix/80-status-plugin-jitter branch from 08f6630 to 3b51a0a Compare July 20, 2026 03:01
@JsonProperty("min_delay_seconds")
private Integer minDelaySeconds = 30;

@JsonProperty("max_delay_seconds")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting maxDelaySeconds to a non-null 30 makes the "unset max → 2 * min" fallback in StatusPlugin.start() unreachable through config, and turns a min-only configuration into a validation error.

Jackson only invokes a setter for keys physically present in the config, so an omitted max_delay_seconds retains this 30 default rather than becoming null. As a result:

  • maxDelaySeconds = minDelaySeconds * 2 in start() can only fire if a caller explicitly passes null (as the test does) — never via normal YAML/JSON — contradicting the PR description's "if max_delay_seconds is unset, it now defaults to 2 * min_delay_seconds."
  • A user who sets only min_delay_seconds: 60 (max omitted) keeps max = 30, so validate() fails with min_delay_seconds (60) cannot be greater than max_delay_seconds (30) at startup — even though they never configured a max.

DecisionLogsConfig sidesteps this with split defaults (300/600), and ReportingConfig uses null/null. Consider defaulting maxDelaySeconds (and probably minDelaySeconds) to null here and applying the 30 default in start(), so the documented min-only fallback actually works.

@arimu1

arimu1 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @sspaink — good catch. StatusConfig.minDelaySeconds / maxDelaySeconds now default to null (same pattern as ReportingConfig), so an omitted max_delay_seconds in YAML stays unset and StatusPlugin.start() can apply the documented 2 * min fallback. The runtime default of 30 when both are unset is unchanged.

int minDelaySeconds =
(status.getMinDelaySeconds() != null) ? status.getMinDelaySeconds() : 30;
int maxDelaySeconds =
(status.getMaxDelaySeconds() != null) ? status.getMaxDelaySeconds() : minDelaySeconds * 2;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When only max_delay_seconds is configured and it's below 30, min defaults to 30, so min > max and the report just fires every 30s — the configured max is silently ignored. validate() doesn't catch it either, since it only compares the two when both are non-null. Consider validating this case or defaulting min to max when only max is set.

}

// Validate delay settings
if (statusConfig.getMinDelaySeconds() != null && statusConfig.getMaxDelaySeconds() != null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only checks min > max, but doesn't reject negative values like BundleDownloader.validatePolling does (it guards min < 0 / max < 0). A negative min_delay_seconds passes validation and produces a negative delay, which the scheduler treats as 0 — so the chain busy-loops reporting status. Suggest adding the same >= 0 guards.

arimu1 and others added 3 commits July 22, 2026 06:14
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 (open-policy-agent#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 open-policy-agent#78's DecisionLogPlugin fallback.

Also switch the plugin's scheduler to
BundleDownloader.newPollScheduler(...), matching BundlePlugin,
DiscoveryPlugin, and the DecisionLogPlugin fix in open-policy-agent#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 open-policy-agent#78 twin.

Fixes open-policy-agent#80
Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com>
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>
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 <cursoragent@cursor.com>
@arimu1

arimu1 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @sspaink — addressed both points (and rebased onto main after #170/#171 merged):

  1. Negative delays: validate() now rejects min_delay_seconds < 0 / max_delay_seconds < 0, same shape as BundleDownloader.validatePolling.
  2. Max-only below 30: start() now resolves bounds as:
    • both set → use as-is
    • only min → max = 2 * min
    • only max → min = min(30, max) (so max: 10 becomes a 10–10 window instead of silently scheduling every 30s)
    • neither → 30 / 60

Tests: validate_negativeDelaySeconds_returnsError, validate_onlyMaxDelayNegative_returnsError, start_onlyMaxDelayBelowDefaultMin_defaultsMinToMax.

@arimu1
arimu1 force-pushed the fix/80-status-plugin-jitter branch from 16e9b48 to f3fe89d Compare July 21, 2026 23:15

@sspaink sspaink left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@sspaink
sspaink merged commit 010b5f6 into open-policy-agent:main Jul 22, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configurable status interval with jitter

2 participants