mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-10-21 11:44:39 +02:00
Use lazy copy to patch resources to ensure multiple modifications are applied to the base resource (#4580)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot App
parent
6e4d85f28f
commit
d386789092
@@ -0,0 +1,98 @@
|
||||
package actionsgithubcom
|
||||
|
||||
import "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
// deepCopyObject is satisfied by every generated API type pointer, as well as
|
||||
// by the built-in Kubernetes types.
|
||||
type deepCopyObject[T any] interface {
|
||||
client.Object
|
||||
DeepCopy() T
|
||||
}
|
||||
|
||||
// lazyCopy defers the DeepCopy of a fetched object until the moment it is
|
||||
// actually about to be mutated.
|
||||
//
|
||||
// Reconcilers observe an object far more often than they change it, so taking
|
||||
// the snapshot up front means paying for a full deep copy on every reconcile
|
||||
// just to serve the rare patch. lazyCopy pays for it only on the reconciles
|
||||
// that patch.
|
||||
//
|
||||
// The snapshot must be taken before the first mutation, otherwise the merge
|
||||
// patch is computed against the already mutated object and comes out empty.
|
||||
// Routing a mutation through Mutate is what guarantees that ordering:
|
||||
//
|
||||
// runner := newLazyCopy(&ephemeralRunner)
|
||||
// if !controllerutil.ContainsFinalizer(&ephemeralRunner, name) {
|
||||
// controllerutil.AddFinalizer(runner.Mutate(), name)
|
||||
// }
|
||||
// if runner.Modified() {
|
||||
// err := r.Patch(ctx, &ephemeralRunner, runner.MergeFrom())
|
||||
// }
|
||||
//
|
||||
// Callers must uphold that ordering themselves, because lazyCopy cannot
|
||||
// enforce it. The caller keeps the pointer it passed to newLazyCopy, and as the
|
||||
// example shows it goes on using that pointer to read the object and to address
|
||||
// the patch. Nothing stops it from writing through it as well. A write that
|
||||
// lands before the first Mutate is already present in the snapshot, so the
|
||||
// merge patch against that snapshot is empty and the write is silently dropped
|
||||
// rather than sent to the API server.
|
||||
//
|
||||
// So the invariant is: read through the original as much as you like, but make
|
||||
// every mutation that the patch should carry go through Mutate.
|
||||
//
|
||||
// "The patch" is the qualifier that matters for a type with a status
|
||||
// subresource, because there the object is two independently patchable
|
||||
// surfaces. A write to status cannot go missing from a patch that does not
|
||||
// carry status: the API server ignores status in the body of a merge patch to
|
||||
// the main resource, so a status write made outside Mutate is neither captured
|
||||
// by nor dropped from that patch. EphemeralRunnerSetReconciler.updateStatus
|
||||
// relies on this, writing Status directly while Reconcile holds a lazyCopy over
|
||||
// the same object and persisting it through its own Status().Patch. That reads
|
||||
// like a violation of the rule above and is not one.
|
||||
//
|
||||
// The reverse is not true. A lazyCopy guarding a status patch has the same
|
||||
// exposure to metadata writes, so the rule holds surface by surface rather than
|
||||
// object by object.
|
||||
//
|
||||
// A lazyCopy is not safe for concurrent use.
|
||||
type lazyCopy[T deepCopyObject[T]] struct {
|
||||
obj T
|
||||
original T
|
||||
copied bool
|
||||
}
|
||||
|
||||
// newLazyCopy returns a lazyCopy guarding obj. No copy is taken until the
|
||||
// first call to Mutate.
|
||||
func newLazyCopy[T deepCopyObject[T]](obj T) *lazyCopy[T] {
|
||||
return &lazyCopy[T]{obj: obj}
|
||||
}
|
||||
|
||||
// Mutate snapshots the object on its first call and returns the live object so
|
||||
// the caller can modify it. Every mutation that a later patch should carry must
|
||||
// go through Mutate.
|
||||
func (l *lazyCopy[T]) Mutate() T {
|
||||
if !l.copied {
|
||||
l.original = l.obj.DeepCopy()
|
||||
l.copied = true
|
||||
}
|
||||
return l.obj
|
||||
}
|
||||
|
||||
// Modified reports whether Mutate has been called, and therefore whether there
|
||||
// is anything to patch.
|
||||
func (l *lazyCopy[T]) Modified() bool {
|
||||
return l.copied
|
||||
}
|
||||
|
||||
// MergeFrom returns a merge patch against the snapshot taken by the first
|
||||
// Mutate call, with any of controller-runtime's merge options applied, such as
|
||||
// client.MergeFromWithOptimisticLock{}. It panics when called on an unmodified
|
||||
// lazyCopy, because there is no snapshot to diff against and the caller would
|
||||
// otherwise silently issue a patch computed from the live object against
|
||||
// itself. Guard it with Modified.
|
||||
func (l *lazyCopy[T]) MergeFrom(opts ...client.MergeFromOption) client.Patch {
|
||||
if !l.copied {
|
||||
panic("lazyCopy: MergeFrom called before Mutate")
|
||||
}
|
||||
return client.MergeFromWithOptions(l.original, opts...)
|
||||
}
|
||||
Reference in New Issue
Block a user