From d748c0392112867ae7084f690f472f1acd57b634 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:48:29 -0600 Subject: [PATCH 01/10] B51PJKA feat(config): kv_snapshots and kv_snapshots_mib on an engine entry --- internal/config/config.go | 21 +++++++++++++++++ internal/config/config_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 67e00a8..7c2c3bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,20 @@ type Engine struct { DrainComfyUI string `toml:"drain_comfyui,omitempty" json:"drain_comfyui,omitempty" yaml:"drain_comfyui,omitempty"` // as berth run --drain-comfyui // KeepComfyUI is berth run --keep-comfyui-up: unload and stay up. KeepComfyUI bool `toml:"keep_comfyui_up,omitempty" json:"keep_comfyui_up,omitempty" yaml:"keep_comfyui_up,omitempty"` + // KVSnapshots is the snapshot directory the engine's --slot-save-path + // points at. On a drain, Berth saves every idle slot's prompt cache + // into it; when this gateway later starts the engine again, the empty + // slots restore the newest snapshot for that model before the first + // request, so a long agent context is not prefilled again after every + // switch. Snapshots never cross models, and an engine that does not + // name exactly one model on /v1/models (llama-swap lists many) never + // snapshots. Absolute path: the gateway starts engines from its own + // working directory, and the drained engine writes through the same + // absolute name. + KVSnapshots string `toml:"kv_snapshots,omitempty" json:"kv_snapshots,omitempty" yaml:"kv_snapshots,omitempty"` + // KVSnapshotsMiB is how many MiB of snapshots the directory may hold + // before the least recently saved are removed. 0 = the default. + KVSnapshotsMiB uint64 `toml:"kv_snapshots_mib,omitzero" json:"kv_snapshots_mib,omitempty" yaml:"kv_snapshots_mib,omitempty"` // DrainMax is berth run --drain-max: a ceiling on how long a drain may // wait at a boundary (a processing slot, a running ComfyUI job) before // it stops the engine anyway. 0 = unbounded, the default. @@ -228,6 +242,10 @@ type ModelsConfig struct { Dirs []string `toml:"dirs,omitempty" json:"dirs,omitempty" yaml:"dirs,omitempty"` } +// DefaultKVSnapshotsMiB is the snapshot budget an engine with kv_snapshots +// and no kv_snapshots_mib gets. +const DefaultKVSnapshotsMiB = 16384 + // DefaultFreeFloor is the share of a root's total space a pull must leave // free. A drive never fills: a pull that would go under it is refused. const DefaultFreeFloor = 0.10 @@ -561,6 +579,9 @@ func (c *Config) Validate() error { errs = append(errs, fmt.Errorf("%s: drain_slots %q must be the engine's http(s)://host:port", where, e.DrainSlots)) } } + if e.KVSnapshots != "" && !filepath.IsAbs(e.KVSnapshots) { + errs = append(errs, fmt.Errorf("%s: kv_snapshots %q must be an absolute path: the gateway starts engines from its own working directory", where, e.KVSnapshots)) + } if e.DrainMax.Duration < 0 { errs = append(errs, fmt.Errorf("%s: drain_max %s must be >= 0", where, e.DrainMax)) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7ad645f..dfa2eaf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -718,3 +718,44 @@ func TestLibraryFloorDefaults(t *testing.T) { t.Errorf("explicit 0 = %g, want the guard off", c.LibraryFloor()) } } + +// kv_snapshots is an absolute directory the engine's --slot-save-path points +// at, with an open budget that takes the default; a relative path is refused +// and the size parses as given. +func TestEngineKVSnapshotsParsedAndValidated(t *testing.T) { + abs := filepath.Join(t.TempDir(), "kv") + c, err := Load(write(t, "[[engine]]\nname = \"llama-server\"\ntype = \"llamacpp\"\nurl = \"http://127.0.0.1:8080\"\nkv_snapshots = \""+abs+"\"\nkv_snapshots_mib = 4096\n")) + if err != nil { + t.Fatalf("a valid kv_snapshots was refused: %v", err) + } + if c.Engine[0].KVSnapshots != abs { + t.Errorf("kv_snapshots = %q, want %q", c.Engine[0].KVSnapshots, abs) + } + if c.Engine[0].KVSnapshotsMiB != 4096 { + t.Errorf("kv_snapshots_mib = %d, want 4096", c.Engine[0].KVSnapshotsMiB) + } + c, err = Load(write(t, "[[engine]]\nname = \"x\"\ntype = \"llamacpp\"\nurl = \"http://127.0.0.1:8080\"\n")) + if err != nil { + t.Fatal(err) + } + if c.Engine[0].KVSnapshots != "" || c.Engine[0].KVSnapshotsMiB != 0 { + t.Errorf("an engine that names no snapshot directory carries %q and %d", + c.Engine[0].KVSnapshots, c.Engine[0].KVSnapshotsMiB) + } + if DefaultKVSnapshotsMiB != 16384 { + t.Errorf("the open budget default is %d, want 16384", DefaultKVSnapshotsMiB) + } + + for _, tc := range []struct { + dir string + wantErr string + }{ + {"kv", `engine[0] "x": kv_snapshots "kv" must be an absolute path`}, + {"./kv", `engine[0] "x": kv_snapshots "./kv" must be an absolute path`}, + } { + _, err := Load(write(t, "[[engine]]\nname = \"x\"\ntype = \"llamacpp\"\nurl = \"http://127.0.0.1:8080\"\nkv_snapshots = \""+tc.dir+"\"\n")) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("kv_snapshots %q: error lacks %q: %v", tc.dir, tc.wantErr, err) + } + } +} From 77986140833584a73a07d7e10572d9be83e5bdd0 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:50:20 -0600 Subject: [PATCH 02/10] B51PJKA feat(kvsnap): the engine's one model on /v1/models, and bearer credentials --- internal/kvsnap/client.go | 33 ++++++++++++ internal/kvsnap/client_test.go | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/internal/kvsnap/client.go b/internal/kvsnap/client.go index 08cd2e1..eebd0a1 100644 --- a/internal/kvsnap/client.go +++ b/internal/kvsnap/client.go @@ -15,6 +15,7 @@ import ( "time" "github.com/T-Crypt/berth/internal/engines" + "github.com/T-Crypt/berth/internal/httpauth" ) // ErrUnsupported means the engine cannot save or restore a slot cache: it @@ -22,6 +23,12 @@ import ( // snapshot flow degrades to a plain swap; the engine keeps serving. var ErrUnsupported = errors.New("slot saving is not supported by this engine") +// ErrNoModel means the engine's /v1/models does not name exactly one model, +// so there is no identity to key a snapshot with. llama-swap lists every +// model it can load; an engine still loading answers nothing yet. The +// caller skips snapshots for such an engine and never guesses the model. +var ErrNoModel = errors.New("the engine names no single model on /v1/models") + // Slot is one entry of an engine's /slots list. type Slot struct { ID int @@ -66,6 +73,30 @@ func Slots(ctx context.Context, base string) ([]Slot, error) { return out, nil } +// Model is the one model identity the engine at base lists on /v1/models. +// An engine that lists none, or several (llama-swap), has no snapshot +// identity: the error wraps ErrNoModel with the count, and the caller skips +// snapshots rather than guess which model a save belongs to. +func Model(ctx context.Context, base string) (string, error) { + url := host(base) + "/v1/models" + b, err := get(ctx, url) + if err != nil { + return "", err + } + var r struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(b, &r); err != nil { + return "", fmt.Errorf("GET %s: %w", url, err) + } + if n := len(r.Data); n != 1 { + return "", fmt.Errorf("%w: /v1/models lists %d models", ErrNoModel, n) + } + return r.Data[0].ID, nil +} + // Save asks the engine to write slot's prompt cache into its snapshot // directory as name. func Save(ctx context.Context, base string, slot int, name string) (Result, error) { @@ -174,6 +205,7 @@ func get(ctx context.Context, url string) ([]byte, error) { if err != nil { return nil, err } + httpauth.Apply(req) resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("GET %s: %w", url, err) @@ -195,6 +227,7 @@ func post(ctx context.Context, url string, body []byte) (*http.Response, error) return nil, err } req.Header.Set("Content-Type", "application/json") + httpauth.Apply(req) resp, err := httpClient.Do(req) if err != nil { return nil, err diff --git a/internal/kvsnap/client_test.go b/internal/kvsnap/client_test.go index 3441ee5..80b3149 100644 --- a/internal/kvsnap/client_test.go +++ b/internal/kvsnap/client_test.go @@ -13,6 +13,8 @@ import ( "strings" "testing" "time" + + "github.com/T-Crypt/berth/internal/httpauth" ) // engine returns a server that answers every request with status and body, @@ -24,6 +26,7 @@ func engine(t *testing.T, status int, body string) (*httptest.Server, *captured) c.path = r.URL.Path c.query = r.URL.RawQuery c.body, _ = io.ReadAll(r.Body) + c.auth = r.Header.Get("Authorization") w.WriteHeader(status) _, _ = w.Write([]byte(body)) })) @@ -34,6 +37,7 @@ func engine(t *testing.T, status int, body string) (*httptest.Server, *captured) type captured struct { path, query string body []byte + auth string } func TestSlots(t *testing.T) { @@ -113,6 +117,100 @@ func TestSlotsTrailingSlash(t *testing.T) { } } +func TestModel(t *testing.T) { + cases := []struct { + name string + status int + body string + want string + wantNoModel bool + wantErr string + }{ + { + name: "one model", + status: 200, + body: `{"object":"list","data":[{"id":"/models/qwen3-8b.gguf"}]}`, + want: "/models/qwen3-8b.gguf", + }, + { + name: "no model, an engine before its load", + status: 200, + body: `{"object":"list","data":[]}`, + wantNoModel: true, + }, + { + name: "several models, like llama-swap", + status: 200, + body: `{"object":"list","data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`, + wantNoModel: true, + }, + { + name: "garbage is a plain error", + status: 200, + body: `no`, + wantErr: "/v1/models", + }, + { + name: "a missing endpoint is a plain error, not ErrUnsupported", + status: 404, + body: `nope`, + wantErr: "GET", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, c := engine(t, tc.status, tc.body) + got, err := Model(context.Background(), srv.URL) + if c.path != "/v1/models" { + t.Errorf("requested %s, want /v1/models", c.path) + } + switch { + case tc.wantNoModel: + if !errors.Is(err, ErrNoModel) { + t.Fatalf("err = %v, want ErrNoModel", err) + } + case tc.wantErr != "": + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want one naming %s", err, tc.wantErr) + } + if errors.Is(err, ErrNoModel) { + t.Errorf("a plain failure must not be ErrNoModel: %v", err) + } + default: + if err != nil { + t.Fatalf("Model: %v", err) + } + if got != tc.want { + t.Errorf("model = %q, want %q", got, tc.want) + } + } + }) + } + srv, c := engine(t, 200, `{"object":"list","data":[{"id":"m"}]}`) + if _, err := Model(httpauth.WithBearer(context.Background(), "sekret"), srv.URL); err != nil { + t.Fatalf("Model with a bearer: %v", err) + } + if got := c.auth; got == "" { + t.Error("a bearer context sent no Authorization header") + } +} + +func TestSaveSendsTheBearer(t *testing.T) { + srv, c := engine(t, 200, `{"id_slot":0,"n_saved":10,"n_written":64}`) + if _, err := Save(httpauth.WithBearer(context.Background(), "sekret"), srv.URL, 0, "f"); err != nil { + t.Fatalf("Save: %v", err) + } + if got := c.auth; got == "" { + t.Error("a bearer context sent no Authorization header") + } + if _, err := Save(context.Background(), srv.URL, 0, "f"); err != nil { + t.Fatalf("Save without a bearer: %v", err) + } + if got := c.auth; got != "" { + t.Errorf("a bare context sent %q", got) + } +} + func TestSave(t *testing.T) { cases := []struct { name string From 19e9d150fcc6c94c7ab20902ef6e0ccc6886790a Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:00:32 -0600 Subject: [PATCH 03/10] B51PJKA feat(runner): save a kv_snapshots engine's slots on the drain --- internal/runner/kvsave.go | 110 +++++++++++++ internal/runner/kvsave_test.go | 277 +++++++++++++++++++++++++++++++++ internal/runner/options.go | 18 +++ internal/runner/runner.go | 19 +++ 4 files changed, 424 insertions(+) create mode 100644 internal/runner/kvsave.go create mode 100644 internal/runner/kvsave_test.go diff --git a/internal/runner/kvsave.go b/internal/runner/kvsave.go new file mode 100644 index 0000000..310834c --- /dev/null +++ b/internal/runner/kvsave.go @@ -0,0 +1,110 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +// The drain's KV snapshot step: an engine with kv_snapshots saves every +// idle slot's prompt cache into its snapshot directory while the drain +// waits to stop it, so the next start on the same model restores what would +// otherwise be prefilled again (see internal/kvsnap). The step is a rider, +// never a gate: whatever it hits, the drain goes on exactly as before. + +package runner + +import ( + "context" + "errors" + "time" + + "github.com/T-Crypt/berth/internal/httpauth" + "github.com/T-Crypt/berth/internal/kvsnap" +) + +// kvSaveTimeout bounds the whole step, saves and all, so a wedged engine +// holds the card no longer than this. A var so tests can shorten it. +var kvSaveTimeout = 60 * time.Second + +// kvSave is that step of the drain, called after the slot boundary and +// before the park step or anything stops the engine. It saves the idle +// slots Plan hands back, records each save, then evicts the directory down +// to its budget. An engine without KVDir or KVBase skips everything. +func kvSave(o Options, done <-chan struct{}, say func(string, ...any)) { + if o.KVDir == "" || o.KVBase == "" { + return + } + say0 := say + say = func(format string, a ...any) { say0("kv snapshots: "+format, a...) } + ctx, cancel := context.WithTimeout(httpauth.WithBearer(context.Background(), o.APIKey), kvSaveTimeout) + defer cancel() + // An engine that exits mid-save ends the step at once; nothing left + // to talk to. + go func() { + select { + case <-done: + cancel() + case <-ctx.Done(): + } + }() + idx, err := kvsnap.New(o.KVDir) + if err != nil { + say("the snapshot ledger did not open: %v", err) + return + } + model, err := kvsnap.Model(ctx, o.KVBase) + if err != nil { + // No identity, no snapshot: an engine that lists no model yet, or + // lists many (llama-swap), is skipped by design. + say("skipped, %v", err) + return + } + key := kvsnap.Key{Engine: o.Engine, Model: model} + live, err := kvsnap.Slots(ctx, o.KVBase) + if err != nil { + say("the slot list did not read: %v", err) + return + } + // The save of a slot writes about what its last save wrote; with no + // earlier snapshot of that slot there is nothing to expect. + prev := map[int]int64{} + if ss, err := idx.For(key); err == nil { + for _, s := range ss { + prev[s.Slot] = s.Bytes + } + } + saves := 0 + for _, s := range idx.Plan(key, live) { + if ctx.Err() != nil { + break // out of time, or the engine exited; said where it died + } + if under, warn := idx.Floor(uint64(prev[s.ID]), o.KVFloor); under { + say("the snapshot directory is at its free-space floor; saving no more") + break + } else if warn != "" { + say("%s", warn) + } + r, err := kvsnap.Save(ctx, o.KVBase, s.ID, kvsnap.Name(key, s.ID)) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + // The step outlived its budget, or the engine exited under it. + if errors.Is(ctxErr, context.DeadlineExceeded) { + say("gave up after %s with %d slot(s) saved", kvSaveTimeout, saves) + } + break + } + if errors.Is(err, kvsnap.ErrUnsupported) { + say("%s was started without --slot-save-path; its slots stay unsaved", o.Engine) + return + } + say("saving slot %d failed: %v", s.ID, err) + continue + } + if err := idx.Record(key, s.ID, r, time.Now()); err != nil { + say("recording slot %d failed: %v", s.ID, err) + continue + } + saves++ + } + if n, err := idx.Evict(o.KVBudget); err != nil { + say("evicting to the budget failed: %v", err) + } else if n > 0 { + say("evicted %d snapshot(s) past the budget", n) + } +} diff --git a/internal/runner/kvsave_test.go b/internal/runner/kvsave_test.go new file mode 100644 index 0000000..637cc64 --- /dev/null +++ b/internal/runner/kvsave_test.go @@ -0,0 +1,277 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +//go:build unix + +package runner + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/T-Crypt/berth/internal/config" + "github.com/T-Crypt/berth/internal/kvsnap" +) + +// llamaStub is a llama-server stand-in for the drain's snapshot step: one +// (or many, or no) model on /v1/models, an idle slot list, saves that +// answer what the test sets, and every slot call counted in request order. +type llamaStub struct { + mu sync.Mutex + models []string // what /v1/models lists, in order + noSavePath bool // started without --slot-save-path + saveDelay time.Duration + saves []int // slot ids POSTed to action=save, in order + written map[int]int +} + +func (s *llamaStub) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/models": + s.mu.Lock() + models := append([]string(nil), s.models...) + s.mu.Unlock() + data := "" + for _, m := range models { + data += fmt.Sprintf(`{"id":%q},`, m) + } + fmt.Fprintf(w, `{"object":"list","data":[%s]}`, strings.TrimSuffix(data, ",")) + case r.URL.Path == "/slots" && r.Method == http.MethodGet: + fmt.Fprint(w, `[{"id":0,"is_processing":false},{"id":1,"is_processing":false}]`) + case strings.HasPrefix(r.URL.Path, "/slots/"): + slot := 0 + fmt.Sscanf(r.URL.Path, "/slots/%d", &slot) + if r.URL.Query().Get("action") == "save" { + s.mu.Lock() + s.saves = append(s.saves, slot) + s.mu.Unlock() + if s.noSavePath { // an action without --slot-save-path + fmt.Fprint(w, `{"error":{"code":501,"message":"This server does not support slots action. Start it with --slot-save-path","type":"not_supported_error"}}`) + return + } + select { + case <-time.After(s.saveDelay): + case <-r.Context().Done(): // a client that gave up ends the save + } + fmt.Fprintf(w, `{"id_slot":%d,"n_saved":%d,"n_written":%d,"timings":{"save_ms":1.5}}`, + slot, s.written[slot], 64*1024*1024) + return + } + fmt.Fprint(w, `{"error":{"code":501,"message":"This server does not support slots action. Start it with --slot-save-path","type":"not_supported_error"}}`) + default: + http.NotFound(w, r) + } + }) +} + +func (s *llamaStub) saveCalls() []int { + s.mu.Lock() + defer s.mu.Unlock() + return append([]int(nil), s.saves...) +} + +// ledger is what the snapshot directory's index records after a drain. +func ledger(t *testing.T, dir string) []kvsnap.Snapshot { + t.Helper() + x, err := kvsnap.New(dir) + if err != nil { + t.Fatal(err) + } + var out []kvsnap.Snapshot + for _, m := range []string{"qwen3-8b"} { + ss, err := x.For(kvsnap.Key{Engine: "llama-server", Model: m}) + if err != nil { + t.Fatal(err) + } + out = append(out, ss...) + } + return out +} + +// The drain saves each idle slot, and only a save that wrote tokens is +// kept: an empty slot's file is worthless, so the ledger drops it. +func TestDrainKVSavesSlotsWithTokens(t *testing.T) { + stub := &llamaStub{models: []string{"qwen3-8b"}, + written: map[int]int{0: 4000, 1: 0}} // slot 1's cache is empty + srv := httptest.NewServer(stub.handler()) + defer srv.Close() + dir := t.TempDir() + + cmd, waited := startSleep(t) + done := make(chan struct{}) + var logs strings.Builder + + go drain(Options{Engine: "llama-server", DrainSlots: srv.URL, KVBase: srv.URL, + KVDir: dir, KVBudget: 1 << 30, Grace: 50 * time.Millisecond, Retry: 20 * time.Millisecond}, + cmd, newLine(nil), done, "test", sayTo(&logs)) + + select { + case <-waited: + case <-time.After(5 * time.Second): + t.Fatalf("engine survived the drain; log:\n%s", &logs) + } + if got := stub.saveCalls(); len(got) != 2 { + t.Fatalf("saves = %v, want both idle slots; log:\n%s", got, &logs) + } + l := ledger(t, dir) + if len(l) != 1 || l[0].Slot != 0 || l[0].Tokens != 4000 { + t.Fatalf("ledger = %+v, want slot 0 alone with its 4000 tokens", l) + } +} + +// An engine started without --slot-save-path gets one INFO line naming the +// flag, and the drain carries on and stops the engine exactly as before. +func TestDrainKVUnsupportedStops(t *testing.T) { + stub := &llamaStub{models: []string{"qwen3-8b"}, noSavePath: true} + srv := httptest.NewServer(stub.handler()) + defer srv.Close() + dir := t.TempDir() + + cmd, waited := startSleep(t) + done := make(chan struct{}) + var logs strings.Builder + + drain(Options{Engine: "llama-server", DrainSlots: srv.URL, KVBase: srv.URL, + KVDir: dir, Grace: 50 * time.Millisecond, Retry: 20 * time.Millisecond}, + cmd, newLine(nil), done, "test", sayTo(&logs)) + + select { + case <-waited: + case <-time.After(3 * time.Second): + t.Fatal("engine survived a drain whose saves were unsupported") + } + if got := stub.saveCalls(); len(got) != 1 { + out := logs.String() + t.Fatalf("an unsupported save must end the step at once: %v; log:\n%s", got, out) + } + if out := logs.String(); !strings.Contains(out, "kv snapshots: llama-server was started without --slot-save-path") { + t.Fatalf("no unsupported line; log:\n%s", out) + } + if l := ledger(t, dir); len(l) != 0 { + t.Fatalf("an unsupported engine recorded %+v", l) + } +} + +// A save slower than the step's whole budget loses the race, the step says +// so once, and the drain still stops the engine. +func TestDrainKVSlowSaveTimesOutAndStops(t *testing.T) { + stub := &llamaStub{models: []string{"qwen3-8b"}, saveDelay: 5 * time.Second, + written: map[int]int{0: 4000, 1: 4000}} + srv := httptest.NewServer(stub.handler()) + defer srv.Close() + dir := t.TempDir() + + orig := kvSaveTimeout + kvSaveTimeout = 150 * time.Millisecond + t.Cleanup(func() { kvSaveTimeout = orig }) + + cmd, waited := startSleep(t) + done := make(chan struct{}) + var logs strings.Builder + + drain(Options{Engine: "llama-server", DrainSlots: srv.URL, KVBase: srv.URL, + KVDir: dir, Grace: 50 * time.Millisecond, Retry: 20 * time.Millisecond}, + cmd, newLine(nil), done, "test", sayTo(&logs)) + + select { + case <-waited: + case <-time.After(3 * time.Second): + t.Fatal("engine survived a drain whose saves never finished") + } + if out := logs.String(); !strings.Contains(out, "gave up after 150ms") { + t.Fatalf("no timeout line; log:\n%s", out) + } +} + +// An engine with no kv_snapshots is drained as always: the step is never +// entered, so no save is asked of it at all. +func TestDrainWithoutKVAsksNothing(t *testing.T) { + stub := &llamaStub{models: []string{"qwen3-8b"}} + srv := httptest.NewServer(stub.handler()) + defer srv.Close() + + cmd, waited := startSleep(t) + done := make(chan struct{}) + var logs strings.Builder + + drain(Options{Engine: "llama-server", DrainSlots: srv.URL, + Grace: 50 * time.Millisecond, Retry: 20 * time.Millisecond}, + cmd, newLine(nil), done, "test", sayTo(&logs)) + + select { + case <-waited: + case <-time.After(3 * time.Second): + t.Fatal("engine survived a plain drain") + } + if got := stub.saveCalls(); len(got) != 0 { + t.Fatalf("an engine with no kv_snapshots saw saves %v", got) + } +} + +// llama-swap lists every model it can load, so an engine that names several +// has no snapshot identity and the step skips before asking for a save. +func TestDrainKVManyModelsSkips(t *testing.T) { + stub := &llamaStub{models: []string{"a", "b", "c"}} + srv := httptest.NewServer(stub.handler()) + defer srv.Close() + dir := t.TempDir() + + cmd, waited := startSleep(t) + done := make(chan struct{}) + var logs strings.Builder + + drain(Options{Engine: "llama-swap", DrainPost: srv.URL + "/unload", KVBase: srv.URL, + KVDir: dir, Grace: 50 * time.Millisecond, Retry: 20 * time.Millisecond}, + cmd, newLine(nil), done, "test", sayTo(&logs)) + + select { + case <-waited: + case <-time.After(3 * time.Second): + t.Fatal("engine survived the drain") + } + if got := stub.saveCalls(); len(got) != 0 { + t.Fatalf("a many-model engine saw saves %v", got) + } + if out := logs.String(); !strings.Contains(out, "/v1/models lists 3 models") { + t.Fatalf("no skip line; log:\n%s", out) + } +} + +// OptionsFor builds the step's inputs from the entry: the drain_slots base +// wins, then the drain_post origin, then the engine URL; the budget takes +// its default; entries without kv_snapshots carry nothing. +func TestOptionsForKVSnapshots(t *testing.T) { + e := &config.Engine{Name: "llama-server", URL: "http://127.0.0.1:8092", KVSnapshots: "/tmp/kv", + DrainPost: "http://127.0.0.1:8091/unload"} + o := OptionsFor(e, Over{Socket: "s"}) + if o.KVBase != "http://127.0.0.1:8091" { + t.Errorf("base = %q, want the drain_post origin", o.KVBase) + } + if o.KVBudget != config.DefaultKVSnapshotsMiB<<20 { + t.Errorf("budget = %d, want the default", o.KVBudget) + } + e.DrainSlots, e.DrainPost, e.URL = "http://127.0.0.1:8090", "", "" + o = OptionsFor(e, Over{Socket: "s"}) + if o.KVBase != "http://127.0.0.1:8090" { + t.Errorf("base = %q, want the drain_slots base", o.KVBase) + } + e.DrainSlots = "" + e.URL = "http://127.0.0.1:8092" + o = OptionsFor(e, Over{Socket: "s"}) + if o.KVBase != "http://127.0.0.1:8092" { + t.Errorf("base = %q, want the engine URL", o.KVBase) + } + if o.KVFloor != config.DefaultFreeFloor { + t.Errorf("floor = %g, want the library default", o.KVFloor) + } + bare := OptionsFor(&config.Engine{Name: "x", URL: "http://127.0.0.1:8092"}, Over{Socket: "s"}) + if bare.KVDir != "" || bare.KVBudget != 0 || bare.KVBase != "" || bare.KVFloor != 0 { + t.Errorf("bare = %+v, want the step off", bare) + } +} diff --git a/internal/runner/options.go b/internal/runner/options.go index e3822ec..f9fa107 100644 --- a/internal/runner/options.go +++ b/internal/runner/options.go @@ -57,6 +57,24 @@ func OptionsFor(e *config.Engine, o Over) Options { Grace: grace, FailClosed: o.FailClosed, Argv: argv, Stderr: o.Stderr, Output: o.Output, } + // kv_snapshots: the drain's snapshot step. The slot calls go to the + // entry's drain_slots base when it names one, else the drain_post + // origin, else the engine URL; the budget takes the default when the + // entry left it open, and the save guard keeps the library floor. + if e.KVSnapshots != "" { + mib := e.KVSnapshotsMiB + if mib == 0 { + mib = config.DefaultKVSnapshotsMiB + } + base := e.DrainSlots + if base == "" { + base = drainPostBase(e.DrainPost) + } + if base == "" { + base = e.URL + } + opts.KVDir, opts.KVBudget, opts.KVBase, opts.KVFloor = e.KVSnapshots, int64(mib<<20), base, config.DefaultFreeFloor + } // park = "hook" hands the drain the engine's own offload calls; when // the entry carries no URLs either, the drain probes Berth's park // protocol on the engine's URL instead. keep_comfyui_up is the same diff --git a/internal/runner/runner.go b/internal/runner/runner.go index eafb32a..c6a06ba 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -70,6 +70,21 @@ type Options struct { // the card without it. 0 = unbounded, the default, so a long // generation is never cut off. DrainMax time.Duration + // KV snapshots (config kv_snapshots). On a drain, after the slot + // boundary and before the park step or anything stops the engine, the + // runner saves every idle slot's prompt cache into KVDir through the + // engine's own slot actions and asks the engine to trim nothing: the + // runner itself removes the least recently saved snapshots past + // KVBudget. Empty KVDir or KVBase disables the whole step. + KVDir string + // KVBudget is the snapshot directory's budget in bytes. + KVBudget int64 + // KVBase is the engine base the slot calls go to: the entry's + // drain_slots, else the origin of its drain_post, else the engine URL. + KVBase string + // KVFloor is the [library] free_floor fraction the save guard keeps on + // the snapshot directory's root; 0 turns the guard off. + KVFloor float64 // Park, when set, is the engine's park adapter: on a drain, after the // boundary and before the stop ladder, the runner asks the broker to // admit the lease into RAM parking and the adapter moves the weights @@ -675,6 +690,10 @@ func drain(o Options, cmd *exec.Cmd, line *leaseLine, done <-chan struct{}, reas return } } + // KV snapshots ride the boundary: with nothing generating, the idle + // slots save their prompt caches, before the park step or anything + // stops the engine. A failure here never changes the stop ladder. + kvSave(o, done, say) // The park step sits after the boundary, before anything stops the // engine: on admission the weights move to RAM, the lease is held // parked, and the stop ladder never runs. From f3907e04e11ba3ab6c457d0f6926fed3df51ccfa Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:07:41 -0600 Subject: [PATCH 04/10] B51PJKA feat(gateway): restore a started engine's kv snapshots before the first request --- internal/gateway/gateway.go | 10 ++ internal/gateway/gateway_test.go | 59 +++++++++ internal/gateway/kvrestore.go | 125 +++++++++++++++++++ internal/gateway/kvrestore_test.go | 189 +++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 internal/gateway/kvrestore.go create mode 100644 internal/gateway/kvrestore_test.go diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index ecc75d4..1f91ea4 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -787,6 +787,11 @@ func (g *Gateway) ensure(ctx context.Context, e *config.Engine) error { // free while that happens, so the reads go first. go g.prefetch(e) } + if e.KVSnapshots != "" { + // The same wait warms the engine's saved prompt caches, so the + // restore below reads the page cache where it can. + go g.kvWarm(e, ready) + } go g.run(e, s, ready) } @@ -794,6 +799,11 @@ func (g *Gateway) ensure(ctx context.Context, e *config.Engine) error { for { if probe(ctx, e.URL) { g.markAnswered(e, s) + if starting && e.KVSnapshots != "" { + // Only the start this gateway ran restores: its slots are + // empty, and the restore lands before the first request. + g.kvRestore(e) + } return nil } select { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 683c496..632ff52 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -8,6 +8,7 @@ package gateway import ( "bufio" "context" + "encoding/json" "errors" "flag" "fmt" @@ -66,6 +67,18 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +// slotsSpec parses the env a fake engine reads for its slot surface: +// BERTH_GW_SLOTS is the idle slot count to list (unset: no slot endpoints +// at all), and BERTH_GW_KVLOG the file its slot calls and chat requests +// are appended to, one line each in arrival order. +func slotsSpec() (n int, kvlog string) { + n, _ = 0, "" + if spec := os.Getenv("BERTH_GW_SLOTS"); spec != "" { + fmt.Sscanf(spec, "%d", &n) + } + return n, os.Getenv("BERTH_GW_KVLOG") +} + // testRun is the `berth run` a spawned gateway engine process stands in // for: the same flags the gateway passes, then the engine's command after // "--". It takes a real lease from the test daemon. @@ -100,6 +113,20 @@ func testRun(argv []string) { } func fakeEngine(addr, model string, load time.Duration) { + nSlots, kvlog := slotsSpec() + record := func(line string) { + if kvlog == "" { + return + } + // One small Fprintln behind O_APPEND: each line lands whole, so + // order between handlers is arrival order. + f, err := os.OpenFile(kvlog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return + } + defer f.Close() + fmt.Fprintln(f, line) + } mux := http.NewServeMux() // The port is bound at once and answers 503 until load passes, as a real // engine does while it loads: binding late would leave a window in which @@ -113,6 +140,7 @@ func fakeEngine(addr, model string, load time.Duration) { fmt.Fprintf(w, `{"object":"list","data":[{"id":%q}]}`, model) }) mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + record("chat " + model) w.Header().Set("Content-Type", "text/event-stream") for _, tok := range []string{"lease", "hand off", "never collide"} { fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%q}}]}\n\n", tok) @@ -121,6 +149,37 @@ func fakeEngine(addr, model string, load time.Duration) { } fmt.Fprint(w, "data: [DONE]\n\n") }) + if nSlots > 0 || kvlog != "" { + // The slot surface, off unless the test asks: an idle slot list, + // and save/restore that answer the engine's own numbers and log + // the call in arrival order. + mux.HandleFunc("/slots", func(w http.ResponseWriter, r *http.Request) { + ss := []string{} + for i := 0; i < nSlots; i++ { + ss = append(ss, fmt.Sprintf(`{"id":%d,"is_processing":false}`, i)) + } + fmt.Fprintf(w, "[%s]", strings.Join(ss, ",")) + }) + mux.HandleFunc("/slots/", func(w http.ResponseWriter, r *http.Request) { + slot := 0 + fmt.Sscanf(r.URL.Path, "/slots/%d", &slot) + action := r.URL.Query().Get("action") + var b struct { + Filename string `json:"filename"` + } + _ = json.NewDecoder(r.Body).Decode(&b) + switch action { + case "restore": + record(fmt.Sprintf("restore %d %s", slot, b.Filename)) + fmt.Fprintf(w, `{"id_slot":%d,"filename":%q,"n_restored":12,"n_read":4096,"timings":{"restore_ms":1.0}}`, slot, b.Filename) + case "save": + record(fmt.Sprintf("save %d %s", slot, b.Filename)) + fmt.Fprintf(w, `{"id_slot":%d,"filename":%q,"n_saved":1000,"n_written":8192,"timings":{"save_ms":1.0}}`, slot, b.Filename) + default: + http.NotFound(w, r) + } + }) + } if every := os.Getenv("BERTH_GW_LOG_EVERY"); every != "" { d, err := time.ParseDuration(every) if err != nil { diff --git a/internal/gateway/kvrestore.go b/internal/gateway/kvrestore.go new file mode 100644 index 0000000..d5aa262 --- /dev/null +++ b/internal/gateway/kvrestore.go @@ -0,0 +1,125 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +// The gateway's half of KV snapshots: an engine this gateway starts on +// demand, and only such an engine, gets its saved prompt caches back before +// the first request. While the start waits for the card, the snapshots are +// warmed into the page cache beside the model files' prefetch; once the +// engine answers, its empty slots restore the newest snapshot of their +// model. Both halves are riders: a failure is one line, never a failed +// start or a delayed request. + +package gateway + +import ( + "context" + "errors" + "time" + + "github.com/T-Crypt/berth/internal/config" + "github.com/T-Crypt/berth/internal/httpauth" + "github.com/T-Crypt/berth/internal/kvsnap" +) + +var kvRestoreTimeout = 30 * time.Second + +// kvWarm is fired beside the prefetch in a start: it waits for the engine +// to name its one model on /v1/models, then asks the kernel to read that +// model's snapshot files into the page cache, so the restore reads RAM +// where the kernel kept the pages. It waits as long as the start itself +// may; a platform without the page-cache interface says so once, and +// warming stays best effort. +func (g *Gateway) kvWarm(e *config.Engine, wait time.Duration) { + bearer, ok := g.kvBearer(e) + if !ok { + return + } + idx, err := kvsnap.New(e.KVSnapshots) + if err != nil { + g.log().Debug("kv snapshot ledger did not open", "engine", e.Name, "err", err) + return + } + ctx, cancel := context.WithTimeout(httpauth.WithBearer(context.Background(), bearer), wait) + defer cancel() + for { + model, err := kvsnap.Model(ctx, e.URL) + switch { + case err == nil: + key := kvsnap.Key{Engine: e.Name, Model: model} + if err := idx.Warm(key); err != nil { + // A page cache the kernel does not expose here is no + // mistake to fix; the restore then reads the SSD once. + g.log().Debug("kv snapshots not warmed", "engine", e.Name, "err", err) + } + return + case errors.Is(err, kvsnap.ErrNoModel): + // llama-swap lists every model it can load: no identity, no + // snapshot, by design. + g.log().Debug("kv snapshots skip the engine", "engine", e.Name, "err", err) + return + case ctx.Err() != nil: + return // the engine never named a model during its start + } + select { + case <-ctx.Done(): + return + case <-time.After(250 * time.Millisecond): + } + } +} + +// kvRestore runs once the just-started engine answers, before ensure +// returns: every slot the ledger holds for the engine's model restores its +// newest snapshot. Nothing for another model is in the ledger's answer, so +// a snapshot never crosses models. +func (g *Gateway) kvRestore(e *config.Engine) { + bearer, ok := g.kvBearer(e) + if !ok { + return + } + ctx, cancel := context.WithTimeout(httpauth.WithBearer(context.Background(), bearer), kvRestoreTimeout) + defer cancel() + log := g.log().With("engine", e.Name) + idx, err := kvsnap.New(e.KVSnapshots) + if err != nil { + log.Warn("kv snapshot ledger did not open", "err", err) + return + } + model, err := kvsnap.Model(ctx, e.URL) + if err != nil { + log.Debug("kv snapshots skipped, no identity", "err", err) + return + } + ss, err := idx.For(kvsnap.Key{Engine: e.Name, Model: model}) + if err != nil { + log.Warn("kv snapshot ledger did not read", "err", err) + return + } + for _, s := range ss { + r, err := kvsnap.Restore(ctx, e.URL, s.Slot, s.File) + if err != nil { + if errors.Is(err, kvsnap.ErrUnsupported) { + // The engine started without --slot-save-path; the warm + // cache rides out with it. + log.Info("engine started without --slot-save-path; its slots stay unrestored") + return + } + log.Warn("kv snapshot restore failed", "slot", s.Slot, "file", s.File, "err", err) + continue + } + log.Info("kv snapshot restored", "slot", s.Slot, "tokens", r.Tokens, "took", r.Took) + } +} + +// kvBearer resolves the engine's own credential, the one its slot calls +// answer to; the gateway re-reads it on every restore, as the runs do. +func (g *Gateway) kvBearer(e *config.Engine) (string, bool) { + bearer, err := e.APIKey() + if err != nil { + // A credential that will not resolve is the entry's problem, and + // the engine will refuse the call in its own words. + g.log().Debug("kv snapshots without a credential", "engine", e.Name, "err", err) + return "", false + } + return bearer, true +} diff --git a/internal/gateway/kvrestore_test.go b/internal/gateway/kvrestore_test.go new file mode 100644 index 0000000..ac14f02 --- /dev/null +++ b/internal/gateway/kvrestore_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +//go:build unix + +package gateway + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/T-Crypt/berth/internal/config" + "github.com/T-Crypt/berth/internal/kvsnap" +) + +// fakeModel is what every fake engine here reports as its one model. +const fakeModel = "qwen3-8b" + +// kvFixture names a snapshot the test plants in the ledger: key, slot and +// what the engine is asked to restore under it. +func snapFor(engine, model string, slot int) kvsnap.Snapshot { + key := kvsnap.Key{Engine: engine, Model: model} + at := time.Now() + return kvsnap.Snapshot{Key: key, Slot: slot, File: kvsnap.Name(key, slot), Tokens: 1000, Bytes: 4096, SavedAt: at} +} + +// plantSnapshot writes one snapshot file plus the ledger entry naming it. +func plantSnapshot(t *testing.T, dir string, ss ...kvsnap.Snapshot) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + x, err := kvsnap.New(dir) + if err != nil { + t.Fatal(err) + } + for _, s := range ss { + if err := os.WriteFile(filepath.Join(dir, s.File), []byte("snapshot bytes"), 0o600); err != nil { + t.Fatal(err) + } + if err := x.Record(s.Key, s.Slot, kvsnap.Result{Tokens: s.Tokens, Bytes: s.Bytes}, s.SavedAt); err != nil { + t.Fatal(err) + } + } +} + +// kvLines reads back what the fake engine appended, in arrival order. +func kvLines(t *testing.T, kvlog string) []string { + t.Helper() + b, err := os.ReadFile(kvlog) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + var out []string + for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") { + if l != "" { + out = append(out, l) + } + } + return out +} + +// engineEntry is the configured engine a gateway test starts: the fake +// engine command, one served model, and whatever snapshot directory the +// test gives it (empty: no kv_snapshots). +func engineEntry(t *testing.T, addr, snapDir string) *config.Engine { + t.Helper() + return &config.Engine{Name: "llama-server", Type: "llamacpp", URL: "http://" + addr, + Start: []string{os.Args[0]}, Serves: []string{fakeModel}, KVSnapshots: snapDir} +} + +// A request for a model nobody runs names it through the handler: the +// gateway starts the engine, restores its saved slots, and proxies only +// then. The fake engine logs everything in arrival order, so the order is +// in the log itself. +func TestStartRestoresBeforeTheFirstRequest(t *testing.T) { + sock, _ := daemon(t) + addr := freeAddr(t) + kvlog := filepath.Join(t.TempDir(), "calls.log") + snapDir := filepath.Join(t.TempDir(), "kv") + snap := snapFor("llama-server", fakeModel, 0) + plantSnapshot(t, snapDir, snap) + + t.Setenv("BERTH_GW_ENGINE", addr+","+fakeModel+",300ms") + t.Setenv("BERTH_GW_SLOTS", "1") + t.Setenv("BERTH_GW_KVLOG", kvlog) + + e := engineEntry(t, addr, snapDir) + g := &Gateway{Socket: sock, Ready: 15 * time.Second, Key: testKey, Log: discard(), + Engines: []config.Engine{*e}} + gw := httptest.NewServer(g.Handler()) + defer gw.Close() + + resp := call(t, "POST", gw.URL+"/v1/chat/completions", + `{"model":"`+fakeModel+`","stream":false,"messages":[{"role":"user","content":"hi"}]}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d: %s", resp.StatusCode, b) + } + lines := kvLines(t, kvlog) + restore, chat := -1, -1 + for i, l := range lines { + switch { + case strings.HasPrefix(l, "restore "): + restore = i + if !strings.HasSuffix(l, " "+snap.File) { + t.Errorf("restored %q, want %q", l, snap.File) + } + case strings.HasPrefix(l, "chat "): + chat = i + } + } + if restore < 0 { + t.Fatalf("no restore in %v", lines) + } + if chat < 0 { + t.Fatalf("no chat request in %v", lines) + } + if restore > chat { + t.Errorf("restore at %d, chat at %d: the first request landed on cold slots", restore, chat) + } +} + +// Only the engine's own model is restored: a snapshot of another model sits +// in the same directory without ever reaching the slots. +func TestStartRestoresOnlyTheEnginesModel(t *testing.T) { + sock, _ := daemon(t) + addr := freeAddr(t) + kvlog := filepath.Join(t.TempDir(), "calls.log") + snapDir := filepath.Join(t.TempDir(), "kv") + own := snapFor("llama-server", fakeModel, 0) + other := snapFor("llama-server", "the-other-model", 1) + plantSnapshot(t, snapDir, other, own) + + t.Setenv("BERTH_GW_ENGINE", addr+","+fakeModel+",300ms") + t.Setenv("BERTH_GW_SLOTS", "2") + t.Setenv("BERTH_GW_KVLOG", kvlog) + + e := engineEntry(t, addr, snapDir) + g := &Gateway{Socket: sock, Ready: 15 * time.Second, Key: testKey, Log: discard(), + Engines: []config.Engine{*e}} + if err := g.ensure(context.Background(), e); err != nil { + t.Fatalf("start failed: %v", err) + } + var got []string + for _, l := range kvLines(t, kvlog) { + if strings.HasPrefix(l, "restore ") { + got = append(got, l) + } + } + if len(got) != 1 || !strings.HasSuffix(got[0], " "+own.File) { + t.Fatalf("restores = %v, want own.File alone", got) + } +} + +// An engine with no kv_snapshots never sees a slot call, save or restore. +func TestStartWithoutKVSnapshotsAsksNothing(t *testing.T) { + sock, _ := daemon(t) + addr := freeAddr(t) + kvlog := filepath.Join(t.TempDir(), "calls.log") + + t.Setenv("BERTH_GW_ENGINE", addr+","+fakeModel+",300ms") + t.Setenv("BERTH_GW_SLOTS", "1") + t.Setenv("BERTH_GW_KVLOG", kvlog) + + e := engineEntry(t, addr, "") + e.KVSnapshots = "" + g := &Gateway{Socket: sock, Ready: 15 * time.Second, Key: testKey, Log: discard(), + Engines: []config.Engine{*e}} + if err := g.ensure(context.Background(), e); err != nil { + t.Fatalf("start failed: %v", err) + } + for _, l := range kvLines(t, kvlog) { + if strings.HasPrefix(l, "restore ") { + t.Fatalf("an engine with no kv_snapshots saw %q", l) + } + } +} From 32fd03749368cf4ad9757e98e4e8a3d865a3ff14 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:10:14 -0600 Subject: [PATCH 05/10] B51PJKA docs: kv snapshots on the switch --- CHANGELOG.md | 10 ++++++++++ README.md | 22 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea58be3..d51542f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,16 @@ has a matching GitHub release whose body comes from its section below. machine needs them, and it never delays or fails a start: a file that cannot be asked for is one WARN line. A relative path fails `berth config check`, and `berth prefetch MODEL` makes the same ask by hand. +- `kv_snapshots` and `kv_snapshots_mib` on an `[[engine]]` entry: the drain saves an idle slot's + prompt cache into the engine's `--slot-save-path` directory once the boundary is reached, keeps + the directory within its budget (16 GiB by default, above the library free floor), and the + gateway restores the newest snapshot of the engine's model into its empty slots before the first + request, so a sequence of agents skips the prefill after each switch. The snapshots are keyed by + the model the engine itself names on `/v1/models`, so an engine that lists none or many + (llama-swap) never snapshots, and nothing crosses models. The restore also warms the files into + the page cache during the start's wait: cached, never reserved. A save is bounded at 60 s and a + restore at 30 s, so whatever goes wrong along the way costs one line and the switch goes on as + before. ### Fixed diff --git a/README.md b/README.md index 0e7e3fb..c8d3f6c 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,8 @@ start = ["/path/to/strata/.venv/bin/python", "serve/server.py", "--engine", " serves = ["router-strata", "qwen3.8-flash-next-iq2_xs"] priority = "foreground" # equal to the engine it replaces, so the last model you ask for wins prefetch = ["/models/strata"] # read into the page cache while the holder drains +# kv_snapshots = "/var/cache/berth/kv/strata" # the --slot-save-path the saves and restores share +# kv_snapshots_mib = 16384 ``` Run `berth gateway` (loopback `127.0.0.1:9799`; a user unit is in `contrib/berth-gateway.service`) and send @@ -636,6 +638,24 @@ larger than the RAM the machine can spare gains little: its pages are pushed out its own files and not its subdirectories, and a file that cannot be asked for is one WARN line in the gateway's journal while the start goes on. `berth prefetch MODEL` makes the same ask by hand. +**An agent context comes back without the prefill.** Give a llama-server engine `kv_snapshots`, +the same directory its `--slot-save-path` points at. On a drain, once every slot is idle, Berth asks +the engine to save each slot's prompt cache into that directory, records what each save wrote in a +ledger there, skips slots whose cache is empty, and trims the directory to `kv_snapshots_mib` +(16 GiB by default). When the gateway starts that engine again for the same model, it warms the +snapshots into the page cache during the wait, and the engine's empty slots restore their newest +snapshot once it answers, before the first request. A long agent conversation then switches and +switches back without prefilling again. + +A warm cache is cached, never reserved: the pages sit in the kernel's page cache until the machine +needs the RAM, and a restore afterwards reads its bytes from the SSD again. Snapshots never cross +models: the restore takes only the files the ledger holds for the model the engine itself reports on +`/v1/models`. An engine that lists no model, or more than one (llama-swap lists every model it can +load), gets no snapshots, so a KV snapshot covers llama-swap's many-model entries only when +llama-swap gains per-model slots. An engine started without `--slot-save-path` says so, the drain +logs one line, and the switch is a plain one. The save step is bounded at 60 s and the restore at +30 s, so a failure on either side leaves the switch exactly as it was before. + Measured on the reference RTX 4090 with the [swap bench](contrib/bench/README.md) switching between NInfer (through llama-swap) and Strata (262K context), both on an NVMe SSD, 2026-10-20 (two runs, five switches each way): @@ -1158,8 +1178,6 @@ In the next v0.3.x releases. Each item has its building blocks merged or a spec - Cgroup attribution, which closes the one known stowaway window (chaos scenario 3: a child whose parent exits within one 2 s tick). -- KV snapshots wired into the drain and start paths, so a sequence of agents skips the prefill - after each switch. - Measured switch costs on the live daemon. - Gateway fallback routing, and models started on demand for a standalone llama-server. - Parked resume numbers in [Swap numbers](#swap-numbers), which take the vLLM and SGLang park From 9c73ad137ae3d8a74e00595493ac1a36551a3520 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:12:28 -0600 Subject: [PATCH 06/10] B51PJKA chore: answer the linter in the kv snapshot tests --- internal/gateway/gateway_test.go | 12 +++++++++--- internal/runner/kvsave_test.go | 8 ++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 632ff52..a364f41 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -74,7 +74,10 @@ func TestMain(m *testing.M) { func slotsSpec() (n int, kvlog string) { n, _ = 0, "" if spec := os.Getenv("BERTH_GW_SLOTS"); spec != "" { - fmt.Sscanf(spec, "%d", &n) + if _, err := fmt.Sscanf(spec, "%d", &n); err != nil { + fmt.Fprintf(os.Stderr, "bad BERTH_GW_SLOTS %q: %v\n", spec, err) + os.Exit(2) + } } return n, os.Getenv("BERTH_GW_KVLOG") } @@ -161,8 +164,11 @@ func fakeEngine(addr, model string, load time.Duration) { fmt.Fprintf(w, "[%s]", strings.Join(ss, ",")) }) mux.HandleFunc("/slots/", func(w http.ResponseWriter, r *http.Request) { - slot := 0 - fmt.Sscanf(r.URL.Path, "/slots/%d", &slot) + slot, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/slots/")) + if err != nil { + http.NotFound(w, r) + return + } action := r.URL.Query().Get("action") var b struct { Filename string `json:"filename"` diff --git a/internal/runner/kvsave_test.go b/internal/runner/kvsave_test.go index 637cc64..a7f08b7 100644 --- a/internal/runner/kvsave_test.go +++ b/internal/runner/kvsave_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "testing" @@ -45,8 +46,11 @@ func (s *llamaStub) handler() http.Handler { case r.URL.Path == "/slots" && r.Method == http.MethodGet: fmt.Fprint(w, `[{"id":0,"is_processing":false},{"id":1,"is_processing":false}]`) case strings.HasPrefix(r.URL.Path, "/slots/"): - slot := 0 - fmt.Sscanf(r.URL.Path, "/slots/%d", &slot) + slot, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/slots/")) + if err != nil { + http.NotFound(w, r) + return + } if r.URL.Query().Get("action") == "save" { s.mu.Lock() s.saves = append(s.saves, slot) From 3942b5c4e99e906ea1b0e5414c9f2ae301e1ec2b Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:16:31 -0600 Subject: [PATCH 07/10] B51PJKA chore: trim the slots spec parse in the fake engine --- internal/gateway/gateway_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index a364f41..4a0a6c1 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -72,7 +72,6 @@ func TestMain(m *testing.M) { // at all), and BERTH_GW_KVLOG the file its slot calls and chat requests // are appended to, one line each in arrival order. func slotsSpec() (n int, kvlog string) { - n, _ = 0, "" if spec := os.Getenv("BERTH_GW_SLOTS"); spec != "" { if _, err := fmt.Sscanf(spec, "%d", &n); err != nil { fmt.Fprintf(os.Stderr, "bad BERTH_GW_SLOTS %q: %v\n", spec, err) From c34ef7cb24a8af6a01864958a35f8d9b2ba35bdc Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:59:12 -0600 Subject: [PATCH 08/10] B51PJKA fix(kvsnap): an owner-only ledger, and a private snapshot directory in the docs A slot snapshot holds the prompt tokens, so it is the conversation itself. --- CHANGELOG.md | 3 ++- README.md | 6 +++++- internal/kvsnap/index.go | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d51542f..37e1fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,8 @@ has a matching GitHub release whose body comes from its section below. (llama-swap) never snapshots, and nothing crosses models. The restore also warms the files into the page cache during the start's wait: cached, never reserved. A save is bounded at 60 s and a restore at 30 s, so whatever goes wrong along the way costs one line and the switch goes on as - before. + before. A snapshot holds the conversation's tokens: the ledger is owner-only, and the README asks + for a private directory. ### Fixed diff --git a/README.md b/README.md index c8d3f6c..9f07452 100644 --- a/README.md +++ b/README.md @@ -600,7 +600,7 @@ start = ["/path/to/strata/.venv/bin/python", "serve/server.py", "--engine", " serves = ["router-strata", "qwen3.8-flash-next-iq2_xs"] priority = "foreground" # equal to the engine it replaces, so the last model you ask for wins prefetch = ["/models/strata"] # read into the page cache while the holder drains -# kv_snapshots = "/var/cache/berth/kv/strata" # the --slot-save-path the saves and restores share +# kv_snapshots = "/home/you/.cache/berth/kv/strata" # the --slot-save-path the saves and restores share # kv_snapshots_mib = 16384 ``` @@ -656,6 +656,10 @@ llama-swap gains per-model slots. An engine started without `--slot-save-path` s logs one line, and the switch is a plain one. The save step is bounded at 60 s and the restore at 30 s, so a failure on either side leaves the switch exactly as it was before. +A snapshot holds the slot's prompt tokens, so it is the conversation itself. Keep the directory +private to the user the engine runs as (`mkdir -p DIR && chmod 700 DIR`); Berth writes its ledger +there owner-only. + Measured on the reference RTX 4090 with the [swap bench](contrib/bench/README.md) switching between NInfer (through llama-swap) and Strata (262K context), both on an NVMe SSD, 2026-10-20 (two runs, five switches each way): diff --git a/internal/kvsnap/index.go b/internal/kvsnap/index.go index a452884..4800218 100644 --- a/internal/kvsnap/index.go +++ b/internal/kvsnap/index.go @@ -232,7 +232,9 @@ func (x *Index) write(ss []Snapshot) error { if err != nil { return err } - return fsx.WriteFile(x.ledgerPath(), append(b, '\n'), 0o644) + // Owner-only: the ledger names the engine, model and slot of every + // saved conversation. + return fsx.WriteFile(x.ledgerPath(), append(b, '\n'), 0o600) } // listedBytes is the on-disk total of the files the ledger lists, with From f73088ac90cf1467af11de71fa6aa9913791b371 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:59:20 -0600 Subject: [PATCH 09/10] B51PJKA docs: the kv snapshot entry under Unreleased --- CHANGELOG.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0869e30..7a29682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ has a matching GitHub release whose body comes from its section below. ## [Unreleased] +### Added + +- `kv_snapshots` and `kv_snapshots_mib` on an `[[engine]]` entry: the drain saves an idle slot's + prompt cache into the engine's `--slot-save-path` directory once the boundary is reached, keeps + the directory within its budget (16 GiB by default, above the library free floor), and the + gateway restores the newest snapshot of the engine's model into its empty slots before the first + request, so a sequence of agents skips the prefill after each switch. The snapshots are keyed by + the model the engine itself names on `/v1/models`, so an engine that lists none or many + (llama-swap) never snapshots, and nothing crosses models. The restore also warms the files into + the page cache during the start's wait: cached, never reserved. A save is bounded at 60 s and a + restore at 30 s, so whatever goes wrong along the way costs one line and the switch goes on as + before. A snapshot holds the conversation's tokens: the ledger is owner-only, and the README asks + for a private directory. + ## [v0.3.3] - 2026-10-21 Fixes from the Windows testers and two gateway additions. On a single-GPU Windows desktop, the @@ -32,17 +46,6 @@ chain of them, from those times. machine needs them, and it never delays or fails a start: a file that cannot be asked for is one WARN line. A relative path fails `berth config check`, and `berth prefetch MODEL` makes the same ask by hand. -- `kv_snapshots` and `kv_snapshots_mib` on an `[[engine]]` entry: the drain saves an idle slot's - prompt cache into the engine's `--slot-save-path` directory once the boundary is reached, keeps - the directory within its budget (16 GiB by default, above the library free floor), and the - gateway restores the newest snapshot of the engine's model into its empty slots before the first - request, so a sequence of agents skips the prefill after each switch. The snapshots are keyed by - the model the engine itself names on `/v1/models`, so an engine that lists none or many - (llama-swap) never snapshots, and nothing crosses models. The restore also warms the files into - the page cache during the start's wait: cached, never reserved. A save is bounded at 60 s and a - restore at 30 s, so whatever goes wrong along the way costs one line and the switch goes on as - before. A snapshot holds the conversation's tokens: the ledger is owner-only, and the README asks - for a private directory. ### Fixed From 8d4a50cda3fd147abfb7c12788c8ac5b9a72e21a Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:25:55 -0600 Subject: [PATCH 10/10] B51PJKA test(config): a literal TOML string for the snapshot path A Windows temp path in a basic string read its backslashes as escapes. --- internal/config/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3143570..02d7b81 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -725,7 +725,7 @@ func TestLibraryFloorDefaults(t *testing.T) { // and the size parses as given. func TestEngineKVSnapshotsParsedAndValidated(t *testing.T) { abs := filepath.Join(t.TempDir(), "kv") - c, err := Load(write(t, "[[engine]]\nname = \"llama-server\"\ntype = \"llamacpp\"\nurl = \"http://127.0.0.1:8080\"\nkv_snapshots = \""+abs+"\"\nkv_snapshots_mib = 4096\n")) + c, err := Load(write(t, "[[engine]]\nname = \"llama-server\"\ntype = \"llamacpp\"\nurl = \"http://127.0.0.1:8080\"\nkv_snapshots = '"+abs+"'\nkv_snapshots_mib = 4096\n")) if err != nil { t.Fatalf("a valid kv_snapshots was refused: %v", err) }