Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/architecture/02-edge-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> 子文档 | 主索引:[architecture.md](../architecture.md)
>
> 最后更新:2026-09-02
> 最后更新:2026-09-06

## 职责

Expand Down Expand Up @@ -62,7 +62,11 @@ ProcessExecutor 配置 `RunTimeout`(默认 30 分钟)、`ShutdownGracePeriod

## Store 与事件持久化

`Store`(`internal/store/store.go`)是核心内存数据结构,管理 Project、Thread、Run、Item、Pin、Diff、Artifact、Preview、UserProfile、AgentProfile、Settings 的全量 CRUD。`SQLiteStore`(`internal/store/sqlite_store.go`)通过 snapshot 持久化到 SQLite(WAL 模式,定期 checkpoint),支持崩溃恢复。终端状态 runs(completed/failed/cancelled/completed_with_issues)按 `TerminalTTL` 超时或 `MaxTerminalRunsPerThread` 上限自动清理,级联删除关联 diffs/artifacts/previews/items。
`Store`(`internal/store/store.go`)是核心内存数据结构,管理 Project、Thread、Run、Item、Pin、Diff、Artifact、Preview、UserProfile、AgentProfile、Settings 的全量 CRUD。`FileStore` 与 `SQLiteStore` 的常规 `Get*` / `List*` 委托这个内存 Store,不是直接 SQL 查询;业务读取主要经过内存锁,而不是 SQLite 连接池。

`FileStore`(`internal/store/file_store.go`)对写入信号做 debounce,再保存全量 JSON 快照;显式 `Flush` 同步执行快照、编码和文件 Sync/rename。`SQLiteStore`(`internal/store/sqlite_store.go`)在写入后同步持久化快照差分到 SQLite(WAL 模式,定期 checkpoint),支持崩溃恢复。SQL 连接初始化与持久化串行化独立于普通业务读面,不能用额外 SQL 读者的争用直接代替业务读取测量。

终端状态 runs(completed/failed/cancelled/completed_with_issues)按 `TerminalTTL` 超时或 `MaxTerminalRunsPerThread` 上限自动清理,级联删除关联 diffs/artifacts/previews/items。

`EventBus`(`internal/events/bus.go`)是基于 channel 的发布/订阅模型:4 worker 并发 observer、子 channel 缓冲(256)、gap detection(`system.gap` 事件);通过 `PersistFn` 钩子先持久化再广播。`EventLog` 是 append-only JSON-lines 事件日志(默认 50 MiB 上限,超限截断保留尾部 75%)。

Expand Down
189 changes: 189 additions & 0 deletions edge-server/internal/store/persistence_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package store

import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)

// BenchmarkFileStorePersistence separates snapshot copying, JSON encoding and
// filesystem work. The save/flush cases use real temp-file Sync and Rename;
// encode discards bytes without keeping a second snapshot-sized buffer. These
// are explicit flushes of fixed resident state, NOT debounce/request frequency.
func BenchmarkFileStorePersistence(b *testing.B) {
for _, runs := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("runs=%d", runs), func(b *testing.B) {
s := persistenceBenchmarkStore(b, runs)
want := s.snapshot()
var encoded bytes.Buffer
if err := encodePersistenceBenchmark(&encoded, want); err != nil {
b.Fatal(err)
}
payloadBytes := int64(encoded.Len())

b.Run("snapshot", func(b *testing.B) {
var got fileSnapshot
b.ReportAllocs()
for b.Loop() {
got = s.snapshot()
}
checkPersistenceBenchmarkSnapshot(b, got, want)
})
b.Run("encode", func(b *testing.B) {
sink := &persistenceBenchmarkSink{}
b.SetBytes(payloadBytes)
b.ReportAllocs()
for b.Loop() {
sink.bytes = 0
if err := encodePersistenceBenchmark(sink, want); err != nil {
b.Fatal(err)
}
if sink.bytes != payloadBytes {
b.Fatalf("encoded bytes = %d, want %d", sink.bytes, payloadBytes)
}
}
b.ReportMetric(float64(payloadBytes), "payload-B/op")
})
b.Run("save", func(b *testing.B) {
path := filepath.Join(b.TempDir(), "store.json")
if err := saveFileSnapshot(path, want); err != nil {
b.Fatal(err)
}
b.SetBytes(payloadBytes)
b.ReportAllocs()
for b.Loop() {
if err := saveFileSnapshot(path, want); err != nil {
b.Fatal(err)
}
}
checkPersistenceBenchmarkFile(b, path, want, encoded.Bytes())
b.ReportMetric(float64(payloadBytes), "payload-B/op")
})
b.Run("flush", func(b *testing.B) {
path := filepath.Join(b.TempDir(), "store.json")
f, err := NewFile(path)
if err != nil {
b.Fatal(err)
}
b.Cleanup(f.Close)
// Fixture construction uses the validated Store APIs; installing
// its snapshot here avoids timing a backlog of debounce signals.
f.store.applySnapshot(want)
f.Flush()
if err := f.LastPersistError(); err != nil {
b.Fatal(err)
}
b.SetBytes(payloadBytes)
b.ReportAllocs()
for b.Loop() {
f.Flush()
if err := f.LastPersistError(); err != nil {
b.Fatal(err)
}
}
checkPersistenceBenchmarkFile(b, path, want, encoded.Bytes())
b.ReportMetric(float64(payloadBytes), "payload-B/op")
})
})
}
}

// All paths/content below are generated metadata; no user workspace is read.
// Ten queued runs per thread, ten 512-byte items, two artifact metadata records,
// and one checkpoint containing a 256-byte file per run. The remaining entity
// collections are empty. This is a declared resident-size fixture, not telemetry.
func persistenceBenchmarkStore(b *testing.B, runs int) *Store {
b.Helper()
s := New()
if _, err := s.CreateProject("project", "Benchmark", "owner"); err != nil {
b.Fatal(err)
}
content := strings.Repeat("x", 512)
for i := 0; i < runs; i++ {
threadID := fmt.Sprintf("thread-%04d", i/10)
if i%10 == 0 {
if _, err := s.CreateThread(threadID, "project", "Benchmark", "", "", ""); err != nil {
b.Fatal(err)
}
}
runID := fmt.Sprintf("run-%06d", i)
if _, err := s.CreateRun(runID, "project", threadID); err != nil {
b.Fatal(err)
}
for j := 0; j < 10; j++ {
_, err := s.CreateItem(Item{
ID: fmt.Sprintf("item-%06d-%02d", i, j), ProjectID: "project",
ThreadID: threadID, RunID: runID, Type: "message", Role: "assistant",
Status: "completed", Content: content,
})
if err != nil {
b.Fatal(err)
}
}
for j := 0; j < 2; j++ {
path := fmt.Sprintf("output/result-%02d.txt", j)
_, err := s.UpsertArtifact(Artifact{
ID: fmt.Sprintf("artifact-%06d-%02d", i, j), RunID: runID,
ThreadID: threadID, Kind: "file", Path: path, SizeBytes: 512,
ContentSource: &ArtifactContentSource{Kind: "edge_file", Path: path, Readable: true},
})
if err != nil {
b.Fatal(err)
}
}
_, err := s.UpsertRunCheckpoint(RunCheckpoint{
ID: fmt.Sprintf("checkpoint-%06d", i), RunID: runID, WorkDir: "fixture",
FileCount: 1, TotalBytes: 256,
Files: []CheckpointFile{{Path: "src/input.txt", Size: 256, Content: content[:256]}},
})
if err != nil {
b.Fatal(err)
}
}
return s
}

func encodePersistenceBenchmark(w io.Writer, snapshot fileSnapshot) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ") // Match saveFileSnapshot, including the trailing newline.
return encoder.Encode(snapshot)
}

type persistenceBenchmarkSink struct{ bytes int64 }

func (s *persistenceBenchmarkSink) Write(p []byte) (int, error) {
s.bytes += int64(len(p))
return len(p), nil
}

func checkPersistenceBenchmarkFile(b *testing.B, path string, want fileSnapshot, encoded []byte) {
b.Helper()
actual, err := os.ReadFile(path)
if err != nil {
b.Fatal(err)
}
if !bytes.Equal(actual, encoded) {
b.Fatal("persisted bytes differ from the indented JSON control")
}
restored := New()
if err := loadFileSnapshot(path, restored); err != nil {
b.Fatal(err)
}
checkPersistenceBenchmarkSnapshot(b, restored.snapshot(), want)
}

func checkPersistenceBenchmarkSnapshot(b *testing.B, got, want fileSnapshot) {
b.Helper()
g, w := reflect.ValueOf(got), reflect.ValueOf(want)
for i := 0; i < g.NumField(); i++ {
if !reflect.DeepEqual(g.Field(i).Interface(), w.Field(i).Interface()) {
b.Fatalf("restored snapshot differs in %s", g.Type().Field(i).Name)
}
}
}
Loading
Loading