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 ¶
- Variables
- func ReusableOwner(owner sessionwire.HostLinkRegistryObservation, ...) bool
- func WorkloadName(intent sessionstore.PlacementIntent) string
- type Catalog
- type ClaimSweep
- type ClaimSweepResult
- type Claims
- type Clock
- type Config
- type Decision
- type Desired
- type Directory
- type Outcome
- type Reconciler
- type RecordSweeper
- type Request
- type Result
- type SessionRef
- type SweeperConfig
- type TargetSweep
- type TargetSweepResult
- type WorkloadController
- type WorkloadIdentity
Constants ¶
This section is empty.
Variables ¶
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.
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.
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 ¶
func ReusableOwner(owner sessionwire.HostLinkRegistryObservation, record sessionstore.CatalogRecord, now time.Time) bool
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.
func WorkloadName ¶ added in v0.2.0
func WorkloadName(intent sessionstore.PlacementIntent) string
WorkloadName returns only the platform-safe name derived from intent.
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 ¶
type Claims interface {
AcquireReconciliationClaim(ctx context.Context, req sessionstore.AcquireReconciliationClaimRequest) (sessionstore.ReconciliationClaimEntry, error)
ReleaseReconciliationClaim(ctx context.Context, req sessionstore.ReleaseReconciliationClaimRequest) (sessionstore.ReconciliationClaimEntry, error)
}
Claims is the short-lived reconciliation claim of specification section 10.
type Clock ¶
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 ¶
func Decide( record sessionstore.CatalogRecord, owner sessionwire.HostLinkRegistryObservation, ownerObserved bool, candidates []sessionwire.HostLinkCapacityReport, now time.Time, ) Decision
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 ¶
type Directory interface {
Owner(ctx context.Context, tenant sessionwire.TenantID, session sessionwire.SessionID) (sessionwire.HostLinkRegistryObservation, bool, error)
Candidates(ctx context.Context, req sessionstore.ListCompatibleHostsRequest) (sessionstore.HostTargetPage, error)
}
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 )
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 ¶
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:
- 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.
- 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 desired and observed state rather than scaling again, so the deferral path can report an owner the winner has just produced for the generation it wrote.
- 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. The pinned Core (v0.8.0) now carries the request that asks a Host to take an unowned session -- sessionwire.HostLinkAttachRequest, sent as HostLinkMethodAttach, fenced by the candidate's host_id and host_generation, answered with the registry observation whose lease epoch a bind then names -- so the reason this method stops at naming is no longer the wire. It is that a caller sending the attach must be able to tell a Host that cannot answer it (a v0.1.0 Host resolves the method as a channel and refuses runtime_unavailable) from one that refused it, and no released record carries that capability. The caller that sends it therefore lives above this package and is not built yet; TestThePinnedWireCarriesAnAttachment holds the wire half of the premise.
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(context.Context, sessionstore.PlacementIntent) error
ObserveWorkload(context.Context, sessionstore.PlacementIntent) (sessionwire.HostLinkRegistryObservation, bool, error)
RequestDrain(context.Context, sessionstore.PlacementIntent) (sessionwire.HostLinkDrainObservation, error)
DeleteWorkload(context.Context, sessionstore.PlacementIntent) error
}
WorkloadController is the consumer-owned domain seam for a dedicated workload's lifecycle.
Every argument and result is a Core or SessionStore record. In particular, the platform adapter receives the Factory-authored PlacementIntent and returns the Host-owned registry/drain observations; no Kubernetes, Nomad or other platform type can cross this boundary. The adapter may use the intent to derive its own platform object name, but the placement package never sees that object.
EnsureWorkload is idempotent for an unchanged intent. ObserveWorkload reports whether a Host registry observation exists for that intent. RequestDrain is the only lifecycle operation that initiates Host draining, and DeleteWorkload is called only after the caller has observed a completed drain. Keeping the ordering in the caller makes a platform adapter unable to treat a transport close or a platform deletion as graceful release.
type WorkloadIdentity ¶ added in v0.2.0
type WorkloadIdentity struct {
Name string
TenantID sessionwire.TenantID
SessionID sessionwire.SessionID
AgentID sessionwire.AgentID
RuntimeCompatibilityID string
Generation uint64
}
WorkloadIdentity is the stable identity of one desired workload revision.
Name is deliberately a digest rather than a concatenation of user-controlled identifiers. TenantID and SessionID may contain characters that are legal in Core but unsafe in platform names, and leaking either into a workload name also exposes tenant data to platform operators. The source fields remain available to a domain caller for correlation; only Name is intended for use as a platform object name.
func DeriveWorkloadIdentity ¶ added in v0.2.0
func DeriveWorkloadIdentity(intent sessionstore.PlacementIntent) WorkloadIdentity
DeriveWorkloadIdentity deterministically derives one platform-safe workload identity from a desired placement intent.
The framing is length-prefixed and ordered, so delimiters or UTF-8 bytes in tenant/session/agent/runtime identifiers cannot create an alternate preimage. Desired generation is included as a fixed-width integer: a changed desired revision receives a distinct workload identity, while a restart deriving the same revision receives the exact same name. The 56 hexadecimal digest bytes plus the prefix fit Kubernetes' 63-character DNS-label limit and remain valid for stricter platform name rules as well.