Skip to content

ROX-37814: add compliance data freshness sweeper - #23851

Draft
guzalv wants to merge 2 commits into
gualvare/rox-35712-freshness-hooksfrom
gualvare/rox-35712-freshness-sweeper
Draft

guzalv wants to merge 2 commits into
gualvare/rox-35712-freshness-hooksfrom
gualvare/rox-35712-freshness-sweeper

Conversation

@guzalv

@guzalv guzalv commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Description

The time-driven freshness sweeper for ROX-37814 — the last of the three enforcement points that keep a compliance check result's data_freshness correct (feature ROX-35712). The two write-time hooks (ingest → CURRENT, scan timeout → STALE) land in #23765; this PR adds the periodic backstop that catches staleness no event reports: a scheduled scan that silently stops cycling, or a secured cluster whose sensor is disconnected so no timeout ever fires.

Stacked on #23765 (base branch is gualvare/rox-35712-freshness-hooks). Retarget to master once that merges — the diff here is only the two sweeper commits.

Each ROX_COMPLIANCE_FRESHNESS_SWEEP_INTERVAL (default 5m), gated on the ComplianceSurfaceStaleData feature flag, the sweeper:

  • for every scan configuration that has a schedule, computes cutoff = previousScheduledFire − grace, where grace = ComplianceScanScheduleWatcherTimeout + ComplianceScanWatcherTimeout (85m);
  • marks STALE every check result of that configuration whose LastStartedTime predates the cutoff.

Design choices:

  • STALE-only. The sweeper only ever adds STALE; CURRENT is owned by the ingest path and nil-timestamp rows stay UNKNOWN. Because it never writes CURRENT, it structurally cannot revert a concurrent ingest or fight the timeout hook — agreement is a property of the design, not a tuning coincidence. It reuses the hooks' existing markStale helper (read-modify-upsert under the same KeyFence).
  • Config-name scope (vs. the timeout hook's scan-ref scope): the sweeper's evidence is the schedule, a config-level property that applies to every scan ref under it, so one cutoff is valid for all. A timeout, by contrast, is evidence about one scan on one cluster.
  • Standalone package central/complianceoperator/v2/freshness, modeled on the CVE unsuppress loop (central/cve/suppress): ticker + concurrency.Stopper + singleton, wired in main.go (both Start() and the stoppable gated on the same flag).

New surface: datastore method MarkScanChecksStaleByConfig (mirrors MarkScanChecksStale, keyed by config name) and env ROX_COMPLIANCE_FRESHNESS_SWEEP_INTERVAL.

Scope: sweeper + datastore method + wiring only. API surfacing (so the UI can read/filter/sort by freshness), an on-demand ("scan now") refresh term, and orphan-row cleanup are deferred to later slices.

Alternatives considered:

  • 3-way authoritative sweeper (set CURRENT/STALE/UNKNOWN each tick, as an earlier PoC did) — rejected: it duplicates ingest's CURRENT logic and needs matching age rules to avoid reverting the hooks. STALE-only is smaller and reverts nothing by construction.
  • Fold into the report manager — rejected: broadens its single responsibility and couples a correctness loop to the report subsystem. Fold into the pruning GC — rejected: that subsystem deletes rows on a coarse hourly cadence; wrong domain and cadence.

User-facing documentation

Not needed: behind the ComplianceSurfaceStaleData feature flag (default off); no user-visible API surface in this PR.

Testing and quality

  • the change is production ready: the change is GA, or otherwise the functionality is gated by a feature flag
  • CI results are inspected

Gated by the ComplianceSurfaceStaleData feature flag (default off).

Automated testing

  • added unit tests
  • added e2e tests
  • added regression tests
  • added compatibility tests
  • modified existing tests

How I validated my change

  • Unit (freshness/sweeper_test.go): expectedRefresh (nil / valid daily / unconvertible schedule); sweep (scheduled config → MarkScanChecksStaleByConfig called with cutoff = prevTick − grace, asserted; nil-schedule skipped; list error tolerated; a per-config error does not stop the next); Start()→Stop() terminates.

  • sql_integration (checkresults/datastore/datastore_freshness_test.go): TestMarkScanChecksStaleByConfig (old → STALE; recent and nil-timestamp rows untouched; other config untouched; write context required; idempotent re-run) and TestMarkScanChecksStaleByConfigAgreesWithWriteHooks (ingest-CURRENT + timeout-STALE rows, run the sweeper's method at the tightest cutoff → 0 reverts).

  • Live OpenShift cluster (grace shortened to 2m, sweep 1m; managed config rox35712-sweeper, ocp4-moderate, 125 check results). datafreshness column, count:

    S0 initial scan ............... 1|125   (CURRENT)
    S1 schedule edited to past .... 2|125   (STALE, within one 1m tick)
    S2 on-demand rescan ........... 1|125   (CURRENT via ingest, no revert across ≥4 ticks)
    
    • Marks overdue STALE: editing the config's schedule to a past daily time did not trigger a rescan (LastStartedTime frozen), so cutoff fell after the stored timestamp and all 125 rows went CURRENT→STALE within one tick, with the log line compliance freshness sweeper: marked 125 check result(s) stale for scan configuration "rox35712-sweeper".
    • No false positives: a future-scheduled config stayed CURRENT across ≥4 ticks, no mark line.
    • Fresh clears, no revert: an on-demand rescan set the rows CURRENT via ingest; later ticks never reverted them (newer than the cutoff).
    • Exactly one mark line in the whole window — the same running loop marked only the genuinely overdue config. No timezone skew (Central runs UTC; schedule cron and time.Now() agree).

🤖 This change was developed with AI assistance (ai-assisted).

The freshness sweeper needs to mark a scan configuration's check results
STALE on a time schedule, but the only STALE-marking entry point so far
(MarkScanChecksStale) is keyed by scan ref ID, which the sweeper does not
have. Add MarkScanChecksStaleByConfig, keyed by scan configuration name.

It mirrors MarkScanChecksStale exactly: same SAC check, same empty-key
no-op, same predicate (non-nil LastStartedTime before the cutoff), and it
delegates to the shared markStale helper. Only the query field differs
(ComplianceOperatorScanConfigName instead of ComplianceOperatorScanRef),
so it inherits the same idempotent, batched, key-fenced read-modify-upsert
and the STALE-only guarantee.

Add sql_integration coverage: a mixed-timestamp test across two clusters
and a second configuration, and an agreement test showing a sweeper-style
cutoff reverts neither ingest-CURRENT nor timeout-STALE rows.

Implement the compliance data freshness sweeper (ROX-37814): a STALE-only,
time-driven background loop for ROX-35712, built on the write-time hooks
(#23765). Grace = ComplianceScanScheduleWatcherTimeout +
ComplianceScanWatcherTimeout.

Generated with AI assistance.
The write-time hooks mark a result CURRENT on ingest and STALE when a scan
times out, but neither fires when a scheduled scan simply never runs or its
results never arrive. Such results stay CURRENT forever and silently go
out of date. Add a periodic, time-driven sweeper to close that gap.

The sweeper lists every scan configuration, derives the most recent time
its schedule expected a scan (via the existing Schedule->cron conversion
and gronx), and marks STALE the configuration's results whose
LastStartedTime predates (expected refresh - grace). Grace is the sum of
the two scan-watcher timeouts, the combined budget for a scheduled scan to
complete and be ingested.

It is STALE-only by construction: it reuses MarkScanChecksStaleByConfig,
which never sets CURRENT or UNKNOWN, so the sweeper can never revert the
write-time hooks. The loop mirrors the CVE unsuppress loop
(central/cve/suppress) for Start/Stop/ticker/stopper structure, runs under
an elevated all-access context, and tolerates per-config errors.

Wiring in main.go gates both Start and the stoppable registration on the
same ComplianceSurfaceStaleData flag: gating only one would make shutdown
block forever on a stopper that never reports stopped. The sweep interval
is configurable and defaults to 5m, well under the grace window.

Implement the compliance data freshness sweeper (ROX-37814): a STALE-only,
time-driven background loop for ROX-35712, built on the write-time hooks
(#23765). Grace = ComplianceScanScheduleWatcherTimeout +
ComplianceScanWatcherTimeout.

Generated with AI assistance.
@openshift-ci

openshift-ci Bot commented Nov 3, 2026

Copy link
Copy Markdown

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Build Images Ready

Images are ready for commit d310682. To use with deploy scripts:

export MAIN_IMAGE_TAG=5.1.x-429-gd310682465

1 similar comment
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Build Images Ready

Images are ready for commit d310682. To use with deploy scripts:

export MAIN_IMAGE_TAG=5.1.x-429-gd310682465

@codecov

codecov Bot commented Nov 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.22078% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.52%. Comparing base (b7f531e) to head (d310682).

Files with missing lines Patch % Lines
...ntral/complianceoperator/v2/freshness/singleton.go 0.00% 9 Missing ⚠️
central/complianceoperator/v2/freshness/sweeper.go 92.59% 3 Missing and 1 partial ⚠️
...erator/v2/checkresults/datastore/datastore_impl.go 78.57% 1 Missing and 2 partials ⚠️
Additional details and impacted files
@@                          Coverage Diff                           @@
##           gualvare/rox-35712-freshness-hooks   #23851      +/-   ##
======================================================================
- Coverage                               52.63%   52.52%   -0.12%     
======================================================================
  Files                                    2833     2920      +87     
  Lines                                  177818   182385    +4567     
======================================================================
+ Hits                                    93598    95795    +2197     
- Misses                                  75997    78192    +2195     
- Partials                                 8223     8398     +175     
Flag Coverage Δ
go-unit-tests 52.52% <79.22%> (-0.12%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant