Files
crossbar/docs/plans/v1/04-lease.md
T
kyleandClaude Fable 5.1 c457046f8b v1 plan: leases, limiter, chooser, fingerprint, SQLite store, accounting, admin — acceptance tests first, no reference
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>
2026-09-25 03:10:24 -07:00

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:

  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.
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.