fix(bugseti): add TTL cleanup and max size cap to workspace map (#55)

The workspaces map in WorkspaceService grew unboundedly. Add cleanup()
that evicts entries older than 24h and enforces a 100-entry cap by
removing oldest entries first. Called on each Capture().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude (M3 Studio) 2026-02-10 11:25:00 +00:00 committed by Snider
parent 3af5ce687c
commit a3892209c3
2 changed files with 126 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import (
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
@ -15,6 +16,13 @@ import (
"github.com/host-uk/core/pkg/io/datanode"
)
const (
// maxWorkspaces is the upper bound on cached workspace entries.
maxWorkspaces = 100
// workspaceTTL is how long a workspace stays in memory before eviction.
workspaceTTL = 24 * time.Hour
)
// WorkspaceService manages DataNode-backed workspaces for issues.
// Each issue gets a sandboxed in-memory filesystem that can be
// snapshotted, packaged as a TIM container, or shipped as a crash report.
@ -109,6 +117,7 @@ func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error
}
w.mu.Lock()
w.cleanup()
w.workspaces[issue.ID] = &Workspace{
Issue: issue,
Medium: m,
@ -240,6 +249,38 @@ func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error)
return path, nil
}
// cleanup evicts expired workspaces and enforces the max size cap.
// Must be called with w.mu held for writing.
func (w *WorkspaceService) cleanup() {
now := time.Now()
// First pass: evict entries older than TTL.
for id, ws := range w.workspaces {
if now.Sub(ws.CreatedAt) > workspaceTTL {
delete(w.workspaces, id)
}
}
// Second pass: if still over cap, evict oldest entries.
if len(w.workspaces) > maxWorkspaces {
type entry struct {
id string
createdAt time.Time
}
entries := make([]entry, 0, len(w.workspaces))
for id, ws := range w.workspaces {
entries = append(entries, entry{id, ws.CreatedAt})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].createdAt.Before(entries[j].createdAt)
})
evict := len(w.workspaces) - maxWorkspaces
for i := 0; i < evict; i++ {
delete(w.workspaces, entries[i].id)
}
}
}
// Release removes a workspace from memory.
func (w *WorkspaceService) Release(issueID string) {
w.mu.Lock()

View file

@ -0,0 +1,85 @@
package main
import (
"fmt"
"testing"
"time"
"github.com/host-uk/core/internal/bugseti"
)
func TestCleanup_TTL(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Seed with entries that are older than TTL.
svc.mu.Lock()
for i := 0; i < 5; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
// Add one fresh entry.
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != 1 {
t.Errorf("expected 1 workspace after TTL cleanup, got %d", got)
}
}
func TestCleanup_MaxSize(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Fill beyond the cap with fresh entries.
svc.mu.Lock()
for i := 0; i < maxWorkspaces+20; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != maxWorkspaces {
t.Errorf("expected %d workspaces after cap cleanup, got %d", maxWorkspaces, got)
}
}
func TestCleanup_EvictsOldestWhenOverCap(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Create maxWorkspaces+1 entries; the newest should survive.
svc.mu.Lock()
for i := 0; i <= maxWorkspaces; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(maxWorkspaces-i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
// The newest entry (ws-<maxWorkspaces>) should still exist.
newest := fmt.Sprintf("ws-%d", maxWorkspaces)
if m := svc.GetMedium(newest); m != nil {
// GetMedium returns nil for entries with nil Medium, which is expected here.
// We just want to verify the key still exists.
}
svc.mu.RLock()
_, exists := svc.workspaces[newest]
svc.mu.RUnlock()
if !exists {
t.Error("expected newest workspace to survive eviction")
}
// The oldest entry (ws-0) should have been evicted.
svc.mu.RLock()
_, exists = svc.workspaces["ws-0"]
svc.mu.RUnlock()
if exists {
t.Error("expected oldest workspace to be evicted")
}
}