beehive

package module
v0.31.0 Latest Latest
Warning

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

Go to latest
Published: Aug 22, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

Beehive

Beehive is an embedded, durable, self-healing control-plane for Go apps that takes inspiration from Kubernetes and the stigmergic cooperation of bees in a beehive.

beehive

Go Reference Coverage

Introduction

Beehive is an embedded control plane for Go apps, backed by a durable store. With Beehive, you define desired state as objects and register controllers that reconcile actual state toward it. The system is self-healing which means it converges on restart, tolerates missed events, and handles cascading dependencies without controllers calling each other. The architecture is heavily influenced by Kubernetes and takes inspiration from the stigmergic cooperation of bees in a beehive.

Quickstart

package main

import (
  "context"
  "log"
  "time"

  "github.com/amorey/beehive"
  "github.com/amorey/beehive/sqlite"
)

var ClusterGroupKind = beehive.GroupKind{
  Group: "kstack.sh",
  Kind:  "Cluster",
}

type ClusterSpec struct {
  // TODO: define desired state fields
}

type ClusterStatus struct {
  // TODO: define observed state fields
}

type ClusterController struct{}

func (cc *ClusterController) Reconcile(ctx context.Context, client beehive.ControllerClient[ClusterStatus], obj *beehive.Object[ClusterSpec, ClusterStatus]) beehive.ReconcileResult {
  // Handle deletion: object is finalizing when DeletionRequestedAt is set.
  // Remove any external resources, then clear the finalizer to allow the row to be deleted.
  if obj.DeletionRequestedAt != nil {
    // TODO: clean up external resources for obj.Spec
    // TODO: remove the finalizer: client.DeleteFinalizer(ctx, "kstack.sh/cluster")
    return beehive.Settled()
  }

  // TODO: reconcile obj.Spec against actual state (e.g. create/update external resources)
  // If the resource is not ready yet, say so and come back later:
  // return beehive.Unsettled().RequeueAfter(5 * time.Second)

  // TODO: update observed state
  // if err := client.UpdateStatus(ctx, ClusterStatus{}); err != nil {
  //   return beehive.Fail(err)
  // }

  return beehive.Settled()
}

func main() {
  store, _ := sqlite.Open("/path/to/beehive.db")
  defer store.Close()

  bh, _ := beehive.New(store)
  _ = beehive.Register(bh, ClusterGroupKind, &ClusterController{})

  stop, err := bh.Start(context.Background())
  if err != nil {
    log.Fatal(err)
  }

  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  defer cancel()
  // An error means ctx expired before the loops drained. They are cancelled
  // and ending, and the store's claim outlives them by exactly that long.
  if err := stop(ctx); err != nil {
    log.Printf("beehive: shutdown did not drain cleanly: %v", err)
  }
}

Architecture

  • Embedded, single-process. Beehive runs inside your app, not beside it: no server, no daemon, no network hop. In exchange, a store belongs to one process running one Beehive, which is its only writer while it runs. Restarts are supported but concurrent access from a separate process is not.

    Within a process this is enforced: a second Beehive over a database another is already running fails Start with ErrStoreInUse. The check keys on the database, not the store value, so two sqlite.Open calls on one path collide as they should. Keeping a second process off the database is yours — a single-replica deployment, a lease, a supervisor, whatever you already run. Beehive does not take a lock of its own: any lock it could take rests on fcntl, which is unreliable on exactly the network and overlay filesystems where two replicas share a volume, so it would fail silently in the case that motivates it. Writes made to the database around a running Beehive — by another process or by a tool — are unsupported and undetectable.

  • Declarative core. You write spec, the desired state. Controllers reconcile actual state toward it, working from current state rather than from a sequence of events. That is what makes the system self-healing: it converges on restart, and a missed event costs nothing because the next pass reads the same state anyway. A cold start is just a reconcile from stored desired state.

  • Coordination through the store. Controllers never call each other. They read and write the shared store, and a change reaches another controller by being found there rather than delivered to it. Nothing is pushed: every write leaves a durable trace — a bumped generation, an owed-wake count, a deletion mark, a higher resource_version — and each driver scans for the trace it cares about. So a missed tick costs latency and nothing else. The record is still there next time.

  • Almost every driver is a tick. Reconcile passes, garbage collection and client watches are all periodic scans, each on its own interval (see Drivers). Those intervals are the latency the system runs at; two of them are yours to choose. Object watches also have a commit wake in front of their tick, so a write made through this Beehive reaches them without waiting for the tick. Dependency wakes are the one driver with no tick at all: a write made through this Beehive wakes them, and a 60s pass over dependency watermarks is what covers a write that did not.

  • spec/status separation. Only controllers write status, and the API enforces it: the user-facing Client has no status-write path, only the Controller surface does.

  • Schema-version migration. Spec and Status are stored as opaque JSON, so reshaping a struct would break decoding of older rows. A per-kind Migrator converts an old blob up on read, before unmarshal. Spec and status version independently, and conversion is lazy — a row is re-stamped when it is next written, never by a bulk rewrite.

The reasoning behind each of these is recorded in docs/adr, linked from the sections below.

API

Beehive
func New(store Store, opts ...Option) (*Beehive, error)
func Register[Spec, Status any](bh *Beehive, gk GroupKind, c Controller[Spec, Status], opts ...Option) error

Register installs a controller and hands nothing back. The ControllerClient — the status-write surface — reaches you as a parameter of Reconcile and lives only for that call, which is what keeps a status write inside the pass that owns it.

Where you pass an option decides its scope. WithFullPassInterval at New sets the default for every kind; at Register it overrides that one controller. An option a given call site doesn't recognize is ignored. WithGCInterval is global and therefore only meaningful at New — garbage collection covers kinds with no controller.

Drivers

Every driver is one of these. They run on separate intervals because they are separate jobs with very different costs — a single interval would mean tuning one of them moves the rest. One of them has no interval at all: the dependency wake runs when a write commits and not otherwise.

driver what it scans cost scales with interval
owed pass work the store records as owed — unconverged specs (observed_generation < generation) and owed dependency wakes what is actually outstanding WithOwedPassInterval, default 30s
full pass every object of the kind, converged or not the object count WithFullPassInterval, default 0 (off)
individual pass one object, re-armed by its own last pass; a startup scan admits the rest nothing per tick — there is no tick WithIndividualPassInterval, default 0 (off)
GC sweep deletion-pending rows, event-log retention, then the free space those two leave behind rows being deleted WithGCInterval, default 30s
dependency wake the write log above a watermark, waking dependents of what moved what has changed since the last scan none: a commit wakes it
stale dependents dependents whose targets moved past the watermark their last pass recorded the dependency graph WithStaleDependentsInterval, default 60s
watch tail the write log of each watched kind, once per kind however many watches it has one cheap read per watched kind per tick, and a commit wakes it before the tick WithWatchFloorInterval, default 30s
event watch one object's event log above a cursor, for each live WatchEvents what the log has grown by, and a commit wakes it before the tick WithWatchFloorInterval, default 30s

Every cadence here is configurable, and only the two opt-in passes — the full pass and the individual pass — can be switched off. That is deliberate on both counts. A tick is no longer how work is found — every trigger for a registered kind pushes at commit, and both watch families read on a commit wake — so lengthening one buys a mostly-idle process a much quieter store while costing recovery time on a lost push, which is a trade an embedder is entitled to make. Turning one off is not, because each is the only thing that re-derives its own class of work. → ADR: the driver cadences are configurable

The full pass is opt-in because it is the only driver whose cost is unbounded by outstanding work. It is also the only one that reaches an object the store records nothing about: state that belongs to a process and a restart invalidated — a liveness condition that reads as "verifying" until a controller in this process rewrites it, but equally a live connection, a running worker, an open watch. Set it well above the owed pass, which it subsumes.

Both cadences are off by default — WithFullPassInterval for the periodic one, WithStartupFullPass for the once-per-process one — but for different reasons, and only one of them is a correctness position.

Nothing may depend on the periodic full pass to converge. Its cost is unbounded by outstanding work and it repeats forever, so a convergence bug it hides comes back the moment an embedder lengthens it or the object set outgrows what a sweep can carry. Work genuinely owed is recorded in a column and drained by the owed pass and the GC sweeper, both of which run at every startup no matter how these two are set.

The startup pass is different: a kind may, and sometimes must, depend on it. It runs once per process at O(objects) — the owed pass's own worst case — so it is not the unbounded driver the rule above is about. Enable it for a kind whose reconcile establishes in-process state, and for nothing else. That splits two ways:

  • Reporting state — a liveness condition that reads "verifying" until a controller in this process rewrites it. The object is converged either way; only the display is stale.
  • Load-bearing state — the reconcile opens a connection, starts a worker, holds a watch. Here the object is not converged until this process has reconciled it, and no store column can say so: observed_generation == generation was written by a process that is gone, so every store-visible measure reads settled.

For the second class the startup pass is the convergence mechanism, and what it guarantees is exactly that: every object of a kind that enables it is reconciled at least once per process. Declare it at Register, per kind, so the kinds that own in-process state say so and the rest don't pay. → ADR: the startup full pass may be depended on

The individual pass is the per-object shape of the same idea. WithIndividualPassInterval(d) gives each object a pass roughly every d, measured from the end of its own last pass, so objects spread themselves out instead of arriving as a whole-kind burst; a scan at startup admits the objects no pass would otherwise reach. Reach for it over WithFullPassInterval when a kind must re-poll something the store cannot see, and over a RequeueAfter chain when forgetting to re-arm on one return path would be silent. It is a default cadence rather than a ceiling: a pass that returns RequeueAfter keeps its own schedule, longer or shorter. → ADR: a per-object cadence is armed by a pass

A feed outside the store gets a channel instead of a loop. WithTriggerByID(ch) and WithTriggerByName(ch) at Register hand beehive a channel of addresses — each one received is resolved within the kind and requeued. Reach for them when what changed lives outside the store: a file watcher, a cloud API, a probe. Beehive owns the receive loop and its shutdown; you own the channel, and closing it stops that feed. A poke is a latency hint like Client.Requeue, but with one difference worth knowing: it is not a write, so no driver re-derives a lost one — a kind whose truth is entirely external should still run a cadence of its own. → ADR: a trigger channel requeues by id or by name

To reconcile something sooner than the next pass, use Client.Requeue rather than shortening a cadence: it is a latency hint aimed at one object, where an interval is a cost paid by every object forever. The examples under examples/ all do this — it is what lets them run on production defaults. examples/lowpower is the exception, and shows the other side: every cadence at minutes, with the pushes alone carrying the demo.

Four of the six cadences cannot be disabled: WithGCInterval, WithOwedPassInterval, WithStaleDependentsInterval and WithWatchFloorInterval each reject a non-positive interval with ErrInvalidOption. A long interval means "rarely"; there is no way to say "never". WithFullPassInterval and WithIndividualPassInterval can be set to 0, being opt-in; startup logs when the owed pass is off, so a value left at 0 by accident is visible rather than silent.

ADR: every driver is a periodic scan of the store, for why the cadences are separate, why GC alone is mandatory, and what each driver's cost is bounded by.

GroupKind
type GroupKind struct {
    Group string // "" for core group, "acme.com" for plugins
    Kind  string
}
Condition
type ConditionStatus string

const (
    ConditionTrue    ConditionStatus = "True"
    ConditionFalse   ConditionStatus = "False"
    ConditionUnknown ConditionStatus = "Unknown"
)

type Condition struct {
    Type     string
    Status   ConditionStatus
    Reason   string // machine-readable token, e.g. "DialTimeout"
    Message  string // human-readable detail
    Liveness bool   // see below

    // Set by the store on read, ignored on write.
    Unconfirmed    bool      // Status is a downgrade this process derived, not a write
    TransitionedAt time.Time // when Status last changed
    UpdatedAt      time.Time // when the condition was last written at all
}

TransitionedAt and UpdatedAt are the two clocks a condition carries. UpdatedAt moves on every write that changes the condition — a new Reason or Message counts, a byte-identical rewrite does not. TransitionedAt moves only when Status itself flips, so "how long has this been Ready" is time.Since(cond.TransitionedAt) and "how fresh is this observation" is UpdatedAt. Both are decided by the store, so whatever you put in the Condition you hand to SetCondition or SetConditions is discarded.

A liveness condition downgraded to ConditionUnknown on read keeps the stamps of the stored write: the downgrade is derived per process, not written, so TransitionedAt describes the last stored status change rather than the downgrade. Reason and Message are the stored write's for the same reason — they say what the condition last was, not what this Unknown means.

Unconfirmed is how you tell that apart from an Unknown a controller in this process wrote deliberately, having looked and been unable to say. The two are the same on the wire otherwise, and the rule that separates them — "written before this process started" — is one only the store can evaluate, so it reports the answer rather than its inputs. Branch on Unconfirmed alone; it is set only by the downgrade, so it already implies both ConditionUnknown and Liveness.

switch {
case cond.Unconfirmed:
    fmt.Printf("unconfirmed since restart — last known %s\n", cond.Reason)
case cond.Status == beehive.ConditionUnknown:
    fmt.Printf("cannot tell: %s\n", cond.Message)
default:
    fmt.Printf("%s since %s\n", cond.Status, cond.TransitionedAt)
}

That last branch is the trap Unconfirmed exists to close: a downgraded condition's TransitionedAt predates the restart, so rendering "since" against it would date a status this process never established.

Liveness marks a condition that describes a live, in-process resource, and so is only valid inside the process that wrote it. On read, a liveness condition left by an earlier process is downgraded to ConditionUnknown ("verifying") until a controller confirms it again. The default, false, means the condition is durable and survives restarts. See the ADR.

Event

Events are a per-object, append-only log of observations, grouped into runs. Consecutive records sharing (Category, Type, Reason) merge into one Event: its Count grows and its [FirstAt, LastAt] window widens. Change any of those three fields and a new run starts.

Runs are consecutive, not deduplicated globally. A value that comes back after a different one starts a fresh run, so a flapping object produces a timeline of alternating runs rather than one row that grows forever. Think of the log as the long form of a Condition: a Condition keeps only the current run per type, overwriting its Status and Reason in place, while the log keeps the history.

type EventType string

const (
    EventNormal  EventType = "Normal"  // ✓
    EventWarning EventType = "Warning" // ✗
)

type EventID = int64

type EventSpec struct {
    Category string    // independent timeline; "" = default
    Type     EventType
    Reason   string    // machine-readable token, e.g. "ProbeFailed"
    Message  string    // human-readable; sampled, not keyed
    Detail   any       // optional payload; marshaled on write; nil = none
}

type Event struct {
    ID       EventID
    ObjectID ObjectID        // object this event is about
    Category string
    Type     EventType
    Reason   string
    Message  string          // latest occurrence's message
    Detail   json.RawMessage // latest occurrence's payload; nil = none
    Count    int             // occurrences in this run (>= 1)
    FirstAt  time.Time       // run start
    LastAt   time.Time       // run end (latest occurrence)
}

// EventDetail unmarshals e.Detail into T.
func EventDetail[T any](e Event) (T, error)

Category splits an object's log into independent timelines, one per (object, category), so unrelated concerns — connection probes and config sync, say — never break each other's runs. Category and Reason are both free-form strings you choose per record, like Condition.Reason; declare typed string constants if you want a fixed, typo-proof vocabulary.

Message is sampled, not part of the run key. Recording the same (Category, Type, Reason) with a new message extends the current run and updates the message shown, rather than starting a new run.

Detail is the machine-readable companion to Message: an optional structured payload, so ProbeFailed might carry {"endpoint":"10.0.0.1:443","latencyMs":5000}. Like Spec and Status it goes in typed and comes out opaque. On write it is any JSON-marshalable value, which AddEvent marshals. On read it is a json.RawMessage you decode when you need it, with EventDetail[T](e). Decoding per event, with the type that event's Reason implies, is what lets one timeline mix reasons carrying different payload shapes without making the API generic.

Detail is sampled like Message — latest occurrence wins, and it is not part of the run key — so a payload that varies never splits a run. If you need every occurrence's payload, that event shouldn't aggregate: give it a unique Reason. Unlike Spec and Status, Detail is not schema-versioned, so reshaping it breaks decoding of older rows. That is tolerable only because retention ages events out; put a version inside the payload if you need more.

Only controllers write events, and only during a pass. ControllerClient.AddEvent is the only write path, because events are observations and, like status, have no user-facing writer. Reads live on Client (ListEvents, WatchEvents, GetLatestEvent), plus the eager LoadEvents() / Object.Events() pair, which gates on being loaded exactly like the secondary lookups and returns ErrNotLoaded otherwise.

A connection-health panel renders one category's timeline directly — client.ListEvents(ctx, id, WithEventCategory("connection")) yields, newest first:

10:08:30  ✓ Connected      ×4    10:08:00–10:08:30
10:07:50  ✗ ProbeFailed    ×18   10:05:00–10:07:50   "i/o timeout"
10:04:55  ✓ Connected      ×7    10:03:50–10:04:55

where each row is one Event: LastAt · Type · Reason · Count · FirstAt–LastAt · Message.

Object
type ObjectID = int64

type Object[Spec, Status any] struct {
    ID                  ObjectID
    Group               string
    Kind                string
    Name                string   // required and immutable; the key the Client API addresses the row by
    Spec                Spec
    Status              *Status
    Generation          int64
    ObservedGeneration  *int64
    ObservedAt          *time.Time // when ObservedGeneration was recorded; not a reconcile heartbeat
    ResourceVersion     int64
    DeletionRequestedAt *time.Time
    Finalizers          []string
    Conditions          []Condition // per-type observations reported by controllers
    CreatedAt           time.Time
    UpdatedAt           time.Time

    // Secondary lookups (owner, dependencies, dependents, owned) are held in
    // unexported fields, populated only for the relations a read requested (see
    // Load options) and reached through the accessors below — never as fields.
}

type ObjectRef = storeapi.ObjectRef // { ID ObjectID; Group, Kind string }

Secondary-lookup data is filled in only when the read asked for it, and you read it through the accessors below. They return ErrNotLoaded for a relation nobody requested, so forgetting a Load*() option fails loudly instead of looking empty. The return type carries the cardinality — (ObjectRef, bool, error) for the at-most-one owner, ([]ObjectRef, error) for the rest — so the accessors need no verb in their names.

func (o *Object[Spec, Status]) Owner() (ObjectRef, bool, error) // bool: an owner exists; err: not loaded
func (o *Object[Spec, Status]) Dependencies() ([]ObjectRef, error)
func (o *Object[Spec, Status]) Dependents() ([]ObjectRef, error)
func (o *Object[Spec, Status]) Owned() ([]ObjectRef, error)
func (o *Object[Spec, Status]) Events() ([]Event, error)

Once loaded, an empty slice — or ok == false from Owner — means there really are none. ErrNotLoaded means you forgot to ask: fetch the relation eagerly with a Load*() option, or lazily through the Client/ControllerClient methods below.

ReconcileResult

What Reconcile returns. No exported fields; three constructors build every value, and RequeueAfter schedules the next pass.

func Settled() ReconcileResult                                    // observed this generation; beehive records it
func Unsettled() ReconcileResult                                  // real work done, not caught up yet
func Fail(err error) ReconcileResult                              // the pass failed; backoff ladder

func (ReconcileResult) RequeueAfter(d time.Duration) ReconcileResult // schedule the next pass
func (ReconcileResult) Err() error                                  // the failure, or nil
Return Records ObservedGeneration Requeue
Settled() yes nothing scheduled
Unsettled() no after the owed-pass interval (30s by default)
Settled().RequeueAfter(d), Unsettled().RequeueAfter(d) as above after d
Settled().RequeueAfter(0), Unsettled().RequeueAfter(0) as above as soon as the queue's per-object floor allows
Fail(err) no the backoff ladder

Settled claims only that the pass observed the object's current generation — not that it is healthy, nor that any status was written.

A bare Unsettled() schedules its own return because nothing else would: the owed pass lists an object whose generation has moved, so one that declines to settle without having moved its generation is in no listing. It comes back on that pass's own cadence — WithOwedPassInterval, 30s by default — since the alarm is that pass extended to what its listing cannot see. The interval is an upper bound, not a period: a dependency wake or a spec write landing inside the window dispatches on its own schedule, paced only by the queue's floor. RequeueAfter(0) polls at that floor (1s by default), which is the right answer only for a pass that can make progress the moment it is called again.

RequeueAfter is ignored on a Fail, which takes the backoff ladder.

ReconcileResult{} and Fail(nil) fail the pass with ErrInvalidResult. Neither can settle anything, and Err() reports the sentinel for both.

Schedule
type Schedule struct {
    NextRequeueAt time.Time // when the object is next due to reconcile; zero = nothing scheduled
    // reserved: a future Trigger/Reason (backoff | success-cadence | manual poke)
}

Schedule is what the scheduling API reports: an object's next reconcile time, as a gauge. It is a struct rather than a bare time.Time so fields can be added later without breaking anything — a reschedule trigger, for instance (backoff, success cadence, or manual poke), which is reserved but not yet filled in. NextRequeueAt covers per-id timers only: a pending backoff retry, a result's requeue delay, or a re-enqueue floor holding a wake until the object may run again — or now if the object is already queued, or the zero time if nothing is scheduled.

Client
type ChangeType string

const (
    Added    ChangeType = "Added"
    Modified ChangeType = "Modified"
    Deleted  ChangeType = "Deleted"
)

type ObjectChange[Spec, Status any] struct {
    Type   ChangeType
    Object *Object[Spec, Status] // nil on a Deleted whose row image no longer decodes
}

type ObjectStream[Spec, Status any] struct {
    Object          *Object[Spec, Status] // nil when the id holds nothing yet
    ResourceVersion int64                 // the log position the snapshot is complete as of
    Changes         <-chan ObjectChange[Spec, Status]
}

type ObjectListStream[Spec, Status any] struct {
    Objects         []*Object[Spec, Status]
    ResourceVersion int64 // the log position the snapshot is complete as of
    Changes         <-chan ObjectChange[Spec, Status]
}

// Err, on either stream, reports why Changes closed: ErrWatchTooOld,
// ErrWatchTooNew, ErrStopped, or nil for the caller's own cancellation.
func (s *ObjectStream[Spec, Status]) Err() error
func (s *ObjectListStream[Spec, Status]) Err() error

type Client[Spec, Status any] interface {
    // Creating: the name is positional, because there is no id yet.
    Create(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], error)
    GetOrCreate(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], bool, error)
    CreateOrUpdate(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], bool, error)

    // Id-keyed: acts on one incarnation, or returns ErrNotFound.
    Update(ctx context.Context, id ObjectID, spec Spec) (*Object[Spec, Status], error)
    Get(ctx context.Context, id ObjectID, loads ...LoadOption) (*Object[Spec, Status], error)
    Delete(ctx context.Context, id ObjectID) error
    List(ctx context.Context, loads ...LoadOption) ([]*Object[Spec, Status], error)

    // Name-keyed: acts on whatever holds the name now.
    UpdateByName(ctx context.Context, name string, spec Spec) (*Object[Spec, Status], error)
    GetByName(ctx context.Context, name string, loads ...LoadOption) (*Object[Spec, Status], error)
    DeleteByName(ctx context.Context, name string) error // idempotent: absent or already-deleting is a nil no-op

    // Watching: a snapshot plus the changes above it. Kind-scoped; no controller
    // needed; follows one incarnation, so an id holding nothing is a nil Object
    // rather than ErrNotFound, and the stream ends at Deleted.
    Watch(ctx context.Context, id ObjectID, opts ...WatchOption) (*ObjectStream[Spec, Status], error)
    WatchList(ctx context.Context, opts ...WatchOption) (*ObjectListStream[Spec, Status], error)

    // Lazy secondary lookups — the on-demand counterparts to the Load options.
    GetOwner(ctx context.Context, id ObjectID) (ObjectRef, bool, error)
    ListDependencies(ctx context.Context, id ObjectID) ([]ObjectRef, error)
    ListDependents(ctx context.Context, id ObjectID) ([]ObjectRef, error)
    ListOwned(ctx context.Context, id ObjectID) ([]ObjectRef, error)
    // The typed, kind-scoped form of ListOwned: this kind's decoded children.
    ListOwnedObjects(ctx context.Context, ownerID ObjectID, loads ...LoadOption) ([]*Object[Spec, Status], error)
    WatchOwnedObjects(ctx context.Context, ownerID ObjectID, opts ...WatchOption) (*ObjectListStream[Spec, Status], error)

    // Event log — per-object, category-partitioned, contiguous-run aggregated.
    ListEvents(ctx context.Context, id ObjectID, opts ...EventOption) ([]Event, error)
    GetLatestEvent(ctx context.Context, id ObjectID, category string) (Event, bool, error)
    WatchEvents(ctx context.Context, id ObjectID, opts ...EventOption) (*EventStream, error)

    // Reconcile control.
    Requeue(ctx context.Context, id ObjectID, opts ...RequeueOption) error // requeue now; preserves backoff unless WithResetBackoff()

    // Scheduling — observe the next-requeue time.
    GetSchedule(ctx context.Context, id ObjectID) (Schedule, error)          // current schedule (zero if nothing scheduled)
    WatchSchedule(ctx context.Context, id ObjectID) (<-chan Schedule, error) // stream the schedule live as a gauge
}

func NewClient[Spec, Status any](bh *Beehive, gk GroupKind) Client[Spec, Status]
Writes

The id is the key; the name is how you find it. Every object is named at creation — the name is positional on Create, because there is no id yet — and it is unique and immutable thereafter. Everything after the create takes an ObjectID: the same key the store uses for incarnation identity, foreign-key targets, the work queue and scan ordering. The …ByName siblings resolve a name to whatever holds it now, for callers who have a name and no id. Finalizers and other metadata are options:

client := beehive.NewClient[ClusterSpec, ClusterStatus](bh, ClusterGroupKind)
obj, _ := client.Create(ctx, "prod-cluster", ClusterSpec{...}, beehive.WithFinalizers("kstack.sh/cluster"))
client.Update(ctx, obj.ID, ClusterSpec{...})

The name is required and immutable — there is no UpdateName, and a rename is delete+recreate. If it is already taken, Create returns ErrNameTaken; use GetOrCreate when "already there" is an acceptable outcome. A deletion-pending row still holds its name, so a tombstone reports ErrNameTaken too, until GC clears the finalizers and removes it.

For objects with no natural name, beehive.GenerateName(prefix) returns the prefix joined to a fresh UUIDv7 — time-ordered, so names sharing a prefix sort by creation. Nothing generates a name implicitly: a name the caller never chose is a name nobody can look up, which is the nullable name this API retired. Passing it positionally keeps the value in your hands, where you can log it or write it into a sibling's spec before the create:

// "cache-018f3a5c-8b2e-7c3d-a4f5-6b7c8d9e0f10"
obj, err := client.Create(ctx, beehive.GenerateName("cache"), spec)

It returns a bare string, with no error to handle: the random bytes come from crypto/rand.Read, which is documented never to return an error — it crashes the program if the OS entropy source fails, which is the right answer for an unusable source and not something a name helper could improve on.

It is collision-resistant, not collision-proof, and nothing but the store can settle that atomically — a lookup before the create would be a TOCTOU race. So a caller generating names should bound-retry on ErrNameTaken and no other error. Reaching the bound means generation is broken, not that you were unlucky.

Which key you use decides what a call acts on, and the two answers differ exactly when a name has been reused:

A name-keyed call acts on whatever holds that name now, or reports absence. An id-keyed call acts on that one incarnation, or returns ErrNotFound.

The id is the key, and the ByName siblings are the opt-out. The bare verbs take an ObjectID, so a delete and recreate under the same name cannot make you act on the wrong row — the safe thing is what you get by not thinking about it. Reach for ByName when acting on whatever holds the name now is what you actually mean: "ensure this child exists" / "remove this child" is a statement about a name, and re-evaluating it against current state on every reconcile is the level-triggered principle, not a compromise.

That is why read-modify-write needs no rule. The object a read returns carries ID, so the natural way to write it back is already the incarnation-safe one:

obj, _ := client.GetByName(ctx, "prod-cluster")
obj.Spec.Replicas++
client.Update(ctx, obj.ID, obj.Spec)   // this incarnation, or ErrNotFound

Composing GetByName → mutate → UpdateByName names the row twice, and a GC collect plus a fresh create in between would land the write on a different incarnation. The window is narrower than it looks — a tombstone holds the name's UNIQUE constraint until GC clears finalizers, so opening it takes a full collect plus a new create — but it is real, and using the id closes it.

Each ByName call is atomic on its own: it resolves and writes in one transaction, never two store calls. The hazard is only in composing two of them.

A name is an opaque key and beehive does not validate it — no character rules, no length limit, no normalization — with exactly one exception: the empty string is rejected with ErrInvalidName, by the writes and the reads alike. "" is not a name anyone chooses; it is what an unset configuration field reads as, and treating it as an ordinary name would quietly point every caller whose config was unset at one shared row. Every other malformed name at least addresses the row its author meant. Validate names that come from outside your code; beehive only catches the one case where the mistake is invisible. The store enforces it too, not just the client — Store is a public extension point, and a row admitted under "" is one no name-keyed call could address again.

The name-keyed writes differ only in what they do when the name is taken, and that holds under concurrency. GetOrCreate does its read and write in one transaction, so two callers racing on a name never both insert — the loser sees the winner's row and returns it. Create does no lookup at all, so the loser of that race fails on UNIQUE, just as it would against a row that was already there:

Name already held by Create GetOrCreate UpdateByName CreateOrUpdate
nothing creates creates, created=true ErrNotFound creates, created=true
a live row fails (UNIQUE) returns it untouched, created=false writes the spec writes the spec, created=false
a deletion-pending row fails (UNIQUE) returns it untouched, created=false ErrDeletionPending ErrDeletionPending

The bottom row is one state read four ways, and the differences are the point. A deletion-pending row still holds its name, so Create cannot take it. It is still readable, so GetOrCreate hands it back. But a pass on it runs collection rather than reconcile, so a spec written onto it would be discarded — and would wake every watcher and dependent on the way — so Update, UpdateByName and CreateOrUpdate refuse it with ErrDeletionPending instead.

That sentinel is deliberately not ErrNotFound, because the two ask for opposite responses: absent means create it, pending means you cannot until GC releases the name. A caller whose object is owned answers it by doing nothing — a physical delete pushes its owner, so the owner's next pass creates the replacement. Left unhandled inside a Reconcile it surfaces as a failure and enters the retry ladder, against a condition that retrying does not clear.

CreateOrUpdate is the name-keyed upsert. Create and GetOrCreate never write to a row they found, so neither changes an existing object. CreateOrUpdate does, in one transaction, and its options follow GetOrCreate's — honoured on create, ignored on update, so it never re-parents.

Composing it by hand works, but only in one order: GetOrCreate then Update on the id it returned. Starting from UpdateByName and falling back on ErrNotFound loses the spec when a concurrent create wins, silently. CreateOrUpdate removes the choice. → ADR

Re-applying the spec a row already holds does nothing at all: no generation bump, no resource_version bump, and so nothing for a scan to find — no watch delivery, no reconcile. That matters when a controller re-applies a spec of its own kind on every pass, because the object stays settled instead of owing itself another pass forever.

Every write validates before it commits. Create, GetOrCreate, CreateOrUpdate and Update decode the written row back into Spec/Status inside the transaction. A spec that marshals but does not round-trip — usually a MarshalJSON/UnmarshalJSON pair that disagree — rolls the write back instead of committing a row this process cannot read. So an error from a write means nothing was committed: no unreadable row, nothing added to a driver's listing, no UNIQUE left behind for the retry to trip on, and for Update the previous spec is still there. GetOrCreate and CreateOrUpdate return created=false in that case, since nothing was created. The cost is that the write holds the store's single writer across the decode (json.Marshal still runs before the transaction opens). This only guards the write path — a row can still become unreadable later, say after a schema downgrade, which the read path handles by quarantining it (see Migrator).

Use GetOrCreate when a controller has to make sure a child exists without ever changing it. The alternative is open-coding GetCreateGet again on conflict, where the fallback path tends to drift out of step with the primary one. Its found branch writes nothing, so a deletion-pending row comes back as it is, with DeletionRequestedAt set, rather than being resurrected by a spec update:

The example uses two surfaces, and they are not interchangeable. GetOrCreate is on Client — here the child kind's client, built with NewClient and held by the controller. AddEvent is on the ControllerClient that Reconcile receives, for writes about the object being reconciled. Client has no AddEvent and ControllerClient has no GetOrCreate: a controller creates children through a Client for their kind.

type ProjectController struct {
    // built once at wiring time:
    //   beehive.NewClient[ClusterSpec, ClusterStatus](bh, ClusterGroupKind)
    clusters beehive.Client[ClusterSpec, ClusterStatus]
}

// Ensure the Cluster this Project owns exists, without ever mutating it. The
// options apply only if this call creates the row — a pre-existing row is
// returned exactly as it is (see the caveat below).
func (p *ProjectController) Reconcile(ctx context.Context, cc beehive.ControllerClient[ProjectStatus], obj *beehive.Object[ProjectSpec, ProjectStatus]) beehive.ReconcileResult {
    cluster, created, err := p.clusters.GetOrCreate(ctx, "prod-cluster", ClusterSpec{...},
        beehive.WithOwner(obj.ID), beehive.WithFinalizers("kstack.sh/cluster"))
    if err != nil {
        return beehive.Fail(err)
    }
    if cluster.DeletionRequestedAt != nil {
        // The name is still held by a tombstone; it is released only once GC clears
        // the row's finalizers. Wait and retry — a replacement cannot be created yet.
        return beehive.Unsettled().RequeueAfter(5 * time.Second)
    }
    if created {
        // AddEvent is about obj (this controller's object), not the child.
        if err := cc.AddEvent(ctx, beehive.EventSpec{
            Category: "lifecycle", Reason: "ClusterCreated",
        }); err != nil {
            return beehive.Fail(err)
        }
    }
    return beehive.Settled()
}

created reports whether this call inserted the row. A new object has a generation nothing has observed yet, so the owed pass picks it up, exactly as it would after Create. Returning an existing row writes nothing and so owes nothing. Neither case schedules anything at write time — the row is the record, so a rollback leaves nothing behind.

created is returned synchronously, so inside an enclosing ControllerClient.Within a created=true is provisional until that transaction commits. For a side effect that must run only if the row really lands, use WithOnCreate (below), which waits for the outermost commit.

The options apply only when the call creates the row (WithOwner, WithFinalizers, WithOnCreate). Options that don't apply are ignored, as everywhere else.

That has a sharp edge worth stating plainly: since the found branch ignores the options, created=false does not mean "exists and matches your options." A row created earlier without WithOwner comes back with no owner edge, and a caller that assumes otherwise ends up with a child the GC cascade will never collect when the parent goes. If you depend on the owner edge, check it — GetOrCreate then GetOwner, or GetByName(ctx, name, LoadOwner()) — and fix the difference yourself. Beehive will not adopt the row for you: an object has at most one owner, so adding the edge to a row that already has a different one would give it two, and deciding which owner wins is your policy, not the library's.

DeleteByName is the other half of the pair: GetOrCreate creates if absent, DeleteByName deletes if present. Both are idempotent and both understand tombstones, so a controller that ensures a name-keyed child on one branch and removes it on another writes one call for each. It replaces the usual open-coding of GetByName, treating ErrNotFound as success, treating DeletionRequestedAt as a no-op, then deleting:

Name held by DeleteByName
nothing nil — already gone
a live row soft-deletes it (sets DeletionRequestedAt), advances GC
a deletion-pending row no-op — no write at all — advances GC; nil

It marks the object and hands it to the controller to clear its finalizers. The row is removed once they clear, and only then is the name free again. It is scoped to the kind, like GetByName: another kind's row holding the same name is simply not found, which is reported as success rather than as a wrong-kind error. Delete is the incarnation-keyed sibling, and reports ErrNotFound where this folds absence to nil.

Both idempotent outcomes — no such row, and a row already deletion-pending — are answered by a lock-free probe without opening a write transaction. That is the steady state of the call: a controller that removes a child re-runs it every reconcile, and exactly one of those calls ever deletes anything, so taking the store's single write lock to discover there is nothing to do was the whole cost.

Looking the name up is atomic with the delete — the name goes into the store's WHERE clause rather than being resolved first and deleted after, so no concurrent collection can retire the row and hand its name to a replacement in between. The probe above does not change that: it is advisory, and the fall-through still runs the atomic mark, whose deletion_requested_at IS NULL guard re-checks everything. A nil return means "no object of this kind holds this name", not "the row I resolved is gone". What it cannot promise, and no implementation could, is that the name is still free when the call returns: a concurrent GetOrCreate may take it the instant the delete commits. As always, the next reconcile works from current state.

ADR: name-keyed writes, for the transaction boundaries.

Watching

Watch and WatchList return a stream: the current state, the position it was read at, the Changes channel, and an Err() saying why that channel closed. The shape matches the call's cardinality — Watch returns an ObjectStream, whose Object is nil when the id holds nothing yet, and WatchList returns an ObjectListStream. WatchOwnedObjects(ownerID) is WatchList narrowed to one owner's children — see secondary lookups below. It is the same shape WatchEvents returns, so a stream ends one way across the whole library.

list, err := client.WatchList(ctx)
// list.Objects is current state; list.ResourceVersion is where list.Changes starts.

one, err := client.Watch(ctx, id)
// one.Object is current state, or nil; one.ResourceVersion is where one.Changes starts.

for change := range list.Changes {
    // ...
}
if err := list.Err(); err != nil { /* the stream ended; see below */ }

Do not open a watch inside Within. The snapshot below happens on your goroutine, and a watch is the one call with no right ctx to pass, since its stream must outlive the transaction — so it runs on the read pool and quietly misses your transaction's own writes, which the stream will not carry either. (This is the general rule for Within: pass the ctx you were given to every store call inside it. An ordinary read on the wrong ctx does the same thing, and neither waits.)

One goroutine per Within. The ctx you are given belongs to the goroutine fn runs on. Two goroutines issuing store calls on it share one prepared statement's cursor and interleave on it — each gets part of the other's rows, and neither call fails. Fan out around the transaction, not inside it.

Subscribe, then act. The snapshot is read before either returns, so a change you make after subscribing is always in the stream — delete an object on the next line and its Deleted will come. If that read fails you get the error rather than a stream, since a watch with no snapshot could not report that delete. The stream carries changes strictly above ResourceVersion: no overlap with the snapshot, no gap between them. That is also what makes "have I caught up?" a value rather than a guess — you hold the starting state before you read the first change.

Both share one reader per kind. However many watches a kind has, one tailer reads its write-log position, and only a position that moved costs anything more: the entries above the cursor, then one batched read of the objects they name. A commit wakes that tailer, and a floor tick (WithWatchFloorInterval, 30s by default) covers what a wake cannot — a failed read, a retention trim. That reader lives exactly as long as the kind has watches — the first one starts it, the last one to end takes it down — so cancelling a watch releases everything it held, on a Beehive you started or one you never did. Three things follow, and they are the level-triggered contract the rest of beehive keeps — you are told what is, never what happened:

  • Changes collapse together. Several writes to one object produce one change carrying current state. An object created and updated before you read reports Added, since it was not in your snapshot — and an Added may repeat for an object your snapshot already held, so treat it as "here is this object" rather than "this object is new".
  • Order holds per object, not across objects. Each object's latest state arrives once, newest wins. Nothing is dropped: a delete always arrives, even for an object you never saw created.
  • Latency is the commit, not the interval, for writes made through this Beehive. A quiet kind reads one indexed number per floor tick. Writing to the event log does not move the position, so an object watch stays quiet through a controller that records events on every pass.

Deleted means collected, not requested. Deleting an object sets DeletionRequestedAt and leaves the row live and readable, so you get a Modified with that field set. Deleted follows only when the GC sweeper physically removes the row — after its finalizers clear, which is controller-defined and unbounded, and after nothing references it any more. So: key on DeletionRequestedAt != nil to stop using an object, and on Deleted to evict it from a cache. A Deleted arrives even when its row image will not decode — a peer wrote the row at a schema version this binary cannot read, say — with Object nil and ID set. Nothing later in the log mentions a deleted id, so a Deleted withheld here would leave the object in your cache for good.

A failed read is logged and retried rather than fatal, so the stream survives a transient store error. One failure is terminal: if retention trims log entries the tail had not read, every watch on that kind closes with Err() reporting ErrWatchTooOld, because it cannot continue truthfully. Subscribe again for a fresh snapshot. A WithResumeFrom position that retention has already passed arrives the same way, on the stream rather than as an error from the call — so ErrWatchTooOld has one place to be handled, not two. Stopping the beehive ends every stream the same way, with ErrStopped; unlike ErrWatchTooOld that one cannot be answered by subscribing again. So a Changes channel that closes with a nil Err() closed because your own context ended, and a supervisor can key on exactly that to decide whether to resubscribe. Err() is set before the close, so reading it the moment the channel closes is enough — and because it is not a value on the stream, a caller that forgets it drops an error rather than mistaking one for a change.

WatchOptions tune the rest:

WithResumeFrom(rv int64)       // stream above rv instead of taking a snapshot; a trimmed rv fails the stream, not the call
WithLoads(loads ...LoadOption) // the same eager relations List takes, batched per delivery

A slow subscriber stalls its own stream and nothing else: no change is dropped, and no other subscriber waits on it.

Neither watch needs a registered controller — the tail reads the write log, not a reconciler — and both are kind-scoped: Watch on another kind's id streams nothing. The id need not exist yet; an absent object is a nil Object, and its creation arrives as Added.

(The event log below, ListEvents/WatchEvents, is a different thing: an ObjectChange says an object changed, an Event is a log entry.)

ADR: every driver is a periodic scan of the store, for what a poll costs and the constraints any push path above it would have to satisfy.

Secondary lookups (owner / dependencies / dependents / owned)

An object's ref edges are fetched on request, two ways:

  • Eager — pass LoadOptions to a read: Get(ctx, id, LoadOwner()), List(ctx, LoadDependencies(), LoadDependents()). The returned objects carry the data (read via the accessors). On List each relation is one batched query, not one per object.
  • Lazy — call GetOwner / ListDependencies / ListDependents / ListOwned when you actually need the data. These run the edge query directly, with no validating read in front, so they do not check the kind: another kind's id returns that kind's edges, and a missing id returns nothing, neither as ErrNotFound. Use them for ids the client owns.

ListOwned (and the eager LoadOwned() / Object.Owned()) is the inverse of GetOwner over owned_by: it returns the objects a given owner owns, the same way ListDependents inverts ListDependencies over depends_on.

ListOwnedObjects(ownerID) is the typed version. ListOwned returns untyped ObjectRefs across every owned kind, leaving you to filter by Kind and Get each child through its own client. ListOwnedObjects returns decoded *Object[Spec, Status] children of this client's kind in a single query, because the kind filter and the row read fold into the edge join — no Get per child. Ordering (by id) and missing-owner behaviour match ListOwned. Deletion-pending children are included, so skip them yourself by checking DeletionRequestedAt. It takes the same LoadOptions as List, batched the same way; without them the children have nothing loaded and their accessors return ErrNotLoaded.

WatchOwnedObjects(ownerID) is that read as a watch: the same snapshot, then every change to one of that owner's children — a later child as Added, its collection as Deleted. It takes the same WatchOptions as WatchList and joins the same per-kind tailer, so scoping costs a watch nothing beyond one batched edge read per drained page. Ownership is resolved from current state rather than from the write log, which is why a child created after the snapshot arrives correctly: the create's log entry is appended before its owned_by edge, in the same transaction. Like GetOwner and Object.Owner(), it assumes the one owner WithOwner can express.

ADR: owner-scoped watches, for the ownership invariant the resolution rests on.

Eager and lazy run the same query — edges are always a separate indexed lookup, never joined into the SELECT that carries specs and statuses. Eager just attaches the result to the object and batches it across a List.

ADR: secondary lookups, for the loader sharing, the accessor naming rule, and the store's semi-join.

Reconcile control

Requeue queues an object for reconcile now, and is the only way to reconcile something without waiting for a tick. It is a latency hint, not a synchronous run: it returns once the object is queued, and a worker gets to it on its own schedule. Losing one is harmless whenever the store records that the object is owed a pass, because the owed pass finds it anyway. It is also how you drive reconciles yourself with every periodic driver switched off. Use it to re-examine an object promptly after state the controller reads has changed elsewhere.

By default Requeue keeps the object's retry backoff. A requeue is an ordinary nudge — a config change, a dependency update, a manual poke — and almost never proves the failure is over. The one thing that does prove it is a successful reconcile, which clears backoff already. So: backoff is cleared by a successful reconcile or by an explicit WithResetBackoff(), never by a plain requeue. Pass beehive.WithResetBackoff() only when you know the failure is resolved and the next retry should start from the base interval. (controller-runtime draws the same line between Add/AddAfter and Forget.)

Requeue checks the id against the client's kind first, returning ErrNotFound for a missing or foreign id, then requires a registered controller, returning ErrNoController for a client-only kind that has no reconcile loop. It is on Client only: a controller schedules itself with the delay it returns from Reconcile and reaches other objects through the store, never by poking another reconcile loop.

Scheduling

The scheduling API reports when an object is next due to reconcile, as a Schedule whose NextRequeueAt is a pending backoff retry, a result's requeue delay, or a re-enqueue floor holding a wake — or now, if the object is already queued, or the zero time if nothing is scheduled.

GetSchedule is the point read: a non-blocking read of in-memory state, with no store lookup and no kind check, so it returns no error today (the error is reserved for symmetry with the rest of the surface). A missing id, another kind's id and a client-only kind all read as the zero Schedule, which looks the same as a real object with nothing scheduled.

WatchSchedule streams the same value as a gauge: the current one on subscribe, then a new Schedule whenever it changes — a backoff step, a requeue delay, a wake held by the re-enqueue floor, a pass or dependency wake, a dispatch, a Requeue. None of those fire Watch/WatchList, since rescheduling bumps no generation or resource version, and no other signal covers them all. So this is the way to watch reschedules — for example to drive a "next attempt" countdown that stays accurate while an object's spec and status sit still. It is pushed rather than polled, and emits only on change, which means it converges on the current value and may skip values in between. The channel closes when ctx is cancelled. Unlike GetSchedule it returns ErrNoController for a client-only kind, since a stream that can never emit should say so rather than hang, but the id need not exist: an unscheduled id streams the zero Schedule until something schedules it.

Both are on Client only, and both read per-id timers only. Neither predicts the next reconcile: the real one can come earlier, because the owed pass, the full pass and the dependency wake are not per-id timers, and a zero NextRequeueAt means "nothing scheduled", not "will not reconcile". Treat it as observability, not a guarantee.

ADR: the schedule watch, for why it is an in-memory gauge rather than an event-log surface.

Events

ListEvents returns an object's runs newest first (by LastAt). WithEventCategory narrows to one timeline, and the other EventOptions filter by type, reason or time, or cap how many come back. GetLatestEvent returns the current run in a category, with a bool that folds away the no-events-yet case like GetOwner does.

All three reads — ListEvents, GetLatestEvent, WatchEvents — take an id and read that object's log whatever kind holds it, since an event carries no kind of its own. The write is the asymmetry: AddEvent appends to the object its pass was handed and takes no id at all. (The object watches above are kind-scoped, so the two watches differ deliberately.) WatchEvents still needs a registered controller for the client's own kind — a property of the caller, not of the target.

WatchEvents hands back an EventStream: Runs is the snapshot as of ResourceVersion, Events streams what the log grows by above it — oldest-first — and Err() says why the stream ended once Events is closed. It reads the log above a cursor, and an AddEvent commit wakes it, so a local write arrives at commit rather than on a tick; the floor (WithWatchFloorInterval) is what covers a write another process made. An extend is not a new run: the row comes back with a higher ResourceVersion and a bumped Count, which is what lets you update it in place. There are no tombstones, since a run can only appear or grow.

Retention on the stream is the bound the sweeper enforces, as configured by WithEventRetention — a readout of that option, not a per-stream fact, so a consumer holding runs in memory bounds its own list from the server's number instead of a copy of it. A prune is still not delivered: the stream is the snapshot and what grows above it. Mind what PerTimeline counts — it caps each (object, category) timeline, so it bounds one stream's total only when the watch is scoped to a category; an unset (or unenforced) bound reads zero.

WithEventsResumeFrom(rv) starts above a position instead of snapshotting, so a reconnecting reader pays for the gap rather than the whole log — checkpoint the ResourceVersion of what you were delivered. Two answers end a resume instead of serving it: ErrWatchTooOld when retention has taken runs below your position, and ErrWatchTooNew when the position is above everything that object's log has held. Both mean "subscribe again without the option". A stream also ends with ErrNotFound if the object is collected, because its log cascades away with it — an empty stream would otherwise read as "no events" about an object that no longer exists.

WithEventLimit bounds the snapshot only; a tail has no end to count back from. The other filters apply to both, so a run in a category you filtered out is dropped rather than delivered, and costs you nothing.

WithWriteLogRetention bounds the object write log, which is what the watches tail and what a resume reads. It looks like WithEventRetention and defaults the other way: an event is written when a controller chooses to write one, while a log entry lands on every object write — and a status write bumps resource_version, so the log grows at reconcile rate whether or not you opt in. Hence a 24h default rather than unbounded. The value is a resume window before it is a storage bound: it is how long a subscriber may be disconnected and still resume instead of resyncing. It also governs how long a collected object's final state lives on, since the delete entry carries the row image a Deleted change reports.

WithEventRetention bounds the log per (object, category): a ring that keeps the newest N runs in each timeline — runs, not occurrences, since an extend grows a run in place — so a flapping timeline can't evict a quiet one on the same object. maxAge is the other bound, and it is a different kind of thing: a flat cutoff on a run's end, across every timeline, so a run that keeps being extended never ages out. Both are off by default, which leaves the log unbounded; the GC sweeper enforces whichever you set, on its own interval, so a burst can sit above the cap until the next sweep. Deleting an object deletes its events. EventStream.Retention reports whichever bounds you set, so a watching consumer does not have to mirror this configuration by hand.

ADR: event retention, for why the cap counts runs per timeline and why neither bound is on by default.

ADR: the events API, for the run-aggregation rule, why Detail stays off the generic boundary, and the watch-surface naming.

ControllerClient
type ControllerClient[Status any] interface {
    UpdateStatus(ctx context.Context, status Status) error
    SetCondition(ctx context.Context, condition Condition) error
    SetConditions(ctx context.Context, conditions []Condition) error
    DeleteCondition(ctx context.Context, conditionType string) error
    AddEvent(ctx context.Context, event EventSpec) error
    DeleteFinalizer(ctx context.Context, finalizer string) error
    AddDependency(ctx context.Context, toID ObjectID) error
    DeleteDependency(ctx context.Context, toID ObjectID) error
    HasIncomingEdges(ctx context.Context) (bool, error)
    // Lazy secondary lookups, for reading the object's edges during reconcile.
    GetOwner(ctx context.Context) (ObjectRef, bool, error)
    ListDependencies(ctx context.Context) ([]ObjectRef, error)
    ListDependents(ctx context.Context) ([]ObjectRef, error)
    ListOwned(ctx context.Context) ([]ObjectRef, error)
    Within(ctx context.Context, fn func(ctx context.Context) error) error
}

The client is bound to the object your Reconcile was handed, and writes and reads that object alone; the ids above are the other end of an edge. There is no way to write a sibling of your own kind, which would race that object's own pass and settle nothing.

UpdateStatus does nothing when the status marshals to the bytes already stored, and costs no store call to find that out: the client already holds the status your object was loaded with, so the comparison happens in memory. There is no resource_version bump, so a watch and the dependency waker both find nothing — the same way re-applying an unchanged spec does nothing on the Client side. So report observed state unconditionally; you don't need your own equality check, and a dependent riding on this kind's status won't be woken by a pass that found nothing new.

One consequence of skipping the store: an unchanged report cannot notice that your object was collected mid-pass, so it returns nil where a real write would have returned ErrNotFound.

The generation handshake is beehive's, not yours. UpdateStatus writes status and nothing else; returning Settled records ObservedGeneration, written after Reconcile returns. A pass reporting only conditions — or nothing at all — settles like any other, with no argument to get wrong.

Beehive records the generation it handed you, never a fresh read, so a spec change landing mid-pass stays unobserved and the object reconciles again to pick it up. The write is skipped when the generation is already recorded, so a converged object re-reporting the same status costs no store call at all; when the generation moves, the write bumps resource_version, so a watcher waiting for ObservedGeneration == Generation sees it converge.

A pass that settles a new generation therefore costs two write-log entries where it once cost one, waking the tailers and the dependency waker twice.

ObservedAt records when the object settled at ObservedGeneration, not when the controller last ran — a pass returning Unsettled never moves it, so don't use it as a liveness check. For "when did we last look", record an event: AddEvent bumps the current run's LastAt every time.

ADR: beehive owns the generation handshake, for why the stamp is the generation you were handed and why the in-memory gate is sound. → ADR: a ControllerClient exists only for the pass it is handed to

SetConditions writes several conditions of one object as one write: they land in a single transaction under a single resource_version bump, so a watcher never sees a fresh Connected beside a stale Healthy, and a dependent is woken once for the pass rather than once per condition. Suppression stays per condition — the ones matching what is stored are not rewritten, so their UpdatedAt holds — and a batch where every condition matches writes nothing at all, exactly like a single SetCondition no-op. Naming a type twice in one call is refused with ErrDuplicateConditionType rather than resolved by slice order, and nothing in that batch is written. A condition whose Type, Status, Reason or Message is not valid UTF-8 is refused with ErrInvalidCondition, and nothing in that batch is written either: the store carries all four through an encoding with no room for bytes that are not text, and a type corrupted that way would never match its stored row again. Worth knowing because Message is commonly an err.Error(), which Go does not guarantee is UTF-8 — a controller reporting a wrapped OS error can reach this. An empty slice writes nothing.

SetCondition is the one-condition spelling of the same write. Reach for SetConditions when one pass observes several conditions; both compose inside Within, which is what to use when conditions must land with an UpdateStatus or a DeleteConditionWithin gives you the atomicity, and SetConditions additionally collapses what would be one version bump and one log entry per condition into one of each.

GetOwner, ListDependencies, ListDependents and ListOwned are the Client's lazy lookups, bound to the object being reconciled — they are how a pass reads its own edges, and they answer for nothing else. For another object's graph, hold a Client. GetOwner returns the owner over owned_by and ListOwned the reverse, the owner's children; ListDependents is the reverse of ListDependencies over depends_on.

HasIncomingEdges is a different question, used by GC: does anything with a live claim still point at this object? That means an owned child, or a dependent that is not itself being deleted — one that is going away has no claim. You cannot rebuild it from ListDependents, because it folds in owned children as well. A finalizer can wait on it: a controller holding a shared connection clears its finalizer only once nothing with a live claim references the object, so the connection outlives its last real user.

AddEvent adds an observation to the object's event log — see Event. Adding is not always an insert: repeating the latest run's (Category, Type, Reason) extends that run instead of appending a second one, which is what lets a controller report every poll without growing the log per poll. Like SetCondition it writes the object the pass was handed, and composes inside Within, so a controller can record an event and flip a condition together.

Controller
type Controller[Spec, Status any] interface {
    Reconcile(ctx context.Context, client ControllerClient[Status], obj *Object[Spec, Status]) ReconcileResult
}

A controller has no lifecycle in beehive. It implements Reconcile and nothing else, and receives the kind's ControllerClient as a parameter. Background work — timers, subscriptions, engines — belongs to your application, which already has its own lifecycle. Beehive owns only the reconcile lifecycle: the work queue, backoff, the periodic drivers and shutdown ordering.

The client you are passed is scoped to that one call, and it is the only one there is. Once Reconcile returns, every method on it fails with ErrReconcileReturned: a write arriving after the pass moves status with no pass behind it, which nothing re-derives. A goroutine outliving the pass keeps its result in memory and calls Client.Requeue, which buys it a pass with a live client. It is a fail-fast, not a barrier: calls already in flight are not waited for. → ADR

Reconcile is not wrapped in a transaction. Each ControllerClient write commits on its own, so a write that lands before Reconcile returns an error stays committed. The next pass works from the stored state, so write Reconcile to be idempotent. Each write is still atomic on its own, and the handshake covers a concurrent spec change racing the obj you were handed: beehive records the generation it gave you, so a newer one is left unobserved and the object reconciles again.

The handshake is written after Reconcile returns, so it cannot join a Within of yours: a conditions-only pass commits its conditions and then its stamp, and a crash between them costs one extra reconcile.

When several writes must land together or not at all, wrap them in ControllerClient.Within(ctx, func(ctx) error { … }). Writes made with the inner ctx join one transaction, which commits when the function returns nil and rolls back on error — Client writes included. That transaction holds the store's single write lock for as long as the function runs, so keep external I/O out of it. Nothing waits on it, because nothing is scheduled: a rolled-back transaction leaves no rows, so no driver can list them. That makes it safe to create or delete children inside Within. The one thing deferred past the commit is WithOnCreate, which is skipped on rollback. → ADR

A non-nil error triggers an automatic retry with exponential backoff starting at 1s and capped at 30s by default. Configurable per-controller with WithMaxRetryInterval.

Testing a controller

Reconcile takes an interface and the object it acts on, so the cheapest test calls it directly against a fake ControllerClient — no store and no beehive, and the assertion lands on what the pass decided rather than on what a row ended up holding. Assert a controller's own status writes this way.

That stops covering a pass that reads another kind's status out of the store, because calling Reconcile directly leaves the read where it was. For that fixture, AdminClient writes what only a controller can otherwise write:

c := beehive.NewAdminClient[ClusterStatus](bh, clusterGK)
require.NoError(t, c.UpdateStatus(ctx, cluster.ID, ClusterStatus{Server: ServerStatus{UID: "server-1"}}))

AdminClient for the whole surface, which serves maintenance as well as fixtures.

AdminClient
type AdminClient[Status any] interface {
    AddDependency(ctx context.Context, fromID, toID ObjectID) error
    AddEvent(ctx context.Context, id ObjectID, event EventSpec) error
    DeleteCondition(ctx context.Context, id ObjectID, conditionType string) error
    DeleteDependency(ctx context.Context, fromID, toID ObjectID) error
    DeleteFinalizer(ctx context.Context, id ObjectID, finalizer string) error
    SetCondition(ctx context.Context, id ObjectID, condition Condition) error
    SetConditions(ctx context.Context, id ObjectID, conditions []Condition) error
    UpdateStatus(ctx context.Context, id ObjectID, status Status) error
}

AdminClient writes what only a reconcile pass can otherwise write, for one kind, from outside a pass. It needs no registered controller and no running beehive, and every verb behaves exactly as it does during a pass — it builds a ControllerClient nothing ever ends, so there is one implementation of each write, not a parallel one.

Two uses. A test fixture, as above. And maintenance: a data migration or backfill that has to set status or conditions, an object wedged by a finalizer nothing will clear, a stale depends_on edge holding its target against collection — for an edge whose source has no controller, this is the only way to drop it at all. Spec rewrites and deletes need nothing from here; Client.Update and Client.Delete already do those.

Run it with beehive stopped. A write while beehive runs races that object's own pass and the later write wins, which for a fixture parking state is usually fine and for a migration is not. A write from a second process is never supported, stopped or not — a migration runs in the app's own process, before Start.

It is not for reconcile logic. A controller holds a ControllerClient bound to the object it was handed; reaching for this one inside Reconcile is how you get the sibling-write race the binding exists to remove. Nothing enforces that, the same way nothing enforces the single-writer rule.

It never stamps observed_generation: the handshake stays beehive's, so an object written here is still unsettled and the owed pass reconciles it once beehive starts — which is what you want after a backfill. Every verb is scoped to the client's kind, and another kind's id is ErrWrongKind. → ADR

Migrator
type Migrator interface {
    SchemaVersionSpec() int                                          // spec version this build writes; 0 = not versioned
    SchemaVersionStatus() int                                        // status version this build writes; 0 = not versioned
    ConvertSpec(from int, raw json.RawMessage) (json.RawMessage, error)
    ConvertStatus(from int, raw json.RawMessage) (json.RawMessage, error)
}

Attach a Migrator per kind by passing WithMigrator to Register. The store records the version each blob was written at in two per-row columns, one for spec and one for status. On read, a blob below the current version goes through ConvertSpec/ConvertStatus; an equal version passes through, as does anything when the current version is 0 ("not versioned"); a higher version means the data was written by a newer build and is rejected as a decode error. from == 0 is the unversioned baseline, so once you enable a migrator its converters have to handle it.

Conversion is lazy and per column: a blob is re-stamped when it is next written, so a status-only write re-stamps only the status version.

A blob that fails to convert, fails to unmarshal, or came from a newer build is a decode failure, and each read path handles it in the way that fails safest:

  • List and the watches skip the bad row, log it and carry on. A watch remembers its version, so it warns once per change rather than once per read.
  • Get/GetByName return the error.
  • The reconcile loop quarantines the row. It cannot reconcile what it cannot decode, and the bytes will not change until someone rewrites the spec, so it logs and treats the pass as a successful no-op rather than retrying the same bytes forever under backoff. A deletion-pending row is still collected, since GC needs only the id. The owed pass re-queues the unsettled row every tick, so the warning repeats at that interval — deliberately, so a bad row stays visible instead of logging once and going quiet.

A kind with no migrator is untouched; its columns stay 0. Only registered kinds can have a migrator, so client-only kinds cannot.

ADR: schema-version migration, for convert-on-read / stamp-on-write and why stamping is never downward.

Options
type Option interface{ apply(any) }

func WithFinalizers(f ...string) Option            // declare finalizers before the object is visible to controllers; registered kinds only
func WithOwner(id ObjectID) Option                 // declare owned_by edge; owner cannot be deleted while this object exists
func WithOnCreate(fn func(ctx context.Context)) Option // run fn after the create commits (Create always; GetOrCreate only when it inserts)
func WithFullPassInterval(d time.Duration) Option  // how often to re-dispatch EVERY object (default: 0, off)
func WithIndividualPassInterval(d time.Duration) Option // how often to re-dispatch EACH object, from the end of its own pass (default: 0, off)
func WithTriggerByID(ch <-chan ObjectID) Option     // requeue each id received on ch (Register only)
func WithTriggerByName(ch <-chan string) Option    // requeue the object holding each name received on ch (Register only)
func WithOwedPassInterval(d time.Duration) Option  // how often to drain work the store records as owed (default: 30s; must be > 0)
func WithStaleDependentsInterval(d time.Duration) Option // how often to re-derive which dependents a target moved past (default: 60s; New only; must be > 0)
func WithWatchFloorInterval(d time.Duration) Option // how often a watch reads without a commit wake (default: 30s; New only; must be > 0)
func WithGCInterval(d time.Duration) Option        // how often to collect dead rows + prune the event log + release free pages (default: 30s; New only; must be > 0)
func WithStartupFullPass(enabled bool) Option      // also re-dispatch settled objects once at startup (default: false, off)
func WithMaxRetryInterval(d time.Duration) Option  // cap on exponential backoff after Reconcile errors (default: 30s)
func WithMigrator(m Migrator) Option               // attach a schema-version Migrator for the kind (Register only)
func WithEventRetention(perTimeline int, maxAge time.Duration) Option // event-log retention: per-(object,category) cap-N ring of runs + optional age bound (0 = unbounded)
func WithWriteLogRetention(perKind int, maxAge time.Duration) Option // write-log retention: per-(group,kind) cap-N ring + age bound (default: 24h, no count bound)

WithOwner writes an owned_by edge in the same transaction as the Create. Deleting the owner then cascades to the child through GC.

WithFinalizers is the one create option that needs a kind this process has registered a controller for; otherwise the call fails with ErrInvalidOption. Only ControllerClient.DeleteFinalizer can clear a finalizer, and it folds the calling controller's own kind into the write — so a client-only kind's finalizer is removable by nothing, and the row would stay deletion-pending forever while its owned_by edge blocks its owner's delete.

The check is process-local and evaluated at call time, since the store records no registrations: it refuses a create issued before this process's own Register. Register the kind first. It runs before any store work and only when the option is used, so an ordinary create on a client-only kind is unaffected — and like every other create-option check it is eager, so GetOrCreate rejects it on the found branch too rather than only when a row is really inserted.

WithOnCreate is the safe way to run a side effect only if the row is really created — an external call, an in-memory counter. It waits for the outermost commit, so it runs once and never after a rollback; it is the only thing in beehive deferred that way. Create always fires it, GetOrCreate only when it inserts. Prefer it to branching on GetOrCreate's created bool, which is returned synchronously: inside an enclosing ControllerClient.Within that bool is set before the transaction commits, so acting on it fires your side effect for a row a rollback may still discard.

AddDependency and DeleteDependency manage the depends_on edges of the object being reconciled, which is the only object that can declare its own. When a target changes, the next dependency-wake scan queues the dependent. Each commits on its own, or joins a Within the controller opened.

The target can be any kind, including one you only ever use through Client and never register — configuration, secrets, any reference data your app writes and your controllers read. The waker scans the whole store's write log rather than only the kinds with controllers, so such a target wakes its dependents like any other.

Dropping an edge is what releases a target you were holding open: a deletion-pending object cannot be collected while a live dependent points at it, and DeleteDependency collects it as soon as the edge goes, rather than at the next sweep.

Every call that creates the edge records, durably and atomically with the edge itself, that the dependent owes a reconcile (a count on the row, reconcile_owed, drained by the owed pass). That one rule covers every way a declare could otherwise miss: a change to the target landing between your read and the edge's commit, and a crash before the wake is serviced. Re-asserting your edges on every pass costs nothing after the first, because only the call that created the edge records anything — the cost is one reconcile per edge ever created.

There is nothing else to pass: the call takes no version claim, because nothing conditions on one. An earlier design stamped the wake only when the target had moved past the version the caller read, which made the claim load-bearing and left one interleaving stranded; with the stamp unconditional, a claim would be dead weight in every caller's hands, so it was removed rather than kept as decoration.

A dependency wake is a guarantee, not a best effort. The scan above is fast and lives in memory, so a crash or a restart can drop a wake — and a dependent that has already settled is invisible to every listing of owed work, because its own generation never moved. So beehive records, on each successful reconcile of an object that has dependencies, the store-wide write cursor that pass observed; a slower pass (60s) then enqueues every dependent whose targets have moved past it. Nothing about that is bookkeeping you can lose: it compares current state, so it recovers a wake lost by any means. A failed reconcile records nothing and is therefore found again. What you get, for a write made through this Beehive, is a wake as soon as the target's write commits, and within a minute even if that wake is lost. A write beehive never saw commit — from another process, or issued straight to the Store behind its back — is outside the supported scope and carries no wake guarantee at all.

ADR: stamp every new dependency edge, for how the count is kept atomic with the edge and why the stamp is unconditional on the claim. The waker itself is a periodic scan of the write log, and the backstop under it is dependency watermarks.

Read calls take LoadOptions (a separate type from Option) to eagerly fetch secondary lookups — see Secondary lookups:

func LoadOwner() LoadOption         // fetch the owner (outgoing owned_by)
func LoadDependencies() LoadOption  // fetch dependencies (outgoing depends_on)
func LoadDependents() LoadOption    // fetch dependents (incoming depends_on)
func LoadOwned() LoadOption         // fetch owned children (incoming owned_by)
func LoadEvents() LoadOption        // fetch the most-recent events (default N per (object,category))

Requeue takes RequeueOptions (also a separate type from Option, applying only to Requeue) — see Reconcile control:

func WithResetBackoff() RequeueOption   // clear the retry backoff ladder before requeuing (default: preserve it)

The event read methods take EventOptions (also a separate type from Option, applying only to ListEvents/WatchEvents) — see Events. WithEventsResumeFrom is the one that means nothing outside WatchEvents, and the other reads ignore it:

func WithEventCategory(cat string) EventOption  // restrict to a single timeline
func WithEventType(t EventType) EventOption      // only Normal or only Warning
func WithEventReason(reason string) EventOption  // only runs with this reason
func WithEventLimit(n int) EventOption           // cap the number of runs returned / snapshotted
func WithEventsSince(t time.Time) EventOption    // only runs active at or after t
func WithEventsResumeFrom(rv int64) EventOption  // WatchEvents: stream above rv instead of snapshotting

Documentation

Overview

Package beehive is an embedded, Kubernetes-inspired control plane backed by a durable store: users declare desired Spec and controllers reconcile actual state toward it, level-triggered, coordinating only through the shared store.

Index

Constants

View Source
const (
	RelationOwnedBy   = storeapi.RelationOwnedBy
	RelationDependsOn = storeapi.RelationDependsOn
)
View Source
const (
	WriteCreate = storeapi.WriteCreate
	WriteUpdate = storeapi.WriteUpdate
	WriteDelete = storeapi.WriteDelete
)

The soft delete is a WriteUpdate: the row is still live and readable, so only collection is WriteDelete.

View Source
const (
	Added    = storeapi.Added
	Modified = storeapi.Modified
	Deleted  = storeapi.Deleted
)

Variables

View Source
var ErrConcurrentNestedTx = storeapi.ErrConcurrentNestedTx

ErrConcurrentNestedTx is returned by the outermost Within when a nested frame is still open at commit, which can only mean another goroutine holds one.

View Source
var ErrDeletionPending = storeapi.ErrDeletionPending

ErrDeletionPending is returned by Update and UpdateByName when the object is being torn down. The write is refused rather than applied: a pass on a deleting row runs collection, not reconcile, so the spec would be discarded.

Distinct from ErrNotFound, because the answers differ — ErrNotFound means create it, ErrDeletionPending means you cannot, since the name stays held until GC releases it. A caller whose object is owned can treat it as "not yet" and do nothing: a physical delete pushes its owner, so the owner's next pass creates the replacement.

View Source
var ErrDuplicateConditionType = storeapi.ErrDuplicateConditionType

ErrDuplicateConditionType is returned by SetConditions when one call names a condition type twice, whose outcome would otherwise depend on apply order.

View Source
var ErrInvalidCondition = storeapi.ErrInvalidCondition

ErrInvalidCondition is returned by SetConditions when a condition's type, status, reason or message is not valid UTF-8.

View Source
var ErrInvalidName = storeapi.ErrInvalidName

ErrInvalidName is returned by name-keyed calls when the name is empty.

View Source
var ErrInvalidOption = errors.New("beehive: option value is invalid")

ErrInvalidOption reports an option value that has no meaning (e.g. a non-positive GC interval). It is about the argument alone, so it is returned regardless of the target — distinct from an option being inapplicable, which is ignored by design.

View Source
var ErrInvalidResult = errors.New("beehive: unusable ReconcileResult")

ErrInvalidResult is what an unusable ReconcileResult — the zero value, or Fail(nil) — fails the pass with. It settles nothing and takes the backoff ladder.

View Source
var ErrInvalidStoreIdentity = errors.New("beehive: store reports no identity")

ErrInvalidStoreIdentity is returned by Start when the store's Identity is empty. "" names no database, so two unrelated stores would collide on it.

View Source
var ErrNameTaken = storeapi.ErrNameTaken

ErrNameTaken is returned by Create when the name is already held, by a live row or a deletion-pending one. GetOrCreate returns the existing row instead.

View Source
var ErrNoController = errors.New("beehive: no controller registered for kind")

ErrNoController is returned by Requeue when the client's kind has no registered controller: there is no reconcile loop to schedule against.

View Source
var ErrNotFound = storeapi.ErrNotFound

ErrNotFound is returned by Store reads when no object matches.

View Source
var ErrNotLoaded = errors.New("beehive: secondary lookup not loaded")

ErrNotLoaded is returned by the secondary-lookup accessors when the requested relation was not fetched on the read that produced the object.

View Source
var ErrReconcileReturned = errors.New("beehive: the ControllerClient passed to Reconcile is no longer usable")

ErrReconcileReturned is returned by every method of the ControllerClient Reconcile was passed, once it has returned.

View Source
var ErrSchemaVersionDowngrade = storeapi.ErrSchemaVersionDowngrade

ErrSchemaVersionDowngrade is returned by Objects().UpdateSpec/UpdateStatus when the caller's schema version is lower than the one stamped on the row.

View Source
var ErrStaleTxContext = storeapi.ErrStaleTxContext

ErrStaleTxContext is returned by a nested Within whose ctx is not the transaction's live innermost frame. Deep nesting on one goroutine is fine; using a ctx from outside the frame you are in is not.

View Source
var ErrStopped = errors.New("beehive: the beehive has stopped")

ErrStopped ends a watch whose Beehive has stopped. It is what separates shutdown from the caller cancelling: resubscribing answers ErrWatchTooOld and cannot answer this one, since a stopped Beehive does not start again.

View Source
var ErrStoreInUse = errors.New("beehive: store already has a running Beehive")

ErrStoreInUse is returned by Start when another Beehive is already running over the store. Distinct from the "already started" error beside it: that one reports misuse of this Beehive, which its caller can already see.

View Source
var ErrWatchTooNew = errors.New("beehive: watch resumes above the log's head")

ErrWatchTooNew ends a resume whose position is above everything the log has held, which means the position did not come from this store — a restored backup or a swapped file restarts the sequence. Answered the same way as ErrWatchTooOld: subscribe again without the resume option. Unreported, such a stream says "caught up" and then drops everything until the sequence climbs past the position.

View Source
var ErrWatchTooOld = errors.New("beehive: watch is below the log's retention horizon")

ErrWatchTooOld ends a watch whose unread entries retention has already removed — the object write log for Watch/WatchList, the event log for WatchEvents. The stream cannot continue truthfully, so it reports this and closes; the caller answers by subscribing again for a fresh snapshot.

View Source
var ErrWrongKind = storeapi.ErrWrongKind

ErrWrongKind is returned by an id-keyed write whose target belongs to another kind. The store folds the caller's kind into every write, so a wrong id fails loudly instead of corrupting another kind's row.

Functions

func EventDetail added in v0.11.0

func EventDetail[T any](e Event) (T, error)

EventDetail unmarshals an event's Detail payload into T. An empty Detail yields the zero value with a nil error.

func GenerateName added in v0.19.0

func GenerateName(prefix string) string

func Register

func Register[Spec, Status any](bh *Beehive, gk GroupKind, c Controller[Spec, Status], opts ...Option) error

Register installs controller c for the resource kind gk. It must be called before Start, and only once per kind. The kind's ControllerClient reaches the controller as a parameter of Reconcile.

Types

type AdminClient added in v0.26.0

type AdminClient[Status any] struct {
	// contains filtered or unexported fields
}

AdminClient writes what only a reconcile pass can otherwise write — status, conditions, finalizers, events and dependency edges — for one kind, from outside a pass. Two uses: a test fixture, and maintenance on a stopped beehive, where a data migration, a backfill or a wedged object has to be written by hand. Not for reconcile logic, which holds a ControllerClient bound to the object it was handed.

Every verb takes an id and is scoped to gk: another kind's id is ErrWrongKind. It writes through a ControllerClient nothing ever ends, so each verb means exactly what it means during a pass.

Run it with beehive stopped. A write while beehive runs races that object's own pass and the later write wins; a write from a second process is never supported at all. See docs/adr/2026-08-18-an-admin-client-writes-outside-a-pass.md.

func NewAdminClient added in v0.26.0

func NewAdminClient[Status any](bh *Beehive, gk GroupKind) *AdminClient[Status]

NewAdminClient returns an AdminClient for gk. Needs no registered controller and no running beehive.

func (*AdminClient[Status]) AddDependency added in v0.26.0

func (a *AdminClient[Status]) AddDependency(ctx context.Context, fromID, toID ObjectID) error

AddDependency records that fromID depends on toID. See ControllerClient.AddDependency.

func (*AdminClient[Status]) AddEvent added in v0.26.0

func (a *AdminClient[Status]) AddEvent(ctx context.Context, id ObjectID, event EventSpec) error

AddEvent adds an observation to id's event log. See ControllerClient.AddEvent.

func (*AdminClient[Status]) DeleteCondition added in v0.26.0

func (a *AdminClient[Status]) DeleteCondition(ctx context.Context, id ObjectID, conditionType string) error

DeleteCondition removes id's condition of that type. See ControllerClient.DeleteCondition.

func (*AdminClient[Status]) DeleteDependency added in v0.26.0

func (a *AdminClient[Status]) DeleteDependency(ctx context.Context, fromID, toID ObjectID) error

DeleteDependency drops the fromID→toID edge. See ControllerClient.DeleteDependency. This is the only way to drop an edge whose source has no controller.

func (*AdminClient[Status]) DeleteFinalizer added in v0.26.0

func (a *AdminClient[Status]) DeleteFinalizer(ctx context.Context, id ObjectID, finalizer string) error

DeleteFinalizer removes id's finalizer, which is how a wedged object is unstuck. See ControllerClient.DeleteFinalizer.

func (*AdminClient[Status]) SetCondition added in v0.26.0

func (a *AdminClient[Status]) SetCondition(ctx context.Context, id ObjectID, condition Condition) error

SetCondition writes id's condition of that type. See ControllerClient.SetCondition.

func (*AdminClient[Status]) SetConditions added in v0.26.0

func (a *AdminClient[Status]) SetConditions(ctx context.Context, id ObjectID, conditions []Condition) error

SetConditions writes every named condition together. See ControllerClient.SetConditions.

func (*AdminClient[Status]) UpdateStatus added in v0.26.0

func (a *AdminClient[Status]) UpdateStatus(ctx context.Context, id ObjectID, status Status) error

UpdateStatus records status for id. See ControllerClient.UpdateStatus. Never stamps observed_generation: the handshake stays beehive's, so an object given a status here is still unsettled and the owed pass reconciles it. Holding no pass, it holds no loaded status either, so every call reaches the store and the no-op is the store's own — ErrNotFound included.

type Beehive

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

Beehive is the control plane: it owns the durable store and the set of registered controllers, and drives their reconcile loops between Start and Stop.

func New

func New(s Store, opts ...Option) (*Beehive, error)

New creates a control plane backed by store s. Register controllers on the returned Beehive before calling Start.

func (*Beehive) Start

func (bh *Beehive) Start(startCtx context.Context) (func(context.Context) error, error)

Start brings the control plane up: the dependency waker, the reconcile loops, the stale-dependents pass, and the GC sweeper — all periodic. It returns a stop function that tears everything down. A Beehive is one-shot: starting twice, or after stop, is an error.

startCtx covers startup only; the long-lived loops end when the returned stop is called. Startup reads the store to seed the dependency waker, so a startCtx that expires while the store is busy fails the start — a store *error* there does not, since the waker is an optimisation.

A store another Beehive is already running is ErrStoreInUse, and one whose Identity is empty is ErrInvalidStoreIdentity. Both cover this process only; keeping a second process off the database is the embedder's.

The store's claim is released once the loops have stopped, which a nil from the stop call that owns the teardown is proof of. Nothing else is: stop returns its own ctx's error if the loops outlast it, and a second concurrent call returns nil at once rather than waiting for the first. So start another Beehive over the same store only after a nil from the owning call.

type ChangeType added in v0.12.0

type ChangeType = storeapi.ChangeType

ChangeType classifies a Change.

type Client

type Client[Spec, Status any] interface {
	// Create inserts a new object under name, which is required and immutable.
	// A name already held by a live or deletion-pending row fails with
	// ErrNameTaken — Create never writes to a row it found; use GetOrCreate
	// when "already there" is acceptable. The new object is unsettled and owed
	// its first reconcile.
	Create(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], error)
	// CreateOrUpdate makes whatever holds name hold spec, creating it if
	// absent; created says which. The resolve and the write are one
	// transaction.
	//
	// opts are honoured on create and IGNORED on update, as in GetOrCreate: a
	// created=false result says nothing about whether the row matches opts. A
	// deletion-pending row is refused with ErrDeletionPending rather than
	// rewritten, and its name stays held until GC releases it.
	//
	// Like GetOrCreate's, created is synchronous inside a caller's Within —
	// route create-conditional side effects through WithOnCreate.
	CreateOrUpdate(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], bool, error)
	// Delete soft-deletes id by setting DeletionRequestedAt. A registered kind
	// reaches its controller at commit; a client-only kind waits for the GC
	// sweeper, which takes every deletion from there either way. Returns
	// ErrNotFound if id holds no object — it was collected out from under the
	// caller, which is worth hearing about. Idempotent on an already-pending
	// row. Kind-scoped.
	Delete(ctx context.Context, id ObjectID) error
	// DeleteByName is Delete keyed by name: it acts on whatever holds name now.
	// Idempotent, and absence folds to nil — a name nothing holds is the
	// desired state — which is the one place it departs from Delete.
	DeleteByName(ctx context.Context, name string) error
	// Get loads id, or returns ErrNotFound. Kind-scoped: another kind's row is
	// not found. The read half of a read-modify-write, whose write half is
	// Update.
	Get(ctx context.Context, id ObjectID, loads ...LoadOption) (*Object[Spec, Status], error)
	// GetByName is Get keyed by name: it loads whatever holds name now, or
	// returns ErrNotFound. Kind-scoped — another kind's row holding the same
	// name is not found.
	GetByName(ctx context.Context, name string, loads ...LoadOption) (*Object[Spec, Status], error)
	// GetLatestEvent returns the current run in id's category timeline. ok is
	// false (with a nil error) when the timeline is empty.
	//
	// This, ListEvents and WatchEvents all read by id and are not kind-scoped: an
	// event carries no kind of its own, so any id's log reads through any client.
	// The write is the asymmetry — ControllerClient.AddEvent appends to the
	// object its pass was handed.
	GetLatestEvent(ctx context.Context, id ObjectID, category string) (Event, bool, error)
	// GetOrCreate returns the object with the given name, creating it from spec
	// if absent. It NEVER mutates an existing row: a name held by a live or
	// deletion-pending row is returned as-is with created=false, options
	// ignored — do not read created=false as "exists and matches opts". The
	// read-or-create is atomic, so concurrent creates can't both win.
	//
	// There is no name-keyed upsert: to change an existing row, follow with
	// Update. spec and opts are validated up front even when the row exists, so
	// a caller bug fails regardless of store state.
	//
	// The created bool is synchronous: inside a caller's Within it is set
	// before the transaction commits, so route create-conditional side effects
	// through WithOnCreate instead. A non-nil err means nothing was created —
	// a new row that fails to decode rolls back rather than committing bytes
	// the process can't read.
	GetOrCreate(ctx context.Context, name string, spec Spec, opts ...Option) (*Object[Spec, Status], bool, error)
	// GetOwner returns id's owner, if it has one; ok is false with a nil error
	// when it has none. The lazy counterpart to LoadOwner().
	//
	// This and ListDependencies/ListDependents/ListOwned run their edge query
	// directly with no kind check: a foreign id reads that kind's edges and a
	// missing id reads empty — neither returns ErrNotFound. Use them for ids
	// this client owns.
	GetOwner(ctx context.Context, id ObjectID) (ObjectRef, bool, error)
	// GetSchedule reports when the reconcile loop has scheduled id to be
	// requeued (a pending backoff or RequeueAfter delay; for a queued id, the
	// moment it became due), or the zero Schedule when nothing is scheduled. A
	// non-blocking read of in-memory state: a missing or foreign id, and a
	// client-only kind, all read as the zero Schedule; the error is never
	// returned today.
	//
	// This is the next *scheduled* requeue, not a prediction: the periodic
	// passes and dependency wakes never appear here, so the real next reconcile
	// may be sooner. Treat it as observability; use WatchSchedule to follow it
	// live.
	GetSchedule(ctx context.Context, id ObjectID) (Schedule, error)
	List(ctx context.Context, loads ...LoadOption) ([]*Object[Spec, Status], error)
	// ListDependencies returns the objects id depends on (outgoing depends_on).
	// The lazy counterpart to LoadDependencies().
	ListDependencies(ctx context.Context, id ObjectID) ([]ObjectRef, error)
	// ListDependents returns the objects that depend on id (incoming
	// depends_on). The lazy counterpart to LoadDependents().
	ListDependents(ctx context.Context, id ObjectID) ([]ObjectRef, error)
	// ListEvents returns id's event-log runs, newest-first, filtered by opts.
	// Reads by id, not kind-scoped. An empty log is an empty slice.
	ListEvents(ctx context.Context, id ObjectID, opts ...EventOption) ([]Event, error)
	// ListOwned returns the objects id owns (its incoming owned_by edges). The
	// lazy counterpart to LoadOwned().
	ListOwned(ctx context.Context, id ObjectID) ([]ObjectRef, error)
	// ListOwnedObjects returns the objects owned by ownerID that belong to THIS
	// client's kind, fully decoded — the typed, kind-scoped form of ListOwned,
	// resolved in one query instead of a Get per child. ownerID is typically
	// another kind and is not existence-checked: no children, or no such owner,
	// both read empty. Deletion-pending children are included; undecodable rows
	// are quarantined and logged, as in List. Takes the same LoadOptions as
	// List.
	ListOwnedObjects(ctx context.Context, ownerID ObjectID, loads ...LoadOption) ([]*Object[Spec, Status], error)
	// Requeue queues id for reconcile now. A latency hint, not a synchronous
	// run: correctness rests on the periodic drivers. By default it keeps id's
	// retry backoff — a requeue almost never proves a failure is over — pass
	// WithResetBackoff() when it is. Returns ErrNotFound if id does not exist,
	// ErrNoController if the kind has no controller.
	Requeue(ctx context.Context, id ObjectID, opts ...RequeueOption) error
	// Update replaces id's spec, or returns ErrNotFound (a missing row is not
	// "already in the desired state"). A spec whose bytes match what is stored
	// writes nothing at all, so a controller re-applying its own spec does not
	// wake itself forever. The write half of a read-modify-write.
	//
	// A row whose deletion has been requested is refused with
	// ErrDeletionPending, not written: a pass on a deleting row runs collection,
	// so the spec would be discarded. That is distinct from ErrNotFound, because
	// the answers differ — absent means create it, pending means you cannot,
	// since the name stays held until GC releases it. A caller whose object is
	// owned can do nothing and wait: a physical delete pushes its owner, so the
	// owner's next pass creates the replacement. Left unhandled inside a
	// Reconcile the sentinel surfaces as a failure and enters the retry ladder,
	// against a condition that clears on GC's schedule and not on retrying.
	Update(ctx context.Context, id ObjectID, spec Spec) (*Object[Spec, Status], error)
	// UpdateByName is Update keyed by name: it writes whatever holds name now,
	// resolving and writing in one transaction. Not for a read-modify-write —
	// a collect-and-recreate between the read and the write would land it on a
	// different incarnation; use Update. Same ErrDeletionPending refusal.
	UpdateByName(ctx context.Context, name string, spec Spec) (*Object[Spec, Status], error)
	// Watch returns one object's current state plus a stream of the changes
	// above it: Added/Modified/Deleted until ctx is cancelled. Kind-scoped, and
	// needs no registered controller — the tail reads the write log, not a
	// reconciler. It follows one incarnation: an id holding nothing is a nil
	// ObjectStream.Object rather than ErrNotFound, and a recreate under the
	// same name is a different id, so the stream ends at Deleted.
	//
	// The snapshot is read before Watch returns, on the caller's goroutine, so a
	// caller may subscribe and then act: a change it makes afterwards — including
	// a delete — is always in the stream. The snapshot's ResourceVersion is the
	// log position it is complete as of, and the stream carries changes strictly
	// above it: no overlap, no gap. A failed snapshot read is returned rather
	// than handed back as a stream whose guarantee is void.
	//
	// Everything after comes from the kind's shared tailer: a commit wakes it,
	// and a floor tick covers what a wake cannot. Delivery is latest-per-object,
	// so changes to one object collapse. A watch cannot be opened inside a
	// transaction: its snapshot takes the writer, which the transaction holds.
	//
	// The wider rule is worth stating on its own. Pass the ctx you were given to
	// every store call inside a transaction. An ordinary read on another ctx
	// runs on the read pool, so it does not block — it returns committed state,
	// silently missing the transaction's own writes.
	Watch(ctx context.Context, id ObjectID, opts ...WatchOption) (*ObjectStream[Spec, Status], error)
	// WatchEvents streams id's event log: a snapshot of the runs matching opts,
	// the position it was read at, and the runs the log grows by above it. An
	// extend re-samples ResourceVersion, so a run that grew is delivered again
	// carrying its latest state. WithEventsResumeFrom starts above a position
	// instead of snapshotting; one retention has passed is ErrWatchTooOld and
	// one above the log's head is ErrWatchTooNew. The stream reports the
	// configured EventRetention, so a caller holding runs in memory can bound
	// its own list. Reads by id, not kind-scoped. Requires a registered
	// controller for this client's kind, which is a property of the caller and
	// not of the target.
	WatchEvents(ctx context.Context, id ObjectID, opts ...EventOption) (*EventStream, error)
	// WatchList is Watch over every object of this client's kind: the same
	// snapshot-and-stream contract, tailer and errors. See Watch.
	WatchList(ctx context.Context, opts ...WatchOption) (*ObjectListStream[Spec, Status], error)
	// WatchOwnedObjects is ListOwnedObjects as a watch: a snapshot of
	// ownerID's children of this kind, then every change to one of them. A child
	// created under ownerID later arrives as Added and its collection as Deleted.
	// Same options, errors and shared tailer as WatchList.
	//
	// Assumes the one owner WithOwner can express, as GetOwner and Owner() do: a
	// child carrying several owned_by edges — reachable only through a direct
	// Store call — streams to one of them. See docs/TODO.md.
	WatchOwnedObjects(ctx context.Context, ownerID ObjectID, opts ...WatchOption) (*ObjectListStream[Spec, Status], error)
	// WatchSchedule streams id's schedule as a gauge: the current value, then
	// a new Schedule whenever it changes. Unlike the other watches it reports
	// in-memory state as the work queue moves it — no polling, emits only on
	// change. The channel closes when ctx is cancelled OR when the Beehive
	// stops (after delivering the final schedule); a reader cannot tell the two
	// apart. A client-only kind returns ErrNoController; id need not exist.
	WatchSchedule(ctx context.Context, id ObjectID) (<-chan Schedule, error)
}

Client is the user-facing API for a single resource kind: creating, reading, updating, deleting, and watching objects.

func NewClient

func NewClient[Spec, Status any](bh *Beehive, gk GroupKind) Client[Spec, Status]

NewClient returns a Client for the given resource kind. Spec and Status must match the controller registered for gk.

type Condition

type Condition struct {
	Type    string
	Status  ConditionStatus
	Reason  string
	Message string
	// Liveness marks a condition valid only within the writing process. The
	// store downgrades a liveness condition written by a prior process to
	// Unknown until a controller re-confirms it.
	Liveness bool
	// Unconfirmed reports that Status is a downgrade this process derived, not a
	// status anyone wrote: a liveness condition an earlier process left behind,
	// read as Unknown until a controller re-confirms it. Reason, Message and the
	// stamps below are the pre-downgrade write's, so they describe the last known
	// status rather than this Unknown.
	Unconfirmed bool
	// Set by the store on read, ignored on write. A downgraded liveness
	// condition keeps the stored write's stamps.
	TransitionedAt time.Time // when Status last changed
	UpdatedAt      time.Time // when the condition was last written
}

Condition is a standard observation about an object's state, reported by its controller (e.g. type "Ready", status True).

type ConditionStatus

type ConditionStatus string

ConditionStatus is the state of a Condition: True, False, or Unknown.

const (
	ConditionTrue    ConditionStatus = "True"
	ConditionFalse   ConditionStatus = "False"
	ConditionUnknown ConditionStatus = "Unknown"
)

type Controller

type Controller[Spec, Status any] interface {
	Reconcile(ctx context.Context, client ControllerClient[Status], obj *Object[Spec, Status]) ReconcileResult
}

Controller is the user-supplied reconcile logic for a resource kind. Reconcile drives an object toward its desired state; the client is the status-write surface for this controller's kind. Build the return with Settled, Unsettled or Fail.

type ControllerClient

type ControllerClient[Status any] interface {
	// AddDependency records that this pass's object depends on toID, so beehive
	// reconciles it again when toID changes. Every call that creates the edge
	// records one owed reconcile, durably and atomically with the edge, so a
	// declared dependency is a guarantee rather than a subscription.
	// Re-asserting existing edges records nothing.
	AddDependency(ctx context.Context, toID ObjectID) error
	// AddEvent adds an observation to the object's event log. Repeating the
	// latest run's (Category, Type, Reason) extends that run rather than
	// appending, so a controller can report every poll without growing the log
	// per poll.
	AddEvent(ctx context.Context, event EventSpec) error
	DeleteCondition(ctx context.Context, conditionType string) error
	DeleteDependency(ctx context.Context, toID ObjectID) error
	DeleteFinalizer(ctx context.Context, finalizer string) error
	// GetOwner returns the object's owner, if any. ok is false with a nil error
	// when it has none.
	GetOwner(ctx context.Context) (ObjectRef, bool, error)
	// HasIncomingEdges reports whether any object with a live claim still points
	// at this pass's object: an owned child, or a dependent that is not itself
	// being deleted. A finalizer can gate teardown on it.
	HasIncomingEdges(ctx context.Context) (bool, error)
	// ListDependencies returns the objects this one depends on (outgoing
	// depends_on).
	ListDependencies(ctx context.Context) ([]ObjectRef, error)
	// ListDependents returns the objects that depend on this one (incoming
	// depends_on).
	ListDependents(ctx context.Context) ([]ObjectRef, error)
	// ListOwned returns the objects this one owns (its incoming owned_by edges).
	ListOwned(ctx context.Context) ([]ObjectRef, error)
	// SetCondition writes the condition of that type. The store stamps
	// TransitionedAt and UpdatedAt; the passed values are ignored. Refuses text
	// that is not valid UTF-8 with ErrInvalidCondition, as SetConditions does.
	SetCondition(ctx context.Context, condition Condition) error
	// SetConditions writes every named condition together, under a single version
	// bump, so a watcher never sees half a pass. A type named twice is refused
	// with ErrDuplicateConditionType; text that is not valid UTF-8 — in Type,
	// Status, Reason or Message — with ErrInvalidCondition, which a Message
	// carrying a raw err.Error() can reach. An empty slice writes nothing. Same
	// stamping as SetCondition.
	SetConditions(ctx context.Context, conditions []Condition) error
	// UpdateStatus records status and nothing else — the handshake is beehive's,
	// recorded by returning Settled. Status that marshals to the bytes this pass
	// was loaded with writes nothing and reaches no store, so a controller can
	// report on every poll. That skip reads nothing, so it cannot report an
	// object collected mid-pass: it returns nil where a write returns ErrNotFound.
	UpdateStatus(ctx context.Context, status Status) error
	// Within runs fn inside a single transaction: writes made with fn's ctx all
	// commit together or roll back on error. Pass fn's ctx to every store call
	// it makes. A read on any other ctx does not join the transaction, and reads
	// run on their own connection, so it quietly returns state from before the
	// transaction rather than failing. Watches cannot be opened inside it.
	//
	// fn's ctx belongs to one goroutine. Issuing store calls on it from two at
	// once interleaves them on one prepared statement's cursor: each sees part of
	// the other's rows, and neither reports an error.
	Within(ctx context.Context, fn func(ctx context.Context) error) error
}

ControllerClient is the write surface a controller uses to report observed state. It writes only Status and metadata — never Spec, which the user owns.

It is bound to the object its Reconcile was handed and acts on that object alone; the ids below are the other end of an edge. It lives for the one Reconcile it is passed to: afterwards every method returns ErrReconcileReturned, and there is no other way to hold one.

type DeletionCascadeChild added in v0.19.0

type DeletionCascadeChild = storeapi.DeletionCascadeChild

DeletionCascadeChild is one owned child of a deletion cascade, as DeletionRequests().CreateFromOwner reports it.

type Event added in v0.11.0

type Event struct {
	ID       EventID
	ObjectID ObjectID
	Category string
	Type     EventType
	Reason   string
	Message  string          // latest occurrence's message
	Detail   json.RawMessage // latest occurrence's payload; nil = none
	Count    int             // occurrences in this run (>= 1)
	FirstAt  time.Time       // run start
	LastAt   time.Time       // run end (latest occurrence)

	// ResourceVersion orders the log and is what a watch resumes above. An
	// extend re-samples it, so a run that grew carries a fresh one.
	ResourceVersion int64
}

Event is one contiguous run of observations about an object, aggregated by (Category, Type, Reason).

type EventID added in v0.11.0

type EventID = storeapi.EventID

EventID is the store-assigned unique identifier for an event run.

type EventOption added in v0.11.0

type EventOption func(*eventConfig)

EventOption configures a Client.ListEvents / WatchEvents read.

func WithEventCategory added in v0.11.0

func WithEventCategory(category string) EventOption

WithEventCategory restricts a read to a single timeline. The category "" is the default timeline (distinct from "no filter", the absence of this option).

func WithEventLimit added in v0.11.0

func WithEventLimit(n int) EventOption

WithEventLimit caps a read to the newest n runs. On WatchEvents it bounds the snapshot only: a tail has no end to count back from.

func WithEventReason added in v0.11.0

func WithEventReason(reason string) EventOption

WithEventReason restricts a read to runs with the given reason.

func WithEventType added in v0.11.0

func WithEventType(t EventType) EventOption

WithEventType restricts a read to one severity (Normal or Warning).

func WithEventsResumeFrom added in v0.19.0

func WithEventsResumeFrom(rv int64) EventOption

WithEventsResumeFrom streams the runs above rv instead of taking a snapshot. WatchEvents only — the other reads ignore it, the way an Option ignores a target it does not recognise. A position retention has passed ends the stream with ErrWatchTooOld, answered by subscribing again without this option.

func WithEventsSince added in v0.11.0

func WithEventsSince(t time.Time) EventOption

WithEventsSince restricts a read to runs still active at or after t (LastAt >= t).

type EventRetention added in v0.23.0

type EventRetention struct {
	// PerTimeline caps each (object, category) timeline to its newest N runs,
	// so it bounds one stream only when the watch is scoped to one category.
	PerTimeline int
	// MaxAge drops runs whose window ended more than MaxAge ago, across every
	// timeline.
	MaxAge time.Duration
}

EventRetention is the event-log bound the GC sweeper enforces, as configured by WithEventRetention. A zero field is that bound unset.

type EventSpec added in v0.11.0

type EventSpec struct {
	Category string // independent timeline; "" = default
	Type     EventType
	Reason   string // machine-readable token, e.g. "ProbeFailed"
	Message  string // human-readable; sampled, not keyed
	Detail   any    // optional payload; marshaled on write; nil = none
}

EventSpec is the caller-supplied portion of an event, passed to ControllerClient.Events().Add. Consecutive emissions sharing (Category, Type, Reason) coalesce into one run; Message and Detail are sampled (latest wins).

type EventStream added in v0.19.0

type EventStream struct {
	// Runs is the snapshot, newest-first like Events().List. Empty on a resume.
	Runs []Event
	// ResourceVersion is the position Runs was read at, and the value to hand
	// back to WithEventsResumeFrom.
	ResourceVersion int64
	// Events delivers the runs above ResourceVersion, oldest-first, until ctx
	// ends or the stream fails. Closed exactly once.
	Events <-chan Event
	// Retention is the bound the sweeper enforces on this log, so a consumer
	// holding runs in memory can size its own list from it. Zero fields mean
	// unbounded; it is process configuration, fixed for the stream's life.
	Retention EventRetention
	// contains filtered or unexported fields
}

EventStream is a live view of one object's event log: the runs matching the query as of the subscribe, the position they were read at, and what the log grows by after it.

func (*EventStream) Err added in v0.19.0

func (s *EventStream) Err() error

Err reports why the stream ended, after Events is closed: ErrWatchTooOld, ErrNotFound for a collected object, ErrStopped, or nil when the caller's own context ended.

type EventType added in v0.11.0

type EventType string

EventType classifies an event's severity.

const (
	EventNormal  EventType = "Normal"
	EventWarning EventType = "Warning"
)

type EventsAddInput added in v0.19.0

type EventsAddInput = storeapi.EventsAddInput

EventsAddInput is the write shape Events().Add accepts — only the fields a recorded observation carries.

type GroupKind

type GroupKind = storeapi.GroupKind

GroupKind identifies a kind of resource. An empty Group denotes the core group.

type LoadOption added in v0.4.0

type LoadOption func(*LoadSet)

LoadOption selects a secondary lookup to fetch alongside an object on a read (Get/GetByName/List). The lazy alternative: omit it and call Client.GetOwner/ListDependencies when the data is needed.

func LoadDependencies added in v0.4.0

func LoadDependencies() LoadOption

LoadDependencies selects the objects this one depends on (outgoing depends_on).

func LoadDependents added in v0.4.0

func LoadDependents() LoadOption

LoadDependents selects the objects that depend on this one (incoming depends_on).

func LoadEvents added in v0.11.0

func LoadEvents() LoadOption

LoadEvents selects the object's event-log runs, read via Object.Events(). For filtered or bounded reads use the lazy Client.ListEvents instead.

func LoadOwned added in v0.5.0

func LoadOwned() LoadOption

LoadOwned selects the objects this one owns (its incoming owned_by edges).

func LoadOwner added in v0.4.0

func LoadOwner() LoadOption

LoadOwner selects the object's owner (its outgoing owned_by edge).

type LoadSet added in v0.4.0

type LoadSet uint8

LoadSet is a bitset of secondary lookups (owner, dependencies, dependents, owned, events) to fetch alongside an object. The zero value loads nothing.

const (
	// LoadOwnerBit selects the object's owner (its outgoing owned_by edge).
	LoadOwnerBit LoadSet = 1 << iota
	// LoadDependenciesBit selects the object's dependencies (outgoing depends_on).
	LoadDependenciesBit
	// LoadDependentsBit selects the objects that depend on it (incoming depends_on).
	LoadDependentsBit
	// LoadOwnedBit selects the objects this one owns (incoming owned_by edges).
	LoadOwnedBit
	// LoadEventsBit selects the object's event-log runs.
	LoadEventsBit
)

type Migrator added in v0.3.0

type Migrator interface {
	// SchemaVersionSpec is the spec schema version this build writes.
	// 0 means spec is not versioned for this kind.
	SchemaVersionSpec() int
	// SchemaVersionStatus is the status schema version this build writes.
	// 0 means status is not versioned for this kind.
	SchemaVersionStatus() int
	// ConvertSpec upgrades spec bytes written at version from to the current
	// version. Called only when 0 <= from < SchemaVersionSpec(); from == 0 is
	// the unversioned baseline and must be handled.
	ConvertSpec(from int, raw json.RawMessage) (json.RawMessage, error)
	// ConvertStatus upgrades status bytes written at version from to the
	// current version; same contract as ConvertSpec.
	ConvertStatus(from int, raw json.RawMessage) (json.RawMessage, error)
}

Migrator upgrades a kind's stored Spec/Status JSON to the shape this build expects, at the decode boundary. Spec and Status carry independent versions and convert independently; a current version of 0 means "not versioned". Conversion is lazy: bytes are upgraded on read and re-stamped only when the blob is next written. Register one per kind via WithMigrator.

type Object

type Object[Spec, Status any] struct {
	ID                  ObjectID
	Group               string
	Kind                string
	Name                string
	Spec                Spec
	Status              *Status
	Generation          int64      // bumped on every Spec write that isn't a no-op
	ObservedGeneration  *int64     // Generation the controller last reconciled; nil until first reconcile
	ObservedAt          *time.Time // when ObservedGeneration was recorded; not a reconcile heartbeat
	ResourceVersion     int64      // bumped on every write
	DeletionRequestedAt *time.Time // set when deletion is requested; object lingers until finalizers clear
	Finalizers          []string
	Conditions          []Condition // per-type observations reported by controllers
	CreatedAt           time.Time
	UpdatedAt           time.Time
	// contains filtered or unexported fields
}

Object is a single resource: user-owned desired state (Spec) plus controller-owned observed state (Status), along with the metadata Beehive uses to track convergence and deletion.

func (*Object[Spec, Status]) Dependencies added in v0.4.0

func (o *Object[Spec, Status]) Dependencies() ([]ObjectRef, error)

Dependencies returns the objects this one depends on, or ErrNotLoaded if LoadDependencies() was not passed to the read.

func (*Object[Spec, Status]) Dependents added in v0.4.0

func (o *Object[Spec, Status]) Dependents() ([]ObjectRef, error)

Dependents returns the objects that depend on this one, or ErrNotLoaded if LoadDependents() was not passed to the read.

func (*Object[Spec, Status]) Events added in v0.19.0

func (o *Object[Spec, Status]) Events() ([]Event, error)

Events returns the object's event-log runs, newest-first, or ErrNotLoaded if LoadEvents() was not passed to the read.

func (*Object[Spec, Status]) Owned added in v0.5.0

func (o *Object[Spec, Status]) Owned() ([]ObjectRef, error)

Owned returns the objects this one owns, or ErrNotLoaded if LoadOwned() was not passed to the read.

func (*Object[Spec, Status]) Owner added in v0.4.0

func (o *Object[Spec, Status]) Owner() (ObjectRef, bool, error)

Owner returns the object's owner. ok reports presence; ErrNotLoaded if LoadOwner() was not passed to the read.

type ObjectChange added in v0.19.0

type ObjectChange[Spec, Status any] struct {
	Type ChangeType
	// ID is the object this change is about, set whether or not Object is.
	ID ObjectID
	// ResourceVersion is the log position this change was reported at, and what
	// WithResumeFrom takes to continue from here.
	ResourceVersion int64
	Object          *Object[Spec, Status]
}

ObjectChange reports a change to a watched object. On a Deleted change, Object carries the row's final state, or is nil when that state could not be decoded — the removal is reported either way, because nothing later in the log mentions a deleted id. Why a stream ended is reported beside it, by the stream's own Err.

type ObjectID

type ObjectID = storeapi.ObjectID

ObjectID is the store-assigned unique identifier for an object.

type ObjectListStream added in v0.23.0

type ObjectListStream[Spec, Status any] struct {
	// Objects is the snapshot. Empty on a resume.
	Objects []*Object[Spec, Status]
	// ResourceVersion is the position Objects is complete as of, and the value
	// to hand back to WithResumeFrom.
	ResourceVersion int64
	// Changes delivers the changes above ResourceVersion, ascending by resource
	// version, until ctx ends or the stream fails. Closed exactly once.
	Changes <-chan ObjectChange[Spec, Status]
	// contains filtered or unexported fields
}

ObjectListStream is ObjectStream over many objects: a kind, or one owner's children of a kind.

func (ObjectListStream) Err added in v0.23.0

func (f ObjectListStream) Err() error

Err reports why the stream ended, after its change channel is closed: ErrWatchTooOld for a stream that fell below retention, ErrWatchTooNew for a resume position this store never issued, ErrStopped for a Beehive that stopped, or nil when the caller's own context ended. Before the close it reports nil, which says nothing.

type ObjectRef added in v0.19.0

type ObjectRef = storeapi.ObjectRef

ObjectRef identifies a related object — an owner, a dependency, or a dependent — carrying the GroupKind needed to address it.

type ObjectStream added in v0.23.0

type ObjectStream[Spec, Status any] struct {
	// Object is the current state, or nil when the id holds nothing yet. Nil on
	// a resume.
	Object *Object[Spec, Status]
	// ResourceVersion is the position Object was read at, and the value to hand
	// back to WithResumeFrom.
	ResourceVersion int64
	// Changes delivers the changes above ResourceVersion, ascending by resource
	// version, until ctx ends or the stream fails. Closed exactly once.
	Changes <-chan ObjectChange[Spec, Status]
	// contains filtered or unexported fields
}

ObjectStream is a live view of one object: its state as of the subscribe, the position that state was read at, and the changes above it.

func (ObjectStream) Err added in v0.23.0

func (f ObjectStream) Err() error

Err reports why the stream ended, after its change channel is closed: ErrWatchTooOld for a stream that fell below retention, ErrWatchTooNew for a resume position this store never issued, ErrStopped for a Beehive that stopped, or nil when the caller's own context ended. Before the close it reports nil, which says nothing.

type ObjectWrite added in v0.19.0

type ObjectWrite = storeapi.ObjectWrite

ObjectWrite is one entry of the object write log.

type ObjectsCreateInput added in v0.19.0

type ObjectsCreateInput = storeapi.ObjectsCreateInput

ObjectsCreateInput is the write shape Objects().Create accepts — only the fields a create honours.

type Option

type Option func(target any) error

Option configures a target — a Beehive, a reconciler, or a per-object operation — depending on where it is passed. Each option type-switches on the targets it understands and ignores the rest.

func WithConcurrency

func WithConcurrency(n int) Option

WithConcurrency sets the number of concurrent worker goroutines for a controller; <= 1 means single-threaded (the default). Passed to New it is the default for all controllers; passed to Register it overrides one.

func WithEventRetention added in v0.11.0

func WithEventRetention(perTimeline int, maxAge time.Duration) Option

WithEventRetention bounds the event log, enforced globally by the GC sweeper. perTimeline > 0 caps each (object, category) timeline to its newest perTimeline runs — runs, not occurrences, since an extend grows a run in place; maxAge > 0 drops runs whose window ended more than maxAge ago, across every timeline. A zero bound is skipped; both zero (the default) leaves the log unbounded. The sweeper enforces the cap on its own interval, so a burst can sit above it until the next sweep. Meaningful only at New.

func WithFinalizers

func WithFinalizers(f ...string) Option

WithFinalizers attaches finalizers that must be cleared before an object is physically deleted.

It requires a controller registered for the kind in this process, and the call is rejected with ErrInvalidOption otherwise: only ControllerClient.DeleteFinalizer can clear a finalizer, so one no controller here can remove would leave the row deletion-pending forever, RESTRICT- blocking its owner's delete. The check is process-local and evaluated at call time — the store tracks no registrations — so register the kind first.

func WithFullPassInterval added in v0.19.0

func WithFullPassInterval(d time.Duration) Option

WithFullPassInterval sets how often a controller re-dispatches *every* object it owns, converged or not. Default 0 (disabled).

This is the expensive pass, and the only one that reaches an object nothing has recorded as owing work — process-scoped state a restart invalidated, or a wake lost for a reason nothing observed. It is opt-in because its cost scales with the object count, and convergence is already covered by the owed pass and the startup pass. It does not pace the owed-work tick.

func WithGCInterval added in v0.19.0

func WithGCInterval(d time.Duration) Option

WithGCInterval sets how often the global GC sweeper runs: collecting deletion-pending objects of every kind, applying event-log retention, and releasing freed space. Meaningful only at New.

Unlike WithFullPassInterval it cannot be disabled: d <= 0 is rejected with ErrInvalidOption. Nothing on the public surface triggers collect, so a sweeper-less Beehive would strand deletion-pending rows with no recourse. A long interval expresses "collect rarely"; there is no "never".

A deletion cascade over *registered* kinds advances a level per commit, so this is not its latency. A client-only level has no push at all and costs one interval, so a subtree of client-only kinds takes one interval per level. The sweeper's per-sweep work budgets scale with d, so a longer interval trims and reclaims proportionally more rather than at a lower rate. See docs/adr/2026-08-06-driver-cadences-are-configurable.md.

func WithIndividualPassInterval added in v0.27.0

func WithIndividualPassInterval(d time.Duration) Option

WithIndividualPassInterval gives every object of the kind a pass roughly every d, measured from the end of each object's own last pass. Default 0 (disabled).

It schedules a pass that returned settled without asking to be requeued; every other result keeps its own schedule, so d is a default cadence rather than a ceiling. Armings are jittered upward, and a scan at startup spreads the first pass of each object across d — pair it with WithStartupFullPass for a kind that needs that first pass promptly. See docs/adr/2026-08-19-an-individual-pass-interval.md.

Passed to New it sets the default for all controllers; passed to Register it overrides that default for one.

func WithLogLevel

func WithLogLevel(level slog.Level) Option

WithLogLevel sets the minimum level beehive emits, on top of whatever the logger's own handler filters. No effect without WithLogger. Passed to New it applies to the control plane and is the default for all controllers; passed to Register it overrides one.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger routes beehive's internal logging through l (zap, zerolog, logrus and logr all ship slog bridges). A nil logger disables logging entirely, which is the default. Passed to New it sets the control plane's logger and the default for all controllers; passed to Register it overrides one.

func WithMaxRetryInterval

func WithMaxRetryInterval(d time.Duration) Option

WithMaxRetryInterval caps the exponential backoff between failed reconciles for a controller. A value <= 0 is ignored, keeping the default — a non-positive cap would busy-loop the reconciler on a persistent error.

The cap bounds the retry rate outright: a wake arriving while the object sits on its backoff alarm is absorbed by that alarm rather than dispatched.

func WithMigrator added in v0.3.0

func WithMigrator(m Migrator) Option

WithMigrator registers a Migrator for the controller's kind, applied to stored Spec/Status JSON on read. Meaningful only at Register, which installs it into the registry both the client and the reconciler decode through.

func WithOnCreate added in v0.17.0

func WithOnCreate(fn func(ctx context.Context)) Option

WithOnCreate registers fn to run once, on the caller's ctx, only if the call actually inserts a new row — and only after the outermost transaction commits (Store.AfterCommit), so a rollback never runs it. Use it for create-conditional side effects instead of GetOrCreate's returned bool, which inside a caller's Within reports true before the transaction commits.

func WithOwedPassInterval added in v0.19.0

func WithOwedPassInterval(d time.Duration) Option

WithOwedPassInterval sets how often a controller drains work the store records as owed: unconverged specs and owed dependency wakes. Default 30s. Dispatches at New and at Register, so one kind can differ from the rest.

Every trigger for a registered kind pushes at commit, so this is not the latency of a local write. What lengthening it costs is how long a *lost* push waits — a crash between the commit and the dispatch, a stamp made while no reconciler was registered for the source's kind — plus the first drain after a restart, which runs at startup regardless. See docs/adr/2026-08-06-driver-cadences-are-configurable.md.

Cannot be disabled: d <= 0 is rejected with ErrInvalidOption. Unlike WithFullPassInterval, this pass is what makes convergence a guarantee rather than an optimisation, and its cost is bounded by what is outstanding rather than by the object count — so "rarely" is expressible and "never" is not.

func WithOwner

func WithOwner(id ObjectID) Option

WithOwner records an owning object, so the child is cleaned up with its owner.

func WithStaleDependentsInterval added in v0.19.0

func WithStaleDependentsInterval(d time.Duration) Option

WithStaleDependentsInterval sets how often the stale-dependents pass re-derives which dependents a dependency has moved under. Default 60s. Global and meaningful only at New.

The dependency waker propagates a target change per commit, so this is not dependency latency. What lengthening it costs is how long a dependent stays stale when that wake was *lost* — a crash before the scan, a failed seed, a write no waker cursor covers — and it is the only thing that re-derives, so a long value is a long window. It also paces the waker's abandon jump, which hands a range to this pass on the argument that it has already swept it.

Cannot be disabled: d <= 0 is rejected with ErrInvalidOption.

func WithStartupFullPass added in v0.19.0

func WithStartupFullPass(enabled bool) Option

WithStartupFullPass sets whether a controller re-dispatches *every* object once at startup, converged ones included. Default false.

Enable it for a kind whose reconcile establishes in-process state — a live connection, a running worker, a liveness condition — which a restart invalidated and no owed-work listing can see: the store reads settled, because observed_generation was written by a process that is gone.

Unlike the periodic full pass, a kind may depend on this one. It costs O(objects) once per process, and for a controller whose reconcile is what opens the connection or starts the worker it is the only thing that reconverges the object after a restart. What it guarantees: every object of a kind that enables it is reconciled at least once per process.

Passed to New it sets the default for all controllers; passed to Register it overrides that default for one — prefer Register, so the declaration names the kinds that actually own in-process state.

func WithTriggerByID added in v0.28.0

func WithTriggerByID(ch <-chan ObjectID) Option

WithTriggerByID requeues each id received on ch, as Client.Requeue would — retry backoff and all. An id naming nothing, or an object of another kind, is a no-op, and closing ch ends that feed once what it already sent has drained. Repeated options accumulate; meaningful only at Register, and a channel serves one kind.

A poke is a latency hint that nothing records, so correctness rests on the kind's own cadence. See docs/adr/2026-08-19-a-trigger-channel-requeues-by-id-or-name.md.

func WithTriggerByName added in v0.28.0

func WithTriggerByName(ch <-chan string) Option

WithTriggerByName is WithTriggerByID keyed by name, carrying the same contract. A name matching nothing — "" included — is a no-op, since whether a record exists for an address is the app's business and changes under it.

func WithWatchFloorInterval added in v0.19.0

func WithWatchFloorInterval(d time.Duration) Option

WithWatchFloorInterval sets how often a watch reads without a wake — a kind's tailer, and an object's event reader. Default 30s. Global and meaningful only at New.

Both watch families read on a commit wake, so this is not delivery latency for anything this Beehive writes. What lengthening it costs is staleness for what a wake cannot cover: a retention trim, a step that failed after its retry ladder gave up, and a write by a second writer over the same store — which is an unsupported deployment, not a slow one. A failed read still retries on its own ladder, capped in seconds, whatever this is set to.

Cannot be disabled: d <= 0 is rejected with ErrInvalidOption.

func WithWriteLogRetention added in v0.19.0

func WithWriteLogRetention(perKind int, maxAge time.Duration) Option

WithWriteLogRetention bounds the object write log, enforced globally by the GC sweeper. perKind > 0 caps each (group, kind) log to its newest perKind entries — per kind, so a hot kind cannot evict a quiet one; maxAge > 0 drops entries written more than maxAge ago. A zero bound is skipped.

The default is defaultWriteLogMaxAge and no count bound; both zero leaves the log unbounded, which also leaves every resume window unbounded. Retention is what defines that window: a stream cannot resume below what has been trimmed. Meaningful only at New.

type RawEvent added in v0.11.0

type RawEvent = storeapi.Event

RawEvent is the untyped event-log row below the generic boundary.

type RawObject

type RawObject = storeapi.RawObject

RawObject is the untyped row below the generic boundary: opaque Spec/Status JSON plus Beehive-owned metadata.

type ReconcileResult added in v0.24.0

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

ReconcileResult is what Reconcile returns. Build it with Settled, Unsettled or Fail; the zero value fails the pass with ErrInvalidResult.

func Fail added in v0.24.0

func Fail(err error) ReconcileResult

Fail reports a failed pass: settles nothing, takes the backoff ladder. A nil err is itself a failure, reported as ErrInvalidResult.

func Settled added in v0.24.0

func Settled() ReconcileResult

Settled reports that the pass observed the object's current generation, which beehive records. It claims nothing about health. Schedules nothing of its own.

func Unsettled added in v0.24.0

func Unsettled() ReconcileResult

Unsettled reports a successful pass over an object not caught up to its spec, so no generation is recorded. Requeues at the work queue's per-object floor unless RequeueAfter says otherwise.

func (ReconcileResult) Err added in v0.25.0

func (r ReconcileResult) Err() error

Err returns the error a failed pass carries, or nil for a successful one. The zero value and Fail(nil) report ErrInvalidResult, the failure beehive records for them.

func (ReconcileResult) RequeueAfter added in v0.25.0

func (r ReconcileResult) RequeueAfter(d time.Duration) ReconcileResult

RequeueAfter schedules the object's next pass, overriding what the result kind schedules on its own. Zero or less requeues as soon as the work queue's per-object floor allows. Ignored on a failed result, which takes the backoff ladder.

type Relation

type Relation = storeapi.Relation

Relation is the kind of edge in the edges table.

type RequeueOption added in v0.8.0

type RequeueOption func(*requeueOptions)

RequeueOption configures a Client.Requeue call.

func WithResetBackoff added in v0.8.0

func WithResetBackoff() RequeueOption

WithResetBackoff makes a Requeue clear the object's retry backoff ladder. Pass it only when the failure condition is known to be resolved; a plain Requeue preserves the ladder.

type Schedule added in v0.13.0

type Schedule struct {
	// NextRequeueAt is when the reconcile loop has scheduled the object to be
	// requeued, or the zero time when nothing is scheduled. It reflects only
	// per-id timers (backoff, a result's requeue delay, an immediate enqueue),
	// not the periodic drivers.
	NextRequeueAt time.Time
}

Schedule reports when an object is next due to reconcile. A struct so fields can be added without a breaking change.

type StalePos added in v0.19.0

type StalePos = storeapi.StalePos

StalePos is a position in the stale-dependents scan. See storeapi.StalePos.

type Store

type Store = storeapi.Store

Store is the durable-store contract Beehive depends on. It is non-generic and deals only in raw rows; the generic boundary lives in the typedController adapter. See storeapi.Store for the full contract.

type WatchOption added in v0.19.0

type WatchOption func(*watchConfig)

WatchOption configures one watch call. A distinct type from Option: these are meaningful only here, and dispatching them on a Beehive or a controller would silently accept nonsense.

func WithLoads added in v0.19.0

func WithLoads(loads ...LoadOption) WatchOption

WithLoads eager-loads the same secondary lookups List takes, on the snapshot and on every delivered batch. Batched per batch, not per object, so a watch does not become an N+1.

func WithResumeFrom added in v0.19.0

func WithResumeFrom(rv int64) WatchOption

WithResumeFrom streams the changes above rv instead of taking a snapshot. The returned stream holds no objects and carries rv back. A position retention has already passed ends the stream with Err reporting ErrWatchTooOld — the same way a live stream reports it — which the caller answers by subscribing again without this option. A position above the log's head arrives the same way with ErrWatchTooNew: it did not come from this store, so no retention window would have kept it.

type WriteOp added in v0.19.0

type WriteOp = storeapi.WriteOp

WriteOp is what an ObjectWrite recorded.

Directories

Path Synopsis
internal
driver
Package driver holds the two periodic-scan loop shapes, plus the timer and backoff primitives a wake-driven loop builds its own shape from.
Package driver holds the two periodic-scan loop shapes, plus the timer and backoff primitives a wake-driven loop builds its own shape from.
logging
Package logging resolves the user-supplied logger into the never-nil, optionally level-gated *slog.Logger the rest of beehive logs through.
Package logging resolves the user-supplied logger into the never-nil, optionally level-gated *slog.Logger the rest of beehive logs through.
rategate
Package rategate holds a key for a fixed interval after it acts.
Package rategate holds a key for a fixed interval after it acts.
sqlitemigrate
Package sqlitemigrate is a tiny, forward-only SQL migration runner for SQLite.
Package sqlitemigrate is a tiny, forward-only SQL migration runner for SQLite.
storeapi
Package storeapi defines the storage contract between the beehive control plane and its store implementations (e.g.
Package storeapi defines the storage contract between the beehive control plane and its store implementations (e.g.
Package sqlite provides a durable, SQLite-backed implementation of the beehive Store.
Package sqlite provides a durable, SQLite-backed implementation of the beehive Store.

Jump to

Keyboard shortcuts

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