557 lines
23 KiB
Go
557 lines
23 KiB
Go
package files
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// hardRetentionDocumentCategories enumerates every documents.category bucket
|
|
// the per-category retention sweep loops over. MediaCategoryNone covers
|
|
// unclassified documents (stickers and anything else classifyDocumentCategory
|
|
// doesn't tag) and always uses the shared global age -- there is no
|
|
// per-category override for that bucket. Photo/Avatar are not in this list:
|
|
// photos have no category column and are instead split by a profile_photos
|
|
// join (see categoryRetentionAge/avatarRetentionAge below and the dedicated
|
|
// photo/avatar query variants).
|
|
var hardRetentionDocumentCategories = []domain.MediaCategory{
|
|
domain.MediaCategoryNone,
|
|
domain.MediaCategoryVideo,
|
|
domain.MediaCategoryGif,
|
|
domain.MediaCategoryFile,
|
|
domain.MediaCategoryMusic,
|
|
domain.MediaCategoryVoice,
|
|
domain.MediaCategoryRoundVideo,
|
|
}
|
|
|
|
// categoryRetentionAge returns the effective retention age for a document
|
|
// media category: its configured override if positive, otherwise the shared
|
|
// global age.
|
|
func (s *Service) categoryRetentionAge(category domain.MediaCategory) time.Duration {
|
|
if age, ok := s.storageRetentionCategoryAges[category]; ok && age > 0 {
|
|
return age
|
|
}
|
|
return s.storageRetentionGlobalMaxAge
|
|
}
|
|
|
|
// avatarRetentionAge is the Photo category's counterpart for photos currently
|
|
// active as someone's avatar -- see categoryRetentionAge.
|
|
func (s *Service) avatarRetentionAge() time.Duration {
|
|
if s.storageRetentionAvatarMaxAge > 0 {
|
|
return s.storageRetentionAvatarMaxAge
|
|
}
|
|
return s.storageRetentionGlobalMaxAge
|
|
}
|
|
|
|
// 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, category domain.MediaCategory, cutoff time.Time, limit int) ([]int64, error)
|
|
ListOrphanedPhotoIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error)
|
|
ListAvatarOrphanedPhotoIDsOlderThan(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)
|
|
|
|
// -- "hard" retention mode (TELESRV_STORAGE_RETENTION_MODE=hard) --
|
|
// Candidate selection ignores media_references entirely: a document/
|
|
// photo still referenced by a live message is exactly as eligible as an
|
|
// orphaned one once it's old enough. The delete methods physically
|
|
// remove only the file_blobs row(s)/bytes, never the document/photo
|
|
// metadata row -- see DeleteFileBlobsForDocument's doc comment. cutoff is
|
|
// a *time.Time so it can be nil ("no age filter at all") -- the automatic
|
|
// sweep below always passes a real cutoff; the manual purge admin action
|
|
// (see ManualPurge) is what actually uses nil.
|
|
ListDocumentIDsForHardRetentionOlderThan(ctx context.Context, category domain.MediaCategory, cutoff *time.Time, limit int) ([]int64, error)
|
|
ListPhotoIDsForHardRetentionOlderThan(ctx context.Context, cutoff *time.Time, limit int) ([]int64, error)
|
|
ListAvatarPhotoIDsForHardRetentionOlderThan(ctx context.Context, cutoff *time.Time, limit int) ([]int64, error)
|
|
// Count* are the exact-count counterparts of the List* queries just
|
|
// above, used by CountManualPurgeCandidates's dry-run preview: a
|
|
// LIMIT-capped list length is not a real total.
|
|
CountDocumentsForHardRetention(ctx context.Context, category domain.MediaCategory, cutoff *time.Time) (int, error)
|
|
CountPhotosForHardRetention(ctx context.Context, cutoff *time.Time) (int, error)
|
|
CountAvatarPhotosForHardRetention(ctx context.Context, cutoff *time.Time) (int, error)
|
|
DeleteFileBlobsForDocument(ctx context.Context, id int64) ([]domain.FileBlob, error)
|
|
DeleteFileBlobsForPhoto(ctx context.Context, id int64) ([]domain.FileBlob, error)
|
|
|
|
// -- active eviction (TELESRV_STORAGE_EVICTION_ENABLE) --
|
|
SumFileBlobBytes(ctx context.Context) (int64, error)
|
|
ListOldestMediaForEviction(ctx context.Context, limit int) ([]domain.EvictionCandidate, error)
|
|
|
|
// -- retention purge notice (see retention_purge.go) --
|
|
ListMediaReferences(ctx context.Context, kind domain.MediaKind, mediaID int64) ([]domain.MediaReference, 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
|
|
// the per-category cutoff derived from now, 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. Loops one
|
|
// query per document category (each with its own effective age, see
|
|
// categoryRetentionAge) plus a regular/avatar split for photos; limit applies
|
|
// per category per tick, not as one shared budget across all of them --
|
|
// simplest correct behavior, revisit only if one category starves another in
|
|
// practice.
|
|
func (s *Service) DeleteOrphanedOlderThan(ctx context.Context, now time.Time, limit int) (int, error) {
|
|
store, ok := s.media.(mediaRetentionStore)
|
|
if !ok || limit <= 0 {
|
|
return 0, nil
|
|
}
|
|
deleted := 0
|
|
for _, cat := range hardRetentionDocumentCategories {
|
|
age := s.categoryRetentionAge(cat)
|
|
if age <= 0 {
|
|
// Global age is 0 (retention only enabled for other, explicitly
|
|
// overridden categories) and this category has no override of its
|
|
// own -- skip it entirely rather than treating age<=0 as an
|
|
// immediate "everything is older than now" cutoff.
|
|
continue
|
|
}
|
|
cutoff := now.Add(-age)
|
|
docIDs, err := store.ListOrphanedDocumentIDsOlderThan(ctx, cat, cutoff, limit)
|
|
if err != nil {
|
|
return deleted, fmt.Errorf("list orphaned documents (category %d): %w", cat, 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++
|
|
}
|
|
}
|
|
if age := s.categoryRetentionAge(domain.MediaCategoryPhoto); age > 0 {
|
|
photoCutoff := now.Add(-age)
|
|
photoIDs, err := store.ListOrphanedPhotoIDsOlderThan(ctx, photoCutoff, 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++
|
|
}
|
|
}
|
|
if age := s.avatarRetentionAge(); age > 0 {
|
|
avatarCutoff := now.Add(-age)
|
|
avatarIDs, err := store.ListAvatarOrphanedPhotoIDsOlderThan(ctx, avatarCutoff, limit)
|
|
if err != nil {
|
|
return deleted, fmt.Errorf("list orphaned avatar photos: %w", err)
|
|
}
|
|
for _, id := range avatarIDs {
|
|
blobs, err := store.DeletePhotoAndBlobs(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("delete orphaned avatar photo failed", zap.Int64("photo_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
deleted++
|
|
}
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// DeleteBlobBytesForMediaOlderThan implements
|
|
// maintenance.HardMediaRetentionStore ("hard" retention mode): for
|
|
// documents/photos whose upload/created_at is older than the per-category
|
|
// cutoff derived from now (see categoryRetentionAge/avatarRetentionAge),
|
|
// physically deletes their blob bytes (main body + thumbnail/rendition
|
|
// variants) from the backend and removes their file_blobs rows --
|
|
// REGARDLESS of whether a live message/profile-photo/sticker-set still
|
|
// references them. It deliberately never touches the documents/photos
|
|
// metadata row itself: a message must still be able to render "here was a
|
|
// photo/document" (dimensions, mime type, filename) after its bytes are
|
|
// gone, rather than the message breaking outright. A subsequent
|
|
// upload.getFile for the same location key finds no file_blobs row and
|
|
// returns LOCATION_INVALID, which stock clients already render as a "media
|
|
// unavailable" placeholder -- and every message that still embeds the purged
|
|
// media additionally gets turned into a visible retention-purge notice (see
|
|
// notifyRetentionPurge in retention_purge.go).
|
|
func (s *Service) DeleteBlobBytesForMediaOlderThan(ctx context.Context, now time.Time, limit int) (int, error) {
|
|
store, ok := s.media.(mediaRetentionStore)
|
|
if !ok || limit <= 0 {
|
|
return 0, nil
|
|
}
|
|
purged := 0
|
|
for _, cat := range hardRetentionDocumentCategories {
|
|
age := s.categoryRetentionAge(cat)
|
|
if age <= 0 {
|
|
// See DeleteOrphanedOlderThan's identical guard: age<=0 means this
|
|
// category has no override and the global age is 0 (retention
|
|
// enabled only for other categories) -- skip, don't treat it as
|
|
// "everything is older than now".
|
|
continue
|
|
}
|
|
cutoff := now.Add(-age)
|
|
docIDs, err := store.ListDocumentIDsForHardRetentionOlderThan(ctx, cat, &cutoff, limit)
|
|
if err != nil {
|
|
return purged, fmt.Errorf("list documents for hard retention (category %d): %w", cat, err)
|
|
}
|
|
for _, id := range docIDs {
|
|
blobs, err := store.DeleteFileBlobsForDocument(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("hard-delete document blob bytes failed", zap.Int64("document_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindDocument, id)
|
|
purged++
|
|
}
|
|
}
|
|
if age := s.categoryRetentionAge(domain.MediaCategoryPhoto); age > 0 {
|
|
photoCutoff := now.Add(-age)
|
|
photoIDs, err := store.ListPhotoIDsForHardRetentionOlderThan(ctx, &photoCutoff, limit)
|
|
if err != nil {
|
|
return purged, fmt.Errorf("list photos for hard retention: %w", err)
|
|
}
|
|
for _, id := range photoIDs {
|
|
blobs, err := store.DeleteFileBlobsForPhoto(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("hard-delete photo blob bytes failed", zap.Int64("photo_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindPhoto, id)
|
|
purged++
|
|
}
|
|
}
|
|
if age := s.avatarRetentionAge(); age > 0 {
|
|
avatarCutoff := now.Add(-age)
|
|
avatarIDs, err := store.ListAvatarPhotoIDsForHardRetentionOlderThan(ctx, &avatarCutoff, limit)
|
|
if err != nil {
|
|
return purged, fmt.Errorf("list avatar photos for hard retention: %w", err)
|
|
}
|
|
for _, id := range avatarIDs {
|
|
blobs, err := store.DeleteFileBlobsForPhoto(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("hard-delete avatar photo blob bytes failed", zap.Int64("photo_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindPhoto, id)
|
|
purged++
|
|
}
|
|
}
|
|
return purged, nil
|
|
}
|
|
|
|
// manualPurgeCategories enumerates the domain.MediaCategory values the
|
|
// manual purge admin action (ManualPurge/CountManualPurgeCandidates) accepts.
|
|
// Unlike hardRetentionDocumentCategories (used by the automatic sweep),
|
|
// this includes MediaCategoryPhoto -- an operator explicitly choosing
|
|
// categories to purge expects "Photo" to be one of the choices, even though
|
|
// photos have no documents.category column and are resolved through the
|
|
// dedicated ListPhotoIDsForHardRetentionOlderThan query instead. Avatar is
|
|
// deliberately NOT a domain.MediaCategory member and is instead its own
|
|
// includeAvatars bool parameter, matching how the automatic sweep already
|
|
// splits avatar photos out via avatarRetentionAge/ListAvatarPhotoIDsForHardRetentionOlderThan.
|
|
var manualPurgeCategories = map[domain.MediaCategory]bool{
|
|
domain.MediaCategoryPhoto: true,
|
|
domain.MediaCategoryVideo: true,
|
|
domain.MediaCategoryRoundVideo: true,
|
|
domain.MediaCategoryGif: true,
|
|
domain.MediaCategoryMusic: true,
|
|
domain.MediaCategoryVoice: true,
|
|
domain.MediaCategoryFile: true,
|
|
}
|
|
|
|
// validateManualPurgeCategories rejects any category outside
|
|
// manualPurgeCategories (e.g. MediaCategoryNone or MediaCategoryURL, which
|
|
// are not meaningful purge targets) rather than silently ignoring it -- an
|
|
// operator who fat-fingers a category deserves an error, not a purge that
|
|
// quietly did less than they asked for.
|
|
func validateManualPurgeCategories(categories []domain.MediaCategory) error {
|
|
for _, cat := range categories {
|
|
if !manualPurgeCategories[cat] {
|
|
return fmt.Errorf("unsupported manual purge category: %d", cat)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CountManualPurgeCandidates is the dry-run counterpart of ManualPurge: an
|
|
// exact count of how many documents/photos the given selection would purge,
|
|
// without deleting anything. before may be nil, meaning no age filter at all
|
|
// (every document/photo in the selected categories is a candidate,
|
|
// regardless of created_at) -- unlike the automatic sweep, which always
|
|
// filters by a configured age.
|
|
func (s *Service) CountManualPurgeCandidates(ctx context.Context, categories []domain.MediaCategory, includeAvatars bool, before *time.Time) (docs int, photos int, err error) {
|
|
store, ok := s.media.(mediaRetentionStore)
|
|
if !ok {
|
|
return 0, 0, nil
|
|
}
|
|
if err := validateManualPurgeCategories(categories); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
for _, cat := range categories {
|
|
if cat == domain.MediaCategoryPhoto {
|
|
n, err := store.CountPhotosForHardRetention(ctx, before)
|
|
if err != nil {
|
|
return docs, photos, fmt.Errorf("count photos for manual purge: %w", err)
|
|
}
|
|
photos += n
|
|
continue
|
|
}
|
|
n, err := store.CountDocumentsForHardRetention(ctx, cat, before)
|
|
if err != nil {
|
|
return docs, photos, fmt.Errorf("count documents for manual purge (category %d): %w", cat, err)
|
|
}
|
|
docs += n
|
|
}
|
|
if includeAvatars {
|
|
n, err := store.CountAvatarPhotosForHardRetention(ctx, before)
|
|
if err != nil {
|
|
return docs, photos, fmt.Errorf("count avatar photos for manual purge: %w", err)
|
|
}
|
|
photos += n
|
|
}
|
|
return docs, photos, nil
|
|
}
|
|
|
|
// ManualPurge is the admin-triggered counterpart of DeleteBlobBytesForMediaOlderThan
|
|
// ("hard" retention mode's blob-purge primitive): instead of config-derived
|
|
// categories and a configured retention age, the operator explicitly chooses
|
|
// which categories to purge and an optional cutoff date. before == nil means
|
|
// no age filter at all -- everything matching the chosen categories is
|
|
// purged, regardless of how recently it was created. Deletion semantics are
|
|
// identical to the automatic sweep: only file_blobs bytes are removed, never
|
|
// the documents/photos metadata row, and notifyRetentionPurge still turns any
|
|
// message still displaying the purged media into the retention-purge notice.
|
|
// limit bounds how many documents/photos are purged per category/bucket in
|
|
// this single call (the admin action loops/paginates by calling again if the
|
|
// dry-run count exceeds one call's limit).
|
|
func (s *Service) ManualPurge(ctx context.Context, categories []domain.MediaCategory, includeAvatars bool, before *time.Time, limit int) (purgedDocs int, purgedPhotos int, bytesReclaimed int64, err error) {
|
|
store, ok := s.media.(mediaRetentionStore)
|
|
if !ok || limit <= 0 {
|
|
return 0, 0, 0, nil
|
|
}
|
|
if err := validateManualPurgeCategories(categories); err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
for _, cat := range categories {
|
|
if cat == domain.MediaCategoryPhoto {
|
|
photoIDs, err := store.ListPhotoIDsForHardRetentionOlderThan(ctx, before, limit)
|
|
if err != nil {
|
|
return purgedDocs, purgedPhotos, bytesReclaimed, fmt.Errorf("list photos for manual purge: %w", err)
|
|
}
|
|
for _, id := range photoIDs {
|
|
blobs, err := store.DeleteFileBlobsForPhoto(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("manual purge photo blob delete failed", zap.Int64("photo_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
for _, b := range blobs {
|
|
bytesReclaimed += b.Size
|
|
}
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindPhoto, id)
|
|
purgedPhotos++
|
|
}
|
|
continue
|
|
}
|
|
docIDs, err := store.ListDocumentIDsForHardRetentionOlderThan(ctx, cat, before, limit)
|
|
if err != nil {
|
|
return purgedDocs, purgedPhotos, bytesReclaimed, fmt.Errorf("list documents for manual purge (category %d): %w", cat, err)
|
|
}
|
|
for _, id := range docIDs {
|
|
blobs, err := store.DeleteFileBlobsForDocument(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("manual purge document blob delete failed", zap.Int64("document_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
for _, b := range blobs {
|
|
bytesReclaimed += b.Size
|
|
}
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindDocument, id)
|
|
purgedDocs++
|
|
}
|
|
}
|
|
if includeAvatars {
|
|
avatarIDs, err := store.ListAvatarPhotoIDsForHardRetentionOlderThan(ctx, before, limit)
|
|
if err != nil {
|
|
return purgedDocs, purgedPhotos, bytesReclaimed, fmt.Errorf("list avatar photos for manual purge: %w", err)
|
|
}
|
|
for _, id := range avatarIDs {
|
|
blobs, err := store.DeleteFileBlobsForPhoto(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("manual purge avatar photo blob delete failed", zap.Int64("photo_id", id), zap.Error(err))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
for _, b := range blobs {
|
|
bytesReclaimed += b.Size
|
|
}
|
|
s.notifyRetentionPurge(ctx, domain.MediaKindPhoto, id)
|
|
purgedPhotos++
|
|
}
|
|
}
|
|
return purgedDocs, purgedPhotos, bytesReclaimed, nil
|
|
}
|
|
|
|
// EvictOldestMediaOverBudget implements maintenance.StorageEvictionStore
|
|
// (TELESRV_STORAGE_EVICTION_ENABLE): once total physical blob bytes
|
|
// (SumFileBlobBytes) exceed TELESRV_STORAGE_MAX_TOTAL_BYTES, purges the
|
|
// oldest documents/photos overall -- interleaved by created_at across both
|
|
// tables, regardless of category or age -- reusing the exact same blob-purge
|
|
// (DeleteFileBlobsForDocument/DeleteFileBlobsForPhoto) and retention-purge
|
|
// notice primitive as "hard" mode. Stops once the running total (tracked
|
|
// locally from each purge's returned blob sizes, avoiding a re-query per
|
|
// item) is back under budget, or limit purges have happened this tick,
|
|
// whichever comes first -- bounding how much one tick can reclaim at once.
|
|
func (s *Service) EvictOldestMediaOverBudget(ctx context.Context, limit int) (int, error) {
|
|
store, ok := s.media.(mediaRetentionStore)
|
|
if !ok || limit <= 0 || s.storageMaxTotalBytes <= 0 {
|
|
return 0, nil
|
|
}
|
|
total, err := store.SumFileBlobBytes(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sum file blob bytes: %w", err)
|
|
}
|
|
if total <= s.storageMaxTotalBytes {
|
|
return 0, nil
|
|
}
|
|
candidates, err := store.ListOldestMediaForEviction(ctx, limit)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list oldest media for eviction: %w", err)
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i].CreatedAt.Before(candidates[j].CreatedAt) })
|
|
evicted := 0
|
|
for _, c := range candidates {
|
|
if evicted >= limit || total <= s.storageMaxTotalBytes {
|
|
break
|
|
}
|
|
var blobs []domain.FileBlob
|
|
var delErr error
|
|
switch c.Kind {
|
|
case domain.MediaKindDocument:
|
|
blobs, delErr = store.DeleteFileBlobsForDocument(ctx, c.MediaID)
|
|
case domain.MediaKindPhoto:
|
|
blobs, delErr = store.DeleteFileBlobsForPhoto(ctx, c.MediaID)
|
|
default:
|
|
continue
|
|
}
|
|
if delErr != nil {
|
|
s.log.Warn("active storage eviction blob purge failed",
|
|
zap.String("media_kind", string(c.Kind)), zap.Int64("media_id", c.MediaID), zap.Error(delErr))
|
|
continue
|
|
}
|
|
if len(blobs) == 0 {
|
|
continue
|
|
}
|
|
s.deleteOrphanedBlobs(ctx, store, blobs)
|
|
for _, b := range blobs {
|
|
total -= b.Size
|
|
}
|
|
s.notifyRetentionPurge(ctx, c.Kind, c.MediaID)
|
|
evicted++
|
|
}
|
|
return evicted, 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 {
|
|
// The file_blobs row for this exact location_key is already gone
|
|
// (the caller deleted it in the same transaction that produced this
|
|
// blob list) -- so any cached "found" metadata for it is now wrong
|
|
// regardless of whether the underlying bytes turn out to still be
|
|
// shared by another row below. Without this, a hot GetFile path that
|
|
// had this location_key's metadata cached would keep trying to read
|
|
// bytes that may no longer exist (hard retention mode purges blobs
|
|
// for actively-referenced, potentially still-hot media), producing
|
|
// an internal error instead of the graceful LOCATION_INVALID a stock
|
|
// client knows how to render.
|
|
s.blobCache.delete(b.LocationKey)
|
|
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))
|
|
}
|
|
s.byteCache.delete(b.ObjectKey)
|
|
}
|
|
}
|