reconcile

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package reconcile is GoCell's L4 desired-state convergence harness, a level-triggered control loop modeled on Kubernetes controller-runtime's Reconciler. It is delivered across stacked PRs: PR-A2 landed the minimal core documented below (the Reconciler interface, Request / Result, and the PermanentError classifier); PR-A3 landed the scheduling Loop, which is present from that commit on; PR-A4 landed the Trigger source abstraction (TickerTrigger / ChannelTrigger). Comments here describe how each type is consumed by the Loop to pin its contract. PR-A5 (#1166) refined scheduling: it added the shared heap-based delaying queue (F6, one waitingLoop goroutine) + dirty/processing dedup (F5, coalescing in-flight triggers into a single re-run) + per-entity exponential backoff (5ms..1000s, no jitter). PR-A7 (#1168) privatized the Loop constructor behind a Builder DSL: reconcile.New(r).With*().Build() is the sole public construction entry; all Loop config fields are unexported (Hard upstream funnel); a Trigger is required by Build; defaulting runs through a single applyDefaults() funnel at Start with no lazy getter methods. The Builder is a pure wiring layer: it injects config and validates required deps and introduces NO scheduling logic (all scheduling lives in Loop).

When to use

reconcile is for L4 DeviceLatent convergence — periodically observing a set of non-terminal entities a cell OWNS (device commands, certificate rows, trust scores in the cell's own persistence) and driving each toward its desired state. It is deliberately NOT a business orchestrator (that is saga, #969) and NOT a CQRS read-model builder (that is the projection harness, #1079). See the design ADR for the boundary.

The convergence authority is the consuming cell's, not the framework's: the framework provides the Loop harness (PR-A3); the cell exercises write authority over its own entity table inside Reconcile. This is distinct from GoCell's own compile/CI-time self-convergence (ADR 202605041430 §3.3), which this package does not touch.

Three-piece minimal core

A consumer implements Reconciler and wires a Loop via reconcile.New(r).With*().Build(). The whole public surface a consumer must learn at PR-A2 is:

Reconciler  — Reconcile(ctx, Request) (Result, error)
Request     — { EntityID string }
Result      — { RequeueAfter time.Duration }
PermanentError / IsPermanent — non-retryable error classification

controller-runtime abstractions deliberately dropped: Informer / CRD watch, Predicate, Manager, Source.Informer (informer-bound watch), Builder.For·Owns·Watches, Result.Requeue (bool), Result.Priority. GoCell entities live in a cell-local table addressed by a single opaque EntityID — there is no namespace dimension and no K8s control plane to watch.

Trigger (PR-A4)

A Trigger is the source of reconcile work — GoCell's minimal analog of controller-runtime's source.Source, feeding Requests into the Loop's queue:

Trigger       — Start(ctx, chan<- Request) error  (non-blocking; block-don't-drop)
TickerTrigger — emits a zero-value Request{} resync pulse every interval, off
                an injected clock.Clock (deterministically testable; replaces
                the informer resync GoCell lacks)
ChannelTrigger— forwards Requests from an external <-chan (e.g. an outbox
                consumer waking specific entities)

The Loop's source field is the seam a Trigger feeds; the Builder (PR-A7) wires the Trigger's output channel into it via WithTrigger(t).Build(). The Builder's .WithTrigger(t) DSL owns production wiring — a Trigger is required by Build(), ensuring every Loop has a work source.

Reconciler implementation pattern

type certRenewer struct{ repo CertRepo }

func (r *certRenewer) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
	// A zero-value Request{} (empty EntityID) is the TickerTrigger resync
	// pulse — "re-observe everything you own". Fan out to every owned entity;
	// the targeted branch below handles each concrete EntityID. The ticker
	// re-emits the pulse each interval, so a sweep that aborts early is
	// re-driven on the next tick (level-triggered — nothing is lost).
	if req.EntityID == "" {
		ids, err := r.repo.ListPending(ctx)
		if err != nil {
			return reconcile.Result{}, err // transient: Loop backs off + retries
		}
		for _, id := range ids {
			if _, err := r.Reconcile(ctx, reconcile.Request{EntityID: id}); err != nil {
				return reconcile.Result{}, err // re-driven on the next resync pulse
			}
		}
		return reconcile.Result{}, nil
	}
	cert, err := r.repo.Get(ctx, req.EntityID)
	if err != nil {
		return reconcile.Result{}, err // transient: Loop backs off + retries
	}
	if cert.Revoked {
		// Nothing more to do, and retrying cannot help → do not retry.
		return reconcile.Result{}, reconcile.PermanentError(errors.New("cert revoked"))
	}
	if cert.ExpiresAt.After(r.now().Add(30 * 24 * time.Hour)) {
		return reconcile.Result{RequeueAfter: time.Hour}, nil // healthy: re-check later
	}
	if err := r.repo.Renew(ctx, cert); err != nil {
		return reconcile.Result{}, err
	}
	return reconcile.Result{RequeueAfter: time.Hour}, nil
}

Enforced invariants (tools/archtest)

Each invariant is tagged with the stacked PR that delivers it (A1←A2←A3 merge independently; an invariant is CI-enforced for this package only once its delivering PR is on develop):

  • RECONCILE-INTERFACE-FROZEN-01 (PR-A2): Reconciler's method set is exactly Reconcile(context.Context, Request) (Result, error).
  • RECONCILE-REQUEST-FIELDS-FROZEN-01 (PR-A2): Request's field set is exactly { EntityID string }.
  • RECONCILE-RESULT-FIELDS-FROZEN-01 (PR-A2): Result's field set is exactly { RequeueAfter time.Duration } (no Requeue bool / Priority int).
  • PROD-CLOCK-INJECTION-01 (PR-A3): the Loop's control-plane probe / requeue timers are confined to the sealed controlPlaneClock type; kernel/reconcile becomes the sanctioned control-plane host when the Loop + clock carve-out land in PR-A3 — it is NOT yet enforced for this package at the PR-A2 commit. The TickerTrigger (PR-A4) does NOT use this carve-out: its cadence is driven by an injected clock.Clock, so it makes no stdlib time.* call.
  • RECONCILE-TRIGGER-INTERFACE-FROZEN-01 (PR-A4): Trigger's method set is exactly Start(context.Context, chan<- Request) error (send-only sink).
  • RECONCILE-RESULT-LABEL-VALUES-FROZEN-01 (PR-A5): the result* const value set is frozen to {success, transient, permanent, skipped}; recovered panics map to "transient" — no 5th "panic" label.
  • RECONCILE-REQUEUE-ENQUEUE-CALLER-01 (PR-A5): every channel send in kernel/reconcile must be inside one of the three sanctioned functions (drainReadyItems / feedFromSource / enqueueDelayed).
  • RECONCILE-LEADER-INTERFACE-FROZEN-01 (PR-A6): the LeaderElector method set (AcquireLease / ReleaseLease / RenewLease) and the LeaseToken field set (incl. the monotonic Epoch uint64 fencing token) are reflect golden-locked.
  • RECONCILE-FENCED-WRITE-FUNNEL-01 (PR-A6): the epoch-bound FencedWriter is the reconciler's sole write surface — its fields + constructor are sealed (unexported) so a consumer cannot forge an arbitrary-epoch writer; the mint sites are pinned to loop.go / fenced.go.
  • RECONCILE-LEADER-IMPL-FUNNEL-01 (PR-A6): LeaderElector is implemented only in adapters/{redis,postgres} + the reconciletest fake (layering hygiene).

Leader election (PR-A6) is whole-loop: when a LeaderElector is wired only the lease holder dispatches Reconcile; a lost lease cancels the lease-scoped ctx to interrupt the in-flight Reconcile. Leader election is NOT fencing — cross-replica correctness comes from the monotonic LeaseToken.Epoch threaded into a FencedWriter (write-path CAS) plus consumer idempotency, never the lease. A nil LeaderElector is single-process mode (always leader, Epoch 0, no fencing).

ref: kubernetes-sigs/controller-runtime pkg/reconcile/reconcile.go ref: kubernetes/client-go tools/leaderelection/leaderelection.go ref: docs/architecture/202605291600-661-adr-kernel-reconcile-design.md

Index

Constants

This section is empty.

Variables

View Source
var ErrFencedWriteStale = errcode.New(errcode.KindConflict, errcode.ErrFencedWriteStale,
	"reconcile: fenced write rejected (stale epoch)")

ErrFencedWriteStale is the sentinel Write returns (errors.Is-matchable) when the consumer's ApplyFenced rejected the write as stale-epoch.

View Source
var ErrFencedWriterUnbound = errcode.New(errcode.KindInternal, errcode.ErrFencedWriterUnbound,
	"reconcile: FencedWriter has no bound repository (not minted by the Loop)")

ErrFencedWriterUnbound is the sentinel Write returns when invoked on a zero-valued FencedWriter (no bound repository — programmer error).

View Source
var ErrLeaseHeld = errcode.New(errcode.KindConflict, errcode.ErrReconcileLeaseHeld,
	"reconcile: lease held by another holder")

ErrLeaseHeld is the sentinel AcquireLease returns (errors.Is-matchable) when another holder owns a live lease (contention). The Loop logs it at Debug — it is the expected steady-state signal a follower polls into — and retries after leaderRetryPeriod. Adapters and the fake elector MUST wrap their contention path with this sentinel so logLeaderAcquireSkip can classify it.

View Source
var ErrReconcileLeaseLost = errcode.New(errcode.KindConflict, errcode.ErrReconcileLeaseLost,
	"reconcile: lease lost (no longer held by this holder)")

ErrReconcileLeaseLost is the sentinel RenewLease returns (errors.Is-matchable) when the lease is no longer owned by this holder. It drives the Loop's lost-lease ctx cancel. KindConflict so an accidental HTTP surface is a 409 rather than a misleading 500.

Functions

func IsPermanent

func IsPermanent(err error) bool

IsPermanent reports whether err, or any error it structurally wraps, is a genuinely-constructed permanentError. It walks the unwrap tree itself — single (Unwrap() error) and multi (Unwrap() []error, e.g. errors.Join) — so it sees through fmt.Errorf %w chains.

It deliberately does NOT use errors.As. errors.As honors a foreign error's As(any) bool hook, which a foreign error can use (directly, or via reflect to set the unexported target) to report permanent without passing through PermanentError; this walk never consults that hook. Matching the *permanentError type is necessary but not sufficient — reflect can forge the unexported type and inject it via a foreign Unwrap — so the err != nil check is the construction proof (see permanentError's godoc).

func PermanentError

func PermanentError(err error) error

PermanentError wraps err to mark the entity non-retryable: retrying cannot change the outcome (revoked cert, malformed row, policy rejection). The Loop records a dead-letter metric and stops scheduling the entity until a fresh trigger re-observes it. PermanentError(nil) returns nil (no spurious wrapper) — this nil-guard is load-bearing for IsPermanent's construction proof: a genuine permanentError always has a non-nil cause.

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder is the sole public construction entry point for a Loop. Use New(reconciler).With*(...).Build() to create a Loop.

Design: Builder fields are all unexported; Build() constructs a *Loop with private fields set from the Builder's configuration and returns it. A Loop with no configured Trigger never receives external work and cannot converge — Build() returns an error in that case.

Missing-required validation in Build:

  • reconciler must be non-nil (validation.IsNilInterface guard).
  • trigger must be provided (missing trigger → Loop never gets work).
  • a FencedRepo requires a Leader: fencing is only meaningful with a leadership epoch source; WithFencedRepo without WithLeader would silently bind Epoch 0 (no fencing), so Build rejects it.

All other runtime fail-fasts (typed-nil Leader/FencedRepo, backoff inversion, reconcilerID charset) are preserved in preStartValidate (Loop.Start) — not duplicated in Build.

There is no WithClock option by design: control-plane scheduling (timers, startup probe, lease renew) uses a sealed real-only clock (see Loop.controlPlaneClock); Reconciler implementations source their own domain clock.

There is no WithLogger option by design either: the Loop logs through slog.Default(), which every production entry point seals to the redacting process-global sink (runtime/observability/logging). A per-loop raw logger would bypass that fail-closed redaction. (So the With* set is the faithful superset of every CONSUMER-configurable field — logger and the control-plane clock are deliberately framework-owned, not Builder inputs.)

ref: kubernetes-sigs/controller-runtime pkg/builder/controller.go (TypedBuilder.Build — fields unexported, fluent With* return *Builder, terminal Build validates and constructs the controller).

func New

func New(reconciler Reconciler) *Builder

New creates a new Builder with the required Reconciler. The Reconciler is validated for typed-nil in Build().

ref: controller-runtime ControllerManagedBy — GoCell uses reconciler-first entry (no manager concept) per ADR §3.5.

func (*Builder) Build

func (b *Builder) Build() (*Loop, error)

Build validates the Builder configuration and constructs a *Loop.

Required fields:

  • Reconciler (set via New): must be non-nil (validation.IsNilInterface guard).
  • Trigger (set via WithTrigger): must be provided; a Loop with no Trigger never receives external work and cannot converge toward desired state.
  • FencedRepo requires a Leader: fencing needs a leadership epoch source, so WithFencedRepo without WithLeader is rejected (it would silently bind Epoch 0 = no fencing).

All other validations (reconcilerID charset, backoff ordering, typed-nil Leader/FencedRepo) run at Loop.Start via preStartValidate.

On success, Build creates the internal triggerCh (buffer = queueBuffer), wires trigger into loop.trigger, and sets loop.source to the read end of triggerCh. No goroutines are started; Start() begins execution.

func (*Builder) WithBackoff

func (b *Builder) WithBackoff(base, max time.Duration) *Builder

WithBackoff sets the initial (base) and maximum backoff delays for transient errors. Both default to library constants if not set.

func (*Builder) WithConcurrency

func (b *Builder) WithConcurrency(n int) *Builder

WithConcurrency sets the maximum number of concurrent Reconcile calls across distinct EntityIDs. Same-EntityID reconciles are always serial. Defaults to 1.

func (*Builder) WithFencedRepo

func (b *Builder) WithFencedRepo(repo FencedRepository) *Builder

WithFencedRepo sets the optional epoch-aware write seam for cross-replica correctness. When nil (default), no FencedWriter is injected into Reconcile.

func (*Builder) WithInterval

func (b *Builder) WithInterval(d time.Duration) *Builder

WithInterval sets the requeue delay for Result{} (RequeueAfter == 0). Defaults to 30s. Has no effect when WithoutDefaultRequeue is set (a successful Result{} then triggers no self-requeue at all).

func (*Builder) WithLeader

func (b *Builder) WithLeader(leader LeaderElector) *Builder

WithLeader sets an optional LeaderElector. When nil (default), the Loop runs in single-process mode (always leader, Epoch 0, no fencing).

func (*Builder) WithMetrics

func (b *Builder) WithMetrics(m Metrics) *Builder

WithMetrics sets pre-registered metric instruments.

func (*Builder) WithName

func (b *Builder) WithName(name string) *Builder

WithName sets the loop name used in log fields. Defaults to "reconcile.loop".

func (*Builder) WithReconcilerID

func (b *Builder) WithReconcilerID(id string) *Builder

WithReconcilerID sets the metric/log owner dimension. Must be a low-cardinality, label-safe identifier (validated by Loop.Start). Defaults to the "_runtime" sentinel.

func (*Builder) WithRenewInterval

func (b *Builder) WithRenewInterval(d time.Duration) *Builder

WithRenewInterval sets the lease renew cadence override (Leader != nil). Zero → derived as (ExpiresAt-AcquiredAt)/3 from the acquired token.

func (*Builder) WithTrigger

func (b *Builder) WithTrigger(trigger Trigger) *Builder

WithTrigger sets the Trigger that feeds Requests into the Loop's queue. Trigger is required; Build() returns an error if it is not set.

func (*Builder) WithoutDefaultRequeue

func (b *Builder) WithoutDefaultRequeue() *Builder

WithoutDefaultRequeue makes a successful Reconcile that returns the zero Result{} (RequeueAfter == 0) NOT self-requeue at the default tick — the Loop's Trigger becomes the sole driver of re-observation. This is the controller-runtime Result{} semantic (Result{} + nil err = "done, wait for the next source event"), opted into per Loop.

Use it for a reconciler whose Trigger is already periodic (e.g. a TickerTrigger-driven bulk sweeper): the default-tick self-requeue and the ticker pulse are two independent periodic sources of the same work — the ticker pulse arrives via the Loop's source channel (feedFromSource → work queue) while the success self-requeue lands in the delaying-queue heap, so they do NOT coalesce, and steady state runs ~2 sweeps per cycle. Opting out of the default-tick self-requeue leaves the ticker as the single source.

Scope: this affects ONLY the success + RequeueAfter==0 branch. An explicit RequeueAfter > 0 is still honored, and transient/permanent error handling is unchanged (transient → backoff requeue, permanent → cancel) — a failed sweep still retries on backoff rather than waiting for the next Trigger pulse. A coalesced dirty re-run (a Trigger pulse that arrived mid-reconcile) is also unaffected: it is a real Trigger signal, not a periodic self-requeue.

Default (option unset): Result{} re-observes at the Loop's interval (WithInterval / 30s) — GoCell's deliberate divergence for Trigger-less or event-only (ChannelTrigger) reconcilers that rely on the default tick for periodic re-check. See ADR 202605291600-661 §"Result 语义" and kubernetes-sigs/controller-runtime pkg/reconcile/reconcile.go (Result).

type FencedRepository

type FencedRepository interface {
	ApplyFenced(ctx context.Context, entityID string, epoch uint64, mutation any) (accepted bool, err error)
}

FencedRepository is the consumer-implemented, epoch-aware persistence seam that closes the cross-replica correctness gap leader election alone cannot (see LeaderElector — "leader election is NOT fencing"). A consumer that performs device writes / command emission from inside Reconcile wires its repository as a FencedRepository on the Loop; the Loop then injects a per-Reconcile epoch-bound FencedWriter into ctx, and that writer is the reconciler's ONLY write surface.

ApplyFenced MUST implement a monotonic-epoch compare-and-swap: the resource row records the highest epoch it has ever seen, and the write is applied only when epoch >= row.last_epoch (and last_epoch advanced to epoch). It returns accepted=false (NOT an error) when epoch is stale (< highest-seen) so a zombie leader's late write is rejected structurally rather than corrupting state. This is Kleppmann (DDIA §8.4) monotonic fencing, NOT the kernel/outbox UUID identity-fencing (which only rejects "not equal to current"): a device write may land after several L1→L2→L3 handoffs and only "older than highest-seen" can reject an out-of-order late write.

A type-assertion failure on mutation is a programmer error (the wrong mutation type was passed to the writer). The implementation SHOULD return a non-nil error (transient or wrapped with reconcile.PermanentError to dead-letter) rather than panic, so the Loop can classify and log it correctly.

The implementation MUST forward ctx to all I/O operations. Ignoring ctx cancellation defeats the lost-lease interrupt (ADR §4.2): a lease loss cancels the ctx so the in-flight Reconcile terminates promptly; an implementation that ignores the ctx will proceed with a write after the lease is gone, which is the split-brain scenario fencing exists to prevent.

mutation is `any` deliberately. The reconciler reaches FencedWriter through a non-generic context value (Reconciler.Reconcile is frozen single-method and takes no extra param), so a generic FencedRepository[M] would have to be boxed to `any` at the ctx seam anyway — the type parameter buys nothing while making the Loop (which mints the writer without knowing M) impossible to keep monomorphic. The consumer's ApplyFenced does the single type-assert at its own write boundary. The fencing guarantee depends on the epoch being unforgeable and the CAS being applied — not on the payload being statically typed.

type FencedWriter

type FencedWriter struct {
	// contains filtered or unexported fields
}

FencedWriter is the reconciler's intended write surface for one Reconcile call. It is a STRUCT with unexported fields and an unexported constructor (newFencedWriter), so a consumer in another package can RECEIVE one (from FencedWriterFrom) but can never COMPOSE one with an attacker-chosen epoch — reconcile.FencedWriter{epoch: 999} is a compile error outside this package. The epoch a FencedWriter carries is therefore always the Loop's live-lease value; forging it is unrepresentable in Go's type system (type-system Hard).

Honest scope (PR-A6 review C3): this seal closes "forge a writer with a chosen epoch", NOT "a consumer cannot emit an unfenced write at all". A consumer's own FencedRepository.ApplyFenced(ctx, id, epoch, mut) takes epoch as a caller param, so a direct call bypasses this writer — that vector is closed DOWNSTREAM by the RECONCILE-FENCED-WRITE-FUNNEL-01 ApplyFenced caller-allowlist (archtest, not the type system). See that archtest's godoc for the full three-vector grade.

func FencedWriterFrom

func FencedWriterFrom(ctx context.Context) (FencedWriter, bool)

FencedWriterFrom returns the epoch-bound writer the Loop injected for this Reconcile call. ok is false when the Loop runs without a FencedRepository (single-process / no-fencing mode, or consumers that do not wire a FencedRepo): a reconciler that needs fenced writes MUST treat !ok as "no lease-scoped write surface" and skip the side effect (or be wired only under a FencedRepository + LeaderElector).

func (FencedWriter) Epoch

func (w FencedWriter) Epoch() uint64

Epoch returns the bound fencing token (read-only, for logging/metrics). There is no setter — the field is unexported and there is no With/SetEpoch method.

func (FencedWriter) Write

func (w FencedWriter) Write(ctx context.Context, entityID string, mutation any) error

Write applies mutation under the bound epoch's CAS via the consumer's FencedRepository. A stale-epoch rejection surfaces as ErrFencedWriteStale (the reconcile lost a fencing race; this is NOT a transient retry — a fresh lease / trigger re-observes the entity). A zero-valued (unbound) writer being written through is a programmer error (the writer was not minted by the Loop) and surfaces as ErrFencedWriterUnbound.

type LeaderElector

type LeaderElector interface {
	// AcquireLease attempts to become the leader for reconcilerID. On success it
	// returns a LeaseToken whose ExpiresAt is now+LeaseDuration and whose Epoch is
	// the monotonic fencing token (incremented on every holder CHANGE, see
	// LeaseToken.Epoch). On contention (another holder owns a live lease) it
	// returns a non-nil error; the Loop treats any acquire error as fail-closed
	// (it does NOT dispatch) and retries after a backoff.
	AcquireLease(ctx context.Context, reconcilerID string) (LeaseToken, error)
	// ReleaseLease relinquishes the lease for a graceful handoff (the next
	// AcquireLease by any holder takes over immediately rather than waiting for
	// TTL expiry). Idempotent; a release of an already-lost lease is not an error.
	ReleaseLease(ctx context.Context, token LeaseToken) error
	// RenewLease extends the lease's TTL while keeping its Epoch unchanged. It
	// returns ErrReconcileLeaseLost (errors.Is-matchable) when the lease is no
	// longer owned by this holder (expired or taken over) — the Loop cancels its
	// lease-scoped ctx the instant this happens to interrupt the in-flight
	// Reconcile. Other (I/O) errors are transient and also drive a re-acquire.
	RenewLease(ctx context.Context, token LeaseToken) error
}

LeaderElector is the leader-election seam a multi-replica reconcile deployment wires into a Loop. The interface is declared in kernel/reconcile and implemented in adapters (adapters/redis SETNX+EXPIRE, adapters/postgres pg_try_advisory_lock) — the kernel-declares / adapter-implements layering that mirrors outbox.Emitter and persistence.CellTxManager.

CRITICAL — leader election is NOT fencing. client-go's own tools/leaderelection documents: "This implementation does not guarantee that only one client is acting as a leader (a.k.a. fencing)." STW GC pauses, clock skew, and renew/acquire races all leave a residual dual-execution window: an old leader paused mid-Reconcile, its lease expired and taken over, then it wakes and finishes its write. The Loop uses the lease only to NARROW that window (single dispatcher in steady state + lost-lease ctx cancel). Cross-replica correctness is the job of the monotonic LeaseToken.Epoch fed into a FencedWriter (see fenced.go) plus consumer idempotency — never the lease.

A nil LeaderElector on a Loop means single-process mode: the Loop is always the leader, Epoch is 0, and no fencing is applied (the single-replica deployment has no zombie-leader window to fence).

INVARIANT: RECONCILE-LEADER-INTERFACE-FROZEN-01 — the method set is frozen to exactly AcquireLease / ReleaseLease / RenewLease with the signatures below, and LeaseToken's field set is frozen (in particular the monotonic Epoch uint64). A reflect golden assertion in CI rejects any added method or changed field; it is a public-contract change that must be made together with the design ADR.

ref: kubernetes/client-go tools/leaderelection/leaderelection.go (lease/renew model + the explicit "not fencing" disclaimer).

type LeaseToken

type LeaseToken struct {
	// ReconcilerID is the leader-election key (one lease per reconciler identity).
	ReconcilerID string
	// HolderID identifies the replica that holds this lease — minted once per
	// elector instance so two replicas competing for the same ReconcilerID are
	// distinguishable (renew ownership CAS, handoff epoch bump).
	HolderID string
	// Epoch is the monotonic fencing token. The lease store increments it on
	// every holder CHANGE (a successful AcquireLease that takes the lease from
	// free/expired/another-holder) and keeps it UNCHANGED across RenewLease. The
	// Loop binds it to a FencedWriter so a zombie leader's late write — carrying a
	// strictly lower Epoch — is rejected by the resource's write-path CAS. This is
	// monotonic (Kleppmann) fencing: reject ALL epoch < highest-seen, not the
	// outbox UUID identity-fencing (equal-or-reject), because a device write may
	// arrive after several L1→L2→L3 handoffs and only "older than highest" — not
	// "not equal to current" — can reject an out-of-order late write.
	Epoch uint64
	// AcquiredAt is when this holder acquired (or re-acquired) the lease.
	AcquiredAt time.Time
	// ExpiresAt is when the lease lapses absent a successful RenewLease.
	ExpiresAt time.Time
}

LeaseToken is the opaque, value-typed proof of leadership an adapter mints and the Loop threads back into ReleaseLease / RenewLease. It is NOT sealed-construction: adapters in different packages must build it, so its fields are exported. The frozen field set is enforced by RECONCILE-LEADER-INTERFACE-FROZEN-01.

type Loop

type Loop struct {
	// contains filtered or unexported fields
}

Loop is the reconcile scheduling environment: it pulls Requests from a source, dispatches each to the Reconciler across a bounded worker pool (serializing per EntityID), and requeues per the returned Result. Its lifecycle skeleton (Start fast-return + startup probe, owner-ctx derivation, graceful Stop) is the scheduling loop's own control shell; the per-entity worker dispatch and requeue are reconcile-specific.

Construct via the Builder (reconcile.New(r).With*().Build()) in production. All configuration fields are unexported; the Builder is the sole public construction entry point. Loop config fields are private so that &reconcile.Loop{Field: ...} from outside the package is a compile error — the funnel is enforced by the type system (Hard upstream).

Defaulting is handled by a single applyDefaults() called once at the end of preStartValidate(), before the metric preflight, so start() sees all config fields at their final values.

Owner ctx (controller-runtime Runnable.Start semantics): Start receives the long-lived owner ctx and derives the workers' runCtx from it, so assembly shutdown (ownerCancel) drains the Loop even without an explicit Stop.

Start must return promptly (spawn pool + fast probe, then return) — it is usable directly as a cell.LifecycleHook.OnStart; Stop as OnStop.

ref: kubernetes-sigs/controller-runtime pkg/builder/controller.go

func (*Loop) Start

func (l *Loop) Start(ownerCtx context.Context) error

Start launches the worker pool and returns once it is confirmed running.

All stdlib time.* calls are funneled through the sealed controlPlaneClock.

func (*Loop) Stop

func (l *Loop) Stop(ctx context.Context) error

Stop cancels the Loop and waits for all goroutines (workers, feedFromSource, waitingLoop) to exit within ctx's budget.

State (l.cancel / l.done) is cleared by watchDrain ONLY after the goroutines actually drain. Until then the fields stay set so that (a) a concurrent Start remains a no-op — it never spawns a second pool racing the one still unwinding — and (b) a Stop that exhausts ctx's budget can simply be called again to keep waiting. cancel() is idempotent, so a retried Stop re-selects on the same done channel without harm. The leader gauge is reset to 0 by watchDrain (sequenced before close(done)), so by the time this Stop observes <-done the gauge is already 0. A timed-out Stop is "not yet stopped", so leader stays 1 until a later Stop (or owner-ctx cancel) drains it.

type Metrics

type Metrics struct {
	// Total counts reconcile outcomes, labels {reconciler, result}.
	Total kernelmetrics.CounterVec
	// Duration observes reconcile wall-clock seconds, labels {reconciler}.
	Duration kernelmetrics.HistogramVec
	// InFlight tracks concurrently-running reconciles, labels {reconciler}.
	InFlight kernelmetrics.GaugeVec
	// Leader is 1 when this instance owns the reconcile lease, else 0,
	// labels {reconciler}. In the single-process Loop it is set to 1 at Start;
	// real lease gating is the leader-election PR's concern.
	Leader kernelmetrics.GaugeVec
}

Metrics holds the optional pre-bound instruments the Loop records to. A nil field disables that instrument (the Loop nil-checks before every record). Build via RegisterMetrics at the composition root and inject; the Loop preflights label sets at Start (same construction-time label preflight pattern).

func RegisterMetrics

func RegisterMetrics(p kernelmetrics.Provider) (Metrics, error)

RegisterMetrics registers the four reconcile instruments on p with canonical names, labels, and buckets, returning them bundled. It is the single source for reconcile metric identity; consumers call it at the composition root and inject the result into a Loop (or the Builder does so).

type Reconciler

type Reconciler interface {
	Reconcile(ctx context.Context, req Request) (Result, error)
}

Reconciler is the single method a consumer implements to converge ONE entity toward its desired state. The Loop calls it once per scheduled Request.

Contract:

  • Return (Result{RequeueAfter: d>0}, nil) to re-observe this entity after d.
  • Return (Result{}, nil) (RequeueAfter == 0) to re-observe at the default tick interval.
  • Return (Result{}, err) with a transient err to have the Loop back off and retry (Result is ignored when err != nil).
  • Return (Result{}, PermanentError(err)) when retrying cannot help; the Loop records a dead-letter metric and stops scheduling this entity until a fresh trigger re-observes it.

Idempotency contract (P1): Reconcile MUST be idempotent. The framework may invoke it multiple times for the same entity without an intervening state change: on periodic resync (Interval tick), on F5 dirty re-run after coalesced duplicate triggers, and on transient-error retry. This is the level-triggered convergence contract — the reconciler observes current state and drives toward desired state, regardless of how many times it is called.

Reconcile MUST honor ctx cancellation (the Loop cancels ctx on shutdown / StopTimeout).

INVARIANT: RECONCILE-INTERFACE-FROZEN-01 — the method set is frozen to exactly Reconcile(context.Context, Request) (Result, error). Adding a method, or changing this signature, trips an exact-set reflect assertion in CI; it is a public-contract change that must be made together with the design ADR.

ref: kubernetes-sigs/controller-runtime pkg/reconcile/reconcile.go (Reconciler)

type Request

type Request struct {
	// EntityID identifies the single entity to reconcile. It is opaque to the
	// Loop and meaningful only to the Reconciler (typically a primary key in the
	// cell's own table).
	//
	// An empty EntityID is the "resync-all" sentinel emitted by TickerTrigger
	// (a context-free interval ticker has no entity to name): a Reconciler wired
	// to a TickerTrigger MUST treat req.EntityID == "" as a "re-observe every
	// entity you own" sweep, fanning out to its own entity set.
	//
	// Bounded-set contract (S1): EntityID MUST come from a bounded set (e.g.
	// primary keys of a cell-local table). The Loop retains a small per-entity
	// backoff counter in memory for every entity that has experienced at least one
	// transient error; the counter is released on success, permanent failure, or
	// Loop restart (mirroring controller-runtime workqueue). An unbounded or
	// high-cardinality EntityID space (e.g. user input strings) would grow this
	// in-memory map without bound. The dirty/processing maps obey the same
	// constraint: they hold at most one entry per in-flight entity and are cleared
	// on completion or Loop restart.
	EntityID string
}

Request is the minimal locator the Loop hands a Reconciler. Unlike controller-runtime's reconcile.Request (which carries a types.NamespacedName), a GoCell entity lives in a single cell-local table and is addressed by one opaque EntityID — there is no namespace dimension.

INVARIANT: RECONCILE-REQUEST-FIELDS-FROZEN-01 — the field set is frozen to exactly { EntityID string }. Adding a field (e.g. a Namespace or Priority) trips an exact-set reflect assertion in CI.

type Result

type Result struct {
	// RequeueAfter, when > 0, asks the Loop to re-observe this entity after the
	// given duration. When 0, the entity is re-observed at the Loop's default
	// tick interval. It is ignored when Reconcile also returns a non-nil error
	// (the error path drives backoff instead).
	RequeueAfter time.Duration
}

Result is the scheduling hint a Reconciler returns to the Loop. Unlike controller-runtime's reconcile.Result { Requeue bool; RequeueAfter Duration }, GoCell carries only RequeueAfter: "requeue immediately / at the default tick" is expressed as RequeueAfter == 0, so the Requeue bool is redundant and dropped. There is no Priority dimension.

INVARIANT: RECONCILE-RESULT-FIELDS-FROZEN-01 — the field set is frozen to exactly { RequeueAfter time.Duration }. Re-introducing Requeue bool or a Priority int (controller-runtime / K8s-scheduler residue) trips an exact-set reflect assertion in CI.

type Trigger

type Trigger interface {
	Start(ctx context.Context, queue chan<- Request) error
}

Trigger is the source of reconcile work: Start launches a producer that feeds Requests into the Loop's work queue until ctx is canceled. It is GoCell's minimal analog of controller-runtime's source.Source — the K8s informer / predicate machinery is dropped (GoCell has no control plane to watch), so the sink is a plain chan<- Request rather than a rate-limiting workqueue (deduplication / backoff live in the Loop, not the Trigger).

Contract:

  • Start MUST be non-blocking: spawn the producer goroutine and return. It is invoked once by the Loop (or the Builder, PR-A7) with the Loop's internal queue. (Mirrors source.Source's "Start must be non-blocking".)
  • The producer MUST honor ctx: exit promptly on ctx.Done(), and never block a queue send past cancellation (no goroutine leak on shutdown).
  • The producer forwards with backpressure — it blocks on a full queue rather than dropping (level-triggered: a coalesced signal is re-observed on the next trigger / requeue, never silently lost).
  • Start's error return is for genuine runtime startup failures. The two minimal triggers below validate their configuration at CONSTRUCTION (a misconfigured trigger is a programmer error, surfaced via panic — the clock funnel + panic taxonomy convention), so their Start never fails; the return is kept for source.Source parity and future triggers (e.g. one that dials a broker) that can fail at startup.

The Loop's internal WaitGroup does NOT track the producer goroutine started from a Trigger; it is solely the Trigger's responsibility to exit on ctx.Done(). A Trigger that leaks a goroutine after ctx cancellation cannot be detected by Loop.Stop's drain. The two built-in triggers (TickerTrigger, ChannelTrigger) and the reconciletest.FakeTrigger comply with this contract.

INVARIANT: RECONCILE-TRIGGER-INTERFACE-FROZEN-01 — the method set is frozen to exactly Start(context.Context, chan<- Request) error.

ref: kubernetes-sigs/controller-runtime pkg/source/source.go

func ChannelTrigger

func ChannelTrigger(in <-chan Request) Trigger

ChannelTrigger returns a Trigger that forwards every Request from in into the work queue (block-don't-drop). A nil source channel would block forever and is a programmer error: it panics at construction. When in is closed, the forwarding goroutine exits cleanly — the Loop keeps running but receives no further Requests from this Trigger (the source is responsible for its own lifecycle, mirroring Loop.feedFromSource over a closed Source).

func TickerTrigger

func TickerTrigger(clk clock.Clock, interval time.Duration) Trigger

TickerTrigger returns a Trigger that emits a zero-value Request every interval, off the injected clock so the cadence is deterministically testable (advance a fake clock) rather than wall-clock-bound. clk and interval are required: a nil clock or non-positive interval is a programmer error and panics at construction (clock.MustHaveClock / clock.MustHavePositiveInterval) — clock is a mandatory positional dependency per CLOCK-POSITIONAL-INJECTION-01, not a WithClock option.

Directories

Path Synopsis
Package reconciletest provides public test-support fakes and conformance suites for kernel/reconcile consumers and LeaderElector adapters.
Package reconciletest provides public test-support fakes and conformance suites for kernel/reconcile consumers and LeaderElector adapters.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL