// Package retention обслуживает чистку данных инстанса (AGENT.md 6.4, Фаза 5): // истёкшие сессии, старые записи аудита и файлы без ссылок. Запускается // периодически вместе с обслуживанием базы и доступен командой `glchat cleanup`. package retention import ( "context" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "time" "glchat/internal/store" ) // Options — настройки чистки. type Options struct { // DataDir — каталог данных: файлы лежат в его подкаталоге `files`. DataDir string // AuditDays — сколько дней хранить журнал аудита (0 — хранить всегда). AuditDays int // OrphanHours — сколько часов файл может жить без ссылок. OrphanHours int // BatchLimit — сколько файлов удалять за один проход. BatchLimit int // Logger — журнал: чистка пишет итоги и предупреждения. Logger *slog.Logger } // Stats — что сделала одна чистка. type Stats struct { Sessions int64 Audit int64 Files int64 // Unknown — файлы на диске, у которых не осталось записи в базе. Unknown int64 // Bytes — сколько освободили на диске. Bytes int64 // PushSubscriptions — мёртвые подписки Web Push (Фаза 7). PushSubscriptions int64 // LinkPreviews — истёкшие записи кэша превью ссылок (Фаза 7). LinkPreviews int64 } // Мёртвые подписки Web Push и кэш превью ссылок (Фаза 7): подписка, которая // не доставлялась неделю и накопила неудачи, и протухшее превью не нужны. const ( stalePushAfterDays = 7 stalePushMaxFailures = 5 ) // Runner выполняет чистку по расписанию. type Runner struct { store *store.Store options Options now func() time.Time } // New создаёт обслуживание чистки. Логгер по умолчанию — тихий. func New(st *store.Store, options Options) *Runner { if options.Logger == nil { options.Logger = slog.New(slog.DiscardHandler) } if options.BatchLimit <= 0 { options.BatchLimit = 200 } return &Runner{store: st, options: options, now: func() time.Time { return time.Now().UTC() }} } // RunOnce выполняет один проход чистки и возвращает статистику. func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { var stats Stats var errs []error // 1. Истёкшие сессии: вход по ним всё равно невозможен. deleted, err := r.store.DeleteSessionsBefore(ctx, r.now()) if err != nil { errs = append(errs, fmt.Errorf("sessions: %w", err)) } stats.Sessions = deleted // 2. Журнал аудита: хранится ограниченное время (настраивается). if r.options.AuditDays > 0 { cutoff := r.now().AddDate(0, 0, -r.options.AuditDays) deleted, err := r.store.DeleteAuditBefore(ctx, cutoff) if err != nil { errs = append(errs, fmt.Errorf("audit: %w", err)) } stats.Audit = deleted } // 3. Файлы без ссылок: сначала с диска, потом из базы — иначе при сбое // между шагами файл остался бы без записи и не чистился бы никогда. if r.options.OrphanHours > 0 { cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour) orphans, err := r.store.ListOrphanFiles(ctx, cutoff, r.options.BatchLimit) if err != nil { errs = append(errs, fmt.Errorf("orphan files: %w", err)) } for i := range orphans { file := orphans[i] size, err := r.removeFile(file) if err != nil { errs = append(errs, fmt.Errorf("remove file %d: %w", file.ID, err)) continue } if err := r.store.DeleteFileRecord(ctx, file.ID); err != nil { errs = append(errs, fmt.Errorf("delete file record %d: %w", file.ID, err)) continue } stats.Files++ stats.Bytes += size } } // 4. Файлы на диске без записи в базе: так остаются вложения удалённых // серверов — запись уходит каскадом, а содержимое лежит мусором. if r.options.OrphanHours > 0 { cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour) removed, bytes, err := r.pruneUnknownFiles(ctx, cutoff) if err != nil { errs = append(errs, fmt.Errorf("unknown files: %w", err)) } stats.Unknown = removed stats.Bytes += bytes } // 5. Мёртвые подписки Web Push: браузер мог отозвать подписку, а // push-сервис — перестать отвечать. Живые подписки не трогаем. cutoff := r.now().AddDate(0, 0, -stalePushAfterDays) removed, err := r.store.DeleteStalePushSubscriptions(ctx, cutoff, stalePushMaxFailures) if err != nil { errs = append(errs, fmt.Errorf("push subscriptions: %w", err)) } stats.PushSubscriptions = removed // 6. Истёкший кэш превью ссылок: перезапрашивать его незачем. previews, err := r.store.DeleteExpiredLinkPreviews(ctx, r.now()) if err != nil { errs = append(errs, fmt.Errorf("link previews: %w", err)) } stats.LinkPreviews = previews stats2 := stats if len(errs) > 0 { return stats2, errors.Join(errs...) } return stats2, nil } // pruneUnknownFiles удаляет с диска файлы, которых нет в базе, если они старше // cutoff. Возвращает число файлов и освобождённые байты. func (r *Runner) pruneUnknownFiles(ctx context.Context, cutoff time.Time) (int64, int64, error) { known, err := r.store.FileStoragePaths(ctx) if err != nil { return 0, 0, err } // Каталог файлов: пути в базе абсолютные, как их пишет загрузка. root := filepath.Join(r.options.DataDir, "files") // os.Root ограничивает работу каталогом файлов: подменённая символическая // ссылка не выведет удаление за его пределы (AGENT.md 9.2). dir, err := os.OpenRoot(root) if err != nil { if errors.Is(err, fs.ErrNotExist) { return 0, 0, nil } return 0, 0, err } defer func() { _ = dir.Close() }() var ( removed int64 bytes int64 errs []error ) walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() || known[path] { return nil } info, err := entry.Info() if err != nil { errs = append(errs, err) return nil } if info.ModTime().After(cutoff) { // Свежий файл мог ещё не получить запись: не трогаем. return nil } relative, err := filepath.Rel(root, path) if err != nil { errs = append(errs, err) return nil } if err := dir.Remove(relative); err != nil && !errors.Is(err, fs.ErrNotExist) { errs = append(errs, err) return nil } removed++ bytes += info.Size() return nil }) if walkErr != nil { errs = append(errs, walkErr) } // Пустые каталоги после уборки не нужны: их создаёт раскладка по префиксам. if removed > 0 { pruneEmptyDirs(dir, root) } return removed, bytes, errors.Join(errs...) } // pruneEmptyDirs удаляет пустые подкаталоги, начиная с самых глубоких. // Ошибки игнорируются: каталог мог исчезнуть или быть непустым — это норма. func pruneEmptyDirs(dir *os.Root, root string) { dirs := make([]string, 0, 16) _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { if err == nil && entry.IsDir() && path != root { dirs = append(dirs, path) } return nil }) for i := len(dirs) - 1; i >= 0; i-- { relative, err := filepath.Rel(root, dirs[i]) if err != nil { continue } _ = dir.Remove(relative) } } // Run запускает чистку по расписанию; возвращает функцию остановки. func (r *Runner) Run(ctx context.Context, interval time.Duration) func() { if interval <= 0 { interval = time.Hour } ctx, cancel := context.WithCancel(ctx) done := make(chan struct{}) go func() { defer close(done) ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: stats, err := r.RunOnce(ctx) if err != nil { r.options.Logger.WarnContext(ctx, "retention failed", slog.Any("error", err)) continue } if stats != (Stats{}) { r.options.Logger.InfoContext(ctx, "retention done", slog.Int64("sessions", stats.Sessions), slog.Int64("audit", stats.Audit), slog.Int64("files", stats.Files), slog.Int64("unknown_files", stats.Unknown), slog.Int64("push_subscriptions", stats.PushSubscriptions), slog.Int64("link_previews", stats.LinkPreviews), slog.Int64("bytes", stats.Bytes)) } } } }() return func() { cancel() <-done } } // removeFile удаляет содержимое файла и возвращает его размер. Отсутствие файла // на диске не считается ошибкой: запись всё равно нужно убрать. func (r *Runner) removeFile(file store.File) (int64, error) { path, err := r.storagePath(file.StoragePath) if err != nil { return 0, err } info, err := os.Stat(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return 0, nil } return 0, err } if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { return 0, err } return info.Size(), nil } // storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2). // Файлы на диске лежат плоско в `/files`, поэтому проверка сводится // к сравнению с этим каталогом. func (r *Runner) storagePath(stored string) (string, error) { root := filepath.Join(r.options.DataDir, "files") clean := filepath.Clean(stored) relative, err := filepath.Rel(root, clean) if err != nil { return "", err } if strings.HasPrefix(relative, "..") || filepath.IsAbs(relative) { return "", fmt.Errorf("path %q is outside %q", clean, root) } return clean, nil }