fix(heartbeat): bound the penstock verdict cache and close the throttle-family gap (PEN-2462) - #1531
Merged
Merged
Conversation
Author
1 similar comment
Author
Author
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 573bce0
The change is focused and the cache-boundary and error-family fallback are covered by targeted tests.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Expired cache entries are reclaimed without changing the existing TTL read semantics.
- The cache tests cover both reclamation and retention within the TTL window.
- The error-family fallback preserves persisted metadata precedence and adds a regression path for the code-only case.
Recommended Action
- No Critical or Important issues identified; this formal self-review is recorded as a comment because the PR is App-authored.
…le-family gap (PEN-2462) Ally's three non-blocking suggestions from #1427, plus one asymmetry found while answering the retry-budget question on PEN-2385. Landed separately rather than pushed onto #1427's head, which would have re-armed review/ally-complete and discarded a review pinned to that SHA. 1. Bound the verdict cache. #1427 added a credential dimension to the cache key, which made the key space open-ended over the process lifetime: a rotated or retired token is never probed again but its entry had no reason to leave, in a gate constructed once per process. Sweep entries past `cacheTtlMs` on write. Behaviour-preserving by construction -- the read path already refuses to serve them -- and sufficient on its own, since a write only follows a miss, so survivors are exactly the entries written in the last TTL. `cache.delete(key)` on the stale branch would not have worked: a key only recurs for a credential still in use, which is precisely the part the retired tail is not. 2. Trim an overstated comment. The cache-key rationale claimed the key "reaches logs and heap dumps". It does not reach logs -- every log payload in the module carries status/provider/model/reason and never the key. The heap-dump rationale is accurate and stands alone; leaving the false half in invites a future reader to conclude the hashing was unnecessary. 3. Fix a test that passed by a misleading route. `mockResolvedValue(new Response(...))` hands one instance to every call and a Response body is single-use, so a regression surfaced as a drained body rather than as the call-count assertion the test is about. Use the factory idiom already used elsewhere in the file. 4. Close a one-sided backstop. `provider_throttled_no_progress` was missing from the errorCode fallback ladder in `readHeartbeatRunErrorFamily`, though it is tagged `errorFamily: "rate_limit_exhausted"` in the same statement and from the same two booleans that write `errorCode`. Not a live bug -- the fields are co-written and the tag is consulted first -- but its twin had a second line of defence and it did not. Signed-off-by: Devops <devops@blockcast.net> Signed-off-by: Devops <devops@paperclip.blockcast.net>
kkroo
force-pushed
the
pen-2462/bound-verdict-cache
branch
from
August 28, 2026 03:16
573bce0 to
8bb6846
Compare
Author
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8bb6846
No Critical or Important issues identified. The cache sweep uses the same expiration boundary as cache reads and runs before replacing a miss, preserving live entries while reclaiming retired credentials. The fallback mapping preserves persisted metadata precedence and covers the code-only recovery path with targeted tests.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Exact-head cache tests cover both expired-entry reclamation and retention inside the TTL window.
- The cache-key rationale now accurately distinguishes heap exposure from logging behavior.
- The
provider_throttled_no_progressfallback is behaviorally aligned with its write-timerate_limit_exhaustedfamily and has regression coverage.
Recommended Action
- No Critical or Important issues identified; this formal self-review is recorded as a comment because the PR is App-authored.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Refs #1427 — this carries the three non-blocking Suggestions from Ally's consolidated review of that PR at head
8ef55f9f, plus one item found independently.Filed as a follow-up rather than pushed onto
8ef55f9f: a new head there would have re-armedreview/ally-completeand discarded 19 green checks plus a review pinned to that exact SHA, and Ally has repeatedly not returned to a head pushed shortly after a review round. #1427 has since merged, so these land cleanly on top.Tracked internally as PEN-2462.
What Changed
penstock-availability-gate.ts). Sweep entries pastcacheTtlMsonset. Behaviour-preserving by construction: the read path already refuses to serve an entry at or past the TTL, so deleting it changes no verdict. Sweeping atsetis sufficient on its own and a size cap would be redundant — asetonly ever follows a miss, and a miss is followed by a TTL's worth of hits, so survivors are exactly the entries written in the last TTL. Residency is therefore bounded by credentials actively probing, not by every credential ever seen.cache.delete(key)on the stale-read branch would not bound this. A key only recurs for a credential still in use, which is precisely the part the retired tail is not.Cost:paragraph reasoned carefully about probe volume per TTL but said nothing about resident entries. Split intoCost, in probes:andCost, in resident entries:.log.info/log.warnpayload in the module carriesstatus/provider/model/reasonand never the key (verified across all seven call sites). The heap-dump rationale is accurate and stands alone. This comment is the durable record of why the credential is hashed, so an overstated justification invites a future reader to conclude the hashing was unnecessary.mockResolvedValue(new Response(...))hands the same instance to every call and aResponsebody is single-use, so a regression surfaced as a drained body rather than as the call-count assertion the test is about. Switched to the factory idiom already used elsewhere in the file.heartbeat.ts).provider_throttled_no_progresswas missing from theerrorCodefallback ladder inreadHeartbeatRunErrorFamily, though it is taggederrorFamily: "rate_limit_exhausted"at the point of write, in the same statement and from the same two booleans that seterrorCode.Verification
Typecheck of the changed module is clean (
tsc --noEmit --strict).Each new test was run against unmodified
masterto confirm it discriminates — all 5 fail without the fix.The sharpest of these isolates the sweep specifically. With
_cacheSizeForTestingpresent but the singlesweepExpiredCapacityCacheEntries(...)call removed, the growth test failsexpected 25 to be 1— 25 credential rotations, each one full TTL apart, each leaving a permanent entry. That is the unbounded-growth path reproduced directly, with the sweep as the only variable.The companion test is the guard in the other direction: three credentials probing inside one TTL must all stay resident and none may re-probe. It passes with the sweep both enabled and disabled, which is correct — its job is to catch over-eager eviction, not to detect the sweep.
The error-family tests are written with
resultJson: nulldeliberately, since that is the only state that discriminates: with the tag present, both codes already resolve correctly.Risks
Low risk.
nowMs - fetchedAt >= cacheTtlMs). An entry with afetchedAtahead ofnowMsyields a negative age and is kept, so a caller-supplied clock that moves backwards costs at most a retained stale entry, never a wrong answer.errorCodefallback is behaviour-preserving on every row that exists today: the two fields are co-written from the same booleans (heartbeat.ts:28394and:28552), and the persistederrorFamilytag is consulted first. It is a pure backstop for a future path that sets the code alone, or a row whoseresultJsonis dropped downstream._cacheSizeForTestingis declared optional on the interface so the hand-rolled gate fakes in the heartbeat suites do not have to implement it.Model Used
Claude Opus 5 (
claude-opus-5), 1M-context variant, extended thinking, via Claude Code with tool use.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template