adding more functions to media managament system
This commit is contained in:
parent
95e62c2d77
commit
70c0ba44f0
30 changed files with 1494 additions and 104 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue