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>
This commit is contained in:
2026-03-19 20:27:17 -07:00
parent c01e7ffa30
commit 562b69e875
9 changed files with 727 additions and 9 deletions

View File

@@ -1,10 +1,15 @@
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.
@@ -17,9 +22,11 @@ type GCLastRun struct {
// GCState tracks the current state of garbage collection.
type GCState struct {
mu sync.Mutex
Running bool `json:"running"`
LastRun *GCLastRun `json:"last_run,omitempty"`
mu sync.Mutex
Running bool `json:"running"`
LastRun *GCLastRun `json:"last_run,omitempty"`
Collector *gc.Collector
AuditFn AuditFunc
}
type gcStatusResponse struct {
@@ -43,10 +50,51 @@ func AdminTriggerGCHandler(state *GCState) http.HandlerFunc {
state.Running = true
state.mu.Unlock()
// GC engine is Phase 9 -- for now, just mark as running and return.
// The actual GC goroutine will be wired up in Phase 9.
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})
}
}