fix for retention logic
This commit is contained in:
parent
70c0ba44f0
commit
e6bfe2d444
35 changed files with 2108 additions and 132 deletions
|
|
@ -1514,6 +1514,22 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
|
|||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
// EditChannelMessageInternal performs a server-internal channel message edit
|
||||
// with no acting user -- currently only the storage retention sweep
|
||||
// (internal/app/files.notifyRetentionPurge), which needs to turn a purged
|
||||
// message into a visible notice but has no RPC caller/channel member behind
|
||||
// it. Bypasses the userID==0 gate EditMessage enforces for ordinary
|
||||
// RPC-driven edits; the store's own RetentionPurge bypass (see
|
||||
// EditChannelMessageRequest.RetentionPurge) enforces the actual permission
|
||||
// bypass semantics, so this refuses anything that isn't actually a retention
|
||||
// purge request rather than becoming a second general-purpose edit path.
|
||||
func (s *Service) EditChannelMessageInternal(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) {
|
||||
if s == nil || s.channels == nil || !req.RetentionPurge || req.RetentionPurgeAction == nil || req.ChannelID == 0 || req.ID <= 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
// GetInlineBotMessage returns one live channel message addressed by a signed inline id.
|
||||
func (s *Service) GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) {
|
||||
if s == nil || s.channels == nil || botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package files
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -10,14 +11,52 @@ import (
|
|||
"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, cutoff time.Time, limit int) ([]int64, error)
|
||||
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)
|
||||
|
|
@ -31,111 +70,250 @@ type mediaRetentionStore interface {
|
|||
// 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.
|
||||
ListDocumentIDsForHardRetentionOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error)
|
||||
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)
|
||||
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
|
||||
// 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) {
|
||||
// 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
|
||||
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))
|
||||
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
|
||||
}
|
||||
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)
|
||||
cutoff := now.Add(-age)
|
||||
docIDs, err := store.ListOrphanedDocumentIDsOlderThan(ctx, cat, cutoff, limit)
|
||||
if err != nil {
|
||||
s.log.Warn("delete orphaned photo failed", zap.Int64("photo_id", id), zap.Error(err))
|
||||
continue
|
||||
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++
|
||||
}
|
||||
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 cutoff, 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.
|
||||
func (s *Service) DeleteBlobBytesForMediaOlderThan(ctx context.Context, cutoff time.Time, limit int) (int, error) {
|
||||
// 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
|
||||
docIDs, err := store.ListDocumentIDsForHardRetentionOlderThan(ctx, cutoff, limit)
|
||||
if err != nil {
|
||||
return purged, fmt.Errorf("list documents for hard retention: %w", err)
|
||||
}
|
||||
for _, id := range docIDs {
|
||||
blobs, err := store.DeleteFileBlobsForDocument(ctx, id)
|
||||
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 {
|
||||
s.log.Warn("hard-delete document blob bytes failed", zap.Int64("document_id", id), zap.Error(err))
|
||||
continue
|
||||
return purged, fmt.Errorf("list documents for hard retention (category %d): %w", cat, err)
|
||||
}
|
||||
if len(blobs) == 0 {
|
||||
continue
|
||||
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++
|
||||
}
|
||||
s.deleteOrphanedBlobs(ctx, store, blobs)
|
||||
purged++
|
||||
}
|
||||
remaining := limit - len(docIDs)
|
||||
if remaining <= 0 {
|
||||
return purged, nil
|
||||
}
|
||||
photoIDs, err := store.ListPhotoIDsForHardRetentionOlderThan(ctx, cutoff, remaining)
|
||||
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 age := s.categoryRetentionAge(domain.MediaCategoryPhoto); age > 0 {
|
||||
photoCutoff := now.Add(-age)
|
||||
photoIDs, err := store.ListPhotoIDsForHardRetentionOlderThan(ctx, photoCutoff, limit)
|
||||
if err != nil {
|
||||
s.log.Warn("hard-delete photo blob bytes failed", zap.Int64("photo_id", id), zap.Error(err))
|
||||
continue
|
||||
return purged, fmt.Errorf("list photos for hard retention: %w", err)
|
||||
}
|
||||
if len(blobs) == 0 {
|
||||
continue
|
||||
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++
|
||||
}
|
||||
s.deleteOrphanedBlobs(ctx, store, blobs)
|
||||
purged++
|
||||
}
|
||||
return purged, 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 --
|
||||
|
|
|
|||
153
internal/app/files/retention_category_zero_test.go
Normal file
153
internal/app/files/retention_category_zero_test.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// fakeCategorySweepStore records which category/photo/avatar queries
|
||||
// DeleteBlobBytesForMediaOlderThan/DeleteOrphanedOlderThan actually issue, so
|
||||
// the zero-age skip guard can be checked without a live database. Embeds a
|
||||
// nil store.MediaStore so it satisfies that (large) interface for free via
|
||||
// promoted methods that must never actually be called here -- only the
|
||||
// mediaRetentionStore subset overridden below is exercised.
|
||||
type fakeCategorySweepStore struct {
|
||||
store.MediaStore
|
||||
queriedDocCategories []domain.MediaCategory
|
||||
queriedPhotos bool
|
||||
queriedAvatars bool
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListDocumentIDsForHardRetentionOlderThan(_ context.Context, category domain.MediaCategory, _ time.Time, _ int) ([]int64, error) {
|
||||
f.queriedDocCategories = append(f.queriedDocCategories, category)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListPhotoIDsForHardRetentionOlderThan(context.Context, time.Time, int) ([]int64, error) {
|
||||
f.queriedPhotos = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListAvatarPhotoIDsForHardRetentionOlderThan(context.Context, time.Time, int) ([]int64, error) {
|
||||
f.queriedAvatars = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListOrphanedDocumentIDsOlderThan(_ context.Context, category domain.MediaCategory, _ time.Time, _ int) ([]int64, error) {
|
||||
f.queriedDocCategories = append(f.queriedDocCategories, category)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListOrphanedPhotoIDsOlderThan(context.Context, time.Time, int) ([]int64, error) {
|
||||
f.queriedPhotos = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeCategorySweepStore) ListAvatarOrphanedPhotoIDsOlderThan(context.Context, time.Time, int) ([]int64, error) {
|
||||
f.queriedAvatars = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The rest of mediaRetentionStore's methods aren't part of store.MediaStore
|
||||
// (see that interface's own doc comment -- they're kept out of the hot
|
||||
// RPC-facing interface on purpose), so embedding store.MediaStore alone
|
||||
// doesn't provide them. Stub them out; this test never exercises them since
|
||||
// every List* above returns no candidates.
|
||||
func (f *fakeCategorySweepStore) CountFileBlobRefs(context.Context, string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) DeleteDocumentAndBlobs(context.Context, int64) ([]domain.FileBlob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) DeletePhotoAndBlobs(context.Context, int64) ([]domain.FileBlob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) OrphanDocumentIfUnreferenced(context.Context, int64) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) DeleteFileBlobsForDocument(context.Context, int64) ([]domain.FileBlob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) DeleteFileBlobsForPhoto(context.Context, int64) ([]domain.FileBlob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) SumFileBlobBytes(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) ListOldestMediaForEviction(context.Context, int) ([]domain.EvictionCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeCategorySweepStore) ListMediaReferences(context.Context, domain.MediaKind, int64) ([]domain.MediaReference, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestZeroGlobalRetentionAgeSkipsCategoriesWithoutOverride guards the bug
|
||||
// reported live: setting the shared Retention age to 0 (meaning "only clean
|
||||
// up categories I explicitly override") used to be indistinguishable from
|
||||
// "everything is older than right now" at the per-category cutoff math,
|
||||
// which would have hard-purged every uncategorized/photo/avatar item
|
||||
// immediately. The sweep must instead skip any category whose *effective*
|
||||
// age (its own override, or the 0 global) is <=0 entirely -- no query issued
|
||||
// for it at all -- while still sweeping a category that has a real,
|
||||
// positive override.
|
||||
func TestZeroGlobalRetentionAgeSkipsCategoriesWithoutOverride(t *testing.T) {
|
||||
fake := &fakeCategorySweepStore{}
|
||||
s := &Service{
|
||||
media: fake,
|
||||
log: zap.NewNop(),
|
||||
storageRetentionGlobalMaxAge: 0,
|
||||
storageRetentionCategoryAges: map[domain.MediaCategory]time.Duration{
|
||||
domain.MediaCategoryVideo: 24 * time.Hour,
|
||||
},
|
||||
storageRetentionAvatarMaxAge: 0,
|
||||
}
|
||||
|
||||
if _, err := s.DeleteBlobBytesForMediaOlderThan(context.Background(), time.Now(), 50); err != nil {
|
||||
t.Fatalf("DeleteBlobBytesForMediaOlderThan: %v", err)
|
||||
}
|
||||
|
||||
if len(fake.queriedDocCategories) != 1 || fake.queriedDocCategories[0] != domain.MediaCategoryVideo {
|
||||
t.Fatalf("queried document categories = %v, want only [Video]", fake.queriedDocCategories)
|
||||
}
|
||||
if fake.queriedPhotos {
|
||||
t.Fatal("queried regular photos with no photo override and global age 0, want skipped")
|
||||
}
|
||||
if fake.queriedAvatars {
|
||||
t.Fatal("queried avatar photos with no avatar override and global age 0, want skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroGlobalRetentionAgeSkipsOrphanSweepCategoriesWithoutOverride is the
|
||||
// orphan-mode counterpart of the hard-mode test above.
|
||||
func TestZeroGlobalRetentionAgeSkipsOrphanSweepCategoriesWithoutOverride(t *testing.T) {
|
||||
fake := &fakeCategorySweepStore{}
|
||||
s := &Service{
|
||||
media: fake,
|
||||
log: zap.NewNop(),
|
||||
storageRetentionGlobalMaxAge: 0,
|
||||
storageRetentionCategoryAges: map[domain.MediaCategory]time.Duration{
|
||||
domain.MediaCategoryVoice: 48 * time.Hour,
|
||||
},
|
||||
storageRetentionAvatarMaxAge: 0,
|
||||
}
|
||||
|
||||
if _, err := s.DeleteOrphanedOlderThan(context.Background(), time.Now(), 50); err != nil {
|
||||
t.Fatalf("DeleteOrphanedOlderThan: %v", err)
|
||||
}
|
||||
|
||||
if len(fake.queriedDocCategories) != 1 || fake.queriedDocCategories[0] != domain.MediaCategoryVoice {
|
||||
t.Fatalf("queried document categories = %v, want only [Voice]", fake.queriedDocCategories)
|
||||
}
|
||||
if fake.queriedPhotos {
|
||||
t.Fatal("queried regular photos with no photo override and global age 0, want skipped")
|
||||
}
|
||||
if fake.queriedAvatars {
|
||||
t.Fatal("queried avatar photos with no avatar override and global age 0, want skipped")
|
||||
}
|
||||
}
|
||||
204
internal/app/files/retention_purge.go
Normal file
204
internal/app/files/retention_purge.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// RetentionPurgeMessageEditor is the private-message capability the storage
|
||||
// retention sweep needs to turn a hard-retention/eviction blob purge into a
|
||||
// visible messageActionCustomAction notice. Satisfied by
|
||||
// internal/app/messages.Service (GetMessages resolves the box's peer --
|
||||
// EditMessageRequest requires it, and message_box ref_key only encodes
|
||||
// owner_user_id+box_id -- then EditMessage performs the actual in-place
|
||||
// edit with RetentionPurge set).
|
||||
type RetentionPurgeMessageEditor interface {
|
||||
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||
EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error)
|
||||
}
|
||||
|
||||
// RetentionPurgeChannelEditor is the channel-message counterpart of
|
||||
// RetentionPurgeMessageEditor. Satisfied by internal/app/channels.Service.
|
||||
// Unlike private messages, EditChannelMessageRequest needs no peer lookup --
|
||||
// channelMessageRefKey already encodes the channel id directly. Uses
|
||||
// EditChannelMessageInternal (not the ordinary EditMessage) since this is a
|
||||
// server-internal edit with no acting user/channel member behind it.
|
||||
type RetentionPurgeChannelEditor interface {
|
||||
EditChannelMessageInternal(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error)
|
||||
}
|
||||
|
||||
// SetRetentionPurgeNotifier wires the private-message/channel-message edit
|
||||
// capability the storage retention sweep needs to turn a hard-retention/
|
||||
// eviction blob purge into a visible messageActionCustomAction notice on
|
||||
// every message still embedding the purged document/photo. Both app-layer
|
||||
// services are constructed after this Service in cmd/telesrv/main.go (and
|
||||
// the background retention sweep goroutine is started before either exists),
|
||||
// so this is a post-construction setter rather than a NewService option: a
|
||||
// sweep tick that races ahead of this call simply finds both fields nil and
|
||||
// skips the notice for that tick, same as any other per-reference failure
|
||||
// below (best-effort -- the underlying blob purge has already committed and
|
||||
// must never be undone or retried because of a notice failure).
|
||||
func (s *Service) SetRetentionPurgeNotifier(messages RetentionPurgeMessageEditor, channels RetentionPurgeChannelEditor) {
|
||||
s.retentionNotifyMu.Lock()
|
||||
defer s.retentionNotifyMu.Unlock()
|
||||
s.retentionMessages = messages
|
||||
s.retentionChannels = channels
|
||||
}
|
||||
|
||||
func (s *Service) retentionNotifier() (RetentionPurgeMessageEditor, RetentionPurgeChannelEditor) {
|
||||
s.retentionNotifyMu.RLock()
|
||||
defer s.retentionNotifyMu.RUnlock()
|
||||
return s.retentionMessages, s.retentionChannels
|
||||
}
|
||||
|
||||
// notifyRetentionPurge turns a just-completed hard-retention/eviction blob
|
||||
// purge of a document/photo into a visible service-message notice on every
|
||||
// message that still embeds it. profile_photo/sticker_set/gift references
|
||||
// have no message to edit and are skipped. Best-effort throughout: a lookup
|
||||
// or edit failure is logged and never propagated -- the underlying blob purge
|
||||
// has already committed and must not be undone or retried because of this.
|
||||
func (s *Service) notifyRetentionPurge(ctx context.Context, kind domain.MediaKind, mediaID int64) {
|
||||
messages, channels := s.retentionNotifier()
|
||||
if messages == nil && channels == nil {
|
||||
return
|
||||
}
|
||||
store, ok := s.media.(mediaRetentionStore)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
refs, err := store.ListMediaReferences(ctx, kind, mediaID)
|
||||
if err != nil {
|
||||
s.log.Warn("list media references for retention purge notice failed",
|
||||
zap.String("media_kind", string(kind)), zap.Int64("media_id", mediaID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, ref := range refs {
|
||||
switch ref.RefKind {
|
||||
case domain.MediaRefKindMessageBox:
|
||||
s.notifyRetentionPurgeMessageBox(ctx, messages, ref.RefKey)
|
||||
case domain.MediaRefKindChannelMessage:
|
||||
s.notifyRetentionPurgeChannelMessage(ctx, channels, ref.RefKey)
|
||||
case domain.MediaRefKindProfilePhoto, domain.MediaRefKindStickerSet, domain.MediaRefKindGift:
|
||||
// No message to edit for these ref kinds.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRetentionPurgeMessageBox parses a message_box ref_key (format
|
||||
// "user:<owner_user_id>:box:<box_id>", see postgres.messageBoxRefKey -- kept
|
||||
// in sync by convention, not a shared symbol, since the ref_key encoding is
|
||||
// an internal storage detail of that package) and edits the box in place.
|
||||
func (s *Service) notifyRetentionPurgeMessageBox(ctx context.Context, messages RetentionPurgeMessageEditor, refKey string) {
|
||||
if messages == nil {
|
||||
return
|
||||
}
|
||||
ownerUserID, boxID, ok := parseMessageBoxRefKey(refKey)
|
||||
if !ok {
|
||||
s.log.Warn("unparseable message_box retention purge ref_key", zap.String("ref_key", refKey))
|
||||
return
|
||||
}
|
||||
// EditMessageRequest requires the peer explicitly (an ordinary edit's RPC
|
||||
// caller always supplies it) -- resolve it from the box row itself since
|
||||
// this is a server-internal edit with no client request behind it.
|
||||
list, err := messages.GetMessages(ctx, ownerUserID, []int{boxID})
|
||||
if err != nil {
|
||||
s.log.Warn("resolve message_box peer for retention purge notice failed",
|
||||
zap.Int64("owner_user_id", ownerUserID), zap.Int("box_id", boxID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(list.Messages) == 0 {
|
||||
// Box no longer visible to its owner (deleted) -- nothing to notice.
|
||||
return
|
||||
}
|
||||
peer := list.Messages[0].Peer
|
||||
_, err = messages.EditMessage(ctx, ownerUserID, domain.EditMessageRequest{
|
||||
OwnerUserID: ownerUserID,
|
||||
Peer: peer,
|
||||
ID: boxID,
|
||||
SetRichMessage: true,
|
||||
RichMessage: nil,
|
||||
Media: retentionPurgeNoticeMedia(),
|
||||
RetentionPurge: true,
|
||||
})
|
||||
if err != nil && !errors.Is(err, domain.ErrMessageNotModified) && !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
s.log.Warn("retention purge notice edit failed (message_box)",
|
||||
zap.Int64("owner_user_id", ownerUserID), zap.Int("box_id", boxID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRetentionPurgeChannelMessage parses a channel_message ref_key
|
||||
// (format "channel:<channel_id>:msg:<message_id>", see
|
||||
// postgres.channelMessageRefKey) and edits the message in place. Unlike
|
||||
// private messages, no peer lookup is needed: EditChannelMessageRequest only
|
||||
// needs the channel id, already encoded in the ref_key.
|
||||
func (s *Service) notifyRetentionPurgeChannelMessage(ctx context.Context, channels RetentionPurgeChannelEditor, refKey string) {
|
||||
if channels == nil {
|
||||
return
|
||||
}
|
||||
channelID, messageID, ok := parseChannelMessageRefKey(refKey)
|
||||
if !ok {
|
||||
s.log.Warn("unparseable channel_message retention purge ref_key", zap.String("ref_key", refKey))
|
||||
return
|
||||
}
|
||||
_, err := channels.EditChannelMessageInternal(ctx, domain.EditChannelMessageRequest{
|
||||
ChannelID: channelID,
|
||||
ID: messageID,
|
||||
RetentionPurge: true,
|
||||
RetentionPurgeAction: &domain.ChannelMessageAction{Type: domain.ChannelActionCustomText, Text: domain.RetentionPurgeNoticeText},
|
||||
})
|
||||
if err != nil && !errors.Is(err, domain.ErrMessageNotModified) && !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
s.log.Warn("retention purge notice edit failed (channel_message)",
|
||||
zap.Int64("channel_id", channelID), zap.Int("message_id", messageID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// retentionPurgeNoticeMedia builds the messageActionCustomAction service
|
||||
// media payload private-message edits replace a purged message's media with.
|
||||
func retentionPurgeNoticeMedia() *domain.MessageMedia {
|
||||
return &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionCustomText,
|
||||
Text: domain.RetentionPurgeNoticeText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseMessageBoxRefKey parses "user:<owner_user_id>:box:<box_id>".
|
||||
// fmt.Sscanf reports success and silently ignores unconsumed trailing input
|
||||
// (e.g. "user:1:box:2garbage" would still scan fine), so a bare cnt/err check
|
||||
// isn't enough -- round-trip the parsed ids back through the same format
|
||||
// (messageBoxRefKey's own layout) and require an exact match to reject any
|
||||
// malformed/truncated/trailing-garbage key. Defensive only: ref_key is
|
||||
// server-written and never externally supplied, but a bug or future
|
||||
// encoding change should fail closed here, not silently misdirect an edit at
|
||||
// the wrong box.
|
||||
func parseMessageBoxRefKey(refKey string) (ownerUserID int64, boxID int, ok bool) {
|
||||
cnt, err := fmt.Sscanf(refKey, "user:%d:box:%d", &ownerUserID, &boxID)
|
||||
if err != nil || cnt != 2 || ownerUserID == 0 || boxID == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if fmt.Sprintf("user:%d:box:%d", ownerUserID, boxID) != refKey {
|
||||
return 0, 0, false
|
||||
}
|
||||
return ownerUserID, boxID, true
|
||||
}
|
||||
|
||||
// parseChannelMessageRefKey parses "channel:<channel_id>:msg:<message_id>".
|
||||
// See parseMessageBoxRefKey's doc comment -- same round-trip defense against
|
||||
// trailing-garbage input Sscanf alone would silently accept.
|
||||
func parseChannelMessageRefKey(refKey string) (channelID int64, messageID int, ok bool) {
|
||||
cnt, err := fmt.Sscanf(refKey, "channel:%d:msg:%d", &channelID, &messageID)
|
||||
if err != nil || cnt != 2 || channelID == 0 || messageID == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if fmt.Sprintf("channel:%d:msg:%d", channelID, messageID) != refKey {
|
||||
return 0, 0, false
|
||||
}
|
||||
return channelID, messageID, true
|
||||
}
|
||||
29
internal/app/files/retention_purge_test.go
Normal file
29
internal/app/files/retention_purge_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package files
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMessageBoxRefKeyRoundTrip(t *testing.T) {
|
||||
owner, box, ok := parseMessageBoxRefKey("user:42:box:7")
|
||||
if !ok || owner != 42 || box != 7 {
|
||||
t.Fatalf("parse = (%d, %d, %v), want (42, 7, true)", owner, box, ok)
|
||||
}
|
||||
malformed := []string{"", "garbage", "user:1:box:", "user:1:box:2extra", "user:0:box:1", "user:1:box:0", "channel:1:msg:2"}
|
||||
for _, key := range malformed {
|
||||
if _, _, ok := parseMessageBoxRefKey(key); ok {
|
||||
t.Fatalf("parseMessageBoxRefKey(%q) ok=true, want false", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelMessageRefKeyRoundTrip(t *testing.T) {
|
||||
channel, msg, ok := parseChannelMessageRefKey("channel:99:msg:3")
|
||||
if !ok || channel != 99 || msg != 3 {
|
||||
t.Fatalf("parse = (%d, %d, %v), want (99, 3, true)", channel, msg, ok)
|
||||
}
|
||||
malformed := []string{"", "garbage", "channel:1:msg:", "channel:1:msg:2extra", "channel:0:msg:1", "channel:1:msg:0", "user:1:box:2"}
|
||||
for _, key := range malformed {
|
||||
if _, _, ok := parseChannelMessageRefKey(key); ok {
|
||||
t.Fatalf("parseChannelMessageRefKey(%q) ok=true, want false", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,27 @@ type Service struct {
|
|||
// restart -- deleting only the DB row would make a "deleted" GIF come
|
||||
// back on its own.
|
||||
gifSeedDir string
|
||||
|
||||
// storageRetentionGlobalMaxAge/storageRetentionCategoryAges/
|
||||
// storageRetentionAvatarMaxAge back categoryRetentionAge/avatarRetentionAge
|
||||
// (retention.go) -- the per-category storage retention age overrides plus
|
||||
// the shared global fallback age. Set via WithStorageRetentionAges.
|
||||
storageRetentionGlobalMaxAge time.Duration
|
||||
storageRetentionCategoryAges map[domain.MediaCategory]time.Duration
|
||||
storageRetentionAvatarMaxAge time.Duration
|
||||
// storageMaxTotalBytes is TELESRV_STORAGE_MAX_TOTAL_BYTES, reused by
|
||||
// EvictOldestMediaOverBudget as the active-eviction trigger threshold
|
||||
// (<=0 disables eviction regardless of TELESRV_STORAGE_EVICTION_ENABLE).
|
||||
storageMaxTotalBytes int64
|
||||
|
||||
// retentionNotifyMu guards retentionMessages/retentionChannels: they are
|
||||
// set post-construction (see SetRetentionPurgeNotifier) from
|
||||
// cmd/telesrv/main.go once messageapp/channelapp services exist, which
|
||||
// happens after this Service and the background retention sweep that
|
||||
// reads them are already running.
|
||||
retentionNotifyMu sync.RWMutex
|
||||
retentionMessages RetentionPurgeMessageEditor
|
||||
retentionChannels RetentionPurgeChannelEditor
|
||||
}
|
||||
|
||||
// Option 配置 files 服务的可选能力。
|
||||
|
|
@ -200,6 +221,27 @@ func WithGifCatalog(c store.GifCatalogStore) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithStorageRetentionAges configures the per-category storage retention age
|
||||
// overrides (Config.StorageRetentionMaxAgeByCategory/-MaxAgeAvatar) plus the
|
||||
// shared global fallback age (Config.StorageRetentionMaxAge), consumed by
|
||||
// categoryRetentionAge/avatarRetentionAge in retention.go.
|
||||
func WithStorageRetentionAges(global time.Duration, byCategory map[domain.MediaCategory]time.Duration, avatarMaxAge time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.storageRetentionGlobalMaxAge = global
|
||||
s.storageRetentionCategoryAges = byCategory
|
||||
s.storageRetentionAvatarMaxAge = avatarMaxAge
|
||||
}
|
||||
}
|
||||
|
||||
// WithStorageMaxTotalBytes records TELESRV_STORAGE_MAX_TOTAL_BYTES for
|
||||
// EvictOldestMediaOverBudget's active-eviction trigger threshold, reusing the
|
||||
// same cap SpaceGuard already enforces against new uploads.
|
||||
func WithStorageMaxTotalBytes(maxBytes int64) Option {
|
||||
return func(s *Service) {
|
||||
s.storageMaxTotalBytes = maxBytes
|
||||
}
|
||||
}
|
||||
|
||||
// WithGifSeedDir records the gif seed directory (cfg.GifSeedDir) so
|
||||
// AdminDeleteUncategorizedGifs can remove a seed-imported entry's source
|
||||
// file alongside its DB row -- see the field's doc comment for why that
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ type RetentionWorker struct {
|
|||
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
|
||||
orphanedMedia OrphanedMediaRetentionStore
|
||||
hardMedia HardMediaRetentionStore
|
||||
eviction StorageEvictionStore
|
||||
evictionEnabled bool
|
||||
logger *zap.Logger
|
||||
retention time.Duration
|
||||
botAPIRetention time.Duration
|
||||
|
|
@ -311,8 +313,19 @@ func (w *RetentionWorker) mediaRetentionInterval() time.Duration {
|
|||
if w.hardMedia != nil && w.hardMediaMaxAge > 0 && (maxAge == 0 || w.hardMediaMaxAge < maxAge) {
|
||||
maxAge = w.hardMediaMaxAge
|
||||
}
|
||||
evictionActive := w.eviction != nil && w.evictionEnabled
|
||||
if maxAge <= 0 {
|
||||
return 0
|
||||
if !evictionActive {
|
||||
return 0
|
||||
}
|
||||
// Eviction is reactive to total bytes, not a fixed age, and is
|
||||
// independent of TELESRV_STORAGE_RETENTION_MODE -- it can be the only
|
||||
// thing enabled. Fall back to the shared housekeeping interval so it
|
||||
// still gets its own ticker instead of never running.
|
||||
maxAge = w.interval
|
||||
if maxAge <= 0 {
|
||||
maxAge = time.Hour
|
||||
}
|
||||
}
|
||||
interval := w.interval
|
||||
if interval <= 0 || maxAge < interval {
|
||||
|
|
@ -324,12 +337,36 @@ func (w *RetentionWorker) mediaRetentionInterval() time.Duration {
|
|||
return interval
|
||||
}
|
||||
|
||||
// StorageEvictionStore actively reclaims space once total physical blob bytes
|
||||
// exceed TELESRV_STORAGE_MAX_TOTAL_BYTES: the oldest documents/photos
|
||||
// (interleaved by created_at across both tables, regardless of category or
|
||||
// age) are purged -- reusing the exact same blob-purge + retention-purge
|
||||
// notice primitive as HardMediaRetentionStore -- until back under budget.
|
||||
// Independent of TELESRV_STORAGE_RETENTION_MODE.
|
||||
type StorageEvictionStore interface {
|
||||
EvictOldestMediaOverBudget(ctx context.Context, limit int) (int, error)
|
||||
}
|
||||
|
||||
// WithStorageEviction enables the active eviction sweep
|
||||
// (TELESRV_STORAGE_EVICTION_ENABLE). It shares the same ticker as the
|
||||
// orphan/hard media sweeps (see mediaRetentionInterval) and is independent of
|
||||
// TELESRV_STORAGE_RETENTION_MODE -- it can run even when that is "off".
|
||||
func (w *RetentionWorker) WithStorageEviction(store StorageEvictionStore, enabled bool) *RetentionWorker {
|
||||
w.eviction = store
|
||||
w.evictionEnabled = enabled
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) runMediaRetentionOnce(ctx context.Context) {
|
||||
if w.orphanedMedia != nil && w.orphanedMediaMaxAge > 0 {
|
||||
// Only ever touches documents/photos already marked orphaned (no live
|
||||
// message/profile-photo/sticker-set reference remains) -- media still
|
||||
// visible in a conversation is never a candidate, regardless of age.
|
||||
mediaDeleted, err := w.orphanedMedia.DeleteOrphanedOlderThan(ctx, time.Now().Add(-w.orphanedMediaMaxAge), w.batch)
|
||||
// The store itself derives each category's effective cutoff from
|
||||
// "now" (per-category retention age overrides, global age as
|
||||
// fallback) -- this worker only owns when the sweep runs, not the
|
||||
// per-category cutoff math.
|
||||
mediaDeleted, err := w.orphanedMedia.DeleteOrphanedOlderThan(ctx, time.Now(), w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("orphaned media storage retention sweep failed", zap.Error(err))
|
||||
} else if mediaDeleted > 0 {
|
||||
|
|
@ -337,19 +374,28 @@ func (w *RetentionWorker) runMediaRetentionOnce(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
if w.hardMedia != nil && w.hardMediaMaxAge > 0 {
|
||||
// "Hard" mode: purges blob bytes for documents/photos older than
|
||||
// hardMediaMaxAge regardless of whether a live reference remains --
|
||||
// the store is required to keep the document/photo metadata row
|
||||
// intact, only removing file_blobs rows/bytes, so a message still
|
||||
// renders its media placeholder (LOCATION_INVALID on download)
|
||||
// instead of breaking outright.
|
||||
hardDeleted, err := w.hardMedia.DeleteBlobBytesForMediaOlderThan(ctx, time.Now().Add(-w.hardMediaMaxAge), w.batch)
|
||||
// "Hard" mode: purges blob bytes for documents/photos older than the
|
||||
// effective per-category age (falling back to hardMediaMaxAge)
|
||||
// regardless of whether a live reference remains -- the store is
|
||||
// required to keep the document/photo metadata row intact, only
|
||||
// removing file_blobs rows/bytes, so a message still renders its
|
||||
// media placeholder (LOCATION_INVALID on download) instead of
|
||||
// breaking outright.
|
||||
hardDeleted, err := w.hardMedia.DeleteBlobBytesForMediaOlderThan(ctx, time.Now(), w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("hard media storage retention sweep failed", zap.Error(err))
|
||||
} else if hardDeleted > 0 {
|
||||
w.logger.Info("hard media storage retention sweep complete", zap.Int("deleted", hardDeleted))
|
||||
}
|
||||
}
|
||||
if w.eviction != nil && w.evictionEnabled {
|
||||
evicted, err := w.eviction.EvictOldestMediaOverBudget(ctx, w.batch)
|
||||
if err != nil {
|
||||
w.logger.Warn("active storage eviction sweep failed", zap.Error(err))
|
||||
} else if evicted > 0 {
|
||||
w.logger.Info("active storage eviction sweep complete", zap.Int("evicted", evicted))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RetentionWorker) runOutboxPoisonOnce(ctx context.Context) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue