Files
Aether/backend/internal/gc/gc.go
T
marcandClaude Opus 5 c26bcba012 fix(bg): Hintergrundarbeit reisst den Prozess nicht mehr mit
Das Vorschaubild entstand in einer nackten Goroutine. Chis Recoverer haengt
am Anfragebehandler und sieht davon nichts -- eine Panik im Bilddekodierer
nahm den ganzen Go-Prozess mit, ausgeloest durch einen gewoehnlichen
Upload. Die Bibliothek ist seit ef60497 aktuell; das schloss **diese**
Luecke. Dies hier schliesst die **Gattung**.

pkg/bg mit zwei Funktionen, und der Unterschied ist keine Feinheit:

* bg.Go(name, fn)  -- Goroutine, die eine Panik ueberlebt
* bg.Run(name, fn) -- dasselbe ohne eigene Goroutine, fuer Schleifen

Faengt man eine Panik um den **ganzen** Arbeiter ab, ueberlebt der Prozess
-- aber der Arbeiter ist tot, und von da an wird still keine Datei mehr
erkannt. Der OCR-Arbeiter sichert deshalb jeden **Auftrag** einzeln ab.

Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zaehler,
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit
Begruendung im Code -- die Signalbehandlung; ein recover braechte dort
nichts, weil main ohnehin wartete.

Aufgefangene Paniken werden mit Stapel und Herkunft protokolliert. Vier
Tests, darunter der wichtige: Eine Schleife laeuft nach einer Panik im
dritten Durchgang weiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 18:29:02 +02:00

325 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
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)
}
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
prevRows, err := c.db.Query(ctx,
`SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("query preview keys: %w", err)
}
for prevRows.Next() {
var id string
if err := prevRows.Scan(&id); err != nil {
prevRows.Close()
return nil, fmt.Errorf("scan preview key: %w", err)
}
keys["previews/"+id+".pdf"] = true
}
prevRows.Close()
if err := prevRows.Err(); err != nil {
return nil, fmt.Errorf("iterate preview 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
}