Skip to content

Discard run reductions computed before the trigger filter - #13

Merged
fredclausen merged 2 commits into
mainfrom
fix/stale-reduction-resurrects-filtered-runs
Aug 11, 2026
Merged

Discard run reductions computed before the trigger filter#13
fredclausen merged 2 commits into
mainfrom
fix/stale-reduction-resurrects-filtered-runs

Conversation

@fredclausen

@fredclausen fredclausen commented Aug 11, 2026

Copy link
Copy Markdown
Member

Follow-up to #12, which regressed in production. Found on deploy to the monitoring host.

Symptom

sdr-enthusiasts/sdr-e-base-repo-setup's Lint — the PR-only workflow #12's trigger filter was written specifically to drop — came back as conclusion="failure" and drove GitHubCIFailingDefaultBranch into pending. That is worse than before the filter existed, when the same fossil run showed as the harmless, non-alerting stale.

Cause

fetch_runs caches a reduction, not the raw response, keyed by a fingerprint of each workflow's path, name, and triggers.

#12 taught reduce_runs to drop workflows with no default-branch state. That changed what the reduction contains without changing the path, the name, or the triggers — so:

  1. The cache key was unchanged
  2. The persisted file's CACHE_FORMAT_VERSION still matched, so the file was accepted
  3. Every entry still deserialised cleanly, so the per-entry decode fallback never fired
  4. GitHub answered 304, and the exporter replayed a pre-filter reduction still containing the fossil run
  5. record_runs published it — and because such a workflow is no longer masked as stale, published it as an outright failure

Both existing safeguards key on decodability. They catch a projection whose shape changed; this one's shape was byte-identical while its meaning was not. That gap is the actual defect.

Fix

CACHE_FORMAT_VERSION → 2. The cause. Its documentation now states that a change in a projection's meaning requires a bump just as a change in shape does, and carries a version history, since that distinction is precisely what made this invisible.

The filter is enforced at publication as well as at reduction. Reaching record_runs with such a run means the reduction did not come from the current code, and a replayed cache entry is how that happens. Checking there too makes the rule hold regardless of a reduction's provenance — the difference between a stale cache costing one sweep of accuracy and it publishing a fossil failure that pages.

Verification

Reproduced end-to-end with three binaries against the live API, which is how the mechanism was pinned down:

Binary Cache Lint reported as
pre-#12 (cbaeda6) cold stale — the fossil, masked
#12 (bec6c39, deployed) cold absent — filter works
#12 (bec6c39, deployed) inherited v1 failure — the regression

That third row is what the monitoring host hit: correct code, poisoned cache.

A useful consequence, since the second row shows the released binary is fine on a cold cache: a running deployment recovers by deleting etags.json and restarting, at the cost of one uncached sweep. No redeploy needed. The version bump is what stops it recurring on the next semantic change to a projection.

The publication guard is mutation-tested. With it removed, a_replayed_reduction_cannot_resurrect_a_pr_only_workflow fails on the exact production symptom. Its companion, a_replayed_reduction_still_publishes_a_cadenced_workflow, asserts a cadenced workflow's ancient failure is still published and masked as stale, so the guard stays surgical rather than dropping runs wholesale.

138 tests, cargo xtask ci green.

Summary by CodeRabbit

  • Bug Fixes
    • Corrected workflow metrics so runs without default-branch state are excluded.
    • Prevented cached or replayed pull-request-only failures from reappearing in published metrics.
    • Preserved stale failure reporting for workflows that run on a regular cadence.
    • Updated cached data handling to reflect revised metric interpretations and prevent outdated results from being reused incorrectly.

Reached production. `sdr-e-base-repo-setup`'s `Lint` -- the PR-only
workflow the trigger filter was written to drop -- came back as
`conclusion="failure"` and put GitHubCIFailingDefaultBranch into pending.
Worse than before the filter existed, because it previously showed as the
harmless `stale`.

`fetch_runs` caches a *reduction*, keyed by a fingerprint of each
workflow's path, name, and triggers. Teaching `reduce_runs` to drop
workflows with no default-branch state changed what it produces without
changing any of those, so the key was unchanged, the persisted file's
version still matched, and every entry still deserialised cleanly. GitHub
answered 304 and the exporter replayed a pre-filter reduction containing
the run it would now discard. `record_runs` then published it, and since
such a workflow is no longer masked as stale, published it as an outright
failure.

Both existing safeguards missed it because both key on decodability: the
version check and the per-entry decode fallback catch a projection whose
*shape* changed, and this one's shape was byte-identical while its meaning
was not. `CACHE_FORMAT_VERSION` is bumped to 2 and its documentation now
says to bump it for a change in meaning as well as in shape, with a
version history, since that distinction is what made this invisible.

The filter is also enforced in `record_runs`, not only in `reduce_runs`.
Reaching that point means the reduction did not come from the current
code, and a cached projection is the way that happens. Checking at
publication too makes the rule hold regardless of a reduction's
provenance, which is the difference between a stale cache costing one
sweep of accuracy and it publishing a fossil failure that pages.

Note that clearing the cache is sufficient to recover a running
deployment; verified that the released binary filters the workflow
correctly on a cold cache, and that the pre-filter binary is what writes
the poisoned entry.

The publication guard is mutation-tested: removed, the new test fails on
the exact production symptom. Its companion asserts a cadenced workflow's
ancient failure is still published, masked as stale, so the guard stays
surgical.
Copilot AI lite review requested due to automatic review settings August 11, 2026 23:15
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb33665e-ad9f-4983-8c53-ea18f3426bb5

📥 Commits

Reviewing files that changed from the base of the PR and between 1c7dbfe and dc8882d.

📒 Files selected for processing (2)
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/github/client.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • github-ci-exporter/src/github/client.rs

📝 Walkthrough

Walkthrough

The exporter excludes cached workflow runs and last-success entries without default-branch state. Cadenced failures remain published as stale. The persisted cache format version increases to 2 for the semantic reduction change.

Changes

Workflow run cache handling

Layer / File(s) Summary
Cache format contract
github-ci-exporter/src/github/client.rs
The cache documentation now requires version changes for semantic projection changes. The cache format version changes from 1 to 2.
Run filtering and regression coverage
github-ci-exporter/src/collector.rs
record_runs filters runs and last-success entries without default-branch state. Staleness uses DefaultBranchSignal::Cadenced. Tests cover replayed PR-only workflows and replayed cadenced failures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: preventing pre-filter run reductions from being published.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stale-reduction-resurrects-filtered-runs

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@github-ci-exporter/src/collector.rs`:
- Around line 600-614: Apply the same DefaultBranchSignal::None filter used for
runs.latest to the loop publishing runs.last_success, so PR-only workflows are
excluded before emitting workflow_last_success_timestamp. Update the PR-only
replay fixture to include a last_success entry and verify that no metric for the
Lint workflow is emitted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b7560ab9-5d08-47c0-9569-ecd135613d19

📥 Commits

Reviewing files that changed from the base of the PR and between bec6c39 and 1c7dbfe.

📒 Files selected for processing (2)
  • github-ci-exporter/src/collector.rs
  • github-ci-exporter/src/github/client.rs

Comment thread github-ci-exporter/src/collector.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Updates the exporter’s ETag cache semantics and run publication logic to prevent stale cached reductions from reintroducing PR-only workflows (and their potentially “pageable” failures) after trigger-based filtering was introduced.

Changes:

  • Bumps CACHE_FORMAT_VERSION to invalidate previously persisted projections whose meaning changed without a shape change.
  • Enforces DefaultBranchSignal::None filtering at publication time (record_runs) to guard against replayed cached reductions.
  • Adds regression tests ensuring a replayed reduction cannot resurrect a PR-only workflow while still publishing cadenced workflows as stale.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
github-ci-exporter/src/github/client.rs Bumps cache format version and documents semantic-versioning requirements for cached projections.
github-ci-exporter/src/collector.rs Drops DefaultBranchSignal::None runs at publication and adds tests covering stale-cache replay scenarios.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread github-ci-exporter/src/github/client.rs Outdated
Comment thread github-ci-exporter/src/collector.rs
Review findings, all three valid.

The publication guard covered `runs.latest` but not `runs.last_success`,
which a separate loop publishes. A replayed pre-filter reduction populates
both, so a PR-only workflow could still emit
`workflow_last_success_timestamp` -- asserting that it last passed on the
default branch, which is precisely the claim being retracted. No alert or
dashboard reads that series today, so the practical effect was nil, but
the guard's whole premise is that the rule holds regardless of a
reduction's provenance, and it did not.

The signal lookup is now a single closure used by both loops, so the two
cannot drift apart again.

The replay fixture carries a `last_success` entry, without which the new
path was untested; confirmed by mutation that removing only that guard
fails the test. The PR-only assertion now checks line-wise that no sample
mentions the workflow at all, rather than searching the whole document for
`conclusion="failure"`, which would have been brittle against any
unrelated series carrying that label. The cadenced companion gained an
assertion that its last-success timestamp is still published, so the new
gate cannot silently over-reach.

Version-history wording now names `DefaultBranchSignal::None` rather than
"no default-branch trigger", matching the type it describes.
@fredclausen

Copy link
Copy Markdown
Member Author

All three review findings were valid and are fixed in dc8882d.

last_success not gated (CodeRabbit): correct, and the more substantive of the three. The publication guard covered runs.latest but a separate loop publishes runs.last_success, and a replayed pre-filter reduction populates both — so a PR-only workflow could still emit workflow_last_success_timestamp, asserting it last passed on the default branch, which is exactly the claim being retracted. Nothing reads that series today so the practical effect was nil, but the guard's premise is that the rule holds regardless of a reduction's provenance, and it did not. The signal lookup is now one closure used by both loops so they cannot drift apart again.

The fixture gained a last_success entry, without which that path was untested. Confirmed by mutation: removing only the new guard fails the test with not a run status, not a stale flag, and not a last-success timestamp.

Brittle assertion (Copilot): correct. !rendered.contains(r#"conclusion="failure""#) would have broken against any unrelated series carrying that label. Both assertions are now line-wise and scoped to the workflow under test. The cadenced companion also gained an assertion that its last-success timestamp is still published, so the new gate cannot silently over-reach.

Version-history wording (Copilot): correct. It now names DefaultBranchSignal::None rather than "no default-branch trigger", matching the type it describes.

138 tests, all CI green.

@fredclausen
fredclausen merged commit 1ea4e49 into main Aug 11, 2026
8 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.

2 participants