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>
118 lines
5.6 KiB
Markdown
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.
|