adding more functions to media managament system

This commit is contained in:
onysd 2026-09-03 00:54:09 +03:00
parent 95e62c2d77
commit 70c0ba44f0
30 changed files with 1494 additions and 104 deletions

View file

@ -19,9 +19,38 @@ type OrphanedMediaRetentionStore interface {
// how long a document/photo must have been orphaned before it's actually
// deleted (not how old the media itself is) -- <=0 leaves the sweep
// disabled even if a store is provided, matching
// TELESRV_STORAGE_RETENTION_ENABLE=false being the safe default.
// TELESRV_STORAGE_RETENTION_MODE=off/orphan-with-no-age being the safe
// default. Mutually exclusive with WithHardMediaRetention -- the resolved
// TELESRV_STORAGE_RETENTION_MODE is one of "off"/"orphan"/"hard", never
// both orphan and hard sweeps at once.
func (w *RetentionWorker) WithOrphanedMediaRetention(store OrphanedMediaRetentionStore, maxAge time.Duration) *RetentionWorker {
w.orphanedMedia = store
w.orphanedMediaMaxAge = maxAge
return w
}
// HardMediaRetentionStore physically deletes a document/photo's blob bytes
// once the media itself (its upload/created time, not how long it has been
// orphaned) is older than the configured age, REGARDLESS of whether a live
// message/profile-photo/sticker-set still references it. Unlike
// OrphanedMediaRetentionStore, it must never delete the document/photo
// metadata row -- only the underlying file_blobs row(s) and, once confirming
// no other row still needs the same content-addressed object, the physical
// bytes. This keeps a message rendering its media placeholder (dimensions,
// mime type, filename) after the bytes are gone; a subsequent download
// resolves to LOCATION_INVALID instead of the message breaking outright.
type HardMediaRetentionStore interface {
DeleteBlobBytesForMediaOlderThan(ctx context.Context, cutoff time.Time, limit int) (int, error)
}
// WithHardMediaRetention enables the aggressive storage retention sweep
// (TELESRV_STORAGE_RETENTION_MODE=hard). maxAge is how old the media itself
// must be (its created_at, not an orphaned_at grace period) before its blob
// bytes are purged -- <=0 leaves the sweep disabled even if a store is
// provided. Mutually exclusive with WithOrphanedMediaRetention -- call at
// most one of the two, matching the config's single 3-way retention mode.
func (w *RetentionWorker) WithHardMediaRetention(store HardMediaRetentionStore, maxAge time.Duration) *RetentionWorker {
w.hardMedia = store
w.hardMediaMaxAge = maxAge
return w
}

View file

@ -95,6 +95,10 @@ const (
// 继续保留,在线漏推由正常 difference 路径补偿。
defaultOutboxPoisonRetention = time.Minute
defaultOutboxPoisonInterval = 15 * time.Second
// minMediaRetentionInterval floors the derived media-sweep cadence below
// so a very short (e.g. test-only) TELESRV_STORAGE_RETENTION_MAX_AGE
// can't spin the sweep query in a tight loop.
minMediaRetentionInterval = 15 * time.Second
)
// RetentionWorker 周期性回收存储中的死数据。
@ -119,6 +123,7 @@ type RetentionWorker struct {
activeAuthKeys ActiveRawAuthKeyProvider
activeAuthKeyHeartbeat ActiveAuthKeyHeartbeatStore
orphanedMedia OrphanedMediaRetentionStore
hardMedia HardMediaRetentionStore
logger *zap.Logger
retention time.Duration
botAPIRetention time.Duration
@ -128,6 +133,7 @@ type RetentionWorker struct {
outboxPoisonRetention time.Duration
outboxPoisonInterval time.Duration
orphanedMediaMaxAge time.Duration
hardMediaMaxAge time.Duration
interval time.Duration
batch int
}
@ -259,6 +265,15 @@ func (w *RetentionWorker) Run(ctx context.Context) {
heartbeatC = heartbeatTicker.C
defer heartbeatTicker.Stop()
}
var (
mediaTicker *time.Ticker
mediaC <-chan time.Time
)
if interval := w.mediaRetentionInterval(); interval > 0 {
mediaTicker = time.NewTicker(interval)
mediaC = mediaTicker.C
defer mediaTicker.Stop()
}
for {
select {
case <-ctx.Done():
@ -269,6 +284,8 @@ func (w *RetentionWorker) Run(ctx context.Context) {
w.runOutboxPoisonOnce(ctx)
case <-heartbeatC:
w.heartbeatActiveAuthKeys(ctx)
case <-mediaC:
w.runMediaRetentionOnce(ctx)
}
}
}
@ -276,6 +293,63 @@ func (w *RetentionWorker) Run(ctx context.Context) {
func (w *RetentionWorker) runOnce(ctx context.Context) {
w.runOutboxPoisonOnce(ctx)
w.runRetentionOnce(ctx)
w.runMediaRetentionOnce(ctx)
}
// mediaRetentionInterval derives how often the storage media sweep
// (orphan/hard) runs, independent of the shared housekeeping w.interval.
// A short TELESRV_STORAGE_RETENTION_MAX_AGE (e.g. 30m) configured under a
// much longer TELESRV_RETENTION_INTERVAL (default 1h) would otherwise let
// media sit for up to age+interval past its cutoff before actually being
// swept -- capping the sweep cadence at the retention age itself bounds
// that worst case to at most 2x the configured age instead.
func (w *RetentionWorker) mediaRetentionInterval() time.Duration {
var maxAge time.Duration
if w.orphanedMedia != nil && w.orphanedMediaMaxAge > 0 {
maxAge = w.orphanedMediaMaxAge
}
if w.hardMedia != nil && w.hardMediaMaxAge > 0 && (maxAge == 0 || w.hardMediaMaxAge < maxAge) {
maxAge = w.hardMediaMaxAge
}
if maxAge <= 0 {
return 0
}
interval := w.interval
if interval <= 0 || maxAge < interval {
interval = maxAge
}
if interval < minMediaRetentionInterval {
interval = minMediaRetentionInterval
}
return interval
}
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)
if err != nil {
w.logger.Warn("orphaned media storage retention sweep failed", zap.Error(err))
} else if mediaDeleted > 0 {
w.logger.Info("orphaned media storage retention sweep complete", zap.Int("deleted", mediaDeleted))
}
}
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)
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))
}
}
}
func (w *RetentionWorker) runOutboxPoisonOnce(ctx context.Context) {
@ -414,17 +488,6 @@ func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
w.logger.Info("expired channel_update_events contiguous-prefix cleanup complete", zap.Int("deleted", channelDeleted))
}
}
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)
if err != nil {
w.logger.Warn("orphaned media storage retention sweep failed", zap.Error(err))
} else if mediaDeleted > 0 {
w.logger.Info("orphaned media storage retention sweep complete", zap.Int("deleted", mediaDeleted))
}
}
}
func (w *RetentionWorker) orphanHeartbeatInterval() time.Duration {

View file

@ -393,3 +393,67 @@ func TestRetentionWorkerSkipsOrphanDeleteWhenHeartbeatFails(t *testing.T) {
t.Fatalf("heartbeat failure signals = %+v", entries)
}
}
type fakeHardMediaRetention struct {
calls int
cutoff time.Time
limit int
deleted int
}
func (f *fakeHardMediaRetention) DeleteBlobBytesForMediaOlderThan(_ context.Context, cutoff time.Time, limit int) (int, error) {
f.calls++
f.cutoff = cutoff
f.limit = limit
return f.deleted, nil
}
// TestRetentionWorkerMediaSweepIntervalNeverExceedsMaxAge guards the fix for
// a real gotcha reported live: with a short storage retention age (e.g. 30m)
// under the default 1h TELESRV_RETENTION_INTERVAL housekeeping cadence,
// media eligible for hard/orphan deletion used to sit for up to age+interval
// before the shared ticker actually got around to sweeping it (a 30m age
// could take up to 1h30m to actually disappear). The sweep now runs on its
// own ticker capped at the configured age, so it never lags by more than
// roughly one age-window.
func TestRetentionWorkerMediaSweepIntervalNeverExceedsMaxAge(t *testing.T) {
hard := &fakeHardMediaRetention{}
w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 50).
WithHardMediaRetention(hard, 30*time.Minute)
if got := w.mediaRetentionInterval(); got != 30*time.Minute {
t.Fatalf("media retention interval = %v, want min(interval=1h, maxAge=30m) = 30m", got)
}
}
func TestRetentionWorkerMediaSweepIntervalFloorsAtMinimum(t *testing.T) {
hard := &fakeHardMediaRetention{}
w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 50).
WithHardMediaRetention(hard, time.Second)
if got := w.mediaRetentionInterval(); got != minMediaRetentionInterval {
t.Fatalf("media retention interval = %v, want floor %v", got, minMediaRetentionInterval)
}
}
func TestRetentionWorkerMediaSweepIntervalZeroWhenNoModeConfigured(t *testing.T) {
w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 50)
if got := w.mediaRetentionInterval(); got != 0 {
t.Fatalf("media retention interval = %v, want 0 (no separate ticker) when retention is off", got)
}
}
// TestRetentionWorkerRunsHardMediaSweepOnStartup confirms runOnce (called
// once immediately when Run starts, matching every other retention check)
// still fires the media sweep too, not just the new dedicated ticker.
func TestRetentionWorkerRunsHardMediaSweepOnStartup(t *testing.T) {
hard := &fakeHardMediaRetention{deleted: 4}
w := NewRetentionWorker(&fakeOutboxRetention{}, nil, zap.NewNop(), time.Hour, time.Hour, 50).
WithHardMediaRetention(hard, 30*time.Minute)
w.runOnce(context.Background())
if hard.calls != 1 {
t.Fatalf("hard media sweep calls = %d, want 1", hard.calls)
}
}