Use lazy copy to patch resources to ensure multiple modifications are applied to the base resource - #4580
Conversation
|
Hello! Thank you for your contribution. Please review our contribution guidelines to understand the project's testing and code conventions. |
f43f84b to
ab1c70d
Compare
There was a problem hiding this comment.
Pull request overview
This PR modernizes how the controllers detect/propagate desired-state changes and how they compute patches, replacing integrity-hash annotations with explicit revision/generation tracking and adding a shared in-memory resource cache to avoid rebuilding identical desired objects.
Changes:
- Introduces a
ResourceCacheused byResourceBuilderto reuse desired objects based on a main-object key plus dependency refs. - Replaces integrity-hash–based update detection with
ActionableRevision/AppliedActionableRevisionforEphemeralRunnerSetandObservedGenerationforAutoscalingRunnerSet(and updates CRDs accordingly). - Refactors patch flows to use a lazy DeepCopy helper (
once) so multiple in-place mutations can be safely applied beforeclient.MergeFrom(...).
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| main.go | Instantiates and wires a shared ResourceCache into ResourceBuilder. |
| controllers/actions.github.com/utils.go | Adds once lazy-copy helper used to build correct MergeFrom patches. |
| controllers/actions.github.com/utils_test.go | Moves test-only random string helper into tests. |
| controllers/actions.github.com/resourcecache.go | Adds typed resource cache with dependency-key tracking and eviction by owner UID. |
| controllers/actions.github.com/resourcecache_test.go | Adds unit tests for cache semantics (dependency ordering, deletion, invalid inputs). |
| controllers/actions.github.com/resourcebuilder.go | Uses cache for desired-object reuse; removes integrity-hash annotations; refactors label/annotation merging. |
| controllers/actions.github.com/resourcebuilder_test.go | Updates tests for removed integrity-hash annotation; adds merge-map tests and cache expectations. |
| controllers/actions.github.com/helpers.go | Adds helper predicates for actionable revision and pod recreation decisions. |
| controllers/actions.github.com/ephemeralrunnerset_controller.go | Switches to actionable revision + status patching with conflict retries; refactors patching with lazy copies. |
| controllers/actions.github.com/ephemeralrunnerset_controller_test.go | Updates/extends integration tests for cache cleanup and actionable revision behavior. |
| controllers/actions.github.com/ephemeralrunner_controller.go | Refactors finalizer patching to use lazy-copy pattern; deletes cached entries on deletion. |
| controllers/actions.github.com/ephemeralrunner_controller_test.go | Extends tests to assert cache cleanup on runner deletion. |
| controllers/actions.github.com/autoscalingrunnerset_controller.go | Uses observed generation for Pending detection; switches spec-change handling to actionable revision; refactors patch logic. |
| controllers/actions.github.com/autoscalingrunnerset_controller_test.go | Updates tests to assert observed generation and actionable revision behavior; adds cache assertions. |
| controllers/actions.github.com/autoscalinglistener_controller.go | Refactors patching of dependent resources using lazy copies; updates pod recreation decision logic. |
| controllers/actions.github.com/autoscalinglistener_controller_test.go | Extends tests to assert resources are cached and evicted appropriately. |
| config/crd/bases/actions.github.com_ephemeralrunnersets.yaml | Adds actionableRevision, appliedActionableRevision, finishedRunnerCleanupPatchID schema fields. |
| config/crd/bases/actions.github.com_autoscalingrunnersets.yaml | Adds observedGeneration to status schema. |
| charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml | Mirrors ERS CRD schema additions into chart CRDs. |
| charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml | Mirrors ARS CRD schema additions into chart CRDs. |
| charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml | Mirrors ERS CRD schema additions into experimental chart CRDs. |
| charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml | Mirrors ARS CRD schema additions into experimental chart CRDs. |
| apis/actions.github.com/v1alpha1/version.go | Minor condition reordering in version allowance check. |
| apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go | Adds ActionableRevision spec field and applied/cleanup status fields. |
| apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go | Adds ObservedGeneration to status. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7b5e00f to
fdb0651
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Immutable RoleBinding and Secret fields are patched in place, which can leave reconciliation permanently failing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Balanced
Findings: 1
New issues introduced by this change (2)
| Severity | Finding |
|---|---|
controllers/actions.github.com/autoscalinglistener_controller.go — A Secret's immutable flag cannot be unset after it becomes true. For an immutable live proxy… |
|
controllers/actions.github.com/autoscalinglistener_controller.go — RoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the… |
Issues resolved since last review (3)
| Severity | Finding |
|---|---|
controllers/actions.github.com/utils.go — The panic message in (*once).Get() is very generic. Using a more specific message will make… View resolved comment |
|
controllers/actions.github.com/autoscalinglistener_controller.go — rulesModified is misleading here because this block is checking/modifying RoleRef, not RBAC… View resolved comment |
|
controllers/actions.github.com/autoscalingrunnerset_controller.go — Typo in the variable name listnerLabelsModified makes the code harder to read/search. Rename it… View resolved comment |
Suppressed comments (1)
controllers/actions.github.com/autoscalinglistener_controller.go:459
- A Secret's
immutableflag cannot be unset after it becomes true. Setting it to nil guarantees this patch is rejected for an immutable config Secret, leaving reconciliation stuck; preserve it when data is unchanged, or delete and recreate the Secret when the config changes.
if listenerConfigSecret.Immutable != nil {
original.Do()
listenerConfigSecret.Immutable = nil
}
The merge-base changed after approval.
fdb0651 to
a143937
Compare
c73a1a0 to
bf86a32
Compare
bf86a32 to
7cc4c1d
Compare
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The implementation is coherent and tested; the identified documentation correction is non-blocking.
Review tier: Balanced
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
controllers/actions.github.com/lazycopy.go — Do not claim mutation ordering is enforced |
Pre-existing issues (2)
| Severity | Finding |
|---|---|
controllers/actions.github.com/autoscalinglistener_controller.go — A Secret's immutable flag cannot be unset after it becomes true. For an immutable live proxy… View comment |
|
controllers/actions.github.com/autoscalinglistener_controller.go — RoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the… View comment |
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The implementation is consistent and tested; only a non-blocking documentation correction remains.
Review tier: Balanced
Findings: None
Issues resolved since last review (3)
| Severity | Finding |
|---|---|
controllers/actions.github.com/lazycopy.go — Do not claim mutation ordering is enforced View resolved comment |
|
controllers/actions.github.com/autoscalinglistener_controller.go — RoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the… View resolved comment |
|
controllers/actions.github.com/autoscalinglistener_controller.go — A Secret's immutable flag cannot be unset after it becomes true. For an immutable live proxy… View resolved comment |
The merge-base changed after approval.
953e246 to
9b4c03e
Compare
9b4c03e to
edc58ad
Compare
edc58ad to
dd808f2
Compare
Reconcilers deep copied the object they had just fetched on every single reconcile, purely so a merge patch could be computed on the rare pass that actually changes something. The copy is a full recursive walk and allocation of the object, and the overwhelming majority of reconciles throw it away untouched. Introduce lazyCopy, which takes the snapshot on the first call to Mutate and hands back the live object. Because Mutate is the only way to reach the object, the snapshot cannot be taken after the mutation it is supposed to be diffed against, which is the way this optimization is usually gotten wrong. Apply it to the four Reconcile entry points, and move the two EphemeralRunnerSet status copies inside the branch that patches, so they are only paid for when the status really changed. While here, drop the short circuit in the EphemeralRunner finalizer block: `addedFinalizers || AddFinalizer(...)` skipped adding the actions finalizer whenever the first finalizer was added. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
lazycopy.go introduces deepCopyObject, a generic type constraint rather than a collaborator, and the package's "all: true" mockery config picks it up and emits a large mock that nothing can use. Switch the package to an explicit include/exclude regex pair so every other interface is still discovered automatically while deepCopyObject is skipped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
dd808f2 to
7b42061
Compare
The doc claimed Mutate was the only way to reach the object, which made the snapshot-before-mutation ordering impossible to get wrong. That is not true: the caller keeps the pointer it passed to newLazyCopy, and the worked example itself goes on using that pointer to read the object and to address the patch. Nothing stops a caller from writing through it. A write that lands before the first Mutate is already present in the snapshot, so the merge patch computed against that snapshot is empty and the write is silently dropped instead of being sent to the API server. The type cannot prevent that, so state the ordering as an invariant the caller has to uphold and add a test pinning the failure mode, rather than promising a guarantee lazyCopy does not provide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The monotonicity guard used to write the advance directly into the fetched object, so the classifier below, which reads the applied revision from that same object, saw the advanced value. Building the desired status as a value copy redirected the write without moving the read: the classifier line is textually unchanged and now observes the pre-advance revision instead. The revisions only differ when the guard fires, and that is exactly when this matters. A runner left over from the superseded revision, missed by the cleanup because that list is read through the cache, has a revision equal to the pre-advance marker. Judged against it the runner counts as current rather than stale, so it drives the set to Outdated, and that phase is then saved alongside the freshly advanced revision. Nothing recovers from that state. Reconcile returns on the Outdated path before reaching updateStatus, and this function only runs while spec is ahead of applied, so the phase is never recomputed and the set stays switched off until the next spec change. Read the revision from the desired status so the classifier sees the value the patch is about to persist, and add a test for the case where the guard fires. The existing stale-target test covers the opposite case, where the target is behind the live marker: the guard does not fire there, both revisions agree, and the test cannot observe this. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The classifier bug fixed in the previous commit was possible because the function kept the applied revision in two places. The desired status was a shallow value copy, the guard wrote the advance into it, and the classifier read the field back off the fetched object, so the two could disagree. Naming the right one is a fix; not having two is the property worth having. Mutate returns the live object, so routing the writes through the lazy copy leaves exactly one place the applied revision lives and every read below the guard observes it. That the classifier must run after the guard is now the only ordering this depends on, and it is the kind a reader can see. The value copy was also shallow. All three status fields are scalars today, so nothing aliased, but a pointer field added later would have been shared between the copy and the object it was meant to be compared against, and the mistake would look exactly like the one just fixed. The lazy copy deep copies, and only when a mutation actually happens, so the reason the value copy existed at all is preserved. Guarding each write on the value changing keeps the patch conditional: an unmodified lazy copy means the status already says what this call wanted it to say, which is what the whole-struct comparison used to decide. That path had no coverage, so it is pinned now, along with the optimistic lock that MergeFrom forwards for this call site. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rule was written over the object, and a type with a status subresource does not have one object. updateStatus writes Status directly while Reconcile holds a lazyCopy over the same object, which reads as a violation of the rule as stated and is not one. A write cannot go missing from a patch that never carried it. The API server ignores status in the body of a merge patch to the main resource, verified against one rather than assumed: a patch body carrying status.appliedActionableRevision 999 left the stored value at 7 while the metadata change in the same body was applied. updateStatus persists through its own Status().Patch, so the two surfaces are disjoint and the arrangement survives reordering. Worth stating because the alternative reason is the weaker one. Today every updateStatus call happens to sit in a return statement, so no Mutate can follow it, but that is a property of the current control flow rather than of the design. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The one-reconcile finalizer fix needs a regression test that fails when the finalizers are added across separate reconciles.
Get a fresh assessment by requesting another Copilot review.
Review tier: Balanced
Findings: 1
Open findings (1)
Resolved findings (1)
| controllerutil.AddFinalizer(runner.Mutate(), ephemeralRunnerFinalizerName) | ||
| controllerutil.AddFinalizer(runner.Mutate(), ephemeralRunnerActionsFinalizerName) |





Reconcilers read an object far more often than they change it, but every reconcile paid for a full
DeepCopyup front just to have a patch base available for the rare write.lazyCopydefers that copy until the moment something is actually about to be mutated, so only the reconciles that patch pay for it.Behaviour change worth reviewing
EphemeralRunnerpreviously added only one of its two finalizers on the reconcile where both were missing. The old code was:||short-circuits. With both finalizers missing, the firstAddFinalizeradds one and returnstrue, so the second call is never evaluated and its finalizer is not added.It is not lost permanently: the patch triggers another reconcile, where the first
AddFinalizerreturnsfalsebecause its finalizer is now present, so the second call runs and adds the remaining one. The effect is a gap of one reconcile — which is the window in which it matters, because a runner deleted whileephemeralRunnerActionsFinalizerNameis absent skips the cleanup that unregisters it from the Actions service.After this PR both are added on the first pass. This is the "ensure multiple modifications are applied" in the title, and no line of the diff announces it — the change reads as a stylistic rewrite — so it is called out here rather than left to be found later.
How it is used
Mutate()returns the live object and snapshots it on first call. Reads go through the original as before; every mutation the patch should carry goes throughMutate.MergeFromforwards controller-runtime's merge options, so a call site needingclient.MergeFromWithOptimisticLock{}can ask for it.The ordering is a caller invariant, not something the type enforces — the caller keeps the pointer it passed to
newLazyCopy. A write landing before the firstMutateis already in the snapshot, so the patch comes out empty and the write is silently dropped. That is documented on the type and pinned by a test rather than only described.Fixes found in review
Classifier read a superseded revision.
patchAppliedActionableRevisionStatusbuilt its desired status as a value copy. The monotonicity guard wrote the advance into the copy while the classifier below still read the applied revision off the fetched object — a line that is textually unchanged and therefore absent from the diff. A runner left over from the superseded revision, missed by the cache-read cleanup, was then countedOutdatedagainst the pre-advance revision and that phase saved next to the advanced marker. Nothing recomputes it:Reconcilereturns on the Outdated path beforeupdateStatus, and this function only runs while spec is ahead of applied, so the set stayed switched off until the next spec change. Caught by @salmanmkc.The split itself is gone. Keeping the revision in two places is what made the above expressible, so the function now patches through the lazy copy and the revision lives in one place. The value copy was also shallow; all three status fields are scalars today, but a pointer field added later would have been shared between the copy and the object it was meant to be compared against.
Every existing test of that function drove the guard-not-fired branch, where the two revisions agree, so all of them passed with the bug live. The added tests cover the guard firing and the no-patch-when-nothing-changed path, and both were checked against a deliberately broken build to confirm they can fail.
Based on #4575