fix for retention logic

This commit is contained in:
onysd 2026-09-03 05:00:42 +03:00
parent 70c0ba44f0
commit e6bfe2d444
35 changed files with 2108 additions and 132 deletions

View file

@ -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 {

View file

@ -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 --

View 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")
}
}

View 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
}

View 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)
}
}
}

View file

@ -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

View file

@ -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) {