From e2f4135789b6570e8f3d529753812556a4642f81 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:46:51 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20add=20write-behind=20for=20stats.json?= =?UTF-8?q?=20=E2=80=94=20reduce=20disk=20writes=20by=2098%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/picoclaw/cmd_gateway.go | 1 + pkg/agent/loop.go | 8 +++++++ pkg/stats/tracker.go | 43 +++++++++++++++++++++++++++++++++---- pkg/stats/tracker_test.go | 8 ++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index eb7f99430..93effe893 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -288,6 +288,7 @@ func gatewayCmd() { heartbeatService.Stop() cronService.Stop() agentLoop.Stop() + agentLoop.Close() channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7ea6247a5..5738ab9d3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -384,6 +384,14 @@ func (al *AgentLoop) Stop() { 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) { for _, agentID := range al.registry.ListAgentIDs() { if agent, ok := al.registry.GetAgent(agentID); ok { diff --git a/pkg/stats/tracker.go b/pkg/stats/tracker.go index 5e81b5e60..ef65952e7 100644 --- a/pkg/stats/tracker.go +++ b/pkg/stats/tracker.go @@ -31,10 +31,13 @@ type Stats struct { } // 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 { mu sync.Mutex stats Stats stateFile string + done chan struct{} // closed by Close() to stop the flush goroutine } // NewTracker creates a tracker that persists to {workspace}/state/stats.json. @@ -44,6 +47,7 @@ func NewTracker(workspace string) *Tracker { t := &Tracker{ stateFile: filepath.Join(stateDir, "stats.json"), + done: make(chan struct{}), } t.load() @@ -54,10 +58,14 @@ func NewTracker(workspace string) *Tracker { // Lazy day-roll on startup t.rollDay() + + // Start periodic flush goroutine + go t.flushLoop() return t } // 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) { t.mu.Lock() defer t.mu.Unlock() @@ -73,11 +81,10 @@ func (t *Tracker) RecordUsage(prompt, completion, total int) { t.stats.TotalCompletionTokens += int64(completion) t.stats.TotalTokens += int64(total) t.stats.TotalRequests++ - - t.save() } // RecordPrompt increments the user-message counter. +// Stats are kept in memory and flushed to disk periodically. func (t *Tracker) RecordPrompt() { t.mu.Lock() defer t.mu.Unlock() @@ -86,8 +93,6 @@ func (t *Tracker) RecordPrompt() { t.stats.Today.Prompts++ t.stats.TotalPrompts++ - - t.save() } // 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. func (t *Tracker) load() { data, err := os.ReadFile(t.stateFile) diff --git a/pkg/stats/tracker_test.go b/pkg/stats/tracker_test.go index 27c10bdd1..44c990eb9 100644 --- a/pkg/stats/tracker_test.go +++ b/pkg/stats/tracker_test.go @@ -12,9 +12,11 @@ func TestNewTracker_Persistence(t *testing.T) { tr := NewTracker(dir) tr.RecordUsage(100, 50, 150) tr.RecordPrompt() + tr.Close() // flush to disk before reload // Reload from disk tr2 := NewTracker(dir) + defer tr2.Close() s := tr2.GetStats() if s.TotalTokens != 150 { @@ -36,6 +38,7 @@ func TestNewTracker_Persistence(t *testing.T) { func TestTracker_Accumulation(t *testing.T) { tr := NewTracker(t.TempDir()) + defer tr.Close() tr.RecordUsage(10, 5, 15) tr.RecordUsage(20, 10, 30) @@ -63,6 +66,7 @@ func TestTracker_Accumulation(t *testing.T) { func TestTracker_Reset(t *testing.T) { tr := NewTracker(t.TempDir()) + defer tr.Close() tr.RecordUsage(100, 50, 150) tr.RecordPrompt() @@ -86,6 +90,7 @@ func TestTracker_Reset(t *testing.T) { func TestTracker_DayRoll(t *testing.T) { dir := t.TempDir() tr := NewTracker(dir) + defer tr.Close() tr.RecordUsage(100, 50, 150) @@ -115,10 +120,11 @@ func TestTracker_StateFileCreated(t *testing.T) { dir := t.TempDir() tr := NewTracker(dir) tr.RecordUsage(1, 1, 2) + tr.Close() // flush to disk stateFile := filepath.Join(dir, "state", "stats.json") 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()") } }