Expire idle snapshots and abandoned jobs, cap snapshot count

A background sweep every minute drops snapshots unused for an hour,
finished jobs, and uploads never committed, deleting their workspaces.
At most 8 snapshots are held; the least recently used is evicted.
This commit is contained in:
Andy Dixon
2026-02-21 09:37:43 +00:00
parent 25e2d2f390
commit 1c93df022d
3 changed files with 100 additions and 3 deletions
+2
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
)
@@ -54,6 +55,7 @@ type Snapshot struct {
References int
Warnings []string
Root string
lastUsed time.Time
delivered map[uint32][][2]int
released map[uint32]bool
}
+60 -3
View File
@@ -39,9 +39,15 @@ type Job struct {
Error *string `json:"error"`
Warnings []string `json:"warnings"`
root string
owner string // session that created the job
owner string // session that created the job
updated time.Time // last progress, used to expire abandoned jobs
}
const (
idleExpiry = time.Hour // snapshots unused and jobs untouched for this long are dropped
maxSnapshots = 8 // ponytail: fixed cap sized for the 2GB container; make it configurable if memory changes
)
type Server struct {
jobsMu sync.Mutex
jobs map[string]*Job
@@ -76,12 +82,18 @@ func main() {
open := flag.Bool("open", false, "open the viewer in a browser")
flag.Parse()
handler := NewServer(filepath.Join(os.TempDir(), "codenavigator"))
server := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", port),
Handler: NewServer(filepath.Join(os.TempDir(), "codenavigator")),
Handler: handler,
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}
go func() {
for now := range time.Tick(time.Minute) {
handler.expire(now)
}
}()
log.Printf("CodeNavigator listening on http://127.0.0.1:%d", port)
if *open {
_ = exec.Command("xdg-open", fmt.Sprintf("http://127.0.0.1:%d", port)).Start()
@@ -145,7 +157,7 @@ func (s *Server) createJob(w http.ResponseWriter, r *http.Request) {
writeError(w, 500, err.Error())
return
}
job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner}
job := &Job{ID: id, SourceKind: request.Kind, Name: "Local codebase", Phase: "receiving", Message: "Waiting for files…", Warnings: []string{}, root: root, owner: owner, updated: time.Now()}
if request.Name != nil {
job.Name = *request.Name
}
@@ -210,6 +222,7 @@ func (s *Server) jobRoute(w http.ResponseWriter, r *http.Request, parts []string
job.Completed++
job.Total = job.Completed
job.Message = fmt.Sprintf("Received %d files", job.Completed)
job.updated = time.Now()
}
s.jobsMu.Unlock()
writeJSON(w, 200, map[string]bool{"ok": true})
@@ -258,6 +271,7 @@ func (s *Server) snapshotRoute(w http.ResponseWriter, r *http.Request, parts []s
writeError(w, 404, "Unknown snapshot")
return
}
snapshot.lastUsed = time.Now()
switch action {
case "":
body := map[string]any{
@@ -528,7 +542,18 @@ func (s *Server) indexJob(id, root string) {
delete(s.snapshots, key)
}
}
snapshot.lastUsed = time.Now()
s.snapshots[snapshot.ID] = snapshot
for len(s.snapshots) > maxSnapshots {
oldest := snapshot
for _, candidate := range s.snapshots {
if candidate.lastUsed.Before(oldest.lastUsed) {
oldest = candidate
}
}
oldRoots = append(oldRoots, oldest.Root)
delete(s.snapshots, oldest.ID)
}
s.snapshotsMu.Unlock()
for _, old := range oldRoots {
if old != root {
@@ -539,17 +564,48 @@ func (s *Server) indexJob(id, root string) {
s.jobsMu.Lock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Message = "ready", "Landscape ready"
job.updated = time.Now()
job.SnapshotID = &snapshot.ID
job.Completed = max(job.Total, job.Completed)
}
s.jobsMu.Unlock()
}
// expire drops snapshots idle since before now-idleExpiry, finished jobs, and uploads
// that were abandoned before commit, deleting their workspaces.
func (s *Server) expire(now time.Time) {
cutoff := now.Add(-idleExpiry)
var roots []string
s.snapshotsMu.Lock()
for id, snapshot := range s.snapshots {
if snapshot.lastUsed.Before(cutoff) {
roots = append(roots, snapshot.Root)
delete(s.snapshots, id)
}
}
s.snapshotsMu.Unlock()
s.jobsMu.Lock()
for id, job := range s.jobs {
finished := job.SnapshotID != nil || job.Error != nil
if job.updated.Before(cutoff) && (finished || job.Phase == "receiving") {
if job.Phase == "receiving" {
roots = append(roots, job.root)
}
delete(s.jobs, id)
}
}
s.jobsMu.Unlock()
for _, root := range roots {
s.removeWorkspace(root)
}
}
func (s *Server) updateJob(id, phase string, completed, total int, message string) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Completed, job.Total, job.Message = phase, completed, total, message
job.updated = time.Now()
}
}
@@ -558,6 +614,7 @@ func (s *Server) failJob(id, message string) {
defer s.jobsMu.Unlock()
if job := s.jobs[id]; job != nil {
job.Phase, job.Message, job.Error = "error", "Indexing failed", &message
job.updated = time.Now()
}
}
+38
View File
@@ -13,6 +13,7 @@ import (
"reflect"
"strings"
"testing"
"time"
)
func TestDeliveryRangesMergeBeforeSourceIsReleased(t *testing.T) {
@@ -323,3 +324,40 @@ func TestSessionsKeepIndependentSnapshots(t *testing.T) {
t.Fatalf("snapshots = %d, want 2", len(server.snapshots))
}
}
func TestExpireDropsIdleSnapshotsAndAbandonedUploads(t *testing.T) {
server, c := newTestServer(t)
_, snapshot := c.indexLocal(map[string]string{"a.go": "package a"})
var pending struct{ JobID string }
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &pending)
c.json("POST", "/api/jobs/"+pending.JobID+"/files?path=b.go", "package b", 200, nil)
snapshotRoot := server.snapshots[snapshot].Root
pendingRoot := server.jobs[pending.JobID].root
server.expire(time.Now())
c.json("GET", "/api/snapshots/"+snapshot, "", 200, nil)
server.expire(time.Now().Add(idleExpiry + time.Minute))
c.json("GET", "/api/snapshots/"+snapshot, "", 404, nil)
c.json("GET", "/api/jobs/"+pending.JobID, "", 404, nil)
for _, root := range []string{snapshotRoot, pendingRoot} {
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Errorf("workspace %s should be deleted", root)
}
}
if len(server.jobs) != 0 {
t.Errorf("jobs left: %d", len(server.jobs))
}
}
func TestSnapshotCapEvictsLeastRecentlyUsed(t *testing.T) {
server, first := newTestServer(t)
_, oldest := first.indexLocal(map[string]string{"a.go": "package a"})
for i := range maxSnapshots {
first.stranger().indexLocal(map[string]string{"a.go": fmt.Sprint("package p", i)})
}
if len(server.snapshots) != maxSnapshots {
t.Fatalf("snapshots = %d, want %d", len(server.snapshots), maxSnapshots)
}
first.json("GET", "/api/snapshots/"+oldest, "", 404, nil)
}