diff --git a/cmd/bugseti/workspace.go b/cmd/bugseti/workspace.go index df2c02b8..79712d92 100644 --- a/cmd/bugseti/workspace.go +++ b/cmd/bugseti/workspace.go @@ -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() diff --git a/cmd/bugseti/workspace_test.go b/cmd/bugseti/workspace_test.go new file mode 100644 index 00000000..546e8d39 --- /dev/null +++ b/cmd/bugseti/workspace_test.go @@ -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-) 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") + } +}