Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche. `EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit in der Tabelle und wurde nie abgerufen. Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus. Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271) samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien unter previews/ sammelt der GC ein. Zwei Fehler, die dabei sichtbar wurden: fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte, den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste Schritte verschluckt genau diese popstate. fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin; das war die Asymmetrie. Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die Ankündigung nennt 5) und die Android-App als Idee. Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
9.5 KiB
Go
309 lines
9.5 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"
|
||
"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
|
||
}
|