# v1 task 04: the lease table **Branch:** `v1` (run `git switch v1`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add the sticky lease table` ## Goal The heart of crossbar: `lease.Table` remembers which host each conversation is on and keeps it there. A lease moves only when its host is unhealthy, when it has been idle longer than `lease_idle`, or when an operator releases or pins the route. It is written through to the store on every change and loaded back at start, so a restart does not reshuffle sessions. `PLAN.md` §5, §4 step 3. ## Context Why sticky: a `llama-server` prompt cache is per process; moving a 200k-token conversation to another host costs minutes of re-prefill. A "better" host appearing is never a reason to move. A pin ("project A goes to titan right now") is an operator decision and outranks everything, including health: a pinned host that is down yields an error, not a silent move. ## Files - Copy: `internal/lease/lease_test.go` - Create: `internal/lease/lease.go` - Modify: `docs/implementer-log.md` ## Interfaces `internal/lease`, package `lease`: ```go var ( ErrNoHost = errors.New("lease: no usable host") ErrPinnedDown = errors.New("lease: pinned host is not healthy") ErrUnknownHost = errors.New("lease: unknown host") ) type Key struct{ Route, FP, Model string } type Lease struct { Key Host string State store.State Created, LastUsed time.Time } type Persister interface { // *store.Store satisfies it SaveLease(store.Lease) error DeleteLease(route, fp, model string) error ListLeases() ([]store.Lease, error) RecordEvent(store.LeaseEvent) error } type Hosts interface { Healthy(name string) bool Draining(name string) bool } type Chooser interface { Choose(candidates []string, model string) (string, bool) } type Table struct { /* private: mutex, leases map[Key]*Lease, pins map[route]host, p, hosts, choose, idle */ } func New(p Persister, h Hosts, c Chooser, idle time.Duration) (*Table, error) // loads p.ListLeases(): rows with FP=="" && Model=="" && State==Pinned are pins func (t *Table) Acquire(k Key, candidates []string, now time.Time) (host string, reused bool, err error) func (t *Table) ExpireIdle(now time.Time) int // removes leases with now - LastUsed > idle (not pins); events ReasonIdle; returns how many func (t *Table) Pin(route, host string, now time.Time) error // host must be in the candidates of at least one existing lease of the route, or in a candidate list seen for that route; else ErrUnknownHost func (t *Table) Unpin(route string) func (t *Table) Pinned(route string) string // "" if not pinned func (t *Table) Release(route string) int // drops all leases (not the pin) of the route; events ReasonRelease; returns how many func (t *Table) Snapshot() []Lease // copies, sorted by Route, FP, Model; pins excluded ``` `Acquire`, in this order: 1. **Pinned route** (`pins[k.Route]` set): if `hosts.Healthy(pin)` → host = pin; if no lease for `k` exists, create one (state `Active`, event `ReasonPin` only if this is the first lease created under this pin for this key… keep it simple: event `ReasonNew` with `ToHost = pin`); return `(pin, existed, nil)`. If the pin is not healthy → `ErrPinnedDown`. 2. **Existing lease for `k`**: if its host is healthy → touch `LastUsed = now`, save, return `(host, true, nil)`. (A draining host still serves its existing leases.) If not healthy → record `ReasonUnhealthy` (`FromHost` = old host) and fall through to choose, excluding that host. 3. **Inherit**: if `k.FP != ""` and a lease for `Key{k.Route, "", k.Model}` exists on a healthy host, create `k`'s lease on that host, save, return `(host, true, nil)`. 4. **Choose**: candidates minus unhealthy minus draining → `choose.Choose(filtered, k.Model)`. None → `ErrNoHost` (nothing is created). Else create the lease (`Created = LastUsed = now`), save, record `ReasonNew` (or the `ReasonUnhealthy` event from step 2 instead, with `ToHost` filled), return `(host, false, nil)`. `Pin` records `ReasonPin` (`ToHost` = host) and saves a pin row (`store.Lease{Route: route, FP: "", Model: "", Host: host, State: store.Pinned}`); existing leases of the route on other hosts are **deleted** (event `ReasonPin` per lease) so the next turn lands on the pin. `Unpin` deletes the pin row and records `ReasonRelease`; existing leases stay. `Pin` to a host that no candidate list for that route has ever contained → `ErrUnknownHost` (keep a `map[route]map[host]bool` of candidates seen in `Acquire`; at load time, hosts of stored leases count as seen). All persister errors are returned; nothing is left half-changed in memory when a save fails (apply to memory after the save succeeds). ## Steps - [ ] **1. Copy.** `git switch v1`; `mkdir -p internal/lease`; `cp docs/plans/v1/_files/internal/lease/lease_test.go internal/lease/`. Read `TestPinAndUnpin` and `TestFingerprintInheritsRouteLease` twice: they are the rules above as stories. - [ ] **2. See it fail** (compile). **3. Write `lease.go`.** `gofmt -w internal/lease/`. - [ ] **4. See it pass.** `go test -race -count=1 ./internal/lease/`. - [ ] **5. Run the gate.** `make gate`. **6. Log and commit.** Row `v1/04-lease`. ```sh git add internal/lease docs/implementer-log.md git commit ``` ## Done when - `go test -race -count=1 ./internal/lease/` is `ok`; `make gate` prints `gate: ok`; the copied test is byte-identical. ## Stop and report if - A test expects an event sequence you cannot produce under the rules above: quote the test and the rule that conflict.