Files
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

118 lines
5.6 KiB
Markdown

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