perf: add write-behind for stats.json — reduce disk writes by 98%

RecordUsage/RecordPrompt now update in-memory only. A background
goroutine flushes to disk every 5 minutes. Close() performs a
final flush on graceful shutdown. Reset() retains immediate write
as a semantic checkpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 15:46:51 +09:00
parent a08d1031b9
commit e2f4135789
4 changed files with 55 additions and 5 deletions

View file

@ -288,6 +288,7 @@ func gatewayCmd() {
heartbeatService.Stop() heartbeatService.Stop()
cronService.Stop() cronService.Stop()
agentLoop.Stop() agentLoop.Stop()
agentLoop.Close()
channelManager.StopAll(ctx) channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
} }

View file

@ -384,6 +384,14 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) al.running.Store(false)
} }
// Close releases resources held by the loop (e.g. flushes write-behind stats
// and dirty session data). Should be called during graceful shutdown.
func (al *AgentLoop) Close() {
if al.stats != nil {
al.stats.Close()
}
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) RegisterTool(tool tools.Tool) {
for _, agentID := range al.registry.ListAgentIDs() { for _, agentID := range al.registry.ListAgentIDs() {
if agent, ok := al.registry.GetAgent(agentID); ok { if agent, ok := al.registry.GetAgent(agentID); ok {

View file

@ -31,10 +31,13 @@ type Stats struct {
} }
// Tracker accumulates LLM usage statistics with mutex-protected atomic persistence. // Tracker accumulates LLM usage statistics with mutex-protected atomic persistence.
// Write-behind: stats are flushed to disk periodically (every 5 minutes) and on Close(),
// not on every RecordUsage/RecordPrompt call, to reduce microSD write wear.
type Tracker struct { type Tracker struct {
mu sync.Mutex mu sync.Mutex
stats Stats stats Stats
stateFile string stateFile string
done chan struct{} // closed by Close() to stop the flush goroutine
} }
// NewTracker creates a tracker that persists to {workspace}/state/stats.json. // NewTracker creates a tracker that persists to {workspace}/state/stats.json.
@ -44,6 +47,7 @@ func NewTracker(workspace string) *Tracker {
t := &Tracker{ t := &Tracker{
stateFile: filepath.Join(stateDir, "stats.json"), stateFile: filepath.Join(stateDir, "stats.json"),
done: make(chan struct{}),
} }
t.load() t.load()
@ -54,10 +58,14 @@ func NewTracker(workspace string) *Tracker {
// Lazy day-roll on startup // Lazy day-roll on startup
t.rollDay() t.rollDay()
// Start periodic flush goroutine
go t.flushLoop()
return t return t
} }
// RecordUsage records tokens from a single LLM call. // RecordUsage records tokens from a single LLM call.
// Stats are kept in memory and flushed to disk periodically.
func (t *Tracker) RecordUsage(prompt, completion, total int) { func (t *Tracker) RecordUsage(prompt, completion, total int) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@ -73,11 +81,10 @@ func (t *Tracker) RecordUsage(prompt, completion, total int) {
t.stats.TotalCompletionTokens += int64(completion) t.stats.TotalCompletionTokens += int64(completion)
t.stats.TotalTokens += int64(total) t.stats.TotalTokens += int64(total)
t.stats.TotalRequests++ t.stats.TotalRequests++
t.save()
} }
// RecordPrompt increments the user-message counter. // RecordPrompt increments the user-message counter.
// Stats are kept in memory and flushed to disk periodically.
func (t *Tracker) RecordPrompt() { func (t *Tracker) RecordPrompt() {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@ -86,8 +93,6 @@ func (t *Tracker) RecordPrompt() {
t.stats.Today.Prompts++ t.stats.Today.Prompts++
t.stats.TotalPrompts++ t.stats.TotalPrompts++
t.save()
} }
// GetStats returns a snapshot of the current statistics. // GetStats returns a snapshot of the current statistics.
@ -138,6 +143,36 @@ func (t *Tracker) save() {
} }
} }
// Close stops the periodic flush goroutine and writes final stats to disk.
// Must be called on shutdown to avoid data loss.
func (t *Tracker) Close() {
select {
case <-t.done:
return // already closed
default:
}
close(t.done)
t.mu.Lock()
t.save()
t.mu.Unlock()
}
// flushLoop periodically writes stats to disk.
func (t *Tracker) flushLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
t.mu.Lock()
t.save()
t.mu.Unlock()
case <-t.done:
return
}
}
}
// load reads the stats file from disk. Called once at init. // load reads the stats file from disk. Called once at init.
func (t *Tracker) load() { func (t *Tracker) load() {
data, err := os.ReadFile(t.stateFile) data, err := os.ReadFile(t.stateFile)

View file

@ -12,9 +12,11 @@ func TestNewTracker_Persistence(t *testing.T) {
tr := NewTracker(dir) tr := NewTracker(dir)
tr.RecordUsage(100, 50, 150) tr.RecordUsage(100, 50, 150)
tr.RecordPrompt() tr.RecordPrompt()
tr.Close() // flush to disk before reload
// Reload from disk // Reload from disk
tr2 := NewTracker(dir) tr2 := NewTracker(dir)
defer tr2.Close()
s := tr2.GetStats() s := tr2.GetStats()
if s.TotalTokens != 150 { if s.TotalTokens != 150 {
@ -36,6 +38,7 @@ func TestNewTracker_Persistence(t *testing.T) {
func TestTracker_Accumulation(t *testing.T) { func TestTracker_Accumulation(t *testing.T) {
tr := NewTracker(t.TempDir()) tr := NewTracker(t.TempDir())
defer tr.Close()
tr.RecordUsage(10, 5, 15) tr.RecordUsage(10, 5, 15)
tr.RecordUsage(20, 10, 30) tr.RecordUsage(20, 10, 30)
@ -63,6 +66,7 @@ func TestTracker_Accumulation(t *testing.T) {
func TestTracker_Reset(t *testing.T) { func TestTracker_Reset(t *testing.T) {
tr := NewTracker(t.TempDir()) tr := NewTracker(t.TempDir())
defer tr.Close()
tr.RecordUsage(100, 50, 150) tr.RecordUsage(100, 50, 150)
tr.RecordPrompt() tr.RecordPrompt()
@ -86,6 +90,7 @@ func TestTracker_Reset(t *testing.T) {
func TestTracker_DayRoll(t *testing.T) { func TestTracker_DayRoll(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
tr := NewTracker(dir) tr := NewTracker(dir)
defer tr.Close()
tr.RecordUsage(100, 50, 150) tr.RecordUsage(100, 50, 150)
@ -115,10 +120,11 @@ func TestTracker_StateFileCreated(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
tr := NewTracker(dir) tr := NewTracker(dir)
tr.RecordUsage(1, 1, 2) tr.RecordUsage(1, 1, 2)
tr.Close() // flush to disk
stateFile := filepath.Join(dir, "state", "stats.json") stateFile := filepath.Join(dir, "state", "stats.json")
if _, err := os.Stat(stateFile); os.IsNotExist(err) { if _, err := os.Stat(stateFile); os.IsNotExist(err) {
t.Error("expected stats.json to be created") t.Error("expected stats.json to be created after Close()")
} }
} }