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>
115 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|