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:
parent
a08d1031b9
commit
e2f4135789
4 changed files with 55 additions and 5 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue