Skip to content

Commit 4326eb3

Browse files
perf: an idle client stops reopening the vault per key and retrying failed chat copies (#1980)
* test(vaultsync): a restore opens the vault a fixed number of times Fails on dev: a restore of N shared keys opens the whole sealed vault N+1 times, once per key the ledger knows. * perf(vaultsync): read a scope's tombstones once per restore A restore asked the vault whether each key the ledger knew had been removed, and every question opened the whole sealed vault. A profile with 963 shared memories and a 1.5 MB vault decoded about 1.4 GB of JSON per state round, every few seconds, in an idle terminal. Removed lists a scope's tombstones in one read. * refactor(olderchats): one visit per chat behind a copy seam No behaviour change: the eligibility checks and the copy move into olderChats.visit so a test can count copy attempts. * test(olderchats): a failed copy waits for the chat to change Fails on dev: the background copier tries a chat whose copy failed again every round, and each attempt seals the chat's whole folder. * perf(olderchats): a failed copy waits for its chat to change The background copier visits every older chat each pass, and a copy seals the chat's whole folder. A copy that failed was attempted again on every pass for as long as the terminal stayed open. It is now remembered against the journal's size and time, and tried again when the chat changes, on the next launch, or when the person opens it. * test(olderchats): the background copier leaves a folder that holds codeaf's home Fails on dev: a chat started in a home directory is sealed whole in the background, and that seal never settles while codeaf writes its own state inside the folder. * perf(olderchats): leave a folder that holds codeaf's home to be copied on open A chat started in a home directory has codeaf's own state inside its folder. Codeaf writes there while it runs, so the whole-folder seal a copy begins with never settles: in a live terminal each attempt planned the folder eight times and failed with "files kept changing". The background copier now skips these chats; opening one still copies it. * revert(olderchats): leave home-folder chats to the cellstore rule #1976 makes a folder that holds the home folder seal only the chat's own folder, which is the case this skip covered. One rule for it is enough; the failed-copy memo stays. * docs(changes): an idle terminal stops reopening the vault per key
1 parent cf282dd commit 4326eb3

7 files changed

Lines changed: 266 additions & 25 deletions

File tree

‎cmd/codeaf/olderchats.go‎

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,31 +55,16 @@ func runOlderChats(ctx context.Context) {
5555
return
5656
case <-ticker.C:
5757
}
58+
chats := olderChats{copy: copyOlderChat}
5859
for {
5960
if syncsetup.MayTalk(home.Dir()) {
6061
dirs, _ := filepath.Glob(home.Join("v3", "projects", "*", "*"))
6162
for _, dir := range dirs {
6263
if ctx.Err() != nil {
6364
return
6465
}
65-
copied := cell.Copied(dir)
66-
if copied != dir && session.InUse(filepath.Join(copied, cell.TranscriptPath)) {
67-
continue
68-
}
69-
if copied != dir {
70-
if _, err := os.Stat(filepath.Join(copied, cell.CopyOfferedPath)); err == nil {
71-
continue
72-
}
73-
}
74-
if _, err := os.Stat(filepath.Join(dir, "transcript.jsonl")); err != nil {
75-
continue
76-
}
77-
meta, _ := session.LoadMeta(dir)
78-
if meta.LastUserAt.IsZero() || time.Since(meta.LastUserAt) > olderChatRecent || session.InUse(filepath.Join(dir, "transcript.jsonl")) {
79-
continue
80-
}
81-
c, err := copyOlderChat(ctx, dir)
82-
if err != nil {
66+
c, meta, ok := chats.visit(ctx, dir)
67+
if !ok {
8368
continue
8469
}
8570
// A finished copy is uploaded and released as an ordinary chat. The
@@ -106,6 +91,65 @@ func runOlderChats(ctx context.Context) {
10691
}
10792
}
10893

94+
// olderChats is the background copier's view of the older chats: which one is
95+
// due for a copy, the copy itself, and the copies that failed this launch.
96+
//
97+
// A FAILED COPY WAITS FOR ITS CHAT TO CHANGE. Every round visits every older
98+
// chat, and a copy seals the chat's whole folder, so an attempt costs as much as
99+
// the folder is big. A copy that failed was tried again on the next pass for as
100+
// long as the terminal stayed open. The same chat over the same folder fails the
101+
// same way; one whose journal has moved on, or a new launch, is tried again, and
102+
// opening the chat still copies it on demand (movablePlace).
103+
type olderChats struct {
104+
copy func(context.Context, string) (cell.Cell, error)
105+
failed map[string]olderChatStamp
106+
}
107+
108+
// olderChatStamp is what an original's journal looked like when its copy failed.
109+
type olderChatStamp struct {
110+
size int64
111+
mod time.Time
112+
}
113+
114+
// visit copies the chat in dir when it is a recent older chat that nothing is
115+
// using and that has no copy yet. It answers the copy and the original's meta.
116+
func (o *olderChats) visit(ctx context.Context, dir string) (cell.Cell, session.Meta, bool) {
117+
copied := cell.Copied(dir)
118+
if copied != dir && session.InUse(filepath.Join(copied, cell.TranscriptPath)) {
119+
return cell.Cell{}, session.Meta{}, false
120+
}
121+
if copied != dir {
122+
if _, err := os.Stat(filepath.Join(copied, cell.CopyOfferedPath)); err == nil {
123+
return cell.Cell{}, session.Meta{}, false
124+
}
125+
}
126+
journal, err := os.Stat(filepath.Join(dir, "transcript.jsonl"))
127+
if err != nil {
128+
return cell.Cell{}, session.Meta{}, false
129+
}
130+
stamp := olderChatStamp{size: journal.Size(), mod: journal.ModTime()}
131+
if o.failed[dir] == stamp {
132+
return cell.Cell{}, session.Meta{}, false
133+
}
134+
meta, _ := session.LoadMeta(dir)
135+
if meta.LastUserAt.IsZero() || time.Since(meta.LastUserAt) > olderChatRecent || session.InUse(filepath.Join(dir, "transcript.jsonl")) {
136+
return cell.Cell{}, session.Meta{}, false
137+
}
138+
c, err := o.copy(ctx, dir)
139+
if err != nil {
140+
// A copy cut short by the terminal closing did not fail.
141+
if ctx.Err() == nil {
142+
if o.failed == nil {
143+
o.failed = map[string]olderChatStamp{}
144+
}
145+
o.failed[dir] = stamp
146+
}
147+
return cell.Cell{}, session.Meta{}, false
148+
}
149+
delete(o.failed, dir)
150+
return c, meta, true
151+
}
152+
109153
// movablePlace resolves a completed copy or prepares one when the person opens
110154
// an older chat. Selection runs on the existing asynchronous opening door.
111155
func movablePlace(ctx context.Context, place session.Place) (session.Place, error) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/Agent-Field/codeaf/internal/cell"
12+
"github.com/Agent-Field/codeaf/internal/home"
13+
"github.com/Agent-Field/codeaf/internal/session"
14+
)
15+
16+
// TestAFailedOlderChatCopyWaitsForTheChatToChange counts copy attempts. An idle
17+
// terminal visits every older chat each background round, and a copy seals the
18+
// chat's whole folder, so a chat whose copy failed is not copied again until it
19+
// has changed: the same chat and folder fail the same way.
20+
func TestAFailedOlderChatCopyWaitsForTheChatToChange(t *testing.T) {
21+
t.Setenv(home.EnvVar, t.TempDir())
22+
original := filepath.Join(home.Dir(), "v3", "projects", "old", "old-session")
23+
if err := os.MkdirAll(original, 0700); err != nil {
24+
t.Fatal(err)
25+
}
26+
transcript := filepath.Join(original, "transcript.jsonl")
27+
if err := os.WriteFile(transcript, []byte(`{"type":"session","version":1,"id":"old-session"}`+"\n"), 0600); err != nil {
28+
t.Fatal(err)
29+
}
30+
if err := session.SaveMeta(original, session.Meta{ID: "old-session", LastUserAt: time.Now(), Workspace: t.TempDir()}); err != nil {
31+
t.Fatal(err)
32+
}
33+
attempts := 0
34+
chats := olderChats{copy: func(context.Context, string) (cell.Cell, error) {
35+
attempts++
36+
return cell.Cell{}, errors.New("files kept changing while saving this chat; try again")
37+
}}
38+
for range 5 {
39+
if _, _, ok := chats.visit(t.Context(), original); ok {
40+
t.Fatal("a failed copy was reported as made")
41+
}
42+
}
43+
if attempts != 1 {
44+
t.Fatalf("five rounds over an unchanged chat whose copy fails made %d copy attempts, want 1", attempts)
45+
}
46+
47+
f, err := os.OpenFile(transcript, os.O_APPEND|os.O_WRONLY, 0600)
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
if _, err := f.WriteString(`{"type":"message","role":"user","content":"later"}` + "\n"); err != nil {
52+
t.Fatal(err)
53+
}
54+
if err := f.Close(); err != nil {
55+
t.Fatal(err)
56+
}
57+
chats.visit(t.Context(), original)
58+
if attempts != 2 {
59+
t.Fatalf("a chat that changed after its copy failed made %d copy attempts in total, want 2", attempts)
60+
}
61+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
kind: fixed
3+
title: An idle terminal no longer reopens the key vault once per shared key
4+
pr: 1980
5+
surface: [chat]
6+
invalidates:
7+
- "A home-state restore asked the vault about each shared key in turn and opened the whole sealed vault for every answer. It now reads the scope's removals once per restore."
8+
- "The background copier tried an older chat whose copy failed again on every pass. A failed copy now waits until the chat changes, the next launch, or the person opening it."
9+
---
10+
With 963 shared memories and a 1.5 MB vault, a restore decoded about 1.4 GB of
11+
JSON. A terminal that pulled every round sat above one core while nobody typed.
12+
Measured on Spark against a copy of that profile, with the vault moving every
13+
round, the client went from 111 to 115% CPU down to 15%. Without that, it went
14+
from 15% to 11%.

‎internal/keys/vault.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,24 @@ func (v *Vault) IDs(prefix string) ([]string, error) {
198198
return out, nil
199199
}
200200

201+
// Removed lists the ids that start with prefix and hold a tombstone, in order.
202+
// It opens the vault once, so a caller asking about many slots does not pay for
203+
// a whole vault per slot the way a loop over Deleted would.
204+
func (v *Vault) Removed(prefix string) ([]string, error) {
205+
d, err := v.read()
206+
if err != nil {
207+
return nil, err
208+
}
209+
var out []string
210+
for id, r := range d.Secrets {
211+
if r.Deleted && strings.HasPrefix(id, prefix) {
212+
out = append(out, id)
213+
}
214+
}
215+
sort.Strings(out)
216+
return out, nil
217+
}
218+
201219
// Env returns NAME=value pairs for the project, sorted by name, ready to
202220
// append to an exec environment.
203221
func (v *Vault) Env(project string) ([]string, error) {

‎internal/vaultsync/keyed.go‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"os"
9+
"strings"
910
"time"
1011

1112
"github.com/Agent-Field/codeaf/internal/keys"
@@ -195,20 +196,23 @@ func (r *keyedRun) liveKeys() (map[string]string, error) {
195196
}
196197

197198
// changes is what to apply here: live keys that differ, and keys the vault
198-
// deleted that this machine still holds and once shared.
199+
// deleted that this machine still holds and once shared. The tombstones are read
200+
// once for the whole scope: a restore follows every pull, and asking the vault
201+
// per known key opened it a thousand times a round for a thousand memories.
199202
func (r *keyedRun) changes(live map[string]string) (map[string]string, error) {
200203
changes := map[string]string{}
201204
for name, value := range live {
202205
if r.set.Holds(name) && r.held[name] != value {
203206
changes[name] = value
204207
}
205208
}
206-
for name := range r.digests {
207-
gone, err := r.v.Deleted(r.id(name))
208-
if err != nil {
209-
return nil, err
210-
}
211-
if gone && r.held[name] != "" {
209+
removed, err := r.v.Removed(r.id(""))
210+
if err != nil {
211+
return nil, err
212+
}
213+
for _, id := range removed {
214+
name := strings.TrimPrefix(id, r.id(""))
215+
if _, known := r.digests[name]; known && r.held[name] != "" {
212216
changes[name] = ""
213217
}
214218
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package vaultsync
2+
3+
import (
4+
"fmt"
5+
"maps"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/Agent-Field/codeaf/internal/identity"
11+
"github.com/Agent-Field/codeaf/internal/keys"
12+
)
13+
14+
// countedVault counts every call into the vault file. Each one opens the whole
15+
// sealed vault (read, decrypt, decode), so the count is the work a step does.
16+
type countedVault struct {
17+
VaultFile
18+
calls int
19+
}
20+
21+
func (c *countedVault) Export() ([]byte, error) { c.calls++; return c.VaultFile.Export() }
22+
func (c *countedVault) Merge(enc []byte) error { c.calls++; return c.VaultFile.Merge(enc) }
23+
func (c *countedVault) Entries(p string) ([]keys.Entry, error) {
24+
c.calls++
25+
return c.VaultFile.Entries(p)
26+
}
27+
func (c *countedVault) Get(id string) (keys.Entry, error) { c.calls++; return c.VaultFile.Get(id) }
28+
func (c *countedVault) IDs(prefix string) ([]string, error) {
29+
c.calls++
30+
return c.VaultFile.IDs(prefix)
31+
}
32+
func (c *countedVault) PutAt(id string, e keys.Entry, at time.Time) error {
33+
c.calls++
34+
return c.VaultFile.PutAt(id, e, at)
35+
}
36+
func (c *countedVault) Delete(id string) error { c.calls++; return c.VaultFile.Delete(id) }
37+
func (c *countedVault) Deleted(id string) (bool, error) {
38+
c.calls++
39+
return c.VaultFile.Deleted(id)
40+
}
41+
func (c *countedVault) Removed(prefix string) ([]string, error) {
42+
c.calls++
43+
return c.VaultFile.Removed(prefix)
44+
}
45+
46+
// heldKeys is a Keyset that holds its keys in memory.
47+
type heldKeys map[string]string
48+
49+
func (h heldKeys) Values() (map[string]string, error) { return maps.Clone(h), nil }
50+
func (h heldKeys) Holds(string) bool { return true }
51+
func (h heldKeys) Apply(changes map[string]string) error {
52+
for name, value := range changes {
53+
if value == "" {
54+
delete(h, name)
55+
} else {
56+
h[name] = value
57+
}
58+
}
59+
return nil
60+
}
61+
62+
// TestARestoreOpensTheVaultAFixedNumberOfTimes holds the idle cost of a state
63+
// round flat in the number of keys a machine shares. A restore runs after every
64+
// pull, which an idle terminal makes every few seconds; a profile with a
65+
// thousand shared memories once opened its whole vault once per key per round.
66+
func TestARestoreOpensTheVaultAFixedNumberOfTimes(t *testing.T) {
67+
for _, n := range []int{16, 256} {
68+
t.Run(fmt.Sprint(n, " keys"), func(t *testing.T) {
69+
home := filepath.Join(t.TempDir(), "home")
70+
_, err := identity.Ensure(home)
71+
must(t, err)
72+
v, err := keys.Open(home)
73+
must(t, err)
74+
set := heldKeys{}
75+
for i := range n {
76+
set[fmt.Sprintf("key-%04d", i)] = fmt.Sprintf("value %d", i)
77+
}
78+
carrier := Keys("test", set, filepath.Join(home, "test.ledger"))
79+
must(t, carrier.capture(v))
80+
81+
counted := &countedVault{VaultFile: v}
82+
must(t, carrier.restore(counted))
83+
if counted.calls > 2 {
84+
t.Fatalf("a restore of %d unchanged keys opened the vault %d times, want at most 2", n, counted.calls)
85+
}
86+
87+
// Reading the tombstones once still passes a removal on.
88+
must(t, v.Delete(carrier.id("key-0003")))
89+
must(t, carrier.restore(v))
90+
if _, still := set["key-0003"]; still {
91+
t.Fatal("a key the vault removed is still held after a restore")
92+
}
93+
if len(set) != n-1 {
94+
t.Fatalf("a restore after one removal holds %d keys, want %d", len(set), n-1)
95+
}
96+
})
97+
}
98+
}

‎internal/vaultsync/syncer.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type VaultFile interface {
3333
PutAt(id string, e keys.Entry, at time.Time) error
3434
Delete(id string) error
3535
Deleted(id string) (bool, error)
36+
// Removed lists the tombstoned slot ids under a prefix, in one read.
37+
Removed(prefix string) ([]string, error)
3638
}
3739

3840
// ErrTampered reports a vault object whose bytes do not hash to its id.

0 commit comments

Comments
 (0)