Files
mcr/internal/server/admin_gc.go
Kyle Isom 562b69e875 Phase 9: two-phase garbage collection engine
GC engine (internal/gc/): Collector.Run() implements the two-phase
algorithm — Phase 1 finds unreferenced blobs and deletes DB rows in
a single transaction, Phase 2 deletes blob files from storage.
Registry-wide mutex blocks concurrent GC runs. Collector.Reconcile()
scans filesystem for orphaned files with no DB row (crash recovery).

Wired into admin_gc.go: POST /v1/gc now launches the real collector
in a goroutine with gc_started/gc_completed audit events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:27:17 -07:00

115 lines
2.6 KiB
Go

package server
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"git.wntrmute.dev/kyle/mcr/internal/gc"
)
// GCLastRun records the result of the last GC run.
type GCLastRun struct {
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at,omitempty"`
BlobsRemoved int `json:"blobs_removed"`
BytesFreed int64 `json:"bytes_freed"`
}
// GCState tracks the current state of garbage collection.
type GCState struct {
mu sync.Mutex
Running bool `json:"running"`
LastRun *GCLastRun `json:"last_run,omitempty"`
Collector *gc.Collector
AuditFn AuditFunc
}
type gcStatusResponse struct {
Running bool `json:"running"`
LastRun *GCLastRun `json:"last_run,omitempty"`
}
type gcTriggerResponse struct {
ID string `json:"id"`
}
// AdminTriggerGCHandler handles POST /v1/gc.
func AdminTriggerGCHandler(state *GCState) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
state.mu.Lock()
if state.Running {
state.mu.Unlock()
writeAdminError(w, http.StatusConflict, "garbage collection already running")
return
}
state.Running = true
state.mu.Unlock()
gcID := uuid.New().String()
// Run GC asynchronously.
go func() {
startedAt := time.Now().UTC().Format(time.RFC3339)
if state.AuditFn != nil {
state.AuditFn("gc_started", "", "", "", "", map[string]string{
"gc_id": gcID,
})
}
var result *gc.Result
var gcErr error
if state.Collector != nil {
result, gcErr = state.Collector.Run(context.Background())
}
completedAt := time.Now().UTC().Format(time.RFC3339)
state.mu.Lock()
state.Running = false
lastRun := &GCLastRun{
StartedAt: startedAt,
CompletedAt: completedAt,
}
if result != nil {
lastRun.BlobsRemoved = result.BlobsRemoved
lastRun.BytesFreed = result.BytesFreed
}
state.LastRun = lastRun
state.mu.Unlock()
if state.AuditFn != nil && gcErr == nil {
details := map[string]string{
"gc_id": gcID,
}
if result != nil {
details["blobs_removed"] = fmt.Sprintf("%d", result.BlobsRemoved)
details["bytes_freed"] = fmt.Sprintf("%d", result.BytesFreed)
}
state.AuditFn("gc_completed", "", "", "", "", details)
}
}()
writeJSON(w, http.StatusAccepted, gcTriggerResponse{ID: gcID})
}
}
// AdminGCStatusHandler handles GET /v1/gc/status.
func AdminGCStatusHandler(state *GCState) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
state.mu.Lock()
resp := gcStatusResponse{
Running: state.Running,
LastRun: state.LastRun,
}
state.mu.Unlock()
writeJSON(w, http.StatusOK, resp)
}
}