// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung, // die der GC nicht abdeckt. // // Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr // gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für // Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer // Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar // – und der GC hält sie nach 24 Stunden für Reste. // // Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf, // der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen. // Hier geht es um Dateien. package rescan import ( "context" "errors" "fmt" "path" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog/log" "aether/internal/files" "aether/internal/storage" ) // Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die // Antwort nicht ins Uferlose wächst. const ( maxSamples = 200 maxDuration = 10 * time.Minute ) type Kind string const ( // KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank. // Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen // Verzeichnis sie liegt. KindNew Kind = "new" // KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt // Vorschaubild und früheren Fassungen. KindOrphaned Kind = "orphaned" // KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei // von außen ersetzt. Apply übernimmt Größe und Prüfsumme. KindChanged Kind = "changed" ) type Entry struct { Kind Kind `json:"kind"` StorageKey string `json:"storage_key"` // Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte. Path string `json:"path"` Owner string `json:"owner"` Size int64 `json:"size"` Reason string `json:"reason,omitempty"` } type Report struct { New int `json:"new"` Orphaned int `json:"orphaned"` Changed int `json:"changed"` Scanned int `json:"scanned"` Samples []Entry `json:"samples"` Aborted bool `json:"aborted,omitempty"` // AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true" // ohne Grund lässt genau die Frage offen, die man dann hat. AbortReason string `json:"abort_reason,omitempty"` // Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt // sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen // Knopf anbieten, der nichts bewirkt. Overridable bool `json:"overridable,omitempty"` // Nur bei Apply gefüllt. Adopted int `json:"adopted,omitempty"` Removed int `json:"removed,omitempty"` Corrected int `json:"corrected,omitempty"` Errors int `json:"errors,omitempty"` } type Scanner struct { db *pgxpool.Pool store *files.Store storage storage.Storage } func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner { return &Scanner{db: db, store: store, storage: st} } // known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über // den Ablagepfad auffindbar. type known struct { id uuid.UUID size int64 deleted bool seen bool } func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) { rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`) if err != nil { return nil, fmt.Errorf("read known files: %w", err) } defer rows.Close() out := make(map[string]*known, 1024) for rows.Next() { var k known var key string if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil { return nil, fmt.Errorf("scan file row: %w", err) } row := k out[key] = &row } return out, rows.Err() } // splitKey liest aus einem Ablagepfad, wohin die Datei gehört: // // users/{name}/home/{ordner...}/{datei} // groups/{id}/home/{ordner...}/{datei} // // Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp) // ist kein Nutzerinhalt und wird nicht angefasst. func splitKey(key string) (root, owner, folderPath, name string, ok bool) { parts := strings.Split(key, "/") if len(parts) < 4 { return "", "", "", "", false } if parts[0] != "users" && parts[0] != "groups" { return "", "", "", "", false } if parts[2] != "home" { return "", "", "", "", false // .trash und .versions gehören dem System } name = parts[len(parts)-1] folderPath = strings.Join(parts[3:len(parts)-1], "/") return parts[0], parts[1], folderPath, name, name != "" } // Check liest nur und ändert nichts – jederzeit gefahrlos. func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) } // Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste // Einträge. // trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse. func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) { return s.run(ctx, true, trotzdem) } func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) { ctx, cancel := context.WithTimeout(ctx, maxDuration) defer cancel() known, err := s.knownFiles(ctx) if err != nil { return nil, err } // Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null, // und kein Aufrufer braucht dafür einen Sonderfall. rep := &Report{Samples: []Entry{}} sample := func(e Entry) { if len(rep.Samples) < maxSamples { rep.Samples = append(rep.Samples, e) } } for _, prefix := range []string{"users/", "groups/"} { err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { if ctx.Err() != nil { rep.Aborted = true return ctx.Err() } root, owner, folder, name, ok := splitKey(key) if !ok { return nil } rep.Scanned++ if row, exists := known[key]; exists { row.seen = true if row.deleted { return nil // liegt im Papierkorb – kein Widerspruch } if row.size != info.Size { rep.Changed++ e := Entry{Kind: KindChanged, StorageKey: key, Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size} if apply { if err := s.correct(ctx, row.id, key); err != nil { e.Reason = err.Error() rep.Errors++ } else { rep.Corrected++ } } sample(e) } return nil } rep.New++ e := Entry{Kind: KindNew, StorageKey: key, Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size} if apply { if err := s.adopt(ctx, root, owner, folder, name, key); err != nil { e.Reason = err.Error() rep.Errors++ } else { rep.Adopted++ } } sample(e) return nil }) if err != nil && !errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("walk %s: %w", prefix, err) } } // Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen // außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie // pauschal für verwaist zu erklären. // **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in // der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die // Hälfte schon weg ist, und eine Bremse käme zu spät. type waise struct { key string id uuid.UUID } var waisen []waise var lebende int for key, row := range known { if !row.deleted { lebende++ } if row.seen || row.deleted { continue } if _, _, _, _, ok := splitKey(key); !ok { if _, err := s.storage.Stat(ctx, key); err == nil { continue } } waisen = append(waisen, waise{key: key, id: row.id}) } rep.Orphaned = len(waisen) if apply { if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" { rep.Aborted = true rep.AbortReason = grund rep.Overridable = uebersteuerbar log.Ctx(ctx).Error(). Int("scanned", rep.Scanned). Int("lebende_eintraege", lebende). Int("wuerden_fallen", len(waisen)). Str("grund", grund). Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus") // Die Waisen trotzdem in die Stichprobe, damit man im Bericht // sieht, worum es gegangen wäre. for _, o := range waisen { sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}) } return rep, nil } } for _, o := range waisen { e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key} if apply { if err := s.remove(ctx, o.id); err != nil { e.Reason = err.Error() rep.Errors++ } else { rep.Removed++ } } sample(e) } return rep, nil } // ── Die Bremsen ────────────────────────────────────────────────────────────── // mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr // nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist // großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner // entfernt hat, bestätigt einmal und kommt durch. const mengenAnteil = 2 // pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe- // wert heißt: ja. // // **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen // Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben // denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC // löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt // `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die // Datei gab. Die gefährlichere Seite war die ungesicherte. // // Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht // vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau // der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam, // oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede // Datei als verwaist. func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) { // **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen, // obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der // verschwunden ist, das ist ein Speicher, der nicht da war. if gescannt == 0 && lebende > 0 { return "auf der Platte wurde keine einzige Datei gefunden, " + "obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " + "nicht eingehaengt. Es wurde nichts geloescht.", false } // **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die // Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor. if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende { return fmt.Sprintf( "%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+ "Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.", wuerdenFallen, lebende), true } return "", false }