Help us shape the future of Go! Take the 2026 Go Developer Survey

placement

package
v0.1.0 Latest Latest
Warning

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

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

Documentation

Overview

Package placement decides where a session should run, and reconciles the durable desired state that decision implies.

It is split into a PURE half and an I/O half, and the split is the package's main structural claim. policy.go is a total function of a catalog record, an optional registry observation and a page of capacity reports; it reads no clock of its own, performs no store call, and cannot create anything. Every rule a reviewer has to check for correctness lives there, where a test drives it directly rather than through a store. reconciler.go holds the claim, the durable desired-state write and the workload controller call, and its only decision is which of policy.go's answers it acted on.

A CANDIDATE IS A HINT AND AN OWNER IS AN OBSERVATION. Nothing here is authority over a session: specification section 15 keeps the Host lease and its epoch fence as the correctness fence, and a placement claim suppresses duplicate scaling only. Every type this package hands out is therefore a REQUEST or a HINT — sessionstore.PlacementIntent for the controller, a capacity report for the caller that will attach — and none of them carries a lease epoch this package could pretend to hold.

Index

Constants

This section is empty.

Variables

View Source
var ErrInvalidConfig = errors.New("placement: invalid reconciler configuration")

ErrInvalidConfig reports a Reconciler that cannot keep its own bounds. Configuration is checked by NewReconciler before any store call.

View Source
var ErrInvalidSweeperConfig = errors.New("placement: invalid record sweeper configuration")

ErrInvalidSweeperConfig reports a RecordSweeper configuration refused before it could reach a store.

It is a SECOND error in this package rather than a reuse of ErrInvalidConfig, and the duplication is deliberate. ErrInvalidConfig belongs to Reconciler, which places one session; a caller that composes both would otherwise be unable to tell which of the two it misconfigured from the sentinel alone, and the two have no configuration member in common.

View Source
var ErrNoWorkloadController = errors.New("placement: this replica reconciles no dedicated workloads")

ErrNoWorkloadController reports a dedicated session reaching a replica that creates no workloads.

It is H5's two-binary split made a runtime rule rather than a convention. The decision of 2026-09-04 puts the Kubernetes adapter in internal/placement/kubernetes and links it into cmd/controller ALONE: cmd/factory ships with a different ServiceAccount holding no workload create/delete RBAC, so it composes no controller and its import graph reaches no Kubernetes client package. A dedicated session that reaches that binary has therefore arrived somewhere that cannot serve it, and saying so by name is the only honest answer -- reporting it as "no capacity" would send a caller into a retry loop waiting for an autoscaler that will never run, and reporting success would claim a workload nobody created.

Functions

func ReusableOwner

ReusableOwner reports whether a registry observation is this session's own live, admitting owner.

It is THE statement of that rule for the whole module: internal/admission asks the same question before delivering a gate response to a resident Host, and a second copy of the comparison would be free to admit an owner one caller routes to and the other does not.

Placement is compared along with agent and runtime because a record's desired placement is what its target key is built from; an observation under the other admission model is a Host the directory would never have offered. Migration between the two is deferred by specification section 14, so this reports false rather than arranging one — the caller re-places a session that is not resident anywhere, and never moves a running one.

The expiry is read against the caller's clock even though the directory already refuses an expired registration with the STORE's. The two answers differ only under skew, and the conservative one is right here: the cost of treating a live owner as absent is one re-placement that the Host lease then refuses, and the cost of the opposite is routing a command to a process that has lost its lease.

Types

type Catalog

type Catalog interface {
	GetCatalogEntry(ctx context.Context, req sessionstore.GetCatalogEntryRequest) (sessionstore.CatalogEntry, error)
	UpdateCatalogDesiredState(ctx context.Context, req sessionstore.UpdateCatalogDesiredStateRequest) (sessionstore.CatalogEntry, error)
}

Catalog is the durable session record: the desired state a Factory authors and the record every decision here is made against.

type ClaimSweep

type ClaimSweep interface {
	ReleaseReconciliationClaim(context.Context, sessionstore.ReleaseReconciliationClaimRequest) (sessionstore.ReconciliationClaimEntry, error)
}

ClaimSweep is the one durable write the claim half makes.

It is Release and nothing else. Acquire is deliberately absent: a sweeper that could acquire would be a second placement reconciler, and the work a claim suppresses is Reconciler's. Get is absent for a sharper reason — GetReconciliationClaim refuses a lapsed claim rather than returning it (sessionstore@v0.8.0/reconcile.go:450), so it cannot answer the question this sweep asks, and a caller that read it first would still have to attempt the release to learn anything.

type ClaimSweepResult

type ClaimSweepResult struct {
	Examined int

	// Released counts the sessions whose claim is this replica's and is no
	// longer live afterwards. It deliberately does NOT separate "a write
	// happened" from "the record was already ours and lapsed": the store
	// answers both with success and no error, because a repeat release is the
	// ordinary case rather than a mistake, and a sweeper that reported them
	// differently would be reporting the reply it happened to race.
	Released int

	// Held counts the sessions another replica is reconciling right now.
	Held int

	// Lapsed counts another replica's claim that has run out. Nothing is
	// written for one and nothing needs to be: a lapsed claim is already
	// indistinguishable from no claim to every reader of this record.
	Lapsed int

	// Absent counts the sessions with no claim record at all.
	Absent int
}

ClaimSweepResult accounts for every session one SweepClaims call examined.

type Claims

Claims is the short-lived reconciliation claim of specification section 10.

type Clock

type Clock interface {
	Now() time.Time
}

Clock is the time seam. It declares only Now: this package sets a claim horizon and reads an owner's expiry, and schedules nothing.

type Config

type Config struct {
	Directory Directory
	Catalog   Catalog
	Claims    Claims

	// Workloads is OPTIONAL, and its absence is a supported deployment rather
	// than a degraded one: H5's cmd/factory composes exactly this. See
	// ErrNoWorkloadController.
	Workloads WorkloadController

	Clock Clock

	// HolderID names this replica in a claim. It is not authority -- the claim
	// licenses nothing -- but it must be stable for a process, because
	// extending one's own claim and taking over a crashed replica's lapsed one
	// are the same store write and the holder is what tells them apart.
	HolderID string

	// ClaimTTL is how long this replica claims a session's scaling work for.
	ClaimTTL time.Duration

	// CandidateLimit bounds one capacity page.
	CandidateLimit int
}

Config is one replica's placement configuration.

type Decision

type Decision struct {
	Outcome Outcome
	Owner   sessionwire.HostLinkRegistryObservation
	Target  sessionwire.HostLinkCapacityReport
}

Decision is one placement answer together with exactly the record the outcome licenses acting on.

Owner is populated only for OutcomeReuseOwner and Target only for OutcomeAttachPooled; the other member stays zero. That is deliberate rather than tidy: a decision carrying both would let a caller act on the one its switch reached rather than the one the policy chose.

func Decide

Decide chooses where a session should run.

ownerObserved is separate from owner, and the separation is load-bearing: an absent registration is the ZERO observation, and a policy that read the value alone would compare an empty tenant and an empty agent against a record whose members a caller had not filled either, and reuse a Host that does not exist.

candidates arrive in the store's capacity rank, most free capacity first, and nothing here re-ranks them. The first ADMISSIBLE entry is selected, so two Factory replicas reading the same page choose the same Host with no leader and no shared state — which is what H5's "replicas are safe and no leader is introduced" rests on for the pooled half.

The page's liveness is the store's answer, not this function's: routing's Directory already drops lapsed advertisements against the store's clock, and re-deciding expiry here against a Factory clock would take a live Host out of service on skew. The owner's expiry IS re-read here, for the opposite reason ReusableOwner gives.

type Desired

type Desired struct {
	Placement              sessionwire.HostPlacement
	RuntimeCompatibilityID string
	Workload               sessionstore.DesiredWorkload
}

Desired is the desired state a caller wants stored for a session.

It is an INPUT rather than something this package derives, and that boundary is where A4.2 stops. What a dedicated session's workload payload should contain is a launch-template question owned by composition (A9.1); this package's job is that whatever the caller names is stored idempotently and handed to the controller with a generation.

type Directory

Directory is the observed target directory: which Host currently owns a session, and which Hosts could accept one.

type Outcome

type Outcome uint8

Outcome is the closed set of answers Decide gives.

It is an enum rather than a pair of booleans so a caller's handling is exhaustive by construction, and Undecided is a member rather than a zero to fall through: a record naming a desired placement this build does not understand must reach a caller as "no decision", not as the first arm of a switch that happens to be pooled.

const (
	// OutcomeUndecided means no rule here applies to the record, which today
	// means its desired placement is neither of the two admission models.
	OutcomeUndecided Outcome = iota

	// OutcomeReuseOwner means the session already has a live owner and no
	// placement is needed. Specification section 15 step 1.
	OutcomeReuseOwner

	// OutcomeAttachPooled names one ranked pooled candidate the caller should
	// ask to take the session. It is not a reservation: the candidate may
	// refuse, and only its lease acquisition decides ownership.
	OutcomeAttachPooled

	// OutcomeReconcileDedicated means one workload should exist for this one
	// session. Specification section 13.
	OutcomeReconcileDedicated

	// OutcomeNoCapacity means the target has no admissible seat right now.
	// It is not an error: pooled capacity is scaled by an autoscaler outside
	// this module, and the caller retries.
	OutcomeNoCapacity
)

func (Outcome) String

func (o Outcome) String() string

String renders the outcome for a log line. Every member renders distinctly, including an unrecognized one, so a decision is never reported as the empty string.

type Reconciler

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

Reconciler places one session at a time.

It holds no per-session state. Everything it decides from is read within the call: the catalog record, the registry observation and one capacity page. A replica that restarts mid-placement leaves only a claim, which lapses.

func NewReconciler

func NewReconciler(cfg Config) (*Reconciler, error)

NewReconciler validates a configuration before it can reach a store.

func (*Reconciler) Reconcile

func (r *Reconciler) Reconcile(ctx context.Context, req Request) (Result, error)

Reconcile routes or places one session.

The order is specification section 15's routing algorithm and the deviations from a naive reading are all deliberate:

  1. The record and the owner are read FIRST, and a live owner returns without taking a claim at all. An owned session is the common case, and putting a durable compare-and-swap in front of it would serialize routing behind a record that exists to coordinate SCALING.
  2. Only then is the claim taken. Losing it is not a failure: step 4 of the algorithm says a replica that observes an existing claim re-reads observed state rather than scaling again, so the deferral path re-reads the registry and reports an owner the winner has just produced.
  3. Desired state is written before the decision, because the write can change the desired placement the decision branches on.

What this does NOT do is attach the session to the pooled Host it selects. OutcomeAttachPooled names a candidate for the caller; see the package's README section for why the pinned Core wire version has no request that could carry that attachment.

type RecordSweeper

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

RecordSweeper sweeps the two CONTROL RECORDS placement leaves behind: a crashed Host's target advertisement and this replica's own placement claim.

IT IS NOT Reconciler AND DOES NOT EXTEND IT, which is worth stating because both live in this package and both are called "reconciliation" upstream. Reconciler's subject is a SESSION: it reads a catalog record, takes that session's claim and drives a workload controller. This one's subject is a RECORD, it names no session of its own in the target half, and it makes no placement decision at all. They were kept separate rather than fused because a fused type would have to hold both a per-session request and a directory-wide continuation, and the continuation is the one piece of state this module carries between calls — putting it on the type that is invoked once per session would make its lifetime a function of traffic.

func NewRecordSweeper

func NewRecordSweeper(cfg SweeperConfig) (*RecordSweeper, error)

NewRecordSweeper validates a configuration before it can reach a store.

func (*RecordSweeper) SweepClaims

func (s *RecordSweeper) SweepClaims(ctx context.Context, refs []SessionRef) (ClaimSweepResult, error)

SweepClaims releases this replica's own placement claims for the named sessions.

ITS REACH IS THIS REPLICA'S OWN CLAIMS, and that is a limit of the pinned store rather than a choice. A reconciliation claim is filed with an EMPTY due state (sessionstore@v0.8.0/reconcile.go:708 reconciliationClaimDue), so there is no deadline view to walk and no operation that enumerates claims at all: the sessions have to come from the caller. And a release is refused for any holder but the claim's own, so another replica's lapsed claim can be reported but not written back. Nothing is lost by that -- a lapsed claim is already indistinguishable from no claim to every reader of the record -- but the counts say which happened, because "swept" and "was already harmless" are different facts to an operator.

A FAILURE ENDS THE PASS rather than skipping the session. The four dispositions below are outcomes; anything else is the store declining to answer, and walking the rest of the list would spend one request per session against a store that has already said it cannot serve one.

func (*RecordSweeper) SweepTargets

func (s *RecordSweeper) SweepTargets(ctx context.Context) (TargetSweepResult, error)

SweepTargets runs one bounded pass of the directory's deadline view.

THE CONTINUATION IS THE ONLY STATE THIS TYPE CARRIES BETWEEN CALLS, and it is carried rather than dropped because a page budget alone leaves this sweep able to make no progress at all. A row the store steps over -- one a heartbeat saved, one whose compare-and-swap lost, one it could not decode -- stays in the deadline view, so a sweeper that restarted at the head on every pass would meet that row again with its whole budget and never reach the rows behind it.

What is NOT retained is a judgement. This call returns what the store did; it does not remember which Hosts were due, and a later placement decision reads the directory rather than anything recorded here.

type Request

type Request struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
	Desired   *Desired
}

Request is one reconciliation.

Desired is optional. Nil means "reconcile what the record already says", which is the ordinary path for a session whose desired state was written at creation and has not changed since; no desired-state write is attempted at all in that case, so the generation cannot move.

type Result

type Result struct {
	// Decision is the placement answer. It is the zero Decision when Deferred.
	Decision Decision

	// Deferred reports that another replica holds this session's claim and
	// this one did nothing. It is not an error: it is the claim working.
	Deferred bool

	// ClaimExpiresAt is the holder's horizon, populated only when Deferred. It
	// is how long a caller can wait before the work is anyone's again.
	ClaimExpiresAt time.Time

	// DesiredWrites counts the desired-state write calls this reconciliation
	// made: zero when the record already carried the intent, one for an
	// ordinary application, and two when a racer's different intent forced one
	// re-read and retry.
	//
	// It counts CALLS rather than reporting "this replica applied it", and the
	// difference is not a hedge. Two replicas deriving one intent derive one
	// idempotency key, so the loser's write is absorbed as a replay of the
	// winner's and the two are indistinguishable in the record they leave --
	// which is the whole point of the derived key. What is observable, and what
	// the caller can act on, is how much work this call did.
	DesiredWrites int

	// Intent is the stored desired state handed to the controller, populated
	// only for OutcomeReconcileDedicated.
	Intent sessionstore.PlacementIntent
}

Result is what one reconciliation did.

type SessionRef

type SessionRef struct {
	TenantID  sessionwire.TenantID
	SessionID sessionwire.SessionID
}

SessionRef names one session for the claim half.

type SweeperConfig

type SweeperConfig struct {
	Targets TargetSweep
	Claims  ClaimSweep

	// HolderID names this replica in a claim release. It must be the SAME
	// string this replica's Reconciler puts in Config.HolderID, because a
	// release is refused for any other holder and the whole reach of this
	// sweep is the claims this replica itself left behind.
	HolderID string

	// PageLimit bounds one due page.
	PageLimit int

	// MaxPages bounds one SweepTargets call.
	MaxPages int
}

SweeperConfig is one replica's record-sweep configuration.

type TargetSweep

type TargetSweep interface {
	ReconcileHostTargets(context.Context, sessionstore.ReconcileHostTargetsRequest) (sessionstore.HostTargetReconcileResult, error)
}

TargetSweep is the service-owned directory sweep.

It is ONE method and it is the store's, not this package's, which is the whole structural claim of the target half. SessionStore revalidates every due row against that row's OWN STORED EXPIRY at a fresh clock reading and closes the write with a compare-and-swap onto the revision the due page reported (sessionstore@v0.8.0/host_targets.go:1631 reconcileHostTargetRow).

THE TWO MECHANISMS CATCH DIFFERENT HEARTBEATS, and the distinction was stated wrongly here before. reconcileHostTargetRow takes the due page's FROZEN BYTES (stored storage.OrderedRecord) and NEVER RE-READS THE ROW; only the clock it compares against is fresh. So a heartbeat that landed AFTER the page was read cannot present an unlapsed expiry to the revalidation at all -- its only catcher is the compare-and-swap, which loses against the revision that heartbeat advanced. The expiry revalidation catches the EARLIER landing: one already carried in the page's bytes when they were captured, which a weakly consistent due view can still name as due.

The conclusion is unchanged and rests on the pair: Factory must not re-derive that judgement, because a due observation this side of the seam is a hint about which rows to LOOK at and is never evidence that a Host is gone.

type TargetSweepResult

type TargetSweepResult struct {
	// Scanned is every row the store looked at. It is the sum of the five
	// outcomes below, which the store asserts for itself.
	Scanned int

	// Unranked is the store's Withdrawn: rows this sweep removed from the
	// placement rank.
	Unranked int

	// Retained is the store's StillLive: rows the revalidation refused to
	// withdraw. A nonzero value is the heartbeat race being won by the Host,
	// which is the correct outcome and not a failure.
	Retained int

	Contended  int
	Unreadable int
	Unverified int

	// Exhausted reports that the sweep reached the end of the deadline view.
	Exhausted bool

	// Resumed reports that this call presented a continuation retained from an
	// earlier one. It is observable state rather than a log detail: a sweeper
	// that never resumes makes no progress past its first page budget, and
	// nothing else about the result would say so.
	Resumed bool
}

TargetSweepResult is what one SweepTargets call did.

Unranked and Retained are RENAMED rather than passed through, and the names are the point of the type. The store's Withdrawn is the count of rows whose own stored expiry had lapsed at the revalidation instant and whose compare-and-swap then won -- those, and only those, left the placement rank. StillLive is the count the revalidation SAVED: rows a due page named and a heartbeat rescued. Anything that reported the due page's own count as "removed" would be claiming a Host is gone on the strength of an observation that is explicitly weakly consistent.

type WorkloadController

type WorkloadController interface {
	EnsureWorkload(ctx context.Context, intent sessionstore.PlacementIntent) error
}

WorkloadController creates and updates the platform workload a dedicated session needs. A4.2 step 3.

ITS SHAPE FOLLOWS H5 (answered 2026-09-04), and each part of the answer shows up here as a constraint rather than as prose:

  • The adapter is INTERNAL to this module, at internal/placement/kubernetes, so this interface may not name a platform type. sessionstore.PlacementIntent is the whole currency: it is Factory-authored desire and nothing else -- no lease epoch, no HostID, no residency -- and its opaque workload payload is a byte string this module never parses. A Kubernetes PodSpec, a Nomad job and a future platform's manifest are the same value to it.
  • The intent carries its GENERATION, which is what a controller records against the workload it created. A controller that reconciled generation 7 and is handed 7 again has nothing to do, however many Host heartbeats have moved the record's revision in between. That is what makes calling this on every reconciliation cheap rather than wasteful. (Attribution, since it is easy to over-read: H5's recorded text contains no "records generation" clause. The requirement is A4.2 step 1's idempotent desired generation and the field is sessionstore.PlacementIntent.Generation, a member by construction. H5 decides the adapter's placement, not this.)
  • Multiple replicas of either binary run with NO leader election, so this is called concurrently for one session by design. Deterministic workload identity is the mechanism that makes that safe -- the claim only suppresses duplicate work -- and an implementation whose creation is not name-deterministic breaks the decision rather than this interface.

It has exactly one method. Deletion is deliberately absent: specification section 13 makes the ordering normative -- request drain, wait for an epoch-fenced checkpoint and an observed cold release, THEN delete -- and none of those steps exists in this module yet. A Delete declared here today would be a seam with no implementation, no caller and no test, which is the shape of the "guarantee" this lane has twice found inert. The operative widener is D1.1 step 1, which specifies ensure, observe, request-drain and delete; D2.2 supplies the drain protocol those last two depend on. A4.2 step 3 asks for a NARROW interface, so one method is what was requested.

Jump to

Keyboard shortcuts

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