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:
parent
6bf271e4b1
commit
1fe8376cb4
2 changed files with 126 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
85
cmd/bugseti/workspace_test.go
Normal file
85
cmd/bugseti/workspace_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue