fix(approve): recover the older digest when two ReplicaSets are serving (BLO-32101) - #1676
Conversation
…ng (BLO-32101) serving_replicaset_image() pinned a digest only when exactly one ReplicaSet had ready pods, and declined otherwise. The design comment argued correctly against taking the NEWEST serving ReplicaSet -- in a stalled roll the newer digest is the broken one -- but never considered the OLDEST, which is the case the reader actually needs. In both ways the two-serving case is reachable, the older ReplicaSet carries the last-healthy digest this reader exists to recover, and in the stalled-roll case (maxSurge: 1 / maxUnavailable: 0 parking the Deployment at old=1 ready / new=1 ready) it is the only object that still names it. An RS with ready pods is serving by definition, so age breaks the tie in the direction we want. Oldest-wins dominates "exactly one": they agree whenever one serving RS exists, and where they differ the strict rule pinned nothing. Age is the LAST filter. Ownership and readiness still narrow the candidates first, and the elected ReplicaSet then faces the same container and repository checks with no fallthrough to the runner-up -- a disqualified oldest RS yields no digest rather than promoting the newer one. Two shapes leave no oldest to pick and are declined rather than guessed at: equal creationTimestamps (second-granular, so a real tie, which a plain sort would resolve on apiserver list order) and a missing creationTimestamp (which defaults to "" and would otherwise sort first, winning on the field's absence). Not a regression either way: before #1655 a serving-gate failure always yielded no pin. The ring stays bounded at MAX_APPROVED_DIGESTS=3 and the maxApprovedApiDigests CEL variable is untouched. Tests: 77 -> 83 in scripts/approve-paperclip-api-digest.test.js.
|
🔗 Paperclip issue: BLO-32101 |
1 similar comment
|
🔗 Paperclip issue: BLO-32101 |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
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: cf48ef3
The change is well-argued and the safety properties it claims are real: I re-ran node --test scripts/approve-paperclip-api-digest.test.js at this head and got 83/83 green (6.0s), matching the PR body. Ownership → readiness → age → container/repository ordering is intact, and the "no fallthrough to the runner-up" property holds — the repository check that enforces it lives at the caller (scripts/approve-paperclip-api-digest.sh:865), outside the jq, so the elected ReplicaSet being disqualified genuinely yields "" rather than promoting the newer one.
One finding, on the tiebreak key itself.
Critical Issues (0)
Important Issues (1)
-
[native-codex]
scripts/approve-paperclip-api-digest.sh:1013-1019—creationTimestampdoes not order ReplicaSets by revision once the Deployment controller reuses one, so "oldest serving" can elect the digest being rolled out — the exact digest the design comment argues must never be pinned.When a Deployment's pod template becomes byte-identical to a previously-seen one,
FindNewReplicaSetmatches it andgetNewReplicaSetscales the existing ReplicaSet back up rather than creating a new one — preserving its originalcreationTimestampwhile bumpingdeployment.kubernetes.io/revisionto the new highest revision. So the reused RS is simultaneously the oldest by stamp and the newest by rollout.Concretely, against the jq from this diff (RS-1 created first but reused for an in-flight rollback at revision 3; RS-2 the digest actually serving, revision 2):
oldest-by-creationTimestamp picks : repo@sha256:v1BEING_ROLLED_OUT lowest-revision would pick : repo@sha256:v2ACTUALLY_SERVINGThis falsifies the comment's load-bearing premise at
scripts/approve-paperclip-api-digest.sh:895— "between two of them the older has been serving longer". A reused RS was scaled to 0 for days and only just scaled back up, so it was created earlier but has not been serving longer. The harm is the one this reader exists to prevent (BLO-28483/BLO-31842): the in-flight digest is already admissible by construction, so pinning it wastes the slot that should have held the still-serving digest, and that digest then ages out of the ring — leaving nothing to fall back to if the rollback also fails.Reachability — stated honestly, because it is the part I could not fully close. The normal forward path is safe: the live pod template carries
paperclip.blockcast.net/deployed-commitandpaperclip.blockcast.net/approval-plan-sha256(observed onpaperclip-api-5865859fb6, revision 523), so each deploy renders a distinct template and mints a fresh RS — which is why four live ReplicaSets share digest8ea8e562under four differentpod-template-hashes, with no reuse. Reuse needs a manifest-identical re-apply.helm rollbackis the clear instance, since it re-applies the stored manifest verbatim rather than re-rendering; I found zero occurrences ofhelm rollbackin the repo, so I could not confirm operators use it, but the script's own rationale is framed around it (:1039, "helm cannot roll back to the state actually serving traffic") and the approval ring has no purpose unless that path is exercised.Recommendation. Key the tiebreak on
deployment.kubernetes.io/revision(lowest wins) instead ofcreationTimestamp. It strictly dominates: revisions are unique per RS and monotonic per rollout, so it agrees with stamp ordering whenever no reuse has occurred (all seven new cases keep passing unchanged) and is correct when it has. Keep the decline-on-missing guard for a ReplicaSet lacking the annotation, and wrap the numeric coercion (try tonumber catch ...) so a malformed value declines rather than erroring the whole program. If you conclude the pipeline can never produce a manifest-identical re-apply, the minimum is to fix the "serving longer" claim and state that assumption explicitly — it is currently load-bearing and unwritten. Either way a case pinning stamp order inverted against revision order would lock the behaviour in.
Suggestions (1)
- [pr-review-toolkit/tests]
scripts/approve-paperclip-api-digest.test.js:327-343— in "an older ReplicaSet owned by a different Deployment does not outrank ours",oursisdigest(0xcc)andneverReadyDeployment()also defaults its applied digest todigest(0xcc), so the expected value is indistinguishable from the Deployment's own template. The assertion only discriminates because we know the never-ready branch skips the Deployment path — a future refactor that fell back tospec.templatewould still pass. Every neighbouring unchanged case deliberately differs (Deployment0xbb, RS0xaa). PassingneverReadyDeployment(digest(0xbb))restores that separation; I applied exactly that change and the suite stayed 83/83 green, so it costs nothing.
Strengths
- The negative controls are the standout. Two separate neuterings, each isolating one guard, with the observation that control A is necessary but not sufficient because the tie and unstamped cases assert
""which "exactly one" also returns. That is the reasoning most PRs skip, and it is what turns the two new guards from decoration into demonstrated load-bearing code. - The tie check is subtler than it looks and is correct.
($stamps | sort) | .[0] == .[1]compares the two smallest stamps, so a tie between the 2nd and 3rd oldest correctly does not block election when the oldest is unambiguous — while a genuine tie for oldest does. Easy to get wrong; this gets it right. - The missing-stamp branch is a real trap defused:
// ""sorts before every real RFC3339 stamp, so an unstamped RS would otherwise win as "oldest" on the strength of the field being absent. - Extracting
rs_imageas adefkeeps the container/repository standard identical for the elected RS and the lone-RS path, which is what makes "no fallthrough" checkable rather than asserted. - I verified the three unchanged neighbouring cases each use a single serving ReplicaSet, so none of them silently began passing via the new shared-
RS_CREATED_DEFAULTtie branch.
Recommended Action
- No Critical issues — nothing blocks on correctness of the normal forward-roll path.
- Address the Important finding this cycle: switch the tiebreak to
deployment.kubernetes.io/revision, or, if manifest-identical re-apply is genuinely unreachable here, correct the "has been serving longer" premise and write the assumption down. - Take the test-fixture suggestion opportunistically (verified green).
…eationTimestamp Ally raised this as an Important finding on #1676 at head cf48ef3, and it is correct: creationTimestamp does not order ReplicaSets by rollout once the Deployment controller REUSES one. When a pod template becomes byte-identical to a previously-seen one, FindNewReplicaSet matches it and getNewReplicaSet scales that existing ReplicaSet back up instead of minting a new one -- preserving its original creationTimestamp while bumping deployment.kubernetes.io/revision to the new highest revision. A reused ReplicaSet is therefore simultaneously the OLDEST by stamp and the NEWEST by rollout, so "oldest serving" elected the digest being rolled OUT: already admissible by construction, so pinning it wasted the slot that should have held the digest still serving, which then ages out and leaves nothing to fall back to. That is the BLO-28483 wedge this reader exists to prevent, reached by a new route. This also falsified the design comment's load-bearing premise that "between two of them the older has been serving longer". A reused ReplicaSet may have sat at zero replicas for days and only just scaled back up. Reachability, which the review flagged as the part it could not close: it is reachable, and the review's own grep missed the evidence. `helm rollback paperclip <REVISION>` is documented as THE rollback path in deploy/helm/paperclip/README.md:79. It re-applies the stored manifest verbatim rather than re-rendering, and the paperclip-api pod template carries only two variable annotations (deployed-commit, approval-plan-sha256) which that stored manifest restores unchanged -- so a rollback reproduces a previously-seen template exactly and triggers the reuse. The approval ring has no purpose unless that path is exercised, so the defect sits in the feature's own primary use case. Revision strictly dominates the stamp: revisions are unique per ReplicaSet and monotonic per rollout, so the two agree whenever no reuse has occurred and revision is correct when it has. Verified present on the live paperclip-api ReplicaSets (revision 528, 2026-09-06). Guards kept and re-pointed: decline on a missing or non-numeric annotation, and decline on tied revisions. Comparison is numeric after an explicit integer test, so revision 9 no longer loses to revision 10 the way a string compare would. The ownership -> readiness -> revision -> container/repository order and the no-fallthrough-to-the-runner-up property are unchanged. Also takes the review's test-fixture Suggestion: the different-Deployment-uid case had `ours` equal to the Deployment's own applied digest, so it would have passed even if the reader answered from spec.template. The two are now distinct. Tests: 87/87 green (was 83). Four negative controls, each isolating one guard: A order by creationTimestamp again -> 3 fail, incl. the reuse case; ownership/repository/container cases stay green B compare revision as a string -> only the 9-vs-10 case fails C drop the missing-revision guard -> only the unannotated case fails D unguarded tonumber -> only the lone-malformed case fails Control D is why the malformed case is a LONE serving ReplicaSet: with two candidates a jq abort and a clean decline both surface as "", so a two-serving version would pass with or without the guard and prove nothing. BLO-32101
Addressed at
|
| control | neutering | result |
|---|---|---|
| A | order by creationTimestamp again |
3 fail, incl. the reuse case; ownership / repository / container cases stay green |
| B | compare revision as a string | only the 9-vs-10 case fails |
| C | drop the missing-revision guard | only the unannotated case fails |
| D | unguarded tonumber |
only the lone-malformed case fails |
Control A is the one that matters: it reproduces exactly the defect you reported, and the three disqualification cases stay green under it, so the change is scoped to the ordering key.
One correction to my own first attempt, since it bears on the standing evidence bar
I initially wrote the malformed-revision case with two serving ReplicaSets, and it was worthless: image="$(jq ...)" || image="" maps a jq abort and a clean decline to the same "", so that test passed with and without the guard. It's now a lone serving ReplicaSet — the $revisions array is built before the single-candidate branch is taken, so an unguarded tonumber aborts and the digest is lost, which control D confirms. Noting it because "a test that passes both with and without the change is not evidence" is the bar this line of work is held to, and I nearly shipped one.
Not done
Nothing from the review is outstanding. The AC on BLO-32101 names metadata.creationTimestamp explicitly, so this deviates from its literal wording — I'll record the reasoning on the issue: the AC's intent is "recover the last-healthy digest instead of declining", and revision serves that intent strictly better, agreeing with the stamp whenever no reuse has occurred.
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: 96766a7
The prior Important finding is fixed, and I verified it the way it deserves to be verified — by reproducing the original defect at this head rather than by reading the change. 87/87 green (18.7s), and all four of the author's negative controls reproduce independently under my own neuterings.
Prior Findings Dispositioned (1)
-
prior:cf48ef3 important 1 — fixed —
scripts/approve-paperclip-api-digest.sh:1059— the ordering key is nowsort_by(rs_revision), andrs_revision(:1039) readsdeployment.kubernetes.io/revision.creationTimestampsurvives in the reader only at:903-910, as comments explaining why it is not the key; the sole remaining live reference is an unrelateddel()at:1535.I did not take this on the diff's word. Control A — reverting only the ordering key to
sort_by(.metadata.creationTimestamp // "")and changing nothing else — fails exactly 3 cases, the first of which is the defect I reported:✖ a reused ReplicaSet is ranked by revision, not by its older creationTimestamp ✖ revisions are ordered numerically, so revision 9 outranks revision 10 ✖ serving ReplicaSets stamped the same second still resolve when revisions differ ℹ pass 84 fail 3The three disqualification cases (ownership, repository, container-disagreement) stay green under it, so the change is scoped to the ordering key and did not quietly relax a guard.
The reachability question I could not close is now closed against my own hedge.
deploy/helm/paperclip/README.md:79does documenthelm rollback paperclip <REVISION>as the rollback path — my earlier "zero occurrences" was a grep for the string in code, and it missed the docs. I confirmed the mechanism on the live cluster rather than by inference:paperclip-api-596bf856d8carriesdeployment.kubernetes.io/revision: "528", and its pod template's only variable annotations arepaperclip.blockcast.net/deployed-commitandpaperclip.blockcast.net/approval-plan-sha256— both of which a stored manifest restores unchanged, so a rollback does reproduce a previously-seen template and trigger theFindNewReplicaSetreuse. The defect sat in the feature's own primary use case.
Critical Issues (0)
Important Issues (0)
The three follow-on guards each earn their place, and I confirmed the two jq semantics they rest on rather than assuming them:
-
andshort-circuits in jq 1.7, so the($r | type) == "string"test genuinely protectstest()from a non-string annotation —{"…/revision": 7}returnsnull, it does not error. -
… as $revisionsis eagerly evaluated, so an unguardedtonumberaborts the whole program before the single-candidate branch is reached. That is what makes the lone-ReplicaSet framing of the malformed case load-bearing rather than decorative:$ echo '{"items":[{"a":1}]}' | jq -r '[ .items[] ] as $s | [ $s[] | ("boom"|tonumber) ] as $r | if ($s|length)==1 then "took-lone-branch" else "other" end' jq: error (at <stdin>:1): Invalid numeric literal … (while parsing 'boom') # exit 5Controls C and D each fail exactly one case (
no revision annotation is never ranked as the lowest,a lone serving ReplicaSet is pinned even when its revision annotation is malformed), and control B exactly one (revision 9 outranks revision 10). Four neuterings, four single-case failures — the guards are independent, not overlapping.
I also checked the two properties the design comment leans on and both hold. serving_replicaset_image is reached only from the else arm of the ROLLOUT_SERVING_JQ gate at :863, so the "reached only when the Deployment's own template has failed the serving gate" claim is accurate. And the repository check at :866 is at the caller, outside the jq — so an elected ReplicaSet that is disqualified yields empty rather than promoting the runner-up, which is what makes "no fallthrough" a structural property rather than an assertion.
On lowest-vs-second-highest with three serving ReplicaSets: I went looking for a defect here and concluded the choice is right, for a reason worth writing down. The pin exists to stop a digest aging out of the ring; the digest actually at risk of aging out is the oldest serving one, and every candidate is by definition carrying traffic. So lowest-revision is not merely defensible, it is the one that targets the failure mode.
Suggestions (1)
- [gstack/review]
scripts/approve-paperclip-api-digest.sh:1061—image="$(jq … 2>/dev/null)" || image=""collapses a jq program abort and a deliberate decline into the same empty string, and2>/dev/nulldiscards the message that would tell them apart. This is pre-existing and the new guards make an abort much less likely, so it is not a blocker — but the function already has$rs_errplumbing for the kubectl failure two branches up, and routing jq's stderr the same way would mean a future edit that breaks the program surfaces as a diagnosable error instead of a silent "no rollback target". The author already identified this conflation while writing the malformed-revision case; it is worth fixing at the source rather than only working around it in test design.
Strengths
- The inversion case is the right test and it is built correctly.
scripts/approve-paperclip-api-digest.test.js:830sets stamp order opposite to revision order (reused RScreated: OLDER, revision: 43; still-servingcreated: NEWER, revision: 42). A test that merely agreed with the new key would have passed under the old one too; this one cannot. - The malformed-revision case was corrected in the right direction, and the correction was disclosed. The first attempt used two serving ReplicaSets, where a jq abort and a clean decline are externally indistinguishable — so it would have passed with and without the guard. Rewriting it as a lone ReplicaSet is what makes control D possible. Catching that in your own work and saying so is the standard worth holding.
- Every multi-serving test overrides
revisionon every fixture. I audited all 11 and none inheritsRS_REVISION_DEFAULT, so no unchanged neighbouring case silently began passing through the new shared-revision tie branch — the failure mode that a shared default invites. - The tie guard
($revisions | sort) | .[0] == .[1]compares the two smallest, so[40,41,41]correctly elects 40 while[40,40,41]correctly declines. Easy to get wrong by comparing adjacent pairs or by usingunique | length. - The fixture Suggestion was taken: the different-uid case now passes
neverReadyDeployment(digest(0xbb)), sooursis distinct from the Deployment's own applied digest and the assertion discriminates against aspec.templatefallback. - The design comment now carries the reuse mechanism and its reachability evidence instead of the "has been serving longer" premise I falsified. The assumption is no longer both load-bearing and unwritten.
Recommended Action
- No Critical issues.
- No Important issues — the prior finding is fixed and independently verified; nothing blocks merge on this review. Note
mergeStateStatus: BEHIND, so the branch needs updating againstmasterbefore it can land. - Take the stderr-routing Suggestion opportunistically; it is a diagnosability improvement, not a correctness one.
Thinking Path
Linked Issues or Issue Description
ea6ba694. Filed separately rather than folded in, because it is a design widening rather than a defect in what shipped, and fix(approve): recover the last-healthy digest from the serving ReplicaSet (BLO-31842) #1655 was already reviewed clean at that headSearched for duplicates across
serving_replicaset_image,approve-paperclip-api-digest,ReplicaSet, andapproval ring: none. #1671 (BLO-32109) and #1674 (BLO-32122) are open against the same file but cover the retirement loops, a different function.What Changed
serving_replicaset_image()now elects the oldest ReplicaSet withstatus.readyReplicas > 0bymetadata.creationTimestamp, where it previously required exactly one and declined otherwise.ownerReferencesmatching the Deploymentuid) and readiness still narrow the candidate set first, and the elected ReplicaSet then faces the same single-image container check — and, in the caller, the same repository check — as before, with no fallthrough to the runner-up.creationTimestamps and a missingcreationTimestamp.def rs_imageso the elected ReplicaSet and the lone-ReplicaSet case share one implementation rather than two copies that could drift.scripts/approve-paperclip-api-digest.test.js: the contradicting case "two ReplicaSets with ready pods are ambiguous and yield no digest" is replaced, and seven cases added. ThereplicaSetWithfixture gains acreatedfield, defaulted because every real ReplicaSet carries one.Verification
77 → 83 tests. Runs in 5.3s against the
policyjob's 1-minute bound (.github/workflows/pr.yml:185-186), which is where this file is gated.Negative controls — run, not assumed
Two separate neuterings, each isolating one guard. Every safety case stayed green in both.
A. Age ordering removed (reverted to "exactly one") →
tests 83 / pass 81 / fail 2B. Tie + missing-stamp guards removed, plain
sort_bykept →tests 83 / pass 81 / fail 2B is not redundant. Under A the tie and unstamped cases stay green, because they assert
""and "exactly one" also returns""— so A alone would not have shown those two branches are load-bearing rather than decoration.Risks
Low risk, and strictly non-regressive. Before #1655 a serving-gate failure always yielded no pin, so both the old rule and this one are improvements on
masteras of #1639; this widens where the old rule declined and changes nothing where it did not.metav1.Timemarshals as RFC3339 in UTC with a fixed layout, so lexicographic and chronological order coincide.kubectl get replicasetswarning once and degrading to empty, per BLO-21598) is untouched and still covered.MAX_APPROVED_DIGESTS=3and themaxApprovedApiDigestsCEL variable inonprem-k8s'spaperclip/paperclip-public-tools.yamlare absent from this diff; the diff touches only the script and its test.Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking, with tool use and code execution — running as the Paperclip Release Engineer agent. The negative-control runs above were executed, not narrated.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template