server

package
v0.10.0 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package server is billet's control plane: the per-tier scale-set listeners and the scheduler that turns assigned jobs into launched instances.

Index

Constants

This section is empty.

Variables

View Source
var ErrCustody = errors.New("server: the runner is holding this lease's capacity")

ErrCustody means the runner has taken responsibility for a lease's capacity, so the caller must NOT release it.

Returned from Launch when compute may exist that could not be confirmed gone. Releasing then would hand the capacity back while a container is possibly still running on it.

View Source
var ErrHolderUnavailable = errors.New("server: the completion holder is unavailable")

ErrHolderUnavailable means result-dependent teardown has not reached the process that holds the compute, so its durable completion must be retried.

View Source
var ErrNoMessage = errors.New("server: no message")

ErrNoMessage means a long poll timed out with nothing to report — the ordinary outcome, not a failure.

A sentinel rather than (nil, nil), which the upstream client returns: a nil message with a nil error is indistinguishable from "something went wrong and nobody said so".

View Source
var ErrSessionHeld = errors.New("server: this scale set already has an active message " +
	"session, held by a control plane that did not close it")

ErrSessionHeld means a message session for this scale set is already outstanding, held by a control plane that did not close it.

A SENTINEL BECAUSE THE ANSWER IS TO WAIT, NOT TO FAIL. Every other reason a session cannot open is a reason to report and stop; this one resolves by itself when GitHub expires the abandoned session, and it is the ordinary state after any restart that was not graceful.

View Source
var ErrUntrustworthySession = errors.New("server: the scale set returned something billet cannot act on")

ErrUntrustworthySession marks a scale-set response billet cannot act on.

FATAL WHENEVER IT ARRIVES, including in the middle of a shutdown. Once GitHub returns an id nobody offered for, billet cannot tell which of its commitments are real — so it must stop rather than keep operating that session, and a cancellation happening at the same moment must not turn that into a drain.

Functions

This section is empty.

Types

type BoundCompletionAwareRunner

type BoundCompletionAwareRunner interface {
	DestroyCompletedBound(
		ctx context.Context,
		requestID int64,
		result, leaseID, nodeName string,
		leaseEpoch int64,
		outcome alloc.Phase,
	) error
}

BoundCompletionAwareRunner reconciles teardown with the node and lease that actually held compute before a control-plane restart erased live ownership.

type CompletionAwareRunner

type CompletionAwareRunner interface {
	DestroyCompleted(ctx context.Context, requestID int64, result string) error
}

CompletionAwareRunner receives GitHub's authoritative completed-job result. It is optional so runners that have no result-dependent teardown keep the smaller Runner contract.

type ControlPlaneOption

type ControlPlaneOption func(*Server)

ControlPlaneOption configures a Server.

func AdvertiseNothing

func AdvertiseNothing() ControlPlaneOption

AdvertiseNothing makes every listener advertise zero capacity.

It exists so the whole path — App auth, scale-set reconciliation, session, long poll — can be exercised against a REAL organization without accepting a job that nothing in this repository can yet launch. Accepting one would strand somebody's CI, which is a worse first contact than not connecting at all.

func OptionsFromConfig

func OptionsFromConfig(cfg *config.Config) ([]ControlPlaneOption, error)

OptionsFromConfig is the control-plane configuration implied by billet.yaml.

It lives here rather than in cmd/billet so the whole chain — the YAML key, the parse, the control-plane option, and the listener it configures — is one package's worth of code and can be tested end to end. Assembling it at the call site left the only link that matters, a value reaching a listener, spanning two packages with a test on neither side of the join.

func WithCleanupRetry

func WithCleanupRetry(first, ceiling time.Duration) ControlPlaneOption

WithCleanupRetry sets how soon, and at most how far apart, every listener retries a cleanup obligation whose destroy or release failed.

Exposed for the same reason WithReapInterval is: the defaults are sized for a node that will not answer sooner for being asked more often, and a test of a completion that settles on a LATER attempt proves nothing if the later attempt never comes.

func WithCompletionLedger

func WithCompletionLedger(db *state.DB) ControlPlaneOption

WithCompletionLedger durably preserves authoritative results until nodes accept them.

func WithDrainTimeout

func WithDrainTimeout(d time.Duration) ControlPlaneOption

WithDrainTimeout sets when every listener starts REPORTING that its drain is running long.

IT IS NOT A DEADLINE. A drain waits for the jobs already running for as long as they run, and nothing destroys one on the way out. This is the operator's key — server.drain_timeout — reaching the code that honours it; see defaultDrainGrace for why the default is the length of a job rather than the length of a shutdown.

func WithHurry

func WithHurry(c <-chan struct{}) ControlPlaneOption

WithHurry gives every listener the channel that ends its drain wait early.

This is the operator's second signal reaching the code that honours it. See WithHurrySignal for what a listener does with it.

func WithLeadershipLost

func WithLeadershipLost(fn func() bool) ControlPlaneOption

WithLeadershipLost gives every listener the question that decides whether its teardown may act on anything at all.

SUPPLIED BY cmd/billet RATHER THAN DERIVED HERE, because the fact belongs to the ledger handle: `state.DB.LeadershipLost` latches inside the write transaction that was refused, which is before the refusal reaches any listener and therefore before any of them starts unwinding. A server that inferred it from an error would tell the one listener that saw it and leave its siblings tearing down as if nothing had happened.

func WithNodeRunner

func WithNodeRunner(r Runner) ControlPlaneOption

WithNodeRunner attaches the compute every listener launches onto.

Without it the listeners fail closed: they account for capacity and decline the work, rather than accepting jobs nothing can run.

func WithReapInterval

func WithReapInterval(d time.Duration) ControlPlaneOption

WithReapInterval sets how often abandoned capacity is reclaimed.

Exposed so a test can make the reaper actually fire: the default is slow relative to any test, so a reaper/heartbeat test left on it never reaches the reaper and proves nothing while passing.

func WithRolloutCoordinator

func WithRolloutCoordinator(c *rollout.Coordinator, every time.Duration) ControlPlaneOption

WithRolloutCoordinator gives the control plane the driver for a fleet rollout.

OPTIONAL, because a deployment whose runner is not the node plane has nobody to dispatch an upgrade to — and a coordinator that could never dispatch would hold a rollout open forever reporting nothing.

func WithRolloutStarter added in v0.6.0

func WithRolloutStarter(s *rollout.Starter, every time.Duration) ControlPlaneOption

WithRolloutStarter gives the control plane the driver that begins a rollout when the deployment's channel advances.

OPTIONAL FOR THE REASON THE COORDINATOR IS: a deployment whose runner is not the node plane has nobody to converge, so it has nothing to start either.

func WithStagedCredentialSweeper

func WithStagedCredentialSweeper(s StagedCredentialSweeper) ControlPlaneOption

WithStagedCredentialSweeper attaches the sweep. Without it nothing is swept, which is right for a deployment with no backend that stages a credential.

func WithTargets added in v0.10.0

func WithTargets(targets ...Target) ControlPlaneOption

WithTargets names the targets this control plane serves.

A TIER IS RESOLVED TO ITS TARGET, AND EVERYTHING ABOUT ITS SCALE SET GOES THROUGH THAT TARGET'S PROVISIONER: reconciliation, the session, the runner registry and the recorded provenance, keyed by the target's GitHub path so a record of one can be told apart from another target's under the same state directory. A tier naming no target resolves to the only one; with several, a tier naming none or naming one not listed here stops Run before any listener starts, because a scale set created with the wrong credential is a scale set on the wrong owner.

type Job

type Job struct {
	RequestID int64
	RunID     int64
	// RunnerID and RunnerName identify the pool member GitHub actually bound.
	// They are authoritative only on JobStarted and JobCompleted messages.
	RunnerID int64
	// JobID is GitHub's stable workflow-job identity. It is required when the
	// direct-assignment path sends RequestID zero.
	JobID string
	// CompletionID is the scale-set message that delivered the result. A
	// redelivery keeps it; a later reuse of RequestID receives a different one.
	CompletionID int64
	// RunnerName is GitHub's name for the ephemeral runner. Completed messages
	// can omit RequestID, so the billet-issued name is the durable route back to
	// the lease and its assigned request.
	RunnerName string
	// Result is GitHub's conclusion on a completed-job message. It is empty on
	// available and assigned messages.
	Result string
	// The GitHub event that queued this job — retained for diagnostics only. A JIT
	// runner joins a pool before GitHub chooses its job, so event is not launch
	// authority; the tier's static trust policy is.
	Event string
	// Owner, Repository and WorkflowRef are GitHub's authenticated cache scope.
	// They come from the scale-set assignment, never from a workflow-controlled
	// environment variable or by decoding the Actions runtime token.
	Owner       string
	Repository  string
	WorkflowRef string
}

Job identifies one workflow job.

RequestID is billet's numeric scheduler identity. GitHub's positive runnerRequestId is used unchanged; a direct assignment carrying zero receives a durable negative id keyed by JobID, so concurrent jobs never alias at zero.

type Listener

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

Listener runs one tier's scale set.

func NewListener

func NewListener(a *alloc.Allocator, tier string, session Session, opts ...Option) *Listener

NewListener builds a listener for one tier.

func (*Listener) Acquiring

func (l *Listener) Acquiring() int

Acquiring reports how many offers this listener has escrow promised to and has not yet been assigned. Exported for tests, which cannot read the guarded field safely.

func (*Listener) Backlog

func (l *Listener) Backlog() int

Backlog is what GitHub last said was assigned to this scale set and not yet finished.

TotalAssignedJobs is the documented scaling signal; counting messages is not, because a response carries at most 50 and a large backlog is truncated.

func (*Listener) Held

func (l *Listener) Held() []*alloc.Lease

Held returns the leases this listener has escrowed and not yet handed to a job.

Exported for tests, which need lease IDENTITY rather than a count: an escrow that was lost and rebuilt has the same size and different ids.

func (*Listener) Run

func (l *Listener) Run(ctx context.Context) error

Run polls until the context is done.

The order of operations is the design: capacity is escrowed BEFORE it is advertised, and only what the escrow actually returned is advertised.

The other way round — advertise what this tier could theoretically take, reserve when GitHub assigns — over-admits by construction on any host with more than one tier: each listener computes a maximum from the same free pool, GitHub fills all of them at once, and reserving on assignment is too late.

The vendor's own listener package computes a desired runner count itself, which is why billet does not use it.

func (*Listener) Running

func (l *Listener) Running() int

Running reports how many jobs this listener currently has leases for. Exported for tests, which cannot read the guarded field safely.

type Message

type Message struct {
	MessageID  int64
	Statistics *Statistics
	// Available is work GitHub is OFFERING. Acquiring one of these is how a
	// scale set claims it.
	Available []Job
	// Assigned is work this scale set has been given, which is the confirmation
	// that an acquisition succeeded.
	Assigned []Job
	// Started binds a registered pool member to the job it actually consumed.
	// GitHub may choose a different member than the assignment that caused Billet
	// to scale up.
	Started   []Job
	Completed []Job
}

Message is one batch of scale-set news.

type Option

type Option func(*Listener)

Option configures a Listener.

func WithCleanupRetryPacing

func WithCleanupRetryPacing(first, ceiling time.Duration) Option

WithCleanupRetryPacing sets how long a failed cleanup retry waits, and the ceiling it doubles towards.

A first value of zero or less turns pacing off, which only a test wants: in production it lets one unreachable node occupy each pass ahead of a node that has just come back.

func WithCompletionStore

func WithCompletionStore(db *state.DB) Option

WithCompletionStore makes result delivery and its capacity settlement durable across listener restarts.

func WithDrainGrace

func WithDrainGrace(d time.Duration) Option

WithDrainGrace sets when a stopping listener starts REPORTING that its drain is running long. It bounds nothing.

Validated against maxDrainGrace rather than maxGrace, because this is the one budget here that waits on somebody else's job rather than on billet's own teardown. See defaultDrainGrace.

func WithFinishGraces

func WithFinishGraces(closing, releasing time.Duration) Option

WithFinishGraces bounds the two local phases of the teardown: closing the session, and releasing leases.

Separate from the shutdown grace because they wait on nothing remote, and separate from each other because a slow close must not leave the releases to fail on what is left of a shared budget.

func WithHurrySignal

func WithHurrySignal(c <-chan struct{}) Option

WithHurrySignal gives the listener a channel whose closing ends the drain's wait.

THE ONLY THING THAT ENDS A DRAIN WITH WORK STILL RUNNING, now that nothing bounds it. An operator who cannot wait needs a lever that stops the WAITING without stopping the teardown billet owes; what follows is the session close, the idle escrow, and the destroys a completion already asked for. The jobs still executing are left alone. Without this the only escape is killing the process, which loses billet's bookkeeping rather than the work — but leaves the operator no orderly way out.

func WithLeadershipLostCheck

func WithLeadershipLostCheck(fn func() bool) Option

WithLeadershipLostCheck supplies the question "has this process stopped being this deployment's controller", which the teardown asks before it acts on anything. Named like WithHurrySignal beside it: the listener option carries the specific name and the control-plane one that forwards it carries the short one.

A PREDICATE RATHER THAN A CHANNEL, unlike the hurry signal beside it, and the difference is what each one means. A hurry is an EVENT an operator sends once, and a listener that was not watching when it arrived must still see it. This is a durable FACT about the process — `state.DB.LeadershipLost` latches and never clears — so the only thing a caller ever needs is to ask.

NIL EVERYWHERE BUT THE CONTROL PLANE. Nothing else has a claim to lose.

func WithLogger

func WithLogger(log *slog.Logger) Option

WithLogger sets the logger. The default is slog.Default().

func WithMaxCapacity

func WithMaxCapacity(ceiling int) Option

WithMaxCapacity caps what this listener will ever advertise.

Zero means advertise nothing: connect, reconcile, poll, and tell GitHub there is no room — which is what makes a first run against a real organization safe.

A negative value is rejected rather than clamped: "advertise -1" means the caller computed something wrong, and turning it into 0 hides that.

func WithRunner

func WithRunner(r Runner) Option

WithRunner sets what turns assigned leases into running compute.

THE DEFAULT DECLINES THE JOB, which is the opposite of what this comment used to claim. noRunner fails closed: it returns an error, the ordinary failed-launch path hands the capacity back, and GitHub reassigns. It does not hold capacity and it does not quietly succeed — see noRunner's own documentation, which said so correctly while this said the reverse.

func WithRunnerRegistry

func WithRunnerRegistry(registry RunnerRegistry) Option

WithRunnerRegistry installs the GitHub side of safe runner retirement.

func WithShutdownGrace

func WithShutdownGrace(d time.Duration) Option

WithShutdownGrace bounds the teardown: how long the listener will spend destroying compute and closing its session before giving up and letting the reaper deal with what is left.

Worth setting on a deployment whose provider is genuinely slow to destroy — the alternative to a grace that is too short is not a cleaner shutdown, it is leases nobody releases and containers nobody removes.

func WithStalePromiseAfter

func WithStalePromiseAfter(d time.Duration) Option

WithStalePromiseAfter sets how long an acquired job may go unassigned before billet reports it. It does not reclaim anything — see defaultStalePromise.

type Provisioner

type Provisioner interface {
	// EnsureScaleSet makes a tier's scale set exist. It must be idempotent: it is
	// called on every start, and a scale set an operator created by hand is
	// adopted rather than treated as a conflict.
	EnsureScaleSet(ctx context.Context, name, group string, labels []string) (*ScaleSet, error)
	// Session opens a long-poll session on one scale set.
	Session(ctx context.Context, scaleSetID int, owner string) (Session, error)
	RunnerRegistry
}

Provisioner creates scale sets and opens message sessions on them.

Billet's own interface, implemented by internal/scaleset. Two methods is the whole of what the control plane needs from GitHub's API, and keeping it that small is what lets the scheduler be tested against a fake.

type Runner

type Runner interface {
	// The lease is already durable and counted against the budget, so a failure
	// here means capacity is held for something that is not running; the caller
	// releases it.
	Launch(ctx context.Context, lease *alloc.Lease, job Job) error

	// MUST be idempotent: it runs on redelivered completions, on shutdown, and on
	// paths that have already failed once.
	Destroy(ctx context.Context, requestID int64) error
}

Runner turns an assigned lease into running compute, and tears it down again. It is the seam between the control plane and a host.

Both methods are called OUTSIDE the escrow mutex: launching pulls images and talks to a hypervisor, and holding the mutex across that would stall every heartbeat behind it.

type RunnerRegistry

type RunnerRegistry interface {
	RemoveRunner(ctx context.Context, runnerID int64, runnerName string) error
}

RunnerRegistry removes a GitHub registration before its guest is destroyed. A failed removal leaves compute and capacity held so GitHub cannot race a new assignment onto a guest Billet is tearing down.

type ScaleSet

type ScaleSet struct {
	ID    int
	Name  string
	Group string
}

ScaleSet is one provisioned scale set.

type Server

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

Server is billet's control plane: one listener per tier, one shared capacity budget between them.

func New

func New(
	a *alloc.Allocator, prov Provisioner, tiers []config.Tier,
	owner string, log *slog.Logger, opts ...ControlPlaneOption,
) *Server

New builds a control plane over a configured tier catalog.

func (*Server) Run

func (s *Server) Run(ctx context.Context) error

Run reconciles a scale set per tier and listens on all of them until the context is done.

Reconciliation happens FIRST and for every tier, before any listener starts. Starting listeners as their scale sets appear would mean a tier whose reconciliation fails leaves the others running against a budget they are quietly splitting with a tier that will never take work — and the operator sees a half-configured control plane reported as healthy.

type Session

type Session interface {
	// Returns ErrNoMessage when the poll times out with nothing to report, which
	// is the ordinary case.
	GetMessage(ctx context.Context, lastMessageID int64, maxCapacity int) (*Message, error)
	// An unacknowledged message is redelivered, so everything derived from one
	// must be idempotent.
	DeleteMessage(ctx context.Context, messageID int64) error
	// Returns the ids actually acquired, which may be fewer than asked for.
	AcquireJobs(ctx context.Context, requestIDs []int64) ([]int64, error)
	// What GitHub said when the session opened, or nil. The only view of a backlog
	// that predates the session.
	Statistics() *Statistics
	Close(ctx context.Context) error
}

Session is the part of a GitHub scale-set message session billet uses.

billet's own interface rather than the vendor's: the scale-set client is a public preview whose interfaces may change, and a four-method fake is what makes the capacity arithmetic testable without a GitHub organization.

type StagedCredentialSweeper

type StagedCredentialSweeper interface {
	// SweepStagedCredentials runs one pass. An error is reported and the next
	// tick tries again; it never stops the control plane.
	SweepStagedCredentials(ctx context.Context) error
}

StagedCredentialSweeper removes runner registrations that were staged OUTSIDE the compute they were minted for and that nothing else will ever remove.

A CodeBuild build cannot be handed a secret, so its registration lives in Parameter Store and outlives the build; a node that dies between staging one and settling its lease leaks it. Only the ledger can authorise the delete — the lease terminal, and closed longer ago than any build could still be running — and only this process holds the ledger, which is why the sweep runs here on the reaper's clock rather than on a node.

type Statistics

type Statistics struct {
	TotalAvailableJobs     int
	TotalAcquiredJobs      int
	TotalAssignedJobs      int
	TotalRunningJobs       int
	TotalRegisteredRunners int
	TotalBusyRunners       int
	TotalIdleRunners       int
}

Statistics is GitHub's own view of the scale set.

TotalAssignedJobs is the ONLY field to scale on. A message carries at most 50 job entries and a large backlog is truncated, so counting what arrived undercounts exactly when the undercount is most expensive.

type Sweeper

type Sweeper interface {
	// Sweep destroys compute whose lease is no longer open. Called after each
	// reap, because reaping is what MAKES a container an orphan.
	Sweep(ctx context.Context) error

	// Tend advances compute the runner holds capacity for: heartbeating those
	// leases, letting adopted work finish, and destroying what is confirmed
	// finished. The mirror of Sweep — that finds compute no lease is holding, this
	// holds leases whose compute is unaccounted for.
	Tend(ctx context.Context) error

	// KeepAlive renews held leases until the context ends, on its OWN clock.
	// Separate from Tend because renewal must not share a schedule with anything
	// that talks to a compute backend: a slow `docker ps` would delay the next
	// renewal past the lease TTL and let the reaper reclaim capacity held on
	// purpose. Blocks until ctx is done.
	KeepAlive(ctx context.Context)
}

Sweeper is a Runner that can also find compute nothing is asking about.

Optional, and asserted for rather than required: Launch and Destroy are per-job, but enumerating everything a backend runs is a whole-host operation a node may not be able to answer during a partition.

type Target added in v0.10.0

type Target struct {
	Config      config.GitHubTarget
	Provisioner Provisioner
}

Target is one GitHub organization or repository this control plane serves, and the provisioner holding that target's credential.

Jump to

Keyboard shortcuts

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