control

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package control carries commands down and telemetry up between the tiers of a LoadWave cluster.

Both tiers speak the same protocol: an agent joining a coordinator over TCP and a worker process joining its agent over a Unix socket use the identical client and server here. The node always dials, and holds one long-lived bidirectional stream open for as long as it is alive. That direction is a deliberate choice — it lets agents live behind NAT with no inbound ports, and it makes a broken stream an unambiguous liveness signal instead of something that has to be inferred from timeouts.

Index

Constants

View Source
const (
	DefaultQueueSize    = 1024
	DefaultReconnectMin = 250 * time.Millisecond
	DefaultReconnectMax = 15 * time.Second
	// DefaultHeartbeatInterval is used until the supervisor says otherwise.
	DefaultHeartbeatInterval = 2 * time.Second
)

Defaults applied to a zero ClientConfig.

View Source
const (
	DefaultServerHeartbeatInterval = 2 * time.Second
	DefaultMetricsInterval         = time.Second
	DefaultServerQueueSize         = 64
)

Defaults applied to a zero ServerConfig.

Variables

View Source
var ErrSessionClosed = errors.New("control session is closed")

ErrSessionClosed is returned by Session.Send once the node has gone.

Functions

This section is empty.

Types

type Client

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

Client maintains a node's control stream, reconnecting as needed.

func NewClient

func NewClient(cfg ClientConfig) (*Client, error)

NewClient validates the configuration and prepares a client. It does not connect; Run does that.

func (*Client) Connected

func (c *Client) Connected() bool

Connected reports whether a stream is currently established.

func (*Client) Dropped

func (c *Client) Dropped() uint64

Dropped reports how many upstream messages were discarded because the queue was full.

func (*Client) Run

func (c *Client) Run(ctx context.Context) error

Run connects and keeps the stream alive until ctx is cancelled.

It returns nil on cancellation. Any other error means the client gave up, which currently only happens if the target cannot be parsed at all.

func (*Client) Send

func (c *Client) Send(msg *loadwavev1.NodeUp) bool

Send queues a message for the supervisor, reporting whether it was accepted.

It never blocks. Telemetry is worth less than throughput, so a full queue discards the message and increments the drop counter, which is reported in the next batch and surfaced in the dashboard. Nothing here is load-bearing for correctness — the supervisor tolerates gaps by design.

func (*Client) SendLog

func (c *Client) SendLog(event *loadwavev1.LogEvent) bool

SendLog queues a log event.

func (*Client) SendMetrics

func (c *Client) SendMetrics(batch *loadwavev1.MetricBatch) bool

SendMetrics queues a metric batch.

func (*Client) SendRunStatus

func (c *Client) SendRunStatus(update *loadwavev1.RunStatusUpdate) bool

SendRunStatus queues a run status update.

type ClientConfig

type ClientConfig struct {
	// Target is a gRPC target: "host:port" for a coordinator, or
	// "unix:///path/to.sock" for a local agent.
	Target string

	// Hello advertises this node's identity and capacity. It is re-sent on
	// every reconnection, so a supervisor always has current information.
	Hello *loadwavev1.NodeHello

	// Handler receives downstream commands.
	Handler Handler

	// Heartbeat supplies the current statistics each time a heartbeat is due.
	// Optional; without it, heartbeats carry only a sequence number.
	Heartbeat func() *loadwavev1.NodeHeartbeat

	Logger *slog.Logger

	// QueueSize bounds the upstream buffer. When it fills, telemetry is
	// dropped rather than allowed to block the caller: a load generator must
	// not slow down because its reporting channel is congested. Zero applies
	// DefaultQueueSize.
	QueueSize int

	// ReconnectMin and ReconnectMax bound the backoff between attempts.
	ReconnectMin time.Duration
	ReconnectMax time.Duration

	// DialOptions are appended to the defaults, for TLS and interceptors.
	DialOptions []grpc.DialOption
}

ClientConfig configures a node's connection to its supervisor.

type Handler

type Handler interface {
	OnAccepted(ctx context.Context, msg *loadwavev1.Accepted) error
	OnStartRun(ctx context.Context, msg *loadwavev1.StartRun) error
	OnSetQuota(ctx context.Context, msg *loadwavev1.SetQuota) error
	OnStopRun(ctx context.Context, msg *loadwavev1.StopRun) error
}

Handler receives the commands a supervisor sends down the stream.

Calls are made from the client's receive goroutine, one at a time and in order. An implementation that needs to do something slow should hand off to its own goroutine rather than block, since a stalled handler stops the node noticing anything else — including a stop command.

type Server

type Server struct {
	loadwavev1.UnimplementedControlServiceServer
	// contains filtered or unexported fields
}

Server implements the ControlService for one supervisor tier.

func NewServer

func NewServer(cfg ServerConfig) (*Server, error)

NewServer prepares a supervisor endpoint.

func (*Server) Join

Join implements loadwavev1.ControlServiceServer.

type ServerConfig

type ServerConfig struct {
	Handler SessionHandler
	Logger  *slog.Logger

	// Version is reported to nodes so they can warn about a mismatch.
	Version string

	// HeartbeatInterval is how often nodes should report liveness.
	HeartbeatInterval time.Duration

	// MetricsInterval is how often nodes should flush metric batches. It
	// doubles as the bucket width the coordinator's store expects.
	MetricsInterval time.Duration

	// QueueSize bounds each session's outbound command buffer.
	QueueSize int
}

ServerConfig configures a supervisor's end of the protocol.

type Session

type Session struct {
	// ID is the node's self-declared identifier. Reconnections reuse it,
	// which is how a supervisor recognises a returning node rather than
	// treating it as a new one.
	ID string

	// Hello is what the node advertised on its most recent connection.
	Hello *loadwavev1.NodeHello

	// RemoteAddr is the peer address, for operator-facing display.
	RemoteAddr string

	// JoinedAt is when this particular connection was established.
	JoinedAt time.Time
	// contains filtered or unexported fields
}

Session is a supervisor's handle on one connected node.

It is safe for concurrent use, and remains usable until the node disconnects, at which point every Send fails with ErrSessionClosed.

func (*Session) Close

func (s *Session) Close()

Close disconnects the node.

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session has ended.

func (*Session) LastSeen

func (s *Session) LastSeen() time.Time

LastSeen reports when the node last sent anything.

func (*Session) Send

func (s *Session) Send(msg *loadwavev1.NodeDown) error

Send queues a command for the node.

Commands, unlike telemetry, matter: dropping a StopRun would leave a node generating load nobody asked for. So a full queue is an error the caller must deal with, rather than something silently swallowed. In practice the queue only fills when a node has stopped reading, which the stream will shortly report anyway.

type SessionHandler

type SessionHandler interface {
	// OnJoin is called once the node has identified itself. Returning an
	// error rejects the connection, and the node will retry.
	OnJoin(ctx context.Context, session *Session) error

	// OnLeave is called exactly once per accepted session, when it ends.
	OnLeave(session *Session)

	OnHeartbeat(session *Session, beat *loadwavev1.NodeHeartbeat)
	OnMetrics(session *Session, batch *loadwavev1.MetricBatch)
	OnRunStatus(session *Session, update *loadwavev1.RunStatusUpdate)
	OnLog(session *Session, event *loadwavev1.LogEvent)
}

SessionHandler receives everything a connected node does.

Every method is called from that node's own receive goroutine, so implementations must be safe for concurrent use across sessions, and must not block for long.

type SessionRegistry

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

SessionRegistry tracks the nodes currently connected to a supervisor.

It is the piece that makes reconnection work: a node that drops and comes back reuses its id, and the registry replaces the stale session rather than accumulating ghosts. Both the coordinator and the agent embed one.

func NewSessionRegistry

func NewSessionRegistry() *SessionRegistry

NewSessionRegistry returns an empty registry.

func (*SessionRegistry) Add

func (r *SessionRegistry) Add(session *Session) *Session

Add registers a session, displacing and closing any previous one with the same node id. It reports the displaced session, if there was one.

func (*SessionRegistry) All

func (r *SessionRegistry) All() []*Session

All returns every live session.

func (*SessionRegistry) Broadcast

func (r *SessionRegistry) Broadcast(msg *loadwavev1.NodeDown) error

Broadcast sends a command to every connected node, returning the first error. Delivery to the remaining nodes is attempted regardless: a run must still stop on nine agents when the tenth is unreachable.

func (*SessionRegistry) Get

func (r *SessionRegistry) Get(id string) (*Session, bool)

Get returns the current session for a node id.

func (*SessionRegistry) Len

func (r *SessionRegistry) Len() int

Len reports how many nodes are connected.

func (*SessionRegistry) Remove

func (r *SessionRegistry) Remove(session *Session) bool

Remove deregisters a session, but only if it is still the current one for its node id.

The guard matters during a reconnection race: the new session can be registered before the old one's goroutine has finished unwinding, and an unguarded delete would evict the live session on behalf of the dead one.

Jump to

Keyboard shortcuts

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