package files import ( "context" "fmt" "time" "go.uber.org/zap" "telesrv/internal/domain" ) // mediaRetentionStore is implemented by store.MediaStore backends that // support the storage retention sweep (currently only the Postgres store). // A type assertion, not a MediaStore interface method, keeps these // admin/maintenance-only queries out of the hot RPC-facing interface -- // same convention as photoBatchStore above. type mediaRetentionStore interface { ListOrphanedDocumentIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) ListOrphanedPhotoIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) CountFileBlobRefs(ctx context.Context, backend, objectKey string) (int, error) DeleteDocumentAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error) DeletePhotoAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error) // OrphanDocumentIfUnreferenced is the immediate (no grace period) // counterpart to the age-based sweep above -- see its doc comment. OrphanDocumentIfUnreferenced(ctx context.Context, id int64) (bool, error) } // DeleteOrphanedOlderThan implements maintenance.OrphanedMediaRetentionStore: // permanently deletes documents/photos that have had no live reference // (message/profile-photo/sticker-set, see media_references) since at least // cutoff, along with their blob(s) -- but only physically removes bytes // from the backend once confirming no other file_blobs row still needs the // object, since content-addressed storage means the same bytes can be // shared across documents/photos. func (s *Service) DeleteOrphanedOlderThan(ctx context.Context, cutoff time.Time, limit int) (int, error) { store, ok := s.media.(mediaRetentionStore) if !ok || limit <= 0 { return 0, nil } deleted := 0 docIDs, err := store.ListOrphanedDocumentIDsOlderThan(ctx, cutoff, limit) if err != nil { return deleted, fmt.Errorf("list orphaned documents: %w", err) } for _, id := range docIDs { blobs, err := store.DeleteDocumentAndBlobs(ctx, id) if err != nil { s.log.Warn("delete orphaned document failed", zap.Int64("document_id", id), zap.Error(err)) continue } s.deleteOrphanedBlobs(ctx, store, blobs) deleted++ } photoIDs, err := store.ListOrphanedPhotoIDsOlderThan(ctx, cutoff, limit) if err != nil { return deleted, fmt.Errorf("list orphaned photos: %w", err) } for _, id := range photoIDs { blobs, err := store.DeletePhotoAndBlobs(ctx, id) if err != nil { s.log.Warn("delete orphaned photo failed", zap.Int64("photo_id", id), zap.Error(err)) continue } s.deleteOrphanedBlobs(ctx, store, blobs) deleted++ } return deleted, nil } // deleteDocumentNowIfUnreferenced is the immediate counterpart to the // age-based sweep DeleteOrphanedOlderThan runs in the background: orphans // id right now (skipping the grace period) and, only if that succeeds -- // i.e. nothing else currently references it -- physically deletes it and // its blobs immediately. Returns whether it was actually deleted; false // (with no error) means something still references the document, so it and // its blob(s) were deliberately left alone. func (s *Service) deleteDocumentNowIfUnreferenced(ctx context.Context, id int64) (bool, error) { store, ok := s.media.(mediaRetentionStore) if !ok { return false, nil } orphaned, err := store.OrphanDocumentIfUnreferenced(ctx, id) if err != nil { return false, fmt.Errorf("orphan document: %w", err) } if !orphaned { return false, nil } blobs, err := store.DeleteDocumentAndBlobs(ctx, id) if err != nil { return false, fmt.Errorf("delete document: %w", err) } s.deleteOrphanedBlobs(ctx, store, blobs) return true, nil } // deleteOrphanedBlobs removes each blob from its backend once confirming // (via CountFileBlobRefs) no other file_blobs row still references // (backend, object_key). Resolves the correct backend per blob via // backendFor (not just the currently active one) -- a blob written before a // TELESRV_BLOB_BACKEND switch still needs deleting from wherever it // actually lives. If that backend is no longer configured (its credentials // were removed after switching away from it), the blob is logged and // skipped rather than silently dropped, since there's nothing reachable to // delete it from. func (s *Service) deleteOrphanedBlobs(ctx context.Context, store mediaRetentionStore, blobs []domain.FileBlob) { for _, b := range blobs { refs, err := store.CountFileBlobRefs(ctx, string(b.Backend), b.ObjectKey) if err != nil { s.log.Warn("count file blob refs failed", zap.String("object_key", b.ObjectKey), zap.Error(err)) continue } if refs > 0 { continue } backend, err := s.backendFor(b.Backend) if err != nil { s.log.Warn("orphaned blob's backend is not configured, skipping physical delete", zap.String("backend", string(b.Backend)), zap.String("object_key", b.ObjectKey), zap.Error(err)) continue } if err := backend.Delete(ctx, b.ObjectKey); err != nil { s.log.Warn("delete orphaned blob failed", zap.String("object_key", b.ObjectKey), zap.Error(err)) } } }