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 ¶
- Variables
- func IsPermanent(err error) bool
- func PermanentError(err error) error
- type Builder
- func (b *Builder) Build() (*Loop, error)
- func (b *Builder) WithBackoff(base, max time.Duration) *Builder
- func (b *Builder) WithConcurrency(n int) *Builder
- func (b *Builder) WithFencedRepo(repo FencedRepository) *Builder
- func (b *Builder) WithInterval(d time.Duration) *Builder
- func (b *Builder) WithLeader(leader LeaderElector) *Builder
- func (b *Builder) WithMetrics(m Metrics) *Builder
- func (b *Builder) WithName(name string) *Builder
- func (b *Builder) WithReconcilerID(id string) *Builder
- func (b *Builder) WithRenewInterval(d time.Duration) *Builder
- func (b *Builder) WithTrigger(trigger Trigger) *Builder
- func (b *Builder) WithoutDefaultRequeue() *Builder
- type FencedRepository
- type FencedWriter
- type LeaderElector
- type LeaseToken
- type Loop
- type Metrics
- type Reconciler
- type Request
- type Result
- type Trigger
Constants ¶
This section is empty.
Variables ¶
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.
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).
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.
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 ¶
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 ¶
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 ¶
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 ¶
WithBackoff sets the initial (base) and maximum backoff delays for transient errors. Both default to library constants if not set.
func (*Builder) WithConcurrency ¶
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 ¶
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 ¶
WithMetrics sets pre-registered metric instruments.
func (*Builder) WithName ¶
WithName sets the loop name used in log fields. Defaults to "reconcile.loop".
func (*Builder) WithReconcilerID ¶
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 ¶
WithRenewInterval sets the lease renew cadence override (Leader != nil). Zero → derived as (ExpiresAt-AcquiredAt)/3 from the acquired token.
func (*Builder) WithTrigger ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
Source Files
¶
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. |