component

package
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

pkg/controller/component

Shared event-loop scaffold consumed by every controller component that subscribes on construction and dispatches one event at a time.

Overview

Components embed *Base and implement EventHandler. The base subscribes during construction, dispatches events on one goroutine, recovers from handler panics, and stops when its context is cancelled. validator.BaseValidator adds request decoding and response publication for the configuration validators.

*ReadySignal (in ready.go) is a small one-shot helper for components that need to signal "I'm ready" exactly once — used by the deployer, coordinator, config publisher, and a few others.

Quick Start

import (
    "log/slog"

    "github.com/haproxy-haptic/HAPTIC/pkg/controller/component"
    busevents "github.com/haproxy-haptic/HAPTIC/pkg/events"
)

type MyComponent struct {
    *component.Base
}

func (m *MyComponent) HandleEvent(event busevents.Event) {
    // process exactly one event; panics are caught and logged by Base
}

func New(bus *busevents.EventBus, logger *slog.Logger) *MyComponent {
    c := &MyComponent{}
    c.Base = component.New(&component.Config{
        EventBus:   bus,
        Logger:     logger,
        Name:       "my-component",
        BufferSize: 100,
        Handler:    c,
        EventTypes: []string{events.EventTypeFoo, events.EventTypeBar}, // empty = all events
    })
    return c
}

// Then in iteration.go:
c := New(bus, logger)
go c.Start(ctx)

EventTypes is the typed-subscription filter — leave empty for components like the commentator that consume everything; populate it for everyone else so the bus filters at the source instead of dispatching every event to your channel.

Key Interfaces

// Required: dispatch one event at a time.
type EventHandler interface {
    HandleEvent(event busevents.Event)
}

// Optional: a scatter-gather responder that wants to publish a failure
// response if the handler panicked, instead of just logging.
type PanicHandler interface {
    HandlePanic(recovered any, event busevents.Event)
}

The base always logs the panic and keeps the loop alive regardless of whether the component implements PanicHandler — the interface is purely an opt-in extension point.

// Optional: declare event types with latest-wins semantics to run in
// MAILBOX mode.
type CoalescingHandler interface {
    CoalescesOn() []string
}

Returning a non-empty list switches Start into mailbox mode: a dedicated intake goroutine moves events off the subscription channel the instant they arrive into an internal unbounded queue, so the bus-side buffer can never fill and the bus never drops this subscriber's events — no matter how slow HandleEvent is. Uninterrupted runs of coalescible events (per busevents.CoalescibleEvent) of a declared type collapse to their latest element; everything else preserves arrival order. Backlog growth is surfaced via a warning at power-of-two queue lengths from 256.

Two rules, both load-bearing:

  1. Declaring a type asserts that ONLY the latest queued event of that type matters to THIS component. Never declare a type whose every instance carries per-event bookkeeping (the deployer must see every deployment.completed to clear its in-flight flag, so it declares only deployment.scheduled). Coalescing is strictly per-subscriber — your declaration never affects other components' copies.
  2. Across restarts of the same instance (leadership terms), queued mailbox events are discarded at the next Start — same semantics as FlushPending for buffered channel events.

Lifecycle

Method Purpose
New(*Config) Subscribes to the EventBus and returns a *Base. Subscription happens in the constructor so the component is ready to receive events the moment bus.Start() is called.
Start(ctx) Runs the event loop until ctx is cancelled or Stop() is called. Blocks.
Stop() Idempotent shutdown signal. Useful for tests; production code typically just cancels the iteration context.

See Also

License

Apache-2.0 — see root LICENSE.

Documentation

Overview

Package component provides event subscription, dispatch, panic recovery, and shutdown.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func MailboxDepths

func MailboxDepths() map[string]int

MailboxDepths reports the queue length of every running mailbox by component name.

func SafeDispatch

func SafeDispatch(logger *slog.Logger, name string, event busevents.Event, handle func())

SafeDispatch invokes handle, recovering and logging any panic so a single bad event cannot tear down a component's event loop. Components that embed Base get this protection automatically via (*Base).dispatch; components that cannot embed Base — because they use a lossy subscription or add extra ticker/timer arms to their select — call this directly around their per-event handling instead, getting the same recover-and-keep-alive guarantee.

Types

type Base

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

Base is a reusable event-loop implementation. It subscribes on construction (so components are guaranteed to receive events published after EventBus.Start()), wraps each dispatch in a recover, and supports graceful shutdown via either a cancelled context or an explicit Stop.

func New

func New(cfg *Config) *Base

New subscribes to the EventBus and returns a Base ready to Start. The logger is annotated with `component=<name>` before being stored. Config is taken by pointer because the struct is large enough that the linter flags by-value passing.

func (*Base) EventBus

func (b *Base) EventBus() *busevents.EventBus

EventBus returns the bus the component subscribed to, for use in handler implementations that need to publish response events.

func (*Base) FlushPending

func (b *Base) FlushPending()

FlushPending discards every event currently buffered on the subscription channel without dispatching it. Leader-only components that embed Base — and are therefore subscribed for the whole process lifetime, not per leadership term — call this at Start entry so events buffered during a previous leadership term (or while not leader) are not replayed into the new term. Events that arrive after the flush are dispatched normally.

func (*Base) LifecycleContext

func (b *Base) LifecycleContext() context.Context

LifecycleContext returns the context of the current Start call.

func (*Base) Logger

func (b *Base) Logger() *slog.Logger

Logger returns the component-annotated logger.

func (*Base) Name

func (b *Base) Name() string

Name returns the component name supplied at construction.

func (*Base) Start

func (b *Base) Start(ctx context.Context) error

Start drives the event loop until the context is cancelled or Stop is called. Returns nil on graceful shutdown. Handlers implementing CoalescingHandler (non-empty CoalescesOn) run in mailbox mode — see CoalescingHandler for the semantics and why.

func (*Base) Stop

func (b *Base) Stop()

Stop signals the event loop to exit. Safe to call multiple times.

type CoalescingHandler

type CoalescingHandler interface {
	CoalescesOn() []string
}

CoalescingHandler is an optional interface implemented by handlers whose events of the declared types have latest-wins semantics. When it returns a non-empty list, Base runs in MAILBOX mode: a dedicated intake goroutine drains the subscription channel immediately into an internal unbounded queue, so the bus-side buffer can never fill and the bus never drops this subscriber's events — no matter how slow the handler is. Uninterrupted runs of coalescible events of a declared type (i.e. event.(busevents.CoalescibleEvent).Coalescible() == true) collapse to their latest element at the queue tail; any other event is appended, preserving arrival order across event types. The worker dispatches from the queue head at its own pace.

This exists because slow handlers (e.g. status appliers doing SSA round-trips per event) otherwise stall the channel long enough under burst for the bus to overflow the subscriber buffer and drop events — including non-coalescible ones and the final event of a burst, whose loss leaves stale state until the next external trigger.

WHEN A COMPONENT MUST RUN IN MAILBOX MODE. Declare a type when BOTH hold:

  1. The handler can block — a Kubernetes API round-trip, an agent call, any network I/O — for longer than the gap between two events.
  2. At least one subscribed type can arrive faster than the handler drains it: published per render, per pod, per resource change, or by a watcher the chart runs undebounced.

One without the other does not qualify: a blocking handler on a per-config-change stream cannot outrun its input, and a fast handler on a firehose keeps up. Every adopter so far was retrofitted after a measured incident; the point of stating the rule is to identify the next one first.

If a component meets both but must NOT collapse — because its event type string is shared across sources it has to see individually, as resource.index.updated is across every watched kind — it needs a level-triggered re-read instead, not this. Coalescing collapses by event TYPE, so declaring such a type discards the sources the run did not end on.

Two gates hold this together:

  • pkg/controller/subscriber_inventory_test.go records the mailbox decision for every non-lossy subscriber and fails when a new one appears without one.
  • pkg/controller/events/coalescible_inventory_test.go records why each type that implements CoalescibleEvent is safe to collapse. Declaring a type that is not armed is inert, so arming a type is the change that can start losing events — that is the one gated.

Declaring a type is a per-component statement that ONLY the latest queued event of that type matters to THIS component. Never declare a type whose every instance carries per-event bookkeeping for the component (e.g. the deployer must see every deployment.completed to clear its in-flight flag, so it declares only deployment.scheduled).

An empty list disables coalescing — handlers that conditionally need it can return nil to opt out at runtime (plain channel loop, no mailbox).

type Config

type Config struct {
	EventBus   *busevents.EventBus
	Logger     *slog.Logger
	Name       string
	BufferSize int
	Handler    EventHandler
	EventTypes []string

	// EventFilter, when set, is asked about every event that matched
	// EventTypes, before it reaches the buffer. Use it where one event type
	// carries many subjects and this component wants a few: filtering in the
	// handler still spends a buffer slot on each event, and for a non-lossy
	// subscriber a full buffer ends the controller iteration. Runs on the
	// publishing path — field comparisons only.
	EventFilter func(busevents.Event) bool
}

Config wires up a new Base.

If EventTypes is empty the component receives every event on the bus; if it is non-empty the component receives only the listed types (preferred for components that only react to a handful of events).

type EventHandler

type EventHandler interface {
	HandleEvent(event busevents.Event)
}

EventHandler dispatches a single event received on the subscription channel. Implementations should not block the goroutine for longer than the processing budget documented on the event.

type PanicHandler

type PanicHandler interface {
	HandlePanic(recovered any, event busevents.Event)
}

PanicHandler is an optional interface that a component implements when it needs to publish a domain-specific response if event handling panics. The base always logs the panic and keeps the loop alive regardless of whether this interface is implemented.

type QueueCoalescingHandler

type QueueCoalescingHandler interface {
	CoalescingHandler
	CoalescesAcrossQueue() bool
}

QueueCoalescingHandler is an optional refinement of CoalescingHandler for components whose declared types are latest-wins across the WHOLE queue, not just within an uninterrupted run. When CoalescesAcrossQueue reports true, a coalescible event of a declared type supersedes any earlier queued coalescible event of the same type wherever it sits, so a stream that strictly alternates two declared types (render → resources.applied, deploy → deployment.completed, …) still keeps the queue bounded by the number of declared types instead of growing without limit while the handler is slower than the arrival rate. Relative order of the surviving entries is preserved; a non-coalescible event still separates nothing — it simply keeps its place. Only declare this when the component's own per-type semantics are "the newest event carries the complete state"; the status applier is the canonical adopter (each event carries the full patch set for its phase, and a 2048-deep alternating backlog was measured at 6 GB of live heap on a 1,900-route churn).

type ReadySignal

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

ReadySignal is a per-Start signal used by leader-only components to let callers wait until the component has finished subscribing to the event bus. Leader-only components subscribe in Start() (not in their constructor), and the controller start-up sequence waits on SubscriptionReady() before proceeding so published events are not lost.

Embed *ReadySignal in a leader-only component to pick up the accessor and Mark helpers instead of repeating the channel plumbing in each file.

Example:

type Component struct {
    *component.ReadySignal
    // ... other fields
}

func New(...) *Component {
    return &Component{ReadySignal: component.NewReadySignal(), ...}
}

func (c *Component) Start(ctx context.Context) error {
    c.eventChan = bus.SubscribeTypesLeaderOnly(...)
    c.MarkReady()
    // ... event loop
}

func NewReadySignal

func NewReadySignal() *ReadySignal

NewReadySignal constructs an un-signalled ReadySignal.

func (*ReadySignal) MarkReady

func (r *ReadySignal) MarkReady()

MarkReady closes the current channel exactly once. Subsequent calls are no-ops until Rearm prepares the next lifecycle term.

func (*ReadySignal) Rearm

func (r *ReadySignal) Rearm()

Rearm prepares the signal for the next lifecycle Start call.

func (*ReadySignal) SubscriptionReady

func (r *ReadySignal) SubscriptionReady() <-chan struct{}

SubscriptionReady returns a channel that is closed when MarkReady is called. It implements lifecycle.SubscriptionReadySignaler (checked via interface satisfaction at call sites rather than by import to avoid a cyclic dependency on pkg/lifecycle).

Jump to

Keyboard shortcuts

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