Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
// 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"
|
||
)
|
||
|
||
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"} {
|
||
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)
|
||
}
|
||
}
|
||
|
||
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)) {
|
||
go 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)
|
||
}
|
||
}()
|
||
}
|