Every given test compiled against a panic-only interface skeleton (go vet clean); nothing was implemented. modernc.org/sqlite v1.59.0 vetted in a scratch module (WAL works); go.sum given. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.6 KiB
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:
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:
- Pinned route (
pins[k.Route]set): ifhosts.Healthy(pin)→ host = pin; if no lease forkexists, create one (stateActive, eventReasonPinonly if this is the first lease created under this pin for this key… keep it simple: eventReasonNewwithToHost = pin); return(pin, existed, nil). If the pin is not healthy →ErrPinnedDown. - Existing lease for
k: if its host is healthy → touchLastUsed = now, save, return(host, true, nil). (A draining host still serves its existing leases.) If not healthy → recordReasonUnhealthy(FromHost= old host) and fall through to choose, excluding that host. - Inherit: if
k.FP != ""and a lease forKey{k.Route, "", k.Model}exists on a healthy host, createk's lease on that host, save, return(host, true, nil). - Choose: candidates minus unhealthy minus draining →
choose.Choose(filtered, k.Model). None →ErrNoHost(nothing is created). Else create the lease (Created = LastUsed = now), save, recordReasonNew(or theReasonUnhealthyevent from step 2 instead, withToHostfilled), 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/. ReadTestPinAndUnpinandTestFingerprintInheritsRouteLeasetwice: 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. Rowv1/04-lease.
git add internal/lease docs/implementer-log.md
git commit
Done when
go test -race -count=1 ./internal/lease/isok;make gateprintsgate: 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.