// Package gc räumt verwaiste Blobs auf: Dateien im Speicher, auf die keine // Datenbankzeile mehr zeigt. // // Solche Waisen entstehen, wenn ein Vorgang zwischen Blob-Schreiben und // Datenbank-Commit abbricht – etwa weil der Prozess stirbt oder ein Löschen // nur halb durchläuft. Ohne Aufräumen belegen sie für immer Platz, ohne dass // sie irgendwo auftauchen. // // Der Ablauf ist bewusst defensiv: Es wird nur gelöscht, was die Datenbank // nicht kennt UND älter als eine Schonfrist ist. Der Sinn der Schonfrist: // Zwischen `storage.Put` und dem `INSERT` liegt ein kurzer Moment, in dem ein // völlig gesunder Upload wie eine Waise aussieht. package gc import ( "context" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" "aether/internal/storage" "aether/pkg/bg" ) const ( // gracePeriod schützt frische Blobs, deren Datenbankzeile noch unterwegs // ist. Großzügig bemessen – ein Tag zu spät aufgeräumt kostet nichts, einen // laufenden Upload zu löschen dagegen sehr wohl. gracePeriod = 24 * time.Hour // Interval zwischen zwei Durchläufen. Interval = 12 * time.Hour ) type Collector struct { db *pgxpool.Pool storage storage.Storage } func New(db *pgxpool.Pool, st storage.Storage) *Collector { return &Collector{db: db, storage: st} } // Result fasst einen Durchlauf zusammen. type Result struct { Scanned int Orphans int Deleted int FreedBytes int64 SkippedYoung int } // Run führt einen Durchlauf aus. func (c *Collector) Run(ctx context.Context) (Result, error) { var res Result referenced, err := c.referencedKeys(ctx) if err != nil { return res, err } cutoff := time.Now().Add(-gracePeriod) type orphan struct { key string size int64 } var orphans []orphan // "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort // aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads. // "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die // als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und // die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so // sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste, // bleiben sie für immer liegen. for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} { err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { res.Scanned++ if referenced[key] { return nil } if info.ModTime.After(cutoff) { res.SkippedYoung++ return nil } orphans = append(orphans, orphan{key: key, size: info.Size}) return nil }) if err != nil { return res, fmt.Errorf("walk %q: %w", prefix, err) } } res.Orphans = len(orphans) if shouldAbort(len(referenced), res.Scanned) { log.Ctx(ctx).Error(). Int("scanned", res.Scanned). Msg("gc: database reports no referenced blobs while storage is not empty – skipping run") return res, nil } for _, o := range orphans { if err := c.storage.Delete(ctx, o.key); err != nil { log.Ctx(ctx).Warn().Err(err).Str("key", o.key).Msg("gc: could not delete orphan") continue } res.Deleted++ res.FreedBytes += o.size log.Ctx(ctx).Info(). Str("key", o.key).Int64("bytes", o.size). Msg("gc: orphaned blob removed") } return res, nil } // shouldAbort ist die Notbremse vor dem Löschen: Kennt die Datenbank keinen // einzigen Blob, obwohl im Speicher welche liegen, ist mit hoher // Wahrscheinlichkeit die Abfrage kaputt oder die Verbindung auf die falsche // Datenbank gerichtet. Das Ergebnis wäre "alles ist verwaist" – der GC würde // den kompletten Bestand löschen. Dann lieber gar nichts tun und laut sein. // // Die leere Installation (nichts in der Datenbank, nichts auf der Platte) ist // davon ausgenommen: da gibt es schlicht nichts zu verwechseln. func shouldAbort(referencedCount, scanned int) bool { return referencedCount == 0 && scanned > 0 } // referencedKeys sammelt jeden Speicherschlüssel, auf den die Datenbank zeigt – // aktive Dateien, Papierkorb und Versionen gleichermaßen. Thumbnails haben // keine eigene Spalte, ihr Schlüssel leitet sich aus der Datei-ID ab. func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) { keys := make(map[string]bool) // files.storage_key deckt aktive UND gelöschte Dateien ab: beim Löschen // wandert der Schlüssel auf den Papierkorb-Pfad mit. rows, err := c.db.Query(ctx, `SELECT storage_key, id, has_thumbnail FROM files`) if err != nil { return nil, fmt.Errorf("query file keys: %w", err) } for rows.Next() { var key, id string var hasThumb bool if err := rows.Scan(&key, &id, &hasThumb); err != nil { rows.Close() return nil, fmt.Errorf("scan file key: %w", err) } keys[strings.TrimPrefix(key, "/")] = true if hasThumb { keys["thumbs/"+id+".jpg"] = true } } rows.Close() if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate file keys: %w", err) } // Logo und App-Symbol haben keine Zeile in files, sondern stehen in den // Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald // "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf. // // Das Logo ist ein einzelner Schlüssel, das App-Symbol ein Verzeichnis: // Endet der Wert auf "/", zählt alles darunter als referenziert. brows, err := c.db.Query(ctx, `SELECT COALESCE(value, '') FROM settings WHERE key IN ('logo_storage_key', 'app_icon_storage_key')`) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("query branding keys: %w", err) } if err == nil { for brows.Next() { var key string if err := brows.Scan(&key); err != nil { brows.Close() return nil, fmt.Errorf("scan branding key: %w", err) } if key == "" { continue } key = strings.TrimPrefix(key, "/") if !strings.HasSuffix(key, "/") { keys[key] = true continue } if err := c.storage.Walk(ctx, key, func(k string, _ storage.FileInfo) error { keys[k] = true return nil }); err != nil { brows.Close() return nil, fmt.Errorf("walk branding dir %s: %w", key, err) } } brows.Close() if err := brows.Err(); err != nil { return nil, fmt.Errorf("iterate branding keys: %w", err) } } // Profilbilder stehen in der Nutzerzeile, nicht in files. Ohne diese // Abfrage hielte der GC sie für verwaist, sobald "avatars" mitläuft. arows, err := c.db.Query(ctx, `SELECT avatar_key FROM users WHERE avatar_key IS NOT NULL AND avatar_key <> ''`) if err != nil { return nil, fmt.Errorf("query avatar keys: %w", err) } for arows.Next() { var key string if err := arows.Scan(&key); err != nil { arows.Close() return nil, fmt.Errorf("scan avatar key: %w", err) } keys[strings.TrimPrefix(key, "/")] = true } arows.Close() if err := arows.Err(); err != nil { return nil, fmt.Errorf("iterate avatar keys: %w", err) } vrows, err := c.db.Query(ctx, `SELECT storage_key FROM file_versions`) if err != nil { return nil, fmt.Errorf("query version keys: %w", err) } defer vrows.Close() for vrows.Next() { var key string if err := vrows.Scan(&key); err != nil { return nil, fmt.Errorf("scan version key: %w", err) } keys[strings.TrimPrefix(key, "/")] = true } return keys, vrows.Err() } // Start lässt den GC im Hintergrund laufen: einmal kurz nach dem Start, danach // im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem // Hochfahren konkurriert. func (c *Collector) Start(ctx context.Context, onDone func(Result)) { // Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein // Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll. bg.Go("Speicherbereinigung", func() { timer := time.NewTimer(5 * time.Minute) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: } res, err := c.Run(ctx) if err != nil { log.Ctx(ctx).Error().Err(err).Msg("gc: run failed") } else { log.Ctx(ctx).Info(). Int("scanned", res.Scanned). Int("orphans", res.Orphans). Int("deleted", res.Deleted). Int64("freed_bytes", res.FreedBytes). Int("skipped_young", res.SkippedYoung). Msg("gc: run complete") if onDone != nil && res.Deleted > 0 { onDone(res) } } timer.Reset(Interval) } }) } // DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen. // // Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente // zählen nicht gegen das Kontingent eines Kontos – niemand hat sie // hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem, // und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf. // // Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn // irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine // Größe, über die man entscheiden kann. type DerivedUsage struct { Prefix string `json:"prefix"` Files int `json:"files"` Bytes int64 `json:"bytes"` } // Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf // löscht nichts. func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) { out := make([]DerivedUsage, 0, 3) for _, prefix := range []string{"thumbs", "previews", "avatars"} { u := DerivedUsage{Prefix: prefix} err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error { u.Files++ u.Bytes += info.Size return nil }) if err != nil { return nil, fmt.Errorf("walk %s: %w", prefix, err) } out = append(out, u) } return out, nil }