reconcile

package
v0.0.0-...-043add5 Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: MIT Imports: 18 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, tenancy).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, tenancy).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).
  • RECONCILE-BUILDER-FUNNEL-01 (PR-A7): reconcile.Loop config fields are private — no composite literal of reconcile.Loop (incl. the zero-value Loop{}) compiles outside this package, so a consumer cannot bypass the Builder's metric / leader / backoff wiring. Retires the transitional RECONCILE-LOOP-CONSTRUCTION-ALLOWLIST-01.
  • RECONCILE-GOLEAK-TESTMAIN-FUNNEL-01 (#1604): all goroutine-leak checks in the kernel/reconcile test binaries funnel through a single package-level goleak.VerifyTestMain, banning the per-test VerifyNone(IgnoreCurrent()) idiom that flakes under -race (the Trigger / watchDrain tails drain asynchronously on ctx cancel).
  • RECONCILE-TENANCY-DECLARED-01 (#1954): every Loop declares a sealed Tenancy stance (SingleTenant / TenantScoped) as the required second arg of reconcile.New — omitting it is a compile error, the zero value is a Build fail-fast, and the minter set is archtest-frozen. Forces the multi-tenant command-id obligation to be a conscious declaration (was a Soft godoc MUST before #1954). The archtest freezes only the stance VALUE SET (Medium); the sealing + required-param are type/compile Hard. See the Tenancy godoc.
  • RECONCILE-SYSTEM-IDENTITY-INSTALL-CALLER-01 (#1955): the system producer identity installer is callsite-frozen to Loop.process, and the package's principal ctx-key setter references are callsite-frozen to installSystemProducerIdentity. This is the reconcile-specific function-level backstop layered over CTXKEYS-PRINCIPAL-WRITE-CALLER-01.

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).

The lease window an elector enforces is a sealed reconcile.LeaseTTL (leasettl.go), NOT a raw time.Duration: NewLeaseTTL rejects a sub-millisecond duration so the ms-truncation defect (a sub-ms lease → a 0ms Redis PX / 0ms PG interval → instantly-expiring lease → multiple live leaders) is unrepresentable for any constructed value; the residual zero value LeaseTTL{} is rejected fail-fast by each adapter constructor (IsZero). This is a TYPE-enforced invariant (unexported ms field, compile-time sealing), NOT archtest-bound — its authoritative statement lives in the LeaseTTL godoc (INVARIANT: any NewLeaseTTL result has ms >= 1) and ADR 202605291600-661 §threat-matrix (#1953), so it is deliberately absent from the "Enforced invariants (tools/archtest)" list above.

System producer identity (#1821)

INVARIANT: every Reconcile runs under a positively installed SYSTEM PRODUCER IDENTITY. A reconcile loop is a background control loop with no request principal, so Loop.process installs a fixed identity on the reconcile ctx ahead of every Reconcile (installSystemProducerIdentity): actor/subject="system", tenant+session cleared, OVERWRITING any ambient principal. Consequence: any command/event a reconciler emits (via outbox.NewEntry, which injects the producer principal from ctx) carries a tenantless system principal by construction — its Claimer dedup key lands under the "_notenant" namespace as a code fact, never inheriting or being changed by an ambient lifecycle-ctx identity (#1808 F5). Enforcement is two-axis Hard: the installer is unexported (Go visibility — only Loop.process reaches it) and its ctxkeys writes are caller-allowlisted by CTXKEYS-PRINCIPAL-WRITE-CALLER-01, with the in-package callsite grain pinned by RECONCILE-SYSTEM-IDENTITY-INSTALL-CALLER-01. A multi-tenant reconciler must add its own tenant dimension (the system identity is deliberately tenantless); this obligation is machine-enforced at construction by the required sealed Tenancy stance on reconcile.New (RECONCILE-TENANCY-DECLARED-01, #1954) — see the ADR.

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 ref: docs/architecture/202606101200-1821-adr-reconcile-system-producer-identity.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, tenancy).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).
  • tenancy must be a declared stance (Tenancy.IsUnset rejected): a reconciler emits under a tenantless system principal, so it must consciously declare whether "_notenant" Claimer keys are correct for it (#1954).
  • 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, tenancy Tenancy) *Builder

New creates a new Builder with the required Reconciler and tenancy stance. Both are required positional arguments (mirroring the clock.Clock-is-positional convention for mandatory deps): tenancy is NOT a With* option precisely so it cannot be forgotten — reconcile.New(r) does not compile. The Reconciler is validated for typed-nil and the tenancy for the unset zero value in Build().

Pass reconcile.SingleTenant() (the tenantless device/cert archetype, "_notenant" Claimer keys are correct) or reconcile.TenantScoped() (the reconciler encodes the tenant in its own command-id derivation). See the Tenancy godoc and #1954.

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).
  • Tenancy (set via New): must be a declared stance, not the unset zero value (Tenancy.IsUnset rejected) — see #1954 / the Tenancy godoc.
  • 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 + epoch key, adapters/postgres row-TTL CAS in reconcile_leases) — 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. Adapter choice matters for the epoch provenance: Redis keeps the epoch in an evictable key and is best-effort under allkeys-* eviction, while Postgres keeps the epoch in a durable row and is the strong-fencing adapter when eviction residuals are unacceptable.

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 LeaseTTL

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

LeaseTTL is a validated lease-window value — a sealed value type whose only constructor, NewLeaseTTL, rejects sub-millisecond durations so the truncation-to- zero defect is UNREPRESENTABLE for any constructed value rather than guarded at each adapter call site.

The lease window reaches the Redis PX / Postgres `interval '1 millisecond'` lease commands as an integer-millisecond quantity (Milliseconds). A time.Duration finer than that wire unit truncates to 0 there, yielding a lease that expires the instant it is written — so a follower acquires immediately and multiple replicas run as leader at once, breaking the cross-replica mutual exclusion the monotonic LeaseToken.Epoch fencing relies on. NewLeaseTTL fail-closes that window at the boundary; no elector can be built with a sub-millisecond lease.

Sealed construction: the unexported ms field means no package outside reconcile can mint a non-zero LeaseTTL via a struct literal; the only escape is the zero value LeaseTTL{} (ms == 0), which IsZero reports so elector constructors reject it fail-fast — a loud forgot-to-construct error, never the silent sub-ms truncation.

INVARIANT: any LeaseTTL returned by NewLeaseTTL has ms >= 1, so Milliseconds() never truncates to 0 and Duration() is never sub-millisecond. Enforced by the unexported field (compile-time sealing) + NewLeaseTTL's guard; covered by leasettl_test.go. ref: kubernetes/client-go tools/leaderelection (LeaseDuration is a positive, usable window validated before election starts).

func NewLeaseTTL

func NewLeaseTTL(d time.Duration) (LeaseTTL, error)

NewLeaseTTL validates d as a lease window and returns its integer-millisecond form. d must be at least time.Millisecond — the wire granularity of the downstream Redis PX / Postgres interval lease commands; a sub-millisecond d is rejected fail-closed because Milliseconds() would truncate it to 0 and produce an instantly-expiring lease (multiple live leaders). A supra-millisecond d is floored to millisecond granularity, the honest resolution of the wire commands.

func (LeaseTTL) Duration

func (t LeaseTTL) Duration() time.Duration

Duration returns the lease window as a time.Duration (used to stamp LeaseToken.ExpiresAt). At millisecond granularity; always >= 1ms for a constructed value.

func (LeaseTTL) IsZero

func (t LeaseTTL) IsZero() bool

IsZero reports whether this is the unconstructed zero value (ms == 0). A non-zero LeaseTTL can only come from NewLeaseTTL, which guarantees ms >= 1; elector constructors reject the zero value fail-fast.

func (LeaseTTL) Milliseconds

func (t LeaseTTL) Milliseconds() int64

Milliseconds returns the lease window in integer milliseconds for the Redis PX / Postgres interval argument. Always >= 1 for a LeaseTTL built by NewLeaseTTL.

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, tenancy).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 Tenancy

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

Tenancy is a reconciler's declared tenant stance — a sealed, closed-set value a reconcile Loop MUST declare at construction (the required second argument to reconcile.New). It exists so "didn't think about the tenant dimension of the emitted commands' Claimer dedup key" is unrepresentable at construction (#1954).

Why a required declaration

A reconcile Loop runs under a positively-installed, TENANTLESS system producer identity (installSystemProducerIdentity): actor/subject="system", tenant cleared. So every command a reconciler emits lands under the "_notenant" Claimer-key namespace BY CONSTRUCTION (idemkey.DeriveCommandKey with an empty tenant). That is correct for a single-tenant archetype where entity ids are globally unique (e.g. device UUIDs), but a multi-tenant reconciler MUST encode the tenant in its OWN command-id derivation — otherwise two tenants sharing an entity id collide on one "_notenant" key and one tenant's work silently suppresses the other's. Before #1954 that was a pure godoc MUST (Soft, forbidden by ai-robust.md §分级). Making Tenancy a required positional parameter turns the silent omission into a conscious, type-checked, auditable choice.

Sealed construction (closed value set)

The single field `mode` is unexported and the only minters are SingleTenant() and TenantScoped(). Outside this package there is NO way to construct a NON-ZERO Tenancy: reconcile.Tenancy{mode: …} is a compile error (unexported field). The zero value reconcile.Tenancy{} IS constructible (mode == tenancyUnset) but is invalid — IsUnset() reports it and Build() rejects it fail-fast (the same accepted residual as authz.Permission{} / reconcile.LeaseTTL{}). The two stances are exposed as ACCESSOR FUNCTIONS (not exported vars) that construct the value INLINE: no package-level Tenancy var exists to reassign (in OR out of package), and a func declaration cannot be reassigned, so the value is immutable everywhere (an exported var would leave the F6 gap that authz.SingleTenant = Tenancy{} could zero). The frozen membership of the minter set is the external witness RECONCILE-TENANCY-DECLARED-01 (tools/archtest).

AI-robust rating (four axes; full statement in the archtest godoc)

  • Forging a non-zero Tenancy → type-system HARD (sealed construction).
  • Omitting the stance → compile HARD (positional param: reconcile.New(r) does not compile).
  • Passing the zero Tenancy{} → runtime fail-fast (Build rejects it).
  • The minter VALUE SET → MEDIUM (RECONCILE-TENANCY-DECLARED-01 archtest freeze).

Residual blind spot: the guard forces a declaration but cannot verify a TenantScoped reconciler's body actually encodes the tenant, nor cross-check a SingleTenant declaration against an actual multi-tenant cell (no cell-level tenancy signal exists — multi-tenancy is a runtime ctx property). See the archtest godoc and ADR 202606101200-1821.

func SingleTenant

func SingleTenant() Tenancy

SingleTenant declares that the reconciler emits under the tenantless system principal and that "_notenant" Claimer keys are correct for it — its entity ids are globally unique (e.g. device UUIDs) and its tables are not tenant-partitioned. This is the device / cert-renewal archetype.

It is an accessor function (not an exported var) so the value is immutable to external packages — reassigning a func is a compile error.

func TenantScoped

func TenantScoped() Tenancy

TenantScoped declares that the reconciler reconciles entities across multiple tenants. The framework installs a TENANTLESS system identity and strips any ambient ctx tenant, so a tenant-scoped reconciler MUST encode the tenant in its OWN command-id / store derivation — it cannot rely on an ambient ctx tenant to color the principal tenant. Otherwise two tenants sharing an entity id collide on one "_notenant" Claimer key and one tenant's renewal suppresses the other's.

This is an acknowledgement stance: Build() accepts it, but the framework provides no per-tenant driving (there is no tenant-enumeration API) — the consumer owns the tenant dimension end to end. The framework cannot verify the body actually does so (the documented residual blind spot of RECONCILE-TENANCY-DECLARED-01).

It is an accessor function (not an exported var) so the value is immutable to external packages — reassigning a func is a compile error.

func (Tenancy) IsUnset

func (t Tenancy) IsUnset() bool

IsUnset reports whether t is the invalid zero value (no minter ran). Build() rejects an unset Tenancy fail-fast so a reconciler cannot be constructed without a conscious tenancy stance.

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