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
+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()
}
}