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:
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ type Snapshot struct {
|
|||||||
References int
|
References int
|
||||||
Warnings []string
|
Warnings []string
|
||||||
Root string
|
Root string
|
||||||
|
lastUsed time.Time
|
||||||
delivered map[uint32][][2]int
|
delivered map[uint32][][2]int
|
||||||
released map[uint32]bool
|
released map[uint32]bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,15 @@ type Job struct {
|
|||||||
Error *string `json:"error"`
|
Error *string `json:"error"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
root string
|
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 {
|
type Server struct {
|
||||||
jobsMu sync.Mutex
|
jobsMu sync.Mutex
|
||||||
jobs map[string]*Job
|
jobs map[string]*Job
|
||||||
@@ -76,12 +82,18 @@ func main() {
|
|||||||
open := flag.Bool("open", false, "open the viewer in a browser")
|
open := flag.Bool("open", false, "open the viewer in a browser")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
handler := NewServer(filepath.Join(os.TempDir(), "codenavigator"))
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf("0.0.0.0:%d", port),
|
Addr: fmt.Sprintf("0.0.0.0:%d", port),
|
||||||
Handler: NewServer(filepath.Join(os.TempDir(), "codenavigator")),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 30 * time.Second,
|
ReadHeaderTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 2 * time.Minute,
|
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)
|
log.Printf("CodeNavigator listening on http://127.0.0.1:%d", port)
|
||||||
if *open {
|
if *open {
|
||||||
_ = exec.Command("xdg-open", fmt.Sprintf("http://127.0.0.1:%d", port)).Start()
|
_ = 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())
|
writeError(w, 500, err.Error())
|
||||||
return
|
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 {
|
if request.Name != nil {
|
||||||
job.Name = *request.Name
|
job.Name = *request.Name
|
||||||
}
|
}
|
||||||
@@ -210,6 +222,7 @@ func (s *Server) jobRoute(w http.ResponseWriter, r *http.Request, parts []string
|
|||||||
job.Completed++
|
job.Completed++
|
||||||
job.Total = job.Completed
|
job.Total = job.Completed
|
||||||
job.Message = fmt.Sprintf("Received %d files", job.Completed)
|
job.Message = fmt.Sprintf("Received %d files", job.Completed)
|
||||||
|
job.updated = time.Now()
|
||||||
}
|
}
|
||||||
s.jobsMu.Unlock()
|
s.jobsMu.Unlock()
|
||||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
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")
|
writeError(w, 404, "Unknown snapshot")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
snapshot.lastUsed = time.Now()
|
||||||
switch action {
|
switch action {
|
||||||
case "":
|
case "":
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
@@ -528,7 +542,18 @@ func (s *Server) indexJob(id, root string) {
|
|||||||
delete(s.snapshots, key)
|
delete(s.snapshots, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
snapshot.lastUsed = time.Now()
|
||||||
s.snapshots[snapshot.ID] = snapshot
|
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()
|
s.snapshotsMu.Unlock()
|
||||||
for _, old := range oldRoots {
|
for _, old := range oldRoots {
|
||||||
if old != root {
|
if old != root {
|
||||||
@@ -539,17 +564,48 @@ func (s *Server) indexJob(id, root string) {
|
|||||||
s.jobsMu.Lock()
|
s.jobsMu.Lock()
|
||||||
if job := s.jobs[id]; job != nil {
|
if job := s.jobs[id]; job != nil {
|
||||||
job.Phase, job.Message = "ready", "Landscape ready"
|
job.Phase, job.Message = "ready", "Landscape ready"
|
||||||
|
job.updated = time.Now()
|
||||||
job.SnapshotID = &snapshot.ID
|
job.SnapshotID = &snapshot.ID
|
||||||
job.Completed = max(job.Total, job.Completed)
|
job.Completed = max(job.Total, job.Completed)
|
||||||
}
|
}
|
||||||
s.jobsMu.Unlock()
|
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) {
|
func (s *Server) updateJob(id, phase string, completed, total int, message string) {
|
||||||
s.jobsMu.Lock()
|
s.jobsMu.Lock()
|
||||||
defer s.jobsMu.Unlock()
|
defer s.jobsMu.Unlock()
|
||||||
if job := s.jobs[id]; job != nil {
|
if job := s.jobs[id]; job != nil {
|
||||||
job.Phase, job.Completed, job.Total, job.Message = phase, completed, total, message
|
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()
|
defer s.jobsMu.Unlock()
|
||||||
if job := s.jobs[id]; job != nil {
|
if job := s.jobs[id]; job != nil {
|
||||||
job.Phase, job.Message, job.Error = "error", "Indexing failed", &message
|
job.Phase, job.Message, job.Error = "error", "Indexing failed", &message
|
||||||
|
job.updated = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeliveryRangesMergeBeforeSourceIsReleased(t *testing.T) {
|
func TestDeliveryRangesMergeBeforeSourceIsReleased(t *testing.T) {
|
||||||
@@ -323,3 +324,40 @@ func TestSessionsKeepIndependentSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("snapshots = %d, want 2", len(server.snapshots))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user