Skip to content

Use lazy copy to patch resources to ensure multiple modifications are applied to the base resource - #4580

Merged
nikola-jokic merged 6 commits into
masterfrom
nikola-jokic/lazy-copy
Sep 14, 2026
Merged

nikola-jokic merged 6 commits into
masterfrom
nikola-jokic/lazy-copy

Conversation

@nikola-jokic

@nikola-jokic nikola-jokic commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Reconcilers read an object far more often than they change it, but every reconcile paid for a full DeepCopy up front just to have a patch base available for the rare write. lazyCopy defers 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

EphemeralRunner previously added only one of its two finalizers on the reconcile where both were missing. The old code was:

var addedFinalizers bool
addedFinalizers = addedFinalizers || controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName)
addedFinalizers = addedFinalizers || controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName)

|| short-circuits. With both finalizers missing, the first AddFinalizer adds one and returns true, 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 AddFinalizer returns false because 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 while ephemeralRunnerActionsFinalizerName is 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

runner := newLazyCopy(&ephemeralRunner)
if !controllerutil.ContainsFinalizer(&ephemeralRunner, name) {
    controllerutil.AddFinalizer(runner.Mutate(), name)
}
if runner.Modified() {
    err := r.Patch(ctx, &ephemeralRunner, runner.MergeFrom())
}

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 through Mutate. MergeFrom forwards controller-runtime's merge options, so a call site needing client.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 first Mutate is 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. patchAppliedActionableRevisionStatus built 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 counted Outdated against the pre-advance revision and that phase saved next to the advanced marker. Nothing recomputes it: Reconcile returns on the Outdated path before updateStatus, 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

@github-actions

Copy link
Copy Markdown
Contributor

Hello! Thank you for your contribution.

Please review our contribution guidelines to understand the project's testing and code conventions.

@nikola-jokic
nikola-jokic force-pushed the nikola-jokic/lazy-copy branch from f43f84b to ab1c70d Compare July 23, 2026 15:30
@nikola-jokic
nikola-jokic marked this pull request as ready for review July 23, 2026 19:44
Copilot AI review requested due to automatic review settings July 23, 2026 19:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 ResourceCache used by ResourceBuilder to reuse desired objects based on a main-object key plus dependency refs.
  • Replaces integrity-hash–based update detection with ActionableRevision/AppliedActionableRevision for EphemeralRunnerSet and ObservedGeneration for AutoscalingRunnerSet (and updates CRDs accordingly).
  • Refactors patch flows to use a lazy DeepCopy helper (once) so multiple in-place mutations can be safely applied before client.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.

Comment thread controllers/actions.github.com/autoscalingrunnerset_controller.go Outdated
Comment thread controllers/actions.github.com/autoscalinglistener_controller.go Outdated
Comment thread controllers/actions.github.com/utils.go Outdated
@nikola-jokic
nikola-jokic changed the base branch from master to nikola-jokic/remove-annotation-fingerprint September 8, 2026 07:37
@nikola-jokic
nikola-jokic force-pushed the nikola-jokic/lazy-copy branch 2 times, most recently from 7b5e00f to fdb0651 Compare September 9, 2026 20:17
@nikola-jokic
nikola-jokic requested a review from a team as a code owner September 9, 2026 20:17
@nikola-jokic
nikola-jokic changed the base branch from nikola-jokic/remove-annotation-fingerprint to nikola-jokic-revision-aware-outdated September 9, 2026 20:18
@nikola-jokic
nikola-jokic added this pull request to stack #4645 September 9, 2026 21:42
@nikola-jokic
nikola-jokic requested a balanced review from Copilot September 9, 2026 21:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity · 1 Medium severity

New issues introduced by this change (2)
Severity Finding
High severity controllers/​actions.github.com/​autoscalinglistener_controller.go — A Secret's immutable flag cannot be unset after it becomes true. For an immutable live proxy…
Medium severity controllers/​actions.github.com/​autoscalinglistener_controller.goRoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the…
Issues resolved since last review (3)
Severity Finding
Low severity 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
Low severity controllers/​actions.github.com/​autoscalinglistener_controller.gorulesModified is misleading here because this block is checking/modifying RoleRef, not RBAC… View resolved comment
Low severity 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 immutable flag 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
		}

Comment thread controllers/actions.github.com/autoscalinglistener_controller.go Outdated
Comment thread controllers/actions.github.com/autoscalinglistener_controller.go Outdated
rentziass
rentziass previously approved these changes Sep 10, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The implementation is coherent and tested; the identified documentation correction is non-blocking.

Review tier: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

New issues introduced by this change (1)
Severity Finding
Low severity controllers/​actions.github.com/​lazycopy.go — Do not claim mutation ordering is enforced
Pre-existing issues (2)
Severity Finding
High severity 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
Medium severity controllers/​actions.github.com/​autoscalinglistener_controller.goRoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the… View comment

Comment thread controllers/actions.github.com/lazycopy.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Low severity controllers/​actions.github.com/​lazycopy.go — Do not claim mutation ordering is enforced View resolved comment
Medium severity controllers/​actions.github.com/​autoscalinglistener_controller.goRoleBinding.roleRef is immutable after creation. If this comparison ever detects drift, the… View resolved comment
High severity 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

rentziass
rentziass previously approved these changes Sep 11, 2026
@nikola-jokic
nikola-jokic dismissed rentziass’s stale review September 12, 2026 21:23

The merge-base changed after approval.

Base automatically changed from nikola-jokic-revision-aware-outdated to master September 14, 2026 12:20
nikola-jokic and others added 2 commits September 14, 2026 14:20
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>
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>
Comment thread controllers/actions.github.com/ephemeralrunnerset_controller.go
nikola-jokic and others added 2 commits September 14, 2026 15:48
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>
rentziass
rentziass previously approved these changes Sep 14, 2026
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 Medium severity

Open findings (1)
Resolved findings (1)

Comment on lines +178 to +179
controllerutil.AddFinalizer(runner.Mutate(), ephemeralRunnerFinalizerName)
controllerutil.AddFinalizer(runner.Mutate(), ephemeralRunnerActionsFinalizerName)
@nikola-jokic
nikola-jokic merged commit d386789 into master Sep 14, 2026
26 checks passed
@nikola-jokic
nikola-jokic deleted the nikola-jokic/lazy-copy branch September 14, 2026 14:39
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.

4 participants