mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
`go fix` carries the modernize analyzers now, and the tree had drifted behind
them. This is the mechanical result, reviewed rather than trusted: the tool is
capable of rewriting code into something that no longer tests or does what it
did, so every non-test change was read individually and the concurrency-bearing
packages were re-run under -race.
Production code, four changes, all semantics-preserving:
- leases/manager.go: wg.Add(1) + go + defer wg.Done() becomes wg.Go. The
comment above that function turns on Add happening before the goroutine
starts, so that a Wait cannot return before the worker has run. wg.Go does
the Add synchronously on the calling goroutine, so the invariant it
describes still holds.
- auth/token/handler.go: strings.Fields -> strings.FieldsSeq, same splitting,
iterated rather than allocated.
- hold/gc/gc.go: a hand-written map copy -> maps.Copy.
- hold/pds/scan_broadcaster.go: three-clause loop -> range over int.
The rest are tests. The one worth naming is carstore_contention_test.go, where a
careless rewrite could have quietly stopped exercising contention: go fix
converted the reader and side-table goroutines to loopWG.Go but correctly
declined to touch the writer loop, which passes its index as a parameter. The
writer/reader/side-table shape and the stop channel are unchanged, so the test
still contends over the same carstore transactions.
Verified: go build for hold and appview, `make lint` 0 issues, the deploy and
credential-helper modules 0 issues, `make test` green across all 43 packages,
and -race green on leases, hold/pds, hold/gc and auth/token. The scanner module's
two lint findings are unchanged from HEAD and are in files go fix never touched.
Kept separate from the HTTP/2 commit so that one stays readable, and so this can
be reverted on its own if a modernization turns out to matter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
2536 lines
88 KiB
Go
2536 lines
88 KiB
Go
package gc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/hold/pds"
|
|
"atcr.io/pkg/s3"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// maxPreviewItems caps per-category detail slices to prevent memory/HTML bloat
|
|
const maxPreviewItems = 10000
|
|
|
|
// OrphanedRecordDetail holds info about a single orphaned record. Collection
|
|
// names which record type it is — layer records carry a digest, media type,
|
|
// and size; scan and image-config records leave those empty.
|
|
type OrphanedRecordDetail struct {
|
|
Collection string `json:"collection"`
|
|
Rkey string `json:"rkey"`
|
|
// CID identifies the exact record revision that was judged orphaned. Scan
|
|
// and image-config rkeys are derived from the manifest digest and are reused
|
|
// on re-push, so the rkey alone does not identify what we looked at; the
|
|
// delete path re-reads this to confirm the slot still holds the same
|
|
// revision. Empty for layer records, whose rkeys are unique per write.
|
|
CID string `json:"cid,omitempty"`
|
|
Digest string `json:"digest"`
|
|
ManifestURI string `json:"manifestUri"`
|
|
UserDID string `json:"userDid"`
|
|
MediaType string `json:"mediaType"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// auxOrphanState is the analysis state auxRecordOrphaned consults. Grouped into
|
|
// a struct rather than passed positionally because three of the four fields are
|
|
// same-shaped maps that would otherwise be trivial to transpose at a call site,
|
|
// and transposing knownDigests with fetchedUsers would silently widen what the
|
|
// sweep deletes.
|
|
type auxOrphanState struct {
|
|
// knownManifests are the manifests live on their owners' PDSes, by AT-URI.
|
|
knownManifests map[string]*manifestInfo
|
|
// knownDigests are the manifest digests any successfully fetched user still
|
|
// holds, across all users.
|
|
knownDigests map[string]bool
|
|
// digestOwners maps a manifest digest to every DID known to have pushed it
|
|
// to this hold, derived from the hold's own layer records. It is "every
|
|
// owner we can still see": a user whose layer records were collected in an
|
|
// earlier run no longer appears, which is self-consistent, since those
|
|
// records are only collected once that user's manifest is already gone.
|
|
digestOwners map[string]map[string]bool
|
|
// fetchedUsers are the DIDs whose PDS answered completely this run.
|
|
fetchedUsers map[string]bool
|
|
|
|
// now is the wall clock for this analysis pass, held once so every record
|
|
// in the pass is judged against the same instant. The zero value means
|
|
// time.Now(), which is what unit tests that don't care about the clock get.
|
|
now time.Time
|
|
// orphanSince carries forward, per auxRecordKey slot, the first time an
|
|
// earlier pass in this process observed that slot orphaned. Reading it is
|
|
// what makes grace measure orphanhood rather than record age.
|
|
orphanSince map[string]time.Time
|
|
// orphanObserved collects this pass's observations, keyed the same way, for
|
|
// the caller to carry into the next pass. A nil map means "carry nothing
|
|
// forward", which fails safe: no slot accrues age, so nothing is collected.
|
|
orphanObserved map[string]time.Time
|
|
}
|
|
|
|
// auxRecordKey identifies one auxiliary record slot for the orphan clock.
|
|
//
|
|
// Deliberately collection+rkey with no CID: the clock times how long the SLOT
|
|
// has been orphaned, and a rewrite that leaves it orphaned — a rescan
|
|
// restamping io.atcr.hold.scan — must not restart it. A rewrite that makes the
|
|
// slot live again needs no special handling here; the orphan test stops
|
|
// observing it and commit drops the entry.
|
|
func auxRecordKey(collection, rkey string) string {
|
|
return collection + "/" + rkey
|
|
}
|
|
|
|
// graceElapsed records that recordKey looks orphaned as of this pass, and
|
|
// reports whether it has looked that way for at least gcRecordGracePeriod.
|
|
//
|
|
// Every uncertain input starts the clock at now rather than expiring it: an
|
|
// unrecorded slot (first pass, or first pass after a restart), a zero time, and
|
|
// a time in the future all mean "we cannot show this has been orphaned for a
|
|
// full grace period", so the record survives at least one more pass.
|
|
//
|
|
// Call this LAST, after a record has already failed every other test. Observing
|
|
// a slot that is not actually orphaned accrues age against it, and the entry
|
|
// would then already be stale the moment its manifest genuinely disappeared.
|
|
func (s auxOrphanState) graceElapsed(recordKey string) bool {
|
|
now := s.now
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
|
|
since, ok := s.orphanSince[recordKey]
|
|
if !ok || since.IsZero() || since.After(now) {
|
|
since = now
|
|
}
|
|
if s.orphanObserved != nil {
|
|
s.orphanObserved[recordKey] = since
|
|
}
|
|
return now.Sub(since) >= gcRecordGracePeriod
|
|
}
|
|
|
|
// auxOrphanClock is the process-lifetime memory behind that calculation: which
|
|
// auxiliary record slots looked orphaned, and since when.
|
|
//
|
|
// It is in-memory only, so a restart forgets every observation and each
|
|
// surviving orphan starts its grace period over. That is the conservative
|
|
// direction — a restart delays collection and never advances it — and it is
|
|
// why commit replaces rather than merges.
|
|
type auxOrphanClock struct {
|
|
mu sync.Mutex
|
|
since map[string]time.Time
|
|
}
|
|
|
|
// snapshot copies the recorded first-observation times for one analysis pass.
|
|
func (c *auxOrphanClock) snapshot() map[string]time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
out := make(map[string]time.Time, len(c.since))
|
|
maps.Copy(out, c.since)
|
|
return out
|
|
}
|
|
|
|
// commit replaces the recorded times with a completed pass's observations.
|
|
//
|
|
// Replacing rather than merging is the point: a slot the pass did not observe
|
|
// orphaned loses its accrued age. That covers the record coming back to life,
|
|
// the record being deleted, and the record's owner being unreachable — the last
|
|
// of which is why this is a replace. An owner we could not reach is an owner we
|
|
// cannot judge, and the sweep's standing rule is that an unreachable PDS never
|
|
// contributes to a deletion, not even by letting a clock run in the background.
|
|
//
|
|
// Only call this for a pass that completed. A pass that failed partway has
|
|
// observed only a prefix of the collection, and committing it would reset the
|
|
// clock on everything past that point.
|
|
func (c *auxOrphanClock) commit(observed map[string]time.Time) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.since = observed
|
|
}
|
|
|
|
// recordDigestOwner notes that the DID in manifestURI owns a manifest at that
|
|
// URI's digest. Called for every layer record the sweep walks, which is what
|
|
// makes the ownership set free to build: layer records are required for storage
|
|
// accounting and billing, so they exist for anything a user is charged for.
|
|
func recordDigestOwner(owners map[string]map[string]bool, manifestURI string) {
|
|
parts := parseATURI(manifestURI)
|
|
if parts == nil || parts.Collection != atproto.ManifestCollection {
|
|
return
|
|
}
|
|
digest := "sha256:" + parts.Rkey
|
|
if owners[digest] == nil {
|
|
owners[digest] = make(map[string]bool)
|
|
}
|
|
owners[digest][parts.DID] = true
|
|
}
|
|
|
|
// orphanRef is the minimal address needed to delete an orphaned record. CID
|
|
// pins the revision that was judged (see OrphanedRecordDetail.CID); it is empty
|
|
// for layer records, which have unique per-write rkeys and need no such check.
|
|
type orphanRef struct {
|
|
Collection string
|
|
Rkey string
|
|
CID string
|
|
// ManifestURI is the manifest the record belongs to, used at delete time to
|
|
// check the manifest has not reappeared. Empty for layer records.
|
|
ManifestURI string
|
|
}
|
|
|
|
// manifestsWithLayerRecords returns the set of manifest AT-URIs the hold
|
|
// currently holds at least one layer record for.
|
|
//
|
|
// A push writes layer records to this hold, so their presence means the image
|
|
// is here now — regardless of what a previous scan concluded. That makes this a
|
|
// re-check of orphanhood rather than of record identity, and unlike consulting
|
|
// the owner's PDS it is entirely local.
|
|
//
|
|
// A manifest whose layer records are themselves orphaned but not yet collected
|
|
// also lands in this set, which only defers the aux record to the next run.
|
|
// Erring toward one more cycle of a leak is the right trade against deleting a
|
|
// live user's scan and image config.
|
|
func (gc *GarbageCollector) manifestsWithLayerRecords(ctx context.Context) (map[string]bool, error) {
|
|
recordsIndex := gc.pds.RecordsIndex()
|
|
if recordsIndex == nil {
|
|
return nil, fmt.Errorf("records index not available")
|
|
}
|
|
|
|
live := make(map[string]bool)
|
|
cursor := ""
|
|
for {
|
|
records, nextCursor, err := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list layer records: %w", err)
|
|
}
|
|
for _, rec := range records {
|
|
layer, err := gc.decodeLayerRecord(ctx, rec)
|
|
if err != nil {
|
|
// Undecodable: we cannot tell which manifest it belongs to.
|
|
// Skipping only costs protection, never causes a deletion.
|
|
gc.logger.Warn("Failed to decode layer record while checking live manifests",
|
|
"rkey", rec.Rkey, "error", err)
|
|
continue
|
|
}
|
|
live[layer.Manifest] = true
|
|
}
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
return live, nil
|
|
}
|
|
|
|
// OrphanedBlobDetail holds info about a single orphaned blob in S3
|
|
type OrphanedBlobDetail struct {
|
|
Digest string `json:"digest"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// MissingRecordDetail holds info about a layer record that should exist but doesn't
|
|
type MissingRecordDetail struct {
|
|
Digest string `json:"digest"`
|
|
ManifestURI string `json:"manifestUri"`
|
|
UserDID string `json:"userDid"`
|
|
MediaType string `json:"mediaType"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// GCPreview contains detailed analysis results (no mutations performed)
|
|
type GCPreview struct {
|
|
OrphanedRecords []OrphanedRecordDetail `json:"orphanedRecords"`
|
|
OrphanedBlobs []OrphanedBlobDetail `json:"orphanedBlobs"`
|
|
MissingRecords []MissingRecordDetail `json:"missingRecords"`
|
|
|
|
TotalLayerRecords int `json:"totalLayerRecords"`
|
|
TotalBlobs int `json:"totalBlobs"`
|
|
ReferencedBlobs int `json:"referencedBlobs"`
|
|
UsersChecked int `json:"usersChecked"`
|
|
ManifestsChecked int `json:"manifestsChecked"`
|
|
Reconcile bool `json:"reconcile"`
|
|
Duration time.Duration `json:"duration"`
|
|
}
|
|
|
|
// BackfillConfigCandidate identifies one manifest that's missing its image
|
|
// config record on the hold.
|
|
type BackfillConfigCandidate struct {
|
|
ManifestURI string `json:"manifestUri"`
|
|
UserDID string `json:"userDid"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
// BackfillConfigsPreview is the dry-run output for the image-config backfill.
|
|
// No PDS writes or S3 fetches happen during preview — we only check which
|
|
// manifest digests already have an io.atcr.hold.image.config record.
|
|
type BackfillConfigsPreview struct {
|
|
Missing []BackfillConfigCandidate `json:"missing"`
|
|
MissingCount int `json:"missingCount"`
|
|
PresentCount int `json:"presentCount"`
|
|
ManifestsChecked int `json:"manifestsChecked"`
|
|
UsersAffected int `json:"usersAffected"`
|
|
Duration time.Duration `json:"duration"`
|
|
}
|
|
|
|
// TakedownGate is the interface the GC uses to consult the labeler cache when
|
|
// computing reachability. Defined here (rather than imported) to keep the GC
|
|
// package free of any direct dependency on the labeler package.
|
|
//
|
|
// IsTakenDown returns the takedown's creation timestamp and a boolean
|
|
// indicating whether the manifest URI is currently under takedown (either by
|
|
// per-manifest label or via a user-level label on its DID).
|
|
type TakedownGate interface {
|
|
IsTakenDown(manifestURI string) (cts time.Time, ok bool)
|
|
}
|
|
|
|
// Option configures optional GC behavior.
|
|
type Option func(*GarbageCollector)
|
|
|
|
// WithTakedownCache wires a takedown gate (typically the hold's labeler cache)
|
|
// and a grace window. When set, analyzeRecords protects blobs of taken-down
|
|
// manifests from collection until grace expires, and skips reconciliation of
|
|
// their layer records so the labeler-driven purge isn't undone.
|
|
//
|
|
// Passing a nil gate or a non-positive window leaves the GC behaving as before.
|
|
func WithTakedownCache(gate TakedownGate, graceWindow time.Duration) Option {
|
|
return func(gc *GarbageCollector) {
|
|
gc.takedownGate = gate
|
|
gc.takedownGrace = graceWindow
|
|
}
|
|
}
|
|
|
|
// GarbageCollector handles cleanup of orphaned blobs from storage
|
|
type GarbageCollector struct {
|
|
pds *pds.HoldPDS
|
|
s3 *s3.S3Service
|
|
cfg Config
|
|
logger *slog.Logger
|
|
|
|
// takedownGate, if non-nil, is consulted in analyzeRecords to gate blob
|
|
// reachability and skip reconcile for taken-down manifests.
|
|
takedownGate TakedownGate
|
|
takedownGrace time.Duration
|
|
|
|
// stopCh signals the background goroutine to stop
|
|
stopCh chan struct{}
|
|
// wg tracks the background goroutine
|
|
wg sync.WaitGroup
|
|
|
|
// mu protects running state and last results
|
|
mu sync.Mutex
|
|
running bool
|
|
|
|
// Last results (for admin panel display)
|
|
lastPreview *GCPreview
|
|
lastPreviewAt time.Time
|
|
lastResult *GCResult
|
|
lastResultAt time.Time
|
|
lastBackfillPreview *BackfillConfigsPreview
|
|
lastBackfillPreviewAt time.Time
|
|
|
|
// Progress tracking for background operations
|
|
phase string // "manifests", "records", "blobs", "deleting", "complete", "error"
|
|
progressMsg string // e.g. "Fetching manifests (35/69 users)"
|
|
operationType string // "preview", "run", "reconcile", "delete-records", "delete-blobs"
|
|
lastError error
|
|
|
|
// predecessorCache caches holdDID → "is this a predecessor of our hold?"
|
|
// A predecessor is a hold whose captain record has a successor label set.
|
|
//
|
|
// Only definitive answers belong here. The cache is never reset, so a false
|
|
// recorded from an unreachable hold would outlive the outage and unreference
|
|
// that hold's blobs on every subsequent run — deleting content this hold is
|
|
// still serving on the predecessor's behalf.
|
|
predecessorCache map[string]bool
|
|
|
|
// auxOrphans times how long each scan / image-config record slot has looked
|
|
// orphaned, across analysis passes. See auxOrphanClock: it is the grace
|
|
// clock for those two collections, and it lives only as long as the process.
|
|
auxOrphans auxOrphanClock
|
|
|
|
// predecessorUnresolved holds the DIDs whose predecessor status could not be
|
|
// determined during the current analysis. It exists only so that one
|
|
// unreachable hold costs a single 5s timeout per run rather than one per
|
|
// manifest, and it is cleared at the start of every analysis so a hold that
|
|
// was down once is re-checked next time instead of being written off.
|
|
predecessorUnresolved map[string]bool
|
|
}
|
|
|
|
// GCResult contains statistics from a GC run
|
|
type GCResult struct {
|
|
BlobsDeleted int64 `json:"blobs_deleted"`
|
|
BytesReclaimed int64 `json:"bytes_reclaimed"`
|
|
RecordsDeleted int64 `json:"records_deleted"`
|
|
OrphanedRecords int64 `json:"orphaned_records"`
|
|
OrphanedBlobs int64 `json:"orphaned_blobs"`
|
|
ReferencedBlobs int64 `json:"referenced_blobs"`
|
|
RecordsReconciled int64 `json:"records_reconciled"`
|
|
RecordsSkipped int64 `json:"records_skipped"`
|
|
ManifestsChecked int64 `json:"manifests_checked"`
|
|
UsersChecked int64 `json:"users_checked"`
|
|
Duration time.Duration `json:"duration"`
|
|
}
|
|
|
|
// manifestInfo holds a parsed manifest fetched from a user's PDS
|
|
// manifestClaim is what a manifest's hold field tells us about ownership. The
|
|
// domain genuinely has three states, and collapsing it into a bool is what put
|
|
// phantom layer records on hold01: "we could not reach the hold" is not the same
|
|
// statement as either "ours" or "not ours", and the two questions this drives —
|
|
// keep the blobs, and adopt the manifest — want different answers for it.
|
|
type manifestClaim int
|
|
|
|
const (
|
|
// claimNotOurs: the hold answered and this manifest is another hold's, or
|
|
// there is no hold to ask. Ignore it entirely.
|
|
claimNotOurs manifestClaim = iota
|
|
// claimOurs: this hold's own manifest, or one belonging to a confirmed
|
|
// predecessor. Adopt it and reference its blobs.
|
|
claimOurs
|
|
// claimUnknown: the hold did not answer, so ownership is unknowable. Don't
|
|
// delete, but do not adopt. Its blobs stay referenced because an outage must
|
|
// never make content deletable, while the manifest stays out of
|
|
// knownManifests because adopting it reports every layer as a missing layer
|
|
// record and lets reconcileMissingRecords write io.atcr.hold.layer records
|
|
// claiming another hold's content.
|
|
//
|
|
// This is not always transient. A dev push leaves manifests naming
|
|
// did:web:localhost:8080 or an RFC1918 address, which can never resolve from
|
|
// a server, so this state can be permanent for a given hold.
|
|
claimUnknown
|
|
)
|
|
|
|
func (c manifestClaim) String() string {
|
|
switch c {
|
|
case claimOurs:
|
|
return "ours"
|
|
case claimUnknown:
|
|
return "unknown"
|
|
default:
|
|
return "not-ours"
|
|
}
|
|
}
|
|
|
|
type manifestInfo struct {
|
|
URI string // AT-URI of the manifest
|
|
UserDID string // DID of the user who owns it
|
|
Record *atproto.ManifestRecord // Parsed manifest data
|
|
// ProtectOnly marks a claimUnknown manifest: reference its blobs, but do not
|
|
// adopt it. See the ProtectOnly branch in analyzeRecords.
|
|
ProtectOnly bool
|
|
}
|
|
|
|
// analysisResult holds intermediate data from record analysis, shared between Run and Preview
|
|
type analysisResult struct {
|
|
referenced map[string]bool
|
|
orphanedRefs []orphanRef // collection+rkey for deletion in Run
|
|
orphanedDetails []OrphanedRecordDetail // details for display in Preview
|
|
missingDetails []MissingRecordDetail // details for creation in Run / display in Preview
|
|
usersChecked int64
|
|
manifestsChecked int64
|
|
totalRecords int
|
|
}
|
|
|
|
// NewGarbageCollector creates a new GC instance. Optional behavior (such as
|
|
// the labeler-aware takedown gate) is configured via Option arguments.
|
|
func NewGarbageCollector(holdPDS *pds.HoldPDS, s3svc *s3.S3Service, cfg Config, opts ...Option) *GarbageCollector {
|
|
gc := &GarbageCollector{
|
|
pds: holdPDS,
|
|
s3: s3svc,
|
|
cfg: cfg,
|
|
logger: slog.Default().With("component", "gc"),
|
|
stopCh: make(chan struct{}),
|
|
predecessorCache: make(map[string]bool),
|
|
predecessorUnresolved: make(map[string]bool),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(gc)
|
|
}
|
|
return gc
|
|
}
|
|
|
|
// isManifestTakenDown reports whether the labeler cache (if any) currently
|
|
// holds a takedown for this manifest URI, returning the takedown's cts so the
|
|
// caller can decide whether the grace window has elapsed.
|
|
func (gc *GarbageCollector) isManifestTakenDown(manifestURI string) (time.Time, bool) {
|
|
if gc.takedownGate == nil {
|
|
return time.Time{}, false
|
|
}
|
|
return gc.takedownGate.IsTakenDown(manifestURI)
|
|
}
|
|
|
|
// takedownExpired reports whether a takedown's cts is older than the
|
|
// configured grace window. With a non-positive window every takedown is
|
|
// considered expired immediately (i.e. blobs are never protected).
|
|
func (gc *GarbageCollector) takedownExpired(cts time.Time) bool {
|
|
if gc.takedownGrace <= 0 {
|
|
return true
|
|
}
|
|
return time.Since(cts) > gc.takedownGrace
|
|
}
|
|
|
|
// tryStart attempts to mark GC as running. Returns false if already running.
|
|
func (gc *GarbageCollector) tryStart() bool {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
if gc.running {
|
|
return false
|
|
}
|
|
gc.running = true
|
|
return true
|
|
}
|
|
|
|
// finish marks GC as no longer running
|
|
func (gc *GarbageCollector) finish() {
|
|
gc.mu.Lock()
|
|
gc.running = false
|
|
gc.mu.Unlock()
|
|
}
|
|
|
|
// setProgress updates the progress fields (thread-safe).
|
|
func (gc *GarbageCollector) setProgress(phase, msg, opType string) {
|
|
gc.mu.Lock()
|
|
gc.phase = phase
|
|
gc.progressMsg = msg
|
|
gc.operationType = opType
|
|
if phase != "error" {
|
|
gc.lastError = nil
|
|
}
|
|
gc.mu.Unlock()
|
|
}
|
|
|
|
// GCProgress holds a snapshot of the current GC operation progress.
|
|
type GCProgress struct {
|
|
Phase string // "manifests", "records", "blobs", "deleting", "complete", "error"
|
|
Message string
|
|
OperationType string // "preview", "run", "reconcile", "delete-records", "delete-blobs", "backfill-configs", "backfill-configs-preview"
|
|
Running bool
|
|
Error string
|
|
}
|
|
|
|
// GetProgress returns the current progress state.
|
|
func (gc *GarbageCollector) GetProgress() GCProgress {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
p := GCProgress{
|
|
Phase: gc.phase,
|
|
Message: gc.progressMsg,
|
|
OperationType: gc.operationType,
|
|
Running: gc.running,
|
|
}
|
|
if gc.lastError != nil {
|
|
p.Error = gc.lastError.Error()
|
|
}
|
|
return p
|
|
}
|
|
|
|
// startBackground is the common pattern for launching a GC operation in the background.
|
|
// Returns false if already running.
|
|
func (gc *GarbageCollector) startBackground(opType, initialPhase, initialMsg string, fn func(ctx context.Context) error) bool {
|
|
if !gc.tryStart() {
|
|
return false
|
|
}
|
|
gc.setProgress(initialPhase, initialMsg, opType)
|
|
go func() {
|
|
defer gc.finish()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
if err := fn(ctx); err != nil {
|
|
gc.mu.Lock()
|
|
gc.phase = "error"
|
|
gc.progressMsg = err.Error()
|
|
gc.lastError = err
|
|
gc.mu.Unlock()
|
|
gc.logger.Error("GC operation failed", "type", opType, "error", err)
|
|
return
|
|
}
|
|
gc.setProgress("complete", "", opType)
|
|
}()
|
|
return true
|
|
}
|
|
|
|
// StartPreview launches a GC preview in the background.
|
|
func (gc *GarbageCollector) StartPreview() bool {
|
|
return gc.startBackground("preview", "manifests", "Starting preview...", func(ctx context.Context) error {
|
|
_, err := gc.doPreview(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// StartRun launches a full GC run in the background.
|
|
func (gc *GarbageCollector) StartRun() bool {
|
|
return gc.startBackground("run", "manifests", "Starting GC run...", func(ctx context.Context) error {
|
|
_, err := gc.doRun(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// StartReconcile launches record reconciliation in the background.
|
|
func (gc *GarbageCollector) StartReconcile() bool {
|
|
return gc.startBackground("reconcile", "deleting", "Reconciling missing records...", func(ctx context.Context) error {
|
|
_, err := gc.doReconcile(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// StartDeleteRecords launches orphaned record deletion in the background.
|
|
func (gc *GarbageCollector) StartDeleteRecords() bool {
|
|
return gc.startBackground("delete-records", "deleting", "Deleting orphaned records...", func(ctx context.Context) error {
|
|
_, err := gc.doDeleteOrphanedRecords(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// StartDeleteBlobs launches orphaned blob deletion in the background.
|
|
func (gc *GarbageCollector) StartDeleteBlobs() bool {
|
|
return gc.startBackground("delete-blobs", "manifests", "Analyzing records before blob deletion...", func(ctx context.Context) error {
|
|
_, err := gc.doDeleteOrphanedBlobs(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Start begins the GC background process with a nightly ticker
|
|
func (gc *GarbageCollector) Start(ctx context.Context) {
|
|
if !gc.cfg.Enabled {
|
|
gc.logger.Info("GC disabled")
|
|
return
|
|
}
|
|
|
|
gc.wg.Go(func() {
|
|
|
|
ticker := time.NewTicker(gcInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-gc.stopCh:
|
|
gc.logger.Info("GC background process stopped")
|
|
return
|
|
case <-ctx.Done():
|
|
gc.logger.Info("GC context cancelled")
|
|
return
|
|
case <-ticker.C:
|
|
gc.logger.Info("Running nightly GC")
|
|
result, err := gc.Run(ctx)
|
|
if err != nil {
|
|
gc.logger.Warn("Nightly GC skipped or failed", "error", err)
|
|
} else {
|
|
gc.logResult(result)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
gc.logger.Info("GC background process started", "interval", gcInterval)
|
|
}
|
|
|
|
// Stop gracefully stops the GC background process
|
|
func (gc *GarbageCollector) Stop() {
|
|
close(gc.stopCh)
|
|
gc.wg.Wait()
|
|
}
|
|
|
|
// Run executes a single GC cycle
|
|
func (gc *GarbageCollector) Run(ctx context.Context) (*GCResult, error) {
|
|
if !gc.tryStart() {
|
|
return nil, fmt.Errorf("GC operation already in progress")
|
|
}
|
|
defer gc.finish()
|
|
return gc.doRun(ctx)
|
|
}
|
|
|
|
// doRun is the internal implementation of Run (caller must hold the running lock).
|
|
func (gc *GarbageCollector) doRun(ctx context.Context) (*GCResult, error) {
|
|
start := time.Now()
|
|
result := &GCResult{}
|
|
|
|
gc.logger.Info("Starting GC run")
|
|
|
|
// Phase 1: Analyze records (build referenced set, find orphans, identify missing)
|
|
analysis, err := gc.analyzeRecords(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("phase 1 (analyze records) failed: %w", err)
|
|
}
|
|
|
|
result.OrphanedRecords = int64(len(analysis.orphanedRefs))
|
|
result.UsersChecked = analysis.usersChecked
|
|
result.ManifestsChecked = analysis.manifestsChecked
|
|
|
|
gc.logger.Info("Phase 1 complete",
|
|
"referenced", len(analysis.referenced),
|
|
"orphanedRecords", len(analysis.orphanedRefs),
|
|
"missingRecords", len(analysis.missingDetails))
|
|
|
|
// Reconcile: create missing layer records
|
|
gc.setProgress("deleting", "Reconciling missing records...", "run")
|
|
if len(analysis.missingDetails) > 0 {
|
|
gc.reconcileMissingRecords(ctx, analysis.missingDetails, result)
|
|
}
|
|
|
|
// Phase 2: Delete orphaned layer records
|
|
gc.setProgress("deleting", "Deleting orphaned records...", "run")
|
|
if err := gc.deleteOrphanedRecords(ctx, analysis.orphanedRefs, result); err != nil {
|
|
gc.logger.Error("Phase 2 (delete orphaned records) failed", "error", err)
|
|
// Continue to phase 3 - we can still clean up blobs
|
|
}
|
|
|
|
// Phase 3: Walk storage and delete unreferenced blobs
|
|
gc.setProgress("blobs", "Deleting orphaned blobs...", "run")
|
|
if err := gc.deleteOrphanedBlobs(ctx, analysis.referenced, result); err != nil {
|
|
return nil, fmt.Errorf("phase 3 (delete orphaned blobs) failed: %w", err)
|
|
}
|
|
|
|
result.Duration = time.Since(start)
|
|
result.ReferencedBlobs = int64(len(analysis.referenced))
|
|
|
|
// Store last result for admin panel
|
|
gc.mu.Lock()
|
|
gc.lastResult = result
|
|
gc.lastResultAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Preview runs read-only analysis and returns detailed results for the admin panel.
|
|
// It never creates, deletes, or modifies any data.
|
|
func (gc *GarbageCollector) Preview(ctx context.Context) (*GCPreview, error) {
|
|
if !gc.tryStart() {
|
|
return nil, fmt.Errorf("GC operation already in progress")
|
|
}
|
|
defer gc.finish()
|
|
return gc.doPreview(ctx)
|
|
}
|
|
|
|
// doPreview is the internal implementation of Preview (caller must hold the running lock).
|
|
func (gc *GarbageCollector) doPreview(ctx context.Context) (*GCPreview, error) {
|
|
start := time.Now()
|
|
|
|
gc.logger.Info("Starting GC preview")
|
|
|
|
// Phase 1: Analyze records
|
|
analysis, err := gc.analyzeRecords(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("analyze records: %w", err)
|
|
}
|
|
|
|
// Phase 2: Walk S3 to find orphaned blobs (read-only)
|
|
gc.setProgress("blobs", "Walking S3 storage...", "preview")
|
|
orphanedBlobs, totalBlobs, err := gc.scanOrphanedBlobDetails(ctx, analysis.referenced)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan orphaned blobs: %w", err)
|
|
}
|
|
|
|
preview := &GCPreview{
|
|
OrphanedRecords: analysis.orphanedDetails,
|
|
OrphanedBlobs: orphanedBlobs,
|
|
MissingRecords: analysis.missingDetails,
|
|
TotalLayerRecords: analysis.totalRecords,
|
|
TotalBlobs: totalBlobs,
|
|
ReferencedBlobs: len(analysis.referenced),
|
|
UsersChecked: int(analysis.usersChecked),
|
|
ManifestsChecked: int(analysis.manifestsChecked),
|
|
Reconcile: true,
|
|
Duration: time.Since(start),
|
|
}
|
|
|
|
gc.logger.Info("GC preview complete",
|
|
"orphanedRecords", len(preview.OrphanedRecords),
|
|
"orphanedBlobs", len(preview.OrphanedBlobs),
|
|
"missingRecords", len(preview.MissingRecords),
|
|
"referencedBlobs", preview.ReferencedBlobs,
|
|
"duration", preview.Duration)
|
|
|
|
// Store for admin panel
|
|
gc.mu.Lock()
|
|
gc.lastPreview = preview
|
|
gc.lastPreviewAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
return preview, nil
|
|
}
|
|
|
|
// Reconcile creates missing layer records without deleting anything.
|
|
// Requires a prior Preview() to identify missing records.
|
|
func (gc *GarbageCollector) Reconcile(ctx context.Context) (*GCResult, error) {
|
|
if !gc.tryStart() {
|
|
return nil, fmt.Errorf("GC operation already in progress")
|
|
}
|
|
defer gc.finish()
|
|
return gc.doReconcile(ctx)
|
|
}
|
|
|
|
// doReconcile is the internal implementation of Reconcile (caller must hold the running lock).
|
|
func (gc *GarbageCollector) doReconcile(ctx context.Context) (*GCResult, error) {
|
|
gc.mu.Lock()
|
|
preview := gc.lastPreview
|
|
gc.mu.Unlock()
|
|
|
|
if preview == nil {
|
|
return nil, fmt.Errorf("no preview available — run Scan first")
|
|
}
|
|
if len(preview.MissingRecords) == 0 {
|
|
return &GCResult{}, nil
|
|
}
|
|
|
|
start := time.Now()
|
|
result := &GCResult{}
|
|
|
|
gc.logger.Info("Starting reconciliation", "missingRecords", len(preview.MissingRecords))
|
|
gc.reconcileMissingRecords(ctx, preview.MissingRecords, result)
|
|
result.Duration = time.Since(start)
|
|
|
|
gc.mu.Lock()
|
|
gc.lastResult = result
|
|
gc.lastResultAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteOrphanedRecords deletes layer records whose manifests no longer exist.
|
|
// Requires a prior Preview() to identify orphaned records.
|
|
func (gc *GarbageCollector) DeleteOrphanedRecords(ctx context.Context) (*GCResult, error) {
|
|
if !gc.tryStart() {
|
|
return nil, fmt.Errorf("GC operation already in progress")
|
|
}
|
|
defer gc.finish()
|
|
return gc.doDeleteOrphanedRecords(ctx)
|
|
}
|
|
|
|
// doDeleteOrphanedRecords is the internal implementation (caller must hold the running lock).
|
|
func (gc *GarbageCollector) doDeleteOrphanedRecords(ctx context.Context) (*GCResult, error) {
|
|
gc.mu.Lock()
|
|
preview := gc.lastPreview
|
|
previewAt := gc.lastPreviewAt
|
|
gc.mu.Unlock()
|
|
|
|
if preview == nil {
|
|
return nil, fmt.Errorf("no preview available — run Scan first")
|
|
}
|
|
if age := time.Since(previewAt); age > maxPreviewAgeForDelete {
|
|
return nil, fmt.Errorf("preview is %s old (limit %s) — run Scan again before deleting",
|
|
age.Round(time.Minute), maxPreviewAgeForDelete)
|
|
}
|
|
if len(preview.OrphanedRecords) == 0 {
|
|
return &GCResult{}, nil
|
|
}
|
|
|
|
start := time.Now()
|
|
result := &GCResult{
|
|
OrphanedRecords: int64(len(preview.OrphanedRecords)),
|
|
}
|
|
|
|
refs := make([]orphanRef, len(preview.OrphanedRecords))
|
|
for i, r := range preview.OrphanedRecords {
|
|
refs[i] = orphanRef{Collection: r.Collection, Rkey: r.Rkey, CID: r.CID, ManifestURI: r.ManifestURI}
|
|
}
|
|
|
|
gc.logger.Info("Deleting orphaned records", "count", len(refs))
|
|
if err := gc.deleteOrphanedRecords(ctx, refs, result); err != nil {
|
|
return nil, fmt.Errorf("delete orphaned records: %w", err)
|
|
}
|
|
result.Duration = time.Since(start)
|
|
|
|
gc.mu.Lock()
|
|
gc.lastResult = result
|
|
gc.lastResultAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteOrphanedBlobs walks S3 and deletes blobs not referenced by any manifest.
|
|
// Runs a fresh analysis to build the current referenced set (reflects any reconciliation
|
|
// done since the last preview).
|
|
func (gc *GarbageCollector) DeleteOrphanedBlobs(ctx context.Context) (*GCResult, error) {
|
|
if !gc.tryStart() {
|
|
return nil, fmt.Errorf("GC operation already in progress")
|
|
}
|
|
defer gc.finish()
|
|
return gc.doDeleteOrphanedBlobs(ctx)
|
|
}
|
|
|
|
// doDeleteOrphanedBlobs is the internal implementation (caller must hold the running lock).
|
|
func (gc *GarbageCollector) doDeleteOrphanedBlobs(ctx context.Context) (*GCResult, error) {
|
|
start := time.Now()
|
|
result := &GCResult{}
|
|
|
|
gc.logger.Info("Starting orphaned blob deletion (fresh analysis)")
|
|
|
|
// Fresh analysis so the referenced set includes any records reconciled since preview
|
|
analysis, err := gc.analyzeRecords(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("analyze records: %w", err)
|
|
}
|
|
|
|
result.ReferencedBlobs = int64(len(analysis.referenced))
|
|
|
|
gc.setProgress("blobs", "Deleting orphaned blobs...", "delete-blobs")
|
|
if err := gc.deleteOrphanedBlobs(ctx, analysis.referenced, result); err != nil {
|
|
return nil, fmt.Errorf("delete orphaned blobs: %w", err)
|
|
}
|
|
result.Duration = time.Since(start)
|
|
|
|
gc.mu.Lock()
|
|
gc.lastResult = result
|
|
gc.lastResultAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// analyzeRecords performs Phase 1 analysis: builds referenced set, finds orphaned records,
|
|
// and identifies missing layer records. Pure analysis — no mutations.
|
|
// Discovers users, fetches manifests, scans records, identifies missing records.
|
|
func (gc *GarbageCollector) analyzeRecords(ctx context.Context) (*analysisResult, error) {
|
|
result := &analysisResult{
|
|
referenced: make(map[string]bool),
|
|
}
|
|
|
|
// Start each analysis with a clean slate of unresolved holds, so a hold that
|
|
// was unreachable last night gets another chance tonight. Definitive answers
|
|
// in predecessorCache are kept — those do not go stale within a process.
|
|
gc.predecessorUnresolved = make(map[string]bool)
|
|
|
|
recordsIndex := gc.pds.RecordsIndex()
|
|
if recordsIndex == nil {
|
|
return nil, fmt.Errorf("records index not available")
|
|
}
|
|
|
|
// Step 1: Discover all user DIDs
|
|
userDIDs, err := gc.discoverUserDIDs(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover user DIDs: %w", err)
|
|
}
|
|
gc.logger.Info("Discovered user DIDs", "count", len(userDIDs))
|
|
|
|
// Step 2: Fetch manifests from each user's PDS
|
|
knownManifests := make(map[string]*manifestInfo)
|
|
// knownDigests is the set of manifest digests any fetched user still holds,
|
|
// across ALL users. Scan and image-config records are keyed by digest with no
|
|
// DID, so they are shared between users pushing identical images and must
|
|
// survive as long as any one of those users still has the manifest.
|
|
knownDigests := make(map[string]bool)
|
|
// digestOwners is built from the hold's own layer records in step 3 below,
|
|
// giving every DID that pushed a given digest here — the co-owners a shared
|
|
// scan/image-config record actually serves.
|
|
digestOwners := make(map[string]map[string]bool)
|
|
fetchedUsers := make(map[string]bool)
|
|
|
|
totalUsers := len(userDIDs)
|
|
for _, did := range userDIDs {
|
|
result.usersChecked++
|
|
gc.setProgress("manifests", fmt.Sprintf("Fetching manifests (%d/%d users)", result.usersChecked, totalUsers), gc.operationType)
|
|
|
|
// Resolve PDS endpoint (needed for both manifests and optional profile/tag fetches)
|
|
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, did)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to resolve PDS for user, treating their records as referenced",
|
|
"did", did, "error", err)
|
|
continue
|
|
}
|
|
|
|
manifests, err := gc.fetchUserManifestsFromEndpoint(ctx, did, pdsEndpoint, gc.pds.DID())
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to fetch manifests for user, treating their records as referenced",
|
|
"did", did, "error", err)
|
|
continue
|
|
}
|
|
|
|
// If user opted into auto-remove-untagged, filter out untagged manifests
|
|
// so their layers are treated as orphaned and cleaned up
|
|
if profile, _ := gc.fetchUserProfile(ctx, pdsEndpoint, did); profile != nil && profile.AutoRemoveUntagged {
|
|
taggedDigests, tagErr := gc.fetchUserTags(ctx, pdsEndpoint, did)
|
|
if tagErr != nil {
|
|
gc.logger.Warn("Failed to fetch tags for auto-remove, keeping all manifests referenced",
|
|
"did", did, "error", tagErr)
|
|
} else {
|
|
before := len(manifests)
|
|
manifests = gc.filterUntaggedManifests(manifests, taggedDigests)
|
|
if filtered := before - len(manifests); filtered > 0 {
|
|
gc.logger.Info("Filtered untagged manifests for auto-remove user",
|
|
"did", did, "filtered", filtered, "remaining", len(manifests))
|
|
}
|
|
}
|
|
}
|
|
|
|
fetchedUsers[did] = true
|
|
for _, m := range manifests {
|
|
result.manifestsChecked++
|
|
|
|
// Labeler-aware reachability:
|
|
// - in-grace takedown: blobs stay referenced (so a reversal can
|
|
// restore content), but the manifest is NOT added to
|
|
// knownManifests so reconcileMissingRecords won't recreate the
|
|
// layer records the labeler subscriber just purged.
|
|
// - past-grace takedown: skip entirely — digests fall out of
|
|
// the referenced set and become eligible for blob GC.
|
|
if cts, taken := gc.isManifestTakenDown(m.URI); taken {
|
|
if !gc.takedownExpired(cts) {
|
|
for _, layer := range m.Record.Layers {
|
|
result.referenced[layer.Digest] = true
|
|
}
|
|
if m.Record.Config != nil && m.Record.Config.Digest != "" {
|
|
result.referenced[m.Record.Config.Digest] = true
|
|
}
|
|
gc.logger.Debug("Manifest under in-grace takedown: blobs protected, reconcile skipped",
|
|
"manifest", m.URI, "cts", cts)
|
|
} else {
|
|
gc.logger.Debug("Manifest takedown past grace: orphaning blobs",
|
|
"manifest", m.URI, "cts", cts)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Ownership was indefinite: the manifest names a hold we could not
|
|
// reach, so we cannot say it is ours. Protect its blobs exactly as an
|
|
// in-grace takedown does, but do not adopt it. Adopting would report
|
|
// every layer as a missing layer record and let reconcileMissingRecords
|
|
// write io.atcr.hold.layer records claiming another hold's content.
|
|
if m.ProtectOnly {
|
|
for _, layer := range m.Record.Layers {
|
|
result.referenced[layer.Digest] = true
|
|
}
|
|
if m.Record.Config != nil && m.Record.Config.Digest != "" {
|
|
result.referenced[m.Record.Config.Digest] = true
|
|
}
|
|
gc.logger.Debug("Manifest hold unreachable: blobs protected, ownership not claimed",
|
|
"manifest", m.URI, "holdDid", m.Record.HoldDID)
|
|
continue
|
|
}
|
|
|
|
knownManifests[m.URI] = m
|
|
if d := extractDigestFromManifestURI(m.URI); d != "" {
|
|
knownDigests[d] = true
|
|
}
|
|
|
|
// Add all layer digests to referenced set
|
|
for _, layer := range m.Record.Layers {
|
|
result.referenced[layer.Digest] = true
|
|
}
|
|
// Add config digest to referenced set (config blobs are in S3 but
|
|
// don't get layer records — just protect them from deletion)
|
|
if m.Record.Config != nil && m.Record.Config.Digest != "" {
|
|
result.referenced[m.Record.Config.Digest] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
gc.logger.Info("Fetched manifests from user PDS instances",
|
|
"knownManifests", len(knownManifests),
|
|
"fetchedUsers", len(fetchedUsers))
|
|
|
|
// Step 3: Scan existing layer records to find orphans and build coveredPairs
|
|
gc.setProgress("records", "Scanning layer records...", gc.operationType)
|
|
coveredPairs := make(map[string]bool)
|
|
|
|
cursor := ""
|
|
batchSize := 1000
|
|
|
|
for {
|
|
records, nextCursor, err := recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list layer records: %w", err)
|
|
}
|
|
|
|
for _, rec := range records {
|
|
result.totalRecords++
|
|
|
|
layer, err := gc.decodeLayerRecord(ctx, rec)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to decode layer record", "rkey", rec.Rkey, "error", err)
|
|
continue
|
|
}
|
|
|
|
// Track this (manifest, digest) pair as covered
|
|
pairKey := layer.Manifest + "|" + layer.Digest
|
|
coveredPairs[pairKey] = true
|
|
|
|
// Note the manifest's owner. This MUST stay above every continue
|
|
// below: an owner missing from the set is an owner the aux sweep
|
|
// will not wait for, which is how a co-owner's live records get
|
|
// deleted. The one owner we cannot record is a layer record that
|
|
// failed to decode above — there is no manifest URI to read — which
|
|
// is logged as a warning.
|
|
recordDigestOwner(digestOwners, layer.Manifest)
|
|
|
|
// Too young to judge: the manifest may still be in flight. Keep
|
|
// the record and protect its blob until the record matures.
|
|
recordTime := tidToTime(rec.Rkey)
|
|
if time.Since(recordTime) < gcRecordGracePeriod {
|
|
result.referenced[layer.Digest] = true
|
|
continue
|
|
}
|
|
|
|
// Check if this layer's manifest is known
|
|
if _, known := knownManifests[layer.Manifest]; known {
|
|
result.referenced[layer.Digest] = true
|
|
} else {
|
|
// Manifest not in our fetched set — check if the user's PDS was unreachable
|
|
parts := parseATURI(layer.Manifest)
|
|
if parts != nil && !fetchedUsers[parts.DID] {
|
|
// User's PDS was unreachable — safe default: assume referenced
|
|
result.referenced[layer.Digest] = true
|
|
} else {
|
|
// User's PDS was reachable but manifest not found — orphaned
|
|
result.orphanedRefs = append(result.orphanedRefs, orphanRef{
|
|
Collection: atproto.LayerCollection,
|
|
Rkey: rec.Rkey,
|
|
})
|
|
if len(result.orphanedDetails) < maxPreviewItems {
|
|
result.orphanedDetails = append(result.orphanedDetails, OrphanedRecordDetail{
|
|
Collection: atproto.LayerCollection,
|
|
Rkey: rec.Rkey,
|
|
Digest: layer.Digest,
|
|
ManifestURI: layer.Manifest,
|
|
UserDID: layer.UserDID,
|
|
MediaType: layer.MediaType,
|
|
Size: layer.Size,
|
|
})
|
|
}
|
|
gc.logger.Debug("Found orphaned layer record",
|
|
"rkey", rec.Rkey,
|
|
"digest", layer.Digest,
|
|
"manifest", layer.Manifest)
|
|
}
|
|
}
|
|
}
|
|
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
|
|
if result.totalRecords%10000 == 0 {
|
|
gc.logger.Info("Phase 1 progress", "processed", result.totalRecords)
|
|
}
|
|
}
|
|
|
|
gc.logger.Info("Scanned layer records", "total", result.totalRecords, "coveredPairs", len(coveredPairs))
|
|
|
|
// Step 3b: Scan scan and image-config records. These are keyed by manifest
|
|
// digest rather than TID, so the only question is whether their manifest
|
|
// still exists. Without this sweep they survive forever when a user deletes
|
|
// a manifest record directly on their PDS — the purgeManifest XRPC only
|
|
// fires on appview-driven deletes.
|
|
//
|
|
// Their blob references are NOT part of the referenced set this sweep
|
|
// builds. io.atcr.hold.scan carries sbomBlob and vulnReportBlob, but those
|
|
// live in the hold's own PDS blob store at /repos/<safe-did>/blobs/<cid>,
|
|
// while the blob sweep walks the registry prefix /docker/registry/v2/blobs.
|
|
// The two spaces do not overlap, so deleting a scan record here neither
|
|
// frees its SBOM nor risks orphaning a blob this sweep could delete. That
|
|
// blob space is currently never collected at all; a sweep for it would have
|
|
// to read these two fields before the record goes.
|
|
gc.setProgress("records", "Scanning scan and image config records...", gc.operationType)
|
|
auxState := auxOrphanState{
|
|
knownManifests: knownManifests,
|
|
knownDigests: knownDigests,
|
|
digestOwners: digestOwners,
|
|
fetchedUsers: fetchedUsers,
|
|
now: time.Now(),
|
|
orphanSince: gc.auxOrphans.snapshot(),
|
|
orphanObserved: make(map[string]time.Time),
|
|
}
|
|
for _, collection := range []string{atproto.ScanCollection, atproto.ImageConfigCollection} {
|
|
if err := gc.scanAuxRecords(ctx, collection, auxState, result); err != nil {
|
|
// Deliberately not committing: a pass that stopped partway saw only
|
|
// part of the collection, and committing it would reset the grace
|
|
// clock on every slot it never reached.
|
|
return nil, fmt.Errorf("scan %s records: %w", collection, err)
|
|
}
|
|
}
|
|
// Both collections walked end to end, so this pass's observations are a
|
|
// complete picture and can replace the previous one.
|
|
gc.auxOrphans.commit(auxState.orphanObserved)
|
|
|
|
// Step 4: Identify missing layer records (uncovered manifest+layer pairs)
|
|
for _, m := range knownManifests {
|
|
for _, layer := range m.Record.Layers {
|
|
pairKey := m.URI + "|" + layer.Digest
|
|
if coveredPairs[pairKey] {
|
|
continue
|
|
}
|
|
if len(result.missingDetails) < maxPreviewItems {
|
|
result.missingDetails = append(result.missingDetails, MissingRecordDetail{
|
|
Digest: layer.Digest,
|
|
ManifestURI: m.URI,
|
|
UserDID: m.UserDID,
|
|
MediaType: layer.MediaType,
|
|
Size: layer.Size,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// scanAuxRecords walks one auxiliary manifest collection (scan or image
|
|
// config) and appends orphans to result. A record is orphaned when its
|
|
// manifest is absent from the owning user's PDS and that PDS was reachable —
|
|
// the same test the layer sweep applies, so an unreachable PDS never causes a
|
|
// deletion.
|
|
//
|
|
// Grace comes from state's orphan clock, not from the record. These
|
|
// collections use deterministic digest rkeys rather than TIDs, so there is no
|
|
// write time in the rkey to read, and the timestamps in the body cannot stand
|
|
// in for one: io.atcr.hold.scan's scannedAt is restamped by every rescan, so on
|
|
// a hold whose rescan_interval is shorter than gcRecordGracePeriod it never
|
|
// ages past grace and the record is immortal. The clock times orphanhood
|
|
// instead, which is what grace was always meant to measure.
|
|
func (gc *GarbageCollector) scanAuxRecords(
|
|
ctx context.Context,
|
|
collection string,
|
|
state auxOrphanState,
|
|
result *analysisResult,
|
|
) error {
|
|
recordsIndex := gc.pds.RecordsIndex()
|
|
if recordsIndex == nil {
|
|
return fmt.Errorf("records index not available")
|
|
}
|
|
|
|
cursor := ""
|
|
scanned, orphaned := 0, 0
|
|
for {
|
|
records, nextCursor, err := recordsIndex.ListRecords(collection, 1000, cursor, true)
|
|
if err != nil {
|
|
return fmt.Errorf("list records: %w", err)
|
|
}
|
|
|
|
for _, rec := range records {
|
|
scanned++
|
|
result.totalRecords++
|
|
|
|
manifestURI, writtenAt, recCID, err := gc.decodeAuxRecord(ctx, collection, rec)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to decode record",
|
|
"collection", collection, "rkey", rec.Rkey, "error", err)
|
|
continue
|
|
}
|
|
|
|
parts, isOrphan := auxRecordOrphaned(auxRecordKey(collection, rec.Rkey), manifestURI, state)
|
|
if !isOrphan {
|
|
continue
|
|
}
|
|
|
|
orphaned++
|
|
result.orphanedRefs = append(result.orphanedRefs, orphanRef{
|
|
Collection: collection,
|
|
Rkey: rec.Rkey,
|
|
CID: recCID,
|
|
ManifestURI: manifestURI,
|
|
})
|
|
if len(result.orphanedDetails) < maxPreviewItems {
|
|
result.orphanedDetails = append(result.orphanedDetails, OrphanedRecordDetail{
|
|
Collection: collection,
|
|
Rkey: rec.Rkey,
|
|
CID: recCID,
|
|
ManifestURI: manifestURI,
|
|
UserDID: parts.DID,
|
|
})
|
|
}
|
|
// writtenAt is the record body's own timestamp (scannedAt /
|
|
// createdAt). It is diagnostic only — a scan record's is restamped
|
|
// by every rescan, which is exactly why it no longer gates grace.
|
|
gc.logger.Debug("Found orphaned record",
|
|
"collection", collection, "rkey", rec.Rkey, "manifest", manifestURI,
|
|
"recordWrittenAt", writtenAt,
|
|
"orphanedSince", state.orphanSince[auxRecordKey(collection, rec.Rkey)])
|
|
}
|
|
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
|
|
gc.logger.Info("Scanned auxiliary manifest records",
|
|
"collection", collection, "scanned", scanned, "orphaned", orphaned)
|
|
return nil
|
|
}
|
|
|
|
// auxRecordOrphaned decides whether a scan or image-config record should be
|
|
// collected, given the slot it occupies and the manifest it names. It returns
|
|
// the parsed AT-URI alongside the verdict so the caller can record the owning
|
|
// DID.
|
|
//
|
|
// Every uncertain case resolves to "keep": a record whose manifest URI is
|
|
// unparseable, one whose owning PDS was unreachable this run, one a co-owner
|
|
// might still need, and one that has not yet looked orphaned for a full grace
|
|
// period. Only a record whose every reachable owner demonstrably lacks the
|
|
// manifest, sustained across grace, is orphaned.
|
|
//
|
|
// The grace test comes last, and deliberately so. It both reads and advances
|
|
// the orphan clock, so it must only ever see records that have already failed
|
|
// every other test — a record judged live must not accrue orphan age.
|
|
func auxRecordOrphaned(recordKey, manifestURI string, state auxOrphanState) (*atURIParts, bool) {
|
|
if _, known := state.knownManifests[manifestURI]; known {
|
|
return nil, false
|
|
}
|
|
parts := parseATURI(manifestURI)
|
|
if parts == nil || !state.fetchedUsers[parts.DID] {
|
|
return nil, false
|
|
}
|
|
|
|
// Scan and image-config records are keyed by manifest digest alone
|
|
// (atproto.ScanRecordKey), with no DID, so every user who pushes the
|
|
// identical image shares ONE record — and its body names only whoever wrote
|
|
// last. The checks above only establish that THAT user no longer has the
|
|
// manifest, which says nothing about the record's other owners. Deleting on
|
|
// that basis strips the vulnerability scan and the layer
|
|
// history/env/entrypoint from every co-owner's still-live image.
|
|
digest := extractDigestFromManifestURI(manifestURI)
|
|
|
|
// An owner whose PDS we could not reach this run cannot be judged, so the
|
|
// record has to stay. This is the same principle the rest of the sweep
|
|
// follows — an unreachable PDS never causes a deletion — extended from the
|
|
// record's named user to everyone the record actually serves.
|
|
for did := range state.digestOwners[digest] {
|
|
if !state.fetchedUsers[did] {
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
// Every owner was reachable; keep the record if any of them still holds a
|
|
// manifest at this digest.
|
|
if state.knownDigests[digest] {
|
|
return nil, false
|
|
}
|
|
|
|
// The record looks orphaned right now. Grace runs from the first pass that
|
|
// saw it this way — not from any timestamp inside the record, which a
|
|
// rescan would reset — so a slot that has only just started looking
|
|
// orphaned survives to be re-judged next pass.
|
|
if !state.graceElapsed(recordKey) {
|
|
return nil, false
|
|
}
|
|
return parts, true
|
|
}
|
|
|
|
// decodeAuxRecord reads a scan or image-config record from the carstore and
|
|
// returns the manifest AT-URI it belongs to along with its creation time.
|
|
// It also returns the record's CID, which the delete path uses to confirm the
|
|
// rkey still holds this exact revision rather than one written since.
|
|
func (gc *GarbageCollector) decodeAuxRecord(ctx context.Context, collection string, rec pds.Record) (string, time.Time, string, error) {
|
|
recordPath := rec.Collection + "/" + rec.Rkey
|
|
recCID, recBytes, err := gc.pds.GetRecordBytes(ctx, recordPath)
|
|
if err != nil {
|
|
return "", time.Time{}, "", fmt.Errorf("get record bytes: %w", err)
|
|
}
|
|
uri, ts, err := decodeAuxRecordBytes(collection, *recBytes)
|
|
return uri, ts, recCID.String(), err
|
|
}
|
|
|
|
// auxRecordUnchanged reports whether collection/rkey still holds the exact
|
|
// revision that was judged orphaned.
|
|
//
|
|
// This catches a re-push rewriting the slot: image-config records are upserted
|
|
// unconditionally on every push (pkg/hold/oci/xrpc.go), so a re-push always
|
|
// changes their CID. A missing record, an unreadable one, or a ref carrying no
|
|
// recorded CID all report false, so the delete is skipped rather than performed
|
|
// on something we did not examine.
|
|
//
|
|
// It is NOT sufficient on its own. A re-push does not necessarily rewrite the
|
|
// SCAN record — scan-on-push is tier-gated, and the discovery loop skips any
|
|
// manifest that already has a scan record (pkg/hold/pds/scan_broadcaster.go) —
|
|
// so a scan record's CID survives a re-push unchanged. Record identity is not
|
|
// orphanhood; see manifestsWithLayerRecords for the check that covers it.
|
|
func (gc *GarbageCollector) auxRecordUnchanged(ctx context.Context, collection, rkey, wantCID string) bool {
|
|
if wantCID == "" {
|
|
return false
|
|
}
|
|
gotCID, _, err := gc.pds.GetRecordBytes(ctx, collection+"/"+rkey)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return gotCID.String() == wantCID
|
|
}
|
|
|
|
// decodeAuxRecordBytes decodes raw CBOR for one of the auxiliary manifest
|
|
// collections. An unparseable timestamp yields the zero time rather than an
|
|
// error, which auxRecordOrphaned treats as in-grace, so a record with a
|
|
// malformed date is kept instead of collected.
|
|
func decodeAuxRecordBytes(collection string, data []byte) (string, time.Time, error) {
|
|
var manifestURI, timestamp string
|
|
switch collection {
|
|
case atproto.ScanCollection:
|
|
var scan atproto.ScanRecord
|
|
if err := scan.UnmarshalCBOR(bytes.NewReader(data)); err != nil {
|
|
return "", time.Time{}, fmt.Errorf("unmarshal CBOR: %w", err)
|
|
}
|
|
manifestURI, timestamp = scan.Manifest, scan.ScannedAt
|
|
case atproto.ImageConfigCollection:
|
|
var cfg atproto.ImageConfigRecord
|
|
if err := cfg.UnmarshalCBOR(bytes.NewReader(data)); err != nil {
|
|
return "", time.Time{}, fmt.Errorf("unmarshal CBOR: %w", err)
|
|
}
|
|
manifestURI, timestamp = cfg.Manifest, cfg.CreatedAt
|
|
default:
|
|
return "", time.Time{}, fmt.Errorf("unsupported collection %q", collection)
|
|
}
|
|
|
|
parsed, err := time.Parse(time.RFC3339, timestamp)
|
|
if err != nil {
|
|
return manifestURI, time.Time{}, nil
|
|
}
|
|
return manifestURI, parsed, nil
|
|
}
|
|
|
|
// blobPastGrace reports whether a blob is old enough to delete, based on its
|
|
// own S3 modification time rather than on any record that references it.
|
|
//
|
|
// Records now age out faster than blobs, so a blob can outlive every record
|
|
// naming it. Its age is the only thing left to judge it by. A listing that
|
|
// reports no modification time yields the zero time, which is treated as
|
|
// "unknown age" and keeps the blob.
|
|
func blobPastGrace(lastModified time.Time) bool {
|
|
if lastModified.IsZero() {
|
|
return false
|
|
}
|
|
return time.Since(lastModified) >= gcBlobGracePeriod
|
|
}
|
|
|
|
// scanOrphanedBlobDetails walks S3 and returns details of unreferenced blobs.
|
|
// Read-only — no deletions. Returns orphaned blob details and total blob count.
|
|
func (gc *GarbageCollector) scanOrphanedBlobDetails(ctx context.Context, referenced map[string]bool) ([]OrphanedBlobDetail, int, error) {
|
|
var orphaned []OrphanedBlobDetail
|
|
totalBlobs := 0
|
|
blobsPath := "/docker/registry/v2/blobs"
|
|
|
|
err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64, lastModified time.Time) error {
|
|
if !strings.HasSuffix(key, "/data") {
|
|
return nil
|
|
}
|
|
|
|
digest := extractDigestFromPath(key)
|
|
if digest == "" {
|
|
return nil
|
|
}
|
|
|
|
totalBlobs++
|
|
|
|
if !referenced[digest] && blobPastGrace(lastModified) {
|
|
if len(orphaned) < maxPreviewItems {
|
|
orphaned = append(orphaned, OrphanedBlobDetail{
|
|
Digest: digest,
|
|
Size: size,
|
|
})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("walk storage failed: %w", err)
|
|
}
|
|
|
|
return orphaned, totalBlobs, nil
|
|
}
|
|
|
|
// reconcileBatchSize is the number of layer records per repo commit.
|
|
// Batching reduces firehose events from N to N/batchSize.
|
|
const reconcileBatchSize = 200
|
|
|
|
// reconcileMissingRecords creates layer records for manifest+layer pairs that are missing.
|
|
// Records are batched into single commits to avoid flooding relays with firehose events.
|
|
func (gc *GarbageCollector) reconcileMissingRecords(ctx context.Context, missing []MissingRecordDetail, result *GCResult) {
|
|
for i := 0; i < len(missing); i += reconcileBatchSize {
|
|
end := min(i+reconcileBatchSize, len(missing))
|
|
chunk := missing[i:end]
|
|
|
|
records := make([]*atproto.LayerRecord, 0, len(chunk))
|
|
for _, m := range chunk {
|
|
records = append(records, atproto.NewLayerRecord(
|
|
m.Digest,
|
|
m.Size,
|
|
m.MediaType,
|
|
m.UserDID,
|
|
m.ManifestURI,
|
|
))
|
|
}
|
|
|
|
created, err := gc.pds.BatchCreateLayerRecords(ctx, records)
|
|
if err != nil {
|
|
gc.logger.Error("Failed to create reconciled layer batch",
|
|
"batchStart", i,
|
|
"batchSize", len(chunk),
|
|
"error", err)
|
|
continue
|
|
}
|
|
|
|
result.RecordsReconciled += int64(created)
|
|
gc.logger.Info("Reconciliation progress",
|
|
"created", result.RecordsReconciled,
|
|
"total", len(missing),
|
|
"batch", len(chunk))
|
|
|
|
// Small delay between batches as a courtesy to relays
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
|
|
if result.RecordsReconciled > 0 {
|
|
gc.logger.Info("Reconciliation complete", "recordsCreated", result.RecordsReconciled)
|
|
}
|
|
}
|
|
|
|
// StartBackfillConfigsPreview launches a dry-run scan that classifies every
|
|
// manifest URI referenced from layer records as either already having an
|
|
// image config record or missing one. No PDS or S3 writes happen.
|
|
func (gc *GarbageCollector) StartBackfillConfigsPreview() bool {
|
|
return gc.startBackground("backfill-configs-preview", "records", "Scanning for manifests missing image config records...", func(ctx context.Context) error {
|
|
_, err := gc.doBackfillConfigsPreview(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// StartBackfillConfigs launches image config backfill in the background.
|
|
// Creates io.atcr.hold.image.config records for manifests that don't have one yet
|
|
// by fetching OCI config blobs from S3.
|
|
func (gc *GarbageCollector) StartBackfillConfigs() bool {
|
|
return gc.startBackground("backfill-configs", "records", "Scanning for manifests missing image config records...", func(ctx context.Context) error {
|
|
_, err := gc.doBackfillConfigs(ctx)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// scanBackfillCandidates walks every layer record, dedupes the manifest URIs
|
|
// they reference, and bucket each one as already-present or missing an image
|
|
// config record. Returns missing candidates and the count of present.
|
|
//
|
|
// opType is the GC operationType used for progress messages so this helper
|
|
// can serve both the preview and the run.
|
|
func (gc *GarbageCollector) scanBackfillCandidates(ctx context.Context, opType string) (missing []BackfillConfigCandidate, presentCount int, err error) {
|
|
recordsIndex := gc.pds.RecordsIndex()
|
|
if recordsIndex == nil {
|
|
return nil, 0, fmt.Errorf("records index not available")
|
|
}
|
|
|
|
manifestURIs := make(map[string]bool)
|
|
cursor := ""
|
|
totalScanned := 0
|
|
for {
|
|
records, nextCursor, listErr := recordsIndex.ListRecords(atproto.LayerCollection, 1000, cursor, true)
|
|
if listErr != nil {
|
|
return nil, 0, fmt.Errorf("list layer records: %w", listErr)
|
|
}
|
|
for _, rec := range records {
|
|
totalScanned++
|
|
layer, decodeErr := gc.decodeLayerRecord(ctx, rec)
|
|
if decodeErr != nil {
|
|
continue
|
|
}
|
|
manifestURIs[layer.Manifest] = true
|
|
}
|
|
if nextCursor == "" {
|
|
break
|
|
}
|
|
cursor = nextCursor
|
|
}
|
|
|
|
gc.logger.Info("Found unique manifests from layer records",
|
|
"manifests", len(manifestURIs),
|
|
"layersScanned", totalScanned)
|
|
|
|
processed := 0
|
|
for manifestURI := range manifestURIs {
|
|
processed++
|
|
gc.setProgress("records",
|
|
fmt.Sprintf("Checking image configs (%d/%d manifests)...", processed, len(manifestURIs)),
|
|
opType)
|
|
|
|
aturi, parseErr := syntax.ParseATURI(manifestURI)
|
|
if parseErr != nil {
|
|
gc.logger.Warn("Invalid manifest URI", "uri", manifestURI, "error", parseErr)
|
|
continue
|
|
}
|
|
manifestDigest := "sha256:" + aturi.RecordKey().String()
|
|
|
|
if _, _, getErr := gc.pds.GetImageConfigRecord(ctx, manifestDigest); getErr == nil {
|
|
presentCount++
|
|
continue
|
|
}
|
|
missing = append(missing, BackfillConfigCandidate{
|
|
ManifestURI: manifestURI,
|
|
UserDID: aturi.Authority().String(),
|
|
Digest: manifestDigest,
|
|
})
|
|
}
|
|
return missing, presentCount, nil
|
|
}
|
|
|
|
// doBackfillConfigsPreview runs scanBackfillCandidates and stores the result
|
|
// for the admin UI to display. The full missing slice is kept in memory but
|
|
// rendering is capped via maxPreviewItems in the template layer.
|
|
func (gc *GarbageCollector) doBackfillConfigsPreview(ctx context.Context) (*BackfillConfigsPreview, error) {
|
|
start := time.Now()
|
|
|
|
missing, presentCount, err := gc.scanBackfillCandidates(ctx, "backfill-configs-preview")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
users := make(map[string]struct{}, len(missing))
|
|
for _, c := range missing {
|
|
users[c.UserDID] = struct{}{}
|
|
}
|
|
|
|
missingCount := len(missing)
|
|
display := missing
|
|
if len(display) > maxPreviewItems {
|
|
display = display[:maxPreviewItems]
|
|
}
|
|
|
|
preview := &BackfillConfigsPreview{
|
|
Missing: display,
|
|
MissingCount: missingCount,
|
|
PresentCount: presentCount,
|
|
ManifestsChecked: missingCount + presentCount,
|
|
UsersAffected: len(users),
|
|
Duration: time.Since(start),
|
|
}
|
|
|
|
gc.mu.Lock()
|
|
gc.lastBackfillPreview = preview
|
|
gc.lastBackfillPreviewAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
gc.logger.Info("Image config backfill preview complete",
|
|
"missing", missingCount,
|
|
"present", presentCount,
|
|
"usersAffected", preview.UsersAffected,
|
|
"duration", preview.Duration)
|
|
return preview, nil
|
|
}
|
|
|
|
// doBackfillConfigs creates image config records for manifests that are missing them.
|
|
func (gc *GarbageCollector) doBackfillConfigs(ctx context.Context) (*GCResult, error) {
|
|
start := time.Now()
|
|
|
|
missing, presentCount, err := gc.scanBackfillCandidates(ctx, "backfill-configs")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &GCResult{RecordsSkipped: int64(presentCount)}
|
|
created := int64(0)
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
for i, candidate := range missing {
|
|
gc.setProgress("records",
|
|
fmt.Sprintf("Backfilling configs (%d/%d missing)...", i+1, len(missing)),
|
|
"backfill-configs")
|
|
|
|
userDID := candidate.UserDID
|
|
manifestRkey := strings.TrimPrefix(candidate.Digest, "sha256:")
|
|
manifestURI := candidate.ManifestURI
|
|
manifestDigest := candidate.Digest
|
|
|
|
pdsEndpoint, err := atproto.ResolveDIDToPDS(ctx, userDID)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to resolve PDS for backfill", "did", userDID, "error", err)
|
|
continue
|
|
}
|
|
|
|
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
|
pdsEndpoint,
|
|
url.QueryEscape(userDID),
|
|
url.QueryEscape(atproto.ManifestCollection),
|
|
url.QueryEscape(manifestRkey))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to fetch manifest for backfill", "uri", manifestURI, "error", err)
|
|
continue
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
|
|
var envelope struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
resp.Body.Close()
|
|
|
|
var manifest atproto.ManifestRecord
|
|
if err := json.Unmarshal(envelope.Value, &manifest); err != nil {
|
|
continue
|
|
}
|
|
|
|
if manifest.Config == nil || manifest.Config.Digest == "" {
|
|
continue
|
|
}
|
|
|
|
configBytes, err := gc.s3.GetBytes(ctx, s3.BlobPath(manifest.Config.Digest))
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to fetch config blob", "digest", manifest.Config.Digest, "error", err)
|
|
continue
|
|
}
|
|
|
|
configRecord := atproto.NewImageConfigRecord(manifestURI, string(configBytes))
|
|
if _, _, err := gc.pds.CreateImageConfigRecord(ctx, configRecord, manifestDigest); err != nil {
|
|
gc.logger.Warn("Failed to create image config record", "manifest", manifestURI, "error", err)
|
|
continue
|
|
}
|
|
created++
|
|
time.Sleep(200 * time.Millisecond) // throttle firehose events
|
|
}
|
|
|
|
result.RecordsReconciled = created
|
|
result.Duration = time.Since(start)
|
|
|
|
gc.mu.Lock()
|
|
gc.lastResult = result
|
|
gc.lastResultAt = time.Now()
|
|
gc.mu.Unlock()
|
|
|
|
gc.logger.Info("Image config backfill complete",
|
|
"created", created,
|
|
"skipped", result.RecordsSkipped)
|
|
return result, nil
|
|
}
|
|
|
|
// discoverUserDIDs returns all DIDs that may have manifests referencing this hold.
|
|
// Union of: captain owner + crew members + distinct DIDs from layer records.
|
|
func (gc *GarbageCollector) discoverUserDIDs(ctx context.Context) ([]string, error) {
|
|
seen := make(map[string]bool)
|
|
|
|
// Captain owner DID
|
|
_, captain, err := gc.pds.GetCaptainRecord(ctx)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to get captain record", "error", err)
|
|
} else if captain.Owner != "" {
|
|
seen[captain.Owner] = true
|
|
}
|
|
|
|
// Crew member DIDs
|
|
crew, err := gc.pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to list crew members", "error", err)
|
|
} else {
|
|
for _, m := range crew {
|
|
if m.Record.Member != "" {
|
|
seen[m.Record.Member] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Distinct DIDs from existing layer records (catches non-crew pushers on public holds)
|
|
recordsIndex := gc.pds.RecordsIndex()
|
|
if recordsIndex != nil {
|
|
dids, err := recordsIndex.DistinctDIDs(atproto.LayerCollection)
|
|
if err != nil {
|
|
gc.logger.Warn("Failed to get distinct DIDs from records", "error", err)
|
|
} else {
|
|
for _, did := range dids {
|
|
seen[did] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
dids := make([]string, 0, len(seen))
|
|
for did := range seen {
|
|
dids = append(dids, did)
|
|
}
|
|
return dids, nil
|
|
}
|
|
|
|
// fetchUserProfile fetches the sailor profile from a user's PDS.
|
|
// Returns nil without error if the profile doesn't exist.
|
|
func (gc *GarbageCollector) fetchUserProfile(ctx context.Context, pdsEndpoint, userDID string) (*atproto.SailorProfileRecord, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=self",
|
|
pdsEndpoint,
|
|
url.QueryEscape(userDID),
|
|
url.QueryEscape(atproto.SailorProfileCollection))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound {
|
|
return nil, nil // No profile
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("getRecord returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var envelope struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
var profile atproto.SailorProfileRecord
|
|
if err := json.Unmarshal(envelope.Value, &profile); err != nil {
|
|
return nil, fmt.Errorf("unmarshal profile: %w", err)
|
|
}
|
|
|
|
return &profile, nil
|
|
}
|
|
|
|
// GC walks a user's PDS to decide which blobs are still referenced, and a
|
|
// failed walk is treated as "assume everything is referenced" — safe, but it
|
|
// pins that user's storage for the whole run. Finding 16 measured the cost:
|
|
// every DID GC classified unreachable-but-healthy had failed only one or two
|
|
// runs out of six, and replaying the identical calls afterwards returned 200
|
|
// in 45-680 ms with no rate limiting. These are ordinary blips on small
|
|
// self-hosted PDSes, amplified into a full-DID skip by a single-shot fetch.
|
|
const (
|
|
gcListAttempts = 3
|
|
gcListBackoff = 500 * time.Millisecond
|
|
)
|
|
|
|
// retryableFetchErr reports whether a transport error is worth another attempt.
|
|
// The distinction matters in both directions: retrying a blip recovers a
|
|
// healthy user's storage, but retrying a host that is genuinely gone only slows
|
|
// the run and makes a dead PDS look alive for longer. DNS failure, TLS failure
|
|
// and connection refused are stable facts about an endpoint and repeat
|
|
// identically, so they are never retried. Timeouts and resets are the transient
|
|
// shapes. Anything unrecognised is treated as permanent, which errs toward the
|
|
// current behaviour rather than toward hammering.
|
|
func retryableFetchErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var dnsErr *net.DNSError
|
|
if errors.As(err, &dnsErr) {
|
|
return false
|
|
}
|
|
var certErr *tls.CertificateVerificationError
|
|
if errors.As(err, &certErr) {
|
|
return false
|
|
}
|
|
var recordErr tls.RecordHeaderError
|
|
if errors.As(err, &recordErr) {
|
|
return false
|
|
}
|
|
if errors.Is(err, syscall.ECONNREFUSED) {
|
|
return false
|
|
}
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|
return true
|
|
}
|
|
return errors.Is(err, syscall.ECONNRESET) || errors.Is(err, io.ErrUnexpectedEOF)
|
|
}
|
|
|
|
// retryableStatus reports whether an HTTP status deserves another attempt.
|
|
// 5xx and 429 are the server saying "not now"; a 4xx is the server saying
|
|
// "not ever", and repeating it would be pointless.
|
|
func retryableStatus(status int) bool {
|
|
return status == http.StatusTooManyRequests || status >= 500
|
|
}
|
|
|
|
// listRecordsPage performs one listRecords GET and decodes it into out,
|
|
// retrying transient failures with linear backoff. The caller's context still
|
|
// short-circuits everything: a cancelled run stops immediately rather than
|
|
// sleeping through its remaining attempts.
|
|
func (gc *GarbageCollector) listRecordsPage(ctx context.Context, client *http.Client, reqURL string, out any) error {
|
|
var lastErr error
|
|
|
|
for attempt := 1; attempt <= gcListAttempts; attempt++ {
|
|
if attempt > 1 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Duration(attempt-1) * gcListBackoff):
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("http request: %w", err)
|
|
if ctx.Err() != nil || !retryableFetchErr(err) {
|
|
return lastErr
|
|
}
|
|
slog.Debug("GC listRecords attempt failed, retrying",
|
|
"component", "gc", "url", reqURL, "attempt", attempt, "error", err)
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("listRecords returned status %d", resp.StatusCode)
|
|
if !retryableStatus(resp.StatusCode) {
|
|
return lastErr
|
|
}
|
|
slog.Debug("GC listRecords attempt returned retryable status",
|
|
"component", "gc", "url", reqURL, "attempt", attempt, "status", resp.StatusCode)
|
|
continue
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(out)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
// A truncated body is a transport-shaped failure, so it is worth
|
|
// one more attempt rather than writing the whole DID off.
|
|
lastErr = fmt.Errorf("decode response: %w", err)
|
|
if ctx.Err() != nil || !retryableFetchErr(err) {
|
|
return lastErr
|
|
}
|
|
slog.Debug("GC listRecords decode failed, retrying",
|
|
"component", "gc", "url", reqURL, "attempt", attempt, "error", err)
|
|
continue
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("after %d attempts: %w", gcListAttempts, lastErr)
|
|
}
|
|
|
|
// fetchUserTags fetches all tag records from a user's PDS.
|
|
// Returns a map of digest → true for all tagged manifest digests.
|
|
func (gc *GarbageCollector) fetchUserTags(ctx context.Context, pdsEndpoint, userDID string) (map[string]bool, error) {
|
|
tagged := make(map[string]bool)
|
|
cursor := ""
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
for {
|
|
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100",
|
|
pdsEndpoint,
|
|
url.QueryEscape(userDID),
|
|
url.QueryEscape(atproto.TagCollection))
|
|
if cursor != "" {
|
|
reqURL += "&cursor=" + cursor
|
|
}
|
|
|
|
var listResult struct {
|
|
Records []struct {
|
|
Value json.RawMessage `json:"value"`
|
|
} `json:"records"`
|
|
Cursor string `json:"cursor,omitempty"`
|
|
}
|
|
|
|
if err := gc.listRecordsPage(ctx, client, reqURL, &listResult); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rec := range listResult.Records {
|
|
var tag atproto.TagRecord
|
|
if err := json.Unmarshal(rec.Value, &tag); err != nil {
|
|
continue
|
|
}
|
|
if d, err := tag.GetManifestDigest(); err == nil {
|
|
tagged[d] = true
|
|
}
|
|
}
|
|
|
|
if listResult.Cursor == "" {
|
|
break
|
|
}
|
|
cursor = listResult.Cursor
|
|
}
|
|
|
|
return tagged, nil
|
|
}
|
|
|
|
// filterUntaggedManifests removes untagged manifests from the list, preserving
|
|
// manifest list children that are referenced by tagged manifest lists.
|
|
func (gc *GarbageCollector) filterUntaggedManifests(manifests []*manifestInfo, taggedDigests map[string]bool) []*manifestInfo {
|
|
// Build a set of digests that are children of tagged manifest lists
|
|
childDigests := make(map[string]bool)
|
|
for _, m := range manifests {
|
|
// Check if this manifest is a manifest list AND is tagged
|
|
mDigest := extractDigestFromManifestURI(m.URI)
|
|
if mDigest == "" || !taggedDigests[mDigest] {
|
|
continue
|
|
}
|
|
// This is a tagged manifest list — protect its children
|
|
for _, ref := range m.Record.Manifests {
|
|
childDigests[ref.Digest] = true
|
|
}
|
|
}
|
|
|
|
// Filter: keep manifests that are tagged, or are children of tagged manifest lists
|
|
var kept []*manifestInfo
|
|
for _, m := range manifests {
|
|
mDigest := extractDigestFromManifestURI(m.URI)
|
|
if mDigest == "" {
|
|
kept = append(kept, m)
|
|
continue
|
|
}
|
|
if taggedDigests[mDigest] || childDigests[mDigest] {
|
|
kept = append(kept, m)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// extractDigestFromManifestURI extracts the digest from a manifest AT-URI.
|
|
// URI format: at://did:plc:xxx/io.atcr.manifest/{encoded_digest}
|
|
// Returns the full digest (e.g., "sha256:abc123...") or empty string.
|
|
func extractDigestFromManifestURI(uri string) string {
|
|
parts := parseATURI(uri)
|
|
if parts == nil || parts.Collection != atproto.ManifestCollection {
|
|
return ""
|
|
}
|
|
// The rkey is the digest without the "sha256:" prefix
|
|
return "sha256:" + parts.Rkey
|
|
}
|
|
|
|
// fetchUserManifestsFromEndpoint fetches manifests from a specific PDS endpoint.
|
|
func (gc *GarbageCollector) fetchUserManifestsFromEndpoint(ctx context.Context, userDID, pdsEndpoint, holdDID string) ([]*manifestInfo, error) {
|
|
var manifests []*manifestInfo
|
|
cursor := ""
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
for {
|
|
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=100",
|
|
pdsEndpoint,
|
|
url.QueryEscape(userDID),
|
|
url.QueryEscape(atproto.ManifestCollection))
|
|
if cursor != "" {
|
|
reqURL += "&cursor=" + cursor
|
|
}
|
|
|
|
var listResult struct {
|
|
Records []struct {
|
|
URI string `json:"uri"`
|
|
CID string `json:"cid"`
|
|
Value json.RawMessage `json:"value"`
|
|
} `json:"records"`
|
|
Cursor string `json:"cursor,omitempty"`
|
|
}
|
|
|
|
if err := gc.listRecordsPage(ctx, client, reqURL, &listResult); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rec := range listResult.Records {
|
|
var manifest atproto.ManifestRecord
|
|
if err := json.Unmarshal(rec.Value, &manifest); err != nil {
|
|
gc.logger.Warn("Failed to parse manifest record",
|
|
"uri", rec.URI, "error", err)
|
|
continue
|
|
}
|
|
|
|
// The fail-open decision lives here, in the open, rather than inside
|
|
// the classifier: an unknown claim is carried so its blobs stay
|
|
// referenced, but marked so it is never adopted as ours.
|
|
switch gc.classifyManifest(ctx, &manifest, holdDID) {
|
|
case claimOurs:
|
|
manifests = append(manifests, &manifestInfo{
|
|
URI: rec.URI,
|
|
UserDID: userDID,
|
|
Record: &manifest,
|
|
})
|
|
case claimUnknown:
|
|
manifests = append(manifests, &manifestInfo{
|
|
URI: rec.URI,
|
|
UserDID: userDID,
|
|
Record: &manifest,
|
|
ProtectOnly: true,
|
|
})
|
|
case claimNotOurs:
|
|
// Definitively another hold's: nothing to keep and nothing to protect.
|
|
}
|
|
}
|
|
|
|
if listResult.Cursor == "" {
|
|
break
|
|
}
|
|
cursor = listResult.Cursor
|
|
}
|
|
|
|
return manifests, nil
|
|
}
|
|
|
|
// classifyManifest reports what a manifest's hold field says about ownership,
|
|
// via HoldDID, legacy HoldEndpoint, or a predecessor hold that has been migrated.
|
|
// It states what it found and applies no policy; the caller decides what an
|
|
// unknown claim is worth. See manifestClaim.
|
|
func (gc *GarbageCollector) classifyManifest(ctx context.Context, manifest *atproto.ManifestRecord, holdDID string) manifestClaim {
|
|
manifestHoldDID := manifest.HoldDID
|
|
|
|
// Direct match
|
|
if manifestHoldDID == holdDID {
|
|
return claimOurs
|
|
}
|
|
|
|
// Legacy: resolve holdEndpoint to DID.
|
|
//
|
|
// A resolve failure here reports claimNotOurs rather than claimUnknown,
|
|
// preserving long-standing behaviour: this path predates the predecessor
|
|
// check and fails closed. That is arguably the same shape error in the other
|
|
// direction — an unresolvable endpoint drops the blobs — but it is a distinct
|
|
// bug with a distinct blast radius and wants its own change.
|
|
if manifestHoldDID == "" && manifest.HoldEndpoint != "" {
|
|
resolved, err := atproto.ResolveHoldDID(ctx, manifest.HoldEndpoint)
|
|
if err != nil {
|
|
gc.logger.Debug("Failed to resolve hold DID from legacy endpoint", "holdEndpoint", manifest.HoldEndpoint, "error", err)
|
|
return claimNotOurs
|
|
}
|
|
manifestHoldDID = resolved
|
|
if manifestHoldDID == holdDID {
|
|
return claimOurs
|
|
}
|
|
}
|
|
|
|
if manifestHoldDID == "" {
|
|
return claimNotOurs
|
|
}
|
|
|
|
// Check if the manifest's hold is a predecessor (has a successor label set)
|
|
return gc.classifyPredecessorHold(ctx, manifestHoldDID)
|
|
}
|
|
|
|
// classifyPredecessorHold reports whether holdDID is a predecessor of this hold,
|
|
// by fetching its captain record and checking for a successor label that names
|
|
// us. Definitive answers are cached to avoid repeated network calls; an
|
|
// unreachable hold is reported as claimUnknown and deliberately not cached as a
|
|
// negative, since caching it would make one five-second blip permanent for the
|
|
// life of the process.
|
|
//
|
|
// This reports what it found. It does not decide what an unknown claim earns —
|
|
// that policy lives at the call site in fetchUserManifestsFromEndpoint.
|
|
func (gc *GarbageCollector) classifyPredecessorHold(ctx context.Context, holdDID string) manifestClaim {
|
|
if gc.predecessorCache == nil {
|
|
gc.predecessorCache = make(map[string]bool)
|
|
}
|
|
|
|
if cachedPredecessor, cached := gc.predecessorCache[holdDID]; cached {
|
|
if cachedPredecessor {
|
|
return claimOurs
|
|
}
|
|
return claimNotOurs
|
|
}
|
|
|
|
// Already unreachable earlier in this run. Answer the same way without
|
|
// paying another timeout; the next run starts fresh and re-checks.
|
|
if gc.predecessorUnresolved[holdDID] {
|
|
return claimUnknown
|
|
}
|
|
|
|
isPredecessor, definitive := gc.checkPredecessor(ctx, holdDID)
|
|
if !definitive {
|
|
if gc.predecessorUnresolved == nil {
|
|
gc.predecessorUnresolved = make(map[string]bool)
|
|
}
|
|
gc.predecessorUnresolved[holdDID] = true
|
|
gc.logger.Warn("GC: predecessor status unresolved, keeping hold's blobs referenced but not adopting its manifests",
|
|
"holdDID", holdDID)
|
|
return claimUnknown
|
|
}
|
|
|
|
gc.predecessorCache[holdDID] = isPredecessor
|
|
if isPredecessor {
|
|
return claimOurs
|
|
}
|
|
return claimNotOurs
|
|
}
|
|
|
|
// checkPredecessor fetches a hold's captain record to check if it has a successor label
|
|
// (meaning the hold has been migrated/retired and its blobs are served by this hold).
|
|
//
|
|
// The second return value reports whether the answer is definitive. It is false whenever
|
|
// the hold could not be reached or its reply could not be understood: those are cases
|
|
// where the hold may well be a predecessor and we simply cannot tell. Callers must not
|
|
// read an inconclusive result as "not a predecessor" — a single five-second blip would
|
|
// otherwise drop a live predecessor's entire blob set out of the referenced set, and
|
|
// those blobs are long past the grace period that protects recent content.
|
|
//
|
|
// A non-200 is treated as inconclusive rather than a negative on the same reasoning: a
|
|
// reachable service that cannot produce its own captain record is malfunctioning, not
|
|
// answering. Over-protecting an unrelated hold merely leaves some blobs unreclaimed.
|
|
func (gc *GarbageCollector) checkPredecessor(ctx context.Context, holdDID string) (isPredecessor, definitive bool) {
|
|
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
holdURL, err := atproto.ResolveHoldURL(fetchCtx, holdDID)
|
|
if err != nil {
|
|
gc.logger.Debug("GC: failed to resolve predecessor hold URL",
|
|
"holdDID", holdDID, "error", err)
|
|
return false, false
|
|
}
|
|
|
|
return gc.checkPredecessorAt(fetchCtx, holdDID, holdURL)
|
|
}
|
|
|
|
// checkPredecessorAt is checkPredecessor with the hold's base URL already resolved,
|
|
// split out so the fetch-and-parse half can be exercised against a local server.
|
|
// It carries the same contract: the second return value is false whenever the answer
|
|
// is inconclusive rather than negative.
|
|
func (gc *GarbageCollector) checkPredecessorAt(ctx context.Context, holdDID, holdURL string) (isPredecessor, definitive bool) {
|
|
recordURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=self",
|
|
holdURL,
|
|
url.QueryEscape(holdDID),
|
|
url.QueryEscape(atproto.CaptainCollection),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", recordURL, nil)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
gc.logger.Debug("GC: failed to fetch predecessor captain record",
|
|
"holdDID", holdDID, "error", err)
|
|
return false, false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
gc.logger.Debug("GC: predecessor captain record fetch returned non-200",
|
|
"holdDID", holdDID, "status", resp.StatusCode)
|
|
return false, false
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
gc.logger.Debug("GC: failed to read predecessor captain record",
|
|
"holdDID", holdDID, "error", err)
|
|
return false, false
|
|
}
|
|
|
|
var envelope struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
gc.logger.Debug("GC: failed to parse predecessor captain envelope",
|
|
"holdDID", holdDID, "error", err)
|
|
return false, false
|
|
}
|
|
|
|
var captain atproto.CaptainRecord
|
|
if err := json.Unmarshal(envelope.Value, &captain); err != nil {
|
|
gc.logger.Debug("GC: failed to parse predecessor captain record",
|
|
"holdDID", holdDID, "error", err)
|
|
return false, false
|
|
}
|
|
|
|
// The hold answered and declares no successor. This is the one negative we
|
|
// are entitled to cache.
|
|
if captain.Successor == "" {
|
|
return false, true
|
|
}
|
|
|
|
// A successor label alone is not enough: it has to name us. A hold that
|
|
// retired into some third hold is that hold's predecessor, not ours, and its
|
|
// manifests are not a reason to keep blobs referenced here.
|
|
// scan_broadcaster.go makes exactly this comparison, and the two are meant
|
|
// to agree.
|
|
ourHoldDID := gc.ourHoldDID()
|
|
if ourHoldDID == "" {
|
|
// We cannot say who we are, so we cannot say the successor is not us.
|
|
// Keep the older, permissive answer: over-protecting leaks blobs,
|
|
// guessing the other way deletes them.
|
|
gc.logger.Warn("GC: own hold DID unknown, treating any successor label as pointing at us",
|
|
"holdDID", holdDID, "successor", captain.Successor)
|
|
return true, true
|
|
}
|
|
|
|
if captain.Successor != ourHoldDID {
|
|
gc.logger.Info("GC: hold has a successor, but it is not us",
|
|
"holdDID", holdDID, "successor", captain.Successor, "ourHoldDID", ourHoldDID)
|
|
return false, true
|
|
}
|
|
|
|
gc.logger.Info("GC: discovered predecessor hold (successor points at us)",
|
|
"holdDID", holdDID, "successor", captain.Successor)
|
|
return true, true
|
|
}
|
|
|
|
// ourHoldDID reports this hold's own DID, or "" when it cannot be determined.
|
|
func (gc *GarbageCollector) ourHoldDID() string {
|
|
if gc.pds == nil {
|
|
return ""
|
|
}
|
|
return gc.pds.DID()
|
|
}
|
|
|
|
// deleteOrphanedRecords removes layer records whose manifests no longer exist
|
|
func (gc *GarbageCollector) deleteOrphanedRecords(ctx context.Context, refs []orphanRef, result *GCResult) error {
|
|
// Manifests the hold currently holds layer records for. Built once, only if
|
|
// there is an aux record to delete, and used to confirm at delete time that
|
|
// the manifest has not come back since it was judged orphaned. Purely local
|
|
// — no PDS round trip — because a push writes layer records here.
|
|
var liveManifests map[string]bool
|
|
for _, ref := range refs {
|
|
if ref.Collection != "" && ref.Collection != atproto.LayerCollection {
|
|
var err error
|
|
if liveManifests, err = gc.manifestsWithLayerRecords(ctx); err != nil {
|
|
return fmt.Errorf("checking which manifests still have layer records: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
skipped := 0
|
|
for _, ref := range refs {
|
|
// An empty collection means the ref came from a preview taken before
|
|
// the sweep covered scan and image-config records; those were all
|
|
// layer records.
|
|
collection := ref.Collection
|
|
if collection == "" {
|
|
collection = atproto.LayerCollection
|
|
}
|
|
|
|
if collection == atproto.LayerCollection {
|
|
if err := gc.pds.DeleteLayerRecord(ctx, ref.Rkey); err != nil {
|
|
gc.logger.Error("Failed to delete layer record", "rkey", ref.Rkey, "error", err)
|
|
continue
|
|
}
|
|
} else {
|
|
// Confirm the slot still holds the revision we judged. Without this,
|
|
// a re-push between the scan and the delete would have us destroy a
|
|
// live record that merely inherited the same digest-derived rkey.
|
|
if !gc.auxRecordUnchanged(ctx, collection, ref.Rkey, ref.CID) {
|
|
gc.logger.Info("Skipping record: changed or gone since it was scanned",
|
|
"collection", collection, "rkey", ref.Rkey, "scannedCID", ref.CID)
|
|
skipped++
|
|
continue
|
|
}
|
|
// The manifest has layer records again, so it is back on this hold
|
|
// and the record is in use. Covers the case the CID check cannot: a
|
|
// re-push does not rewrite the scan record, so its CID still matches
|
|
// even though the image is live again.
|
|
if ref.ManifestURI != "" && liveManifests[ref.ManifestURI] {
|
|
gc.logger.Info("Skipping record: manifest has layer records again",
|
|
"collection", collection, "rkey", ref.Rkey, "manifest", ref.ManifestURI)
|
|
skipped++
|
|
continue
|
|
}
|
|
deleted, err := gc.pds.DeleteManifestAuxRecord(ctx, collection, ref.Rkey)
|
|
if err != nil {
|
|
gc.logger.Error("Failed to delete record",
|
|
"collection", collection, "rkey", ref.Rkey, "error", err)
|
|
continue
|
|
}
|
|
if !deleted {
|
|
// Already gone (e.g. a concurrent purge) — not an error, but
|
|
// don't count it as work this run did.
|
|
continue
|
|
}
|
|
}
|
|
|
|
result.RecordsDeleted++
|
|
gc.logger.Debug("Deleted orphaned record", "collection", collection, "rkey", ref.Rkey)
|
|
}
|
|
|
|
gc.logger.Info("Phase 2 complete",
|
|
"orphaned", len(refs),
|
|
"deleted", result.RecordsDeleted,
|
|
"skippedChangedSinceScan", skipped)
|
|
|
|
return nil
|
|
}
|
|
|
|
// deleteOrphanedBlobs walks storage and deletes blobs not in the referenced set
|
|
func (gc *GarbageCollector) deleteOrphanedBlobs(ctx context.Context, referenced map[string]bool, result *GCResult) error {
|
|
blobsPath := "/docker/registry/v2/blobs"
|
|
|
|
err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64, lastModified time.Time) error {
|
|
// Only process data files
|
|
if !strings.HasSuffix(key, "/data") {
|
|
return nil
|
|
}
|
|
|
|
// Extract digest from path
|
|
digest := extractDigestFromPath(key)
|
|
if digest == "" {
|
|
return nil
|
|
}
|
|
|
|
// Check if referenced by any layer record
|
|
if referenced[digest] {
|
|
return nil
|
|
}
|
|
|
|
// Unreferenced, but young enough that we'd rather keep paying for it
|
|
// than risk deleting content still being pushed. Records for this
|
|
// blob may already be gone; blob pruning runs on its own clock.
|
|
if !blobPastGrace(lastModified) {
|
|
return nil
|
|
}
|
|
|
|
result.OrphanedBlobs++
|
|
|
|
if err := gc.s3.Delete(ctx, key); err != nil {
|
|
gc.logger.Error("Failed to delete blob", "path", key, "error", err)
|
|
return nil // Continue with other blobs
|
|
}
|
|
result.BlobsDeleted++
|
|
result.BytesReclaimed += size
|
|
gc.logger.Debug("Deleted orphaned blob",
|
|
"digest", digest,
|
|
"size", size)
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("walk storage failed: %w", err)
|
|
}
|
|
|
|
gc.logger.Info("Phase 3 complete",
|
|
"orphanedBlobs", result.OrphanedBlobs,
|
|
"deleted", result.BlobsDeleted,
|
|
"reclaimed", result.BytesReclaimed)
|
|
|
|
return nil
|
|
}
|
|
|
|
// decodeLayerRecord reads and decodes a layer record from the PDS
|
|
func (gc *GarbageCollector) decodeLayerRecord(ctx context.Context, rec pds.Record) (*atproto.LayerRecord, error) {
|
|
// Get the record from the repo
|
|
recordPath := rec.Collection + "/" + rec.Rkey
|
|
_, recBytes, err := gc.pds.GetRecordBytes(ctx, recordPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get record bytes: %w", err)
|
|
}
|
|
|
|
// Decode the layer record
|
|
var layer atproto.LayerRecord
|
|
if err := layer.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
|
return nil, fmt.Errorf("unmarshal CBOR: %w", err)
|
|
}
|
|
|
|
return &layer, nil
|
|
}
|
|
|
|
// atURIParts contains parsed components of an AT-URI
|
|
type atURIParts struct {
|
|
DID string
|
|
Collection string
|
|
Rkey string
|
|
}
|
|
|
|
// parseATURI parses an AT-URI into its components
|
|
// Format: at://did:plc:xxx/collection/rkey
|
|
func parseATURI(uri string) *atURIParts {
|
|
if !strings.HasPrefix(uri, "at://") {
|
|
return nil
|
|
}
|
|
|
|
// Remove at:// prefix
|
|
path := strings.TrimPrefix(uri, "at://")
|
|
|
|
// Split by /
|
|
parts := strings.SplitN(path, "/", 3)
|
|
if len(parts) != 3 {
|
|
return nil
|
|
}
|
|
|
|
return &atURIParts{
|
|
DID: parts[0],
|
|
Collection: parts[1],
|
|
Rkey: parts[2],
|
|
}
|
|
}
|
|
|
|
// tidToTime extracts the timestamp from a TID (Timestamp ID)
|
|
// TIDs are 13-character base32 encoded timestamps with counter
|
|
func tidToTime(tid string) time.Time {
|
|
// TIDs are base32-sortable timestamps
|
|
// Use indigo's syntax package for proper parsing
|
|
t, err := syntax.ParseTID(tid)
|
|
if err != nil {
|
|
// Return zero time - will be older than grace period
|
|
return time.Time{}
|
|
}
|
|
return t.Time()
|
|
}
|
|
|
|
// extractDigestFromPath extracts a digest from a storage path
|
|
// Path format: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data
|
|
// Returns: {algorithm}:{hash}
|
|
func extractDigestFromPath(path string) string {
|
|
// Match pattern: /blobs/{alg}/{xx}/{hash}/data
|
|
re := regexp.MustCompile(`/blobs/([^/]+)/[^/]+/([^/]+)/data$`)
|
|
matches := re.FindStringSubmatch(path)
|
|
if len(matches) != 3 {
|
|
return ""
|
|
}
|
|
return matches[1] + ":" + matches[2]
|
|
}
|
|
|
|
// logResult logs the GC result in a structured format
|
|
func (gc *GarbageCollector) logResult(result *GCResult) {
|
|
gc.logger.Info("GC run complete",
|
|
"duration", result.Duration,
|
|
"referencedBlobs", result.ReferencedBlobs,
|
|
"orphanedRecords", result.OrphanedRecords,
|
|
"recordsDeleted", result.RecordsDeleted,
|
|
"recordsReconciled", result.RecordsReconciled,
|
|
"manifestsChecked", result.ManifestsChecked,
|
|
"usersChecked", result.UsersChecked,
|
|
"orphanedBlobs", result.OrphanedBlobs,
|
|
"blobsDeleted", result.BlobsDeleted,
|
|
"bytesReclaimed", result.BytesReclaimed)
|
|
|
|
// Also log as JSON for easier parsing
|
|
resultJSON, _ := json.Marshal(result)
|
|
gc.logger.Debug("GC result JSON", "result", string(resultJSON))
|
|
}
|
|
|
|
// LastPreview returns the most recent preview result and when it was generated
|
|
func (gc *GarbageCollector) LastPreview() (*GCPreview, time.Time) {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
return gc.lastPreview, gc.lastPreviewAt
|
|
}
|
|
|
|
// LastResult returns the most recent GC run result and when it was generated
|
|
func (gc *GarbageCollector) LastResult() (*GCResult, time.Time) {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
return gc.lastResult, gc.lastResultAt
|
|
}
|
|
|
|
// LastBackfillPreview returns the most recent image-config backfill preview
|
|
// and when it was generated.
|
|
func (gc *GarbageCollector) LastBackfillPreview() (*BackfillConfigsPreview, time.Time) {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
return gc.lastBackfillPreview, gc.lastBackfillPreviewAt
|
|
}
|
|
|
|
// IsRunning returns whether a GC operation is currently in progress
|
|
func (gc *GarbageCollector) IsRunning() bool {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
return gc.running
|
|
}
|
|
|
|
// GetConfig returns the current GC configuration
|
|
func (gc *GarbageCollector) GetConfig() Config {
|
|
return gc.cfg
|
|
}
|