[feature] Unused avatar and header cleanup (#574)
* rename + tidy up remote pruning * fix media attachment account join see https://bun.uptrace.dev/guide/golang-orm.html#table-relationships * update logging to new function name * add get avatars and headers to bun * add pruneallmeta function * don't set uncached since we're deleting anyway * fix totalPruned being in wrong place * test pruning meta * go fmt ./... * rename mediaprune * add meta pruning to routine mediaprune * tidy up cleanup job scheduling * rename adminmediaremoteprune * update mediacleanup to use renamed prune func * update swagger docs a little bit * reuse cancel + context
This commit is contained in:
parent
6e947ff266
commit
b143877995
|
@ -239,7 +239,7 @@ var Start action.GTSAction = func(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform initial media prune in case value of MediaRemoteCacheDays changed
|
// perform initial media prune in case value of MediaRemoteCacheDays changed
|
||||||
if err := processor.AdminMediaRemotePrune(ctx, viper.GetInt(config.Keys.MediaRemoteCacheDays)); err != nil {
|
if err := processor.AdminMediaPrune(ctx, viper.GetInt(config.Keys.MediaRemoteCacheDays)); err != nil {
|
||||||
return fmt.Errorf("error during initial media prune: %s", err)
|
return fmt.Errorf("error during initial media prune: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2580,6 +2580,7 @@ paths:
|
||||||
- application/json
|
- application/json
|
||||||
- application/xml
|
- application/xml
|
||||||
- application/x-www-form-urlencoded
|
- application/x-www-form-urlencoded
|
||||||
|
description: Also cleans up unused headers + avatars from the media cache.
|
||||||
operationId: mediaCleanup
|
operationId: mediaCleanup
|
||||||
parameters:
|
parameters:
|
||||||
- description: |-
|
- description: |-
|
||||||
|
|
|
@ -33,6 +33,7 @@ import (
|
||||||
// MediaCleanupPOSTHandler swagger:operation POST /api/v1/admin/media_cleanup mediaCleanup
|
// MediaCleanupPOSTHandler swagger:operation POST /api/v1/admin/media_cleanup mediaCleanup
|
||||||
//
|
//
|
||||||
// Clean up remote media older than the specified number of days.
|
// Clean up remote media older than the specified number of days.
|
||||||
|
// Also cleans up unused headers + avatars from the media cache.
|
||||||
//
|
//
|
||||||
// ---
|
// ---
|
||||||
// tags:
|
// tags:
|
||||||
|
@ -100,7 +101,7 @@ func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) {
|
||||||
remoteCacheDays = 0
|
remoteCacheDays = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if errWithCode := m.processor.AdminMediaRemotePrune(c.Request.Context(), remoteCacheDays); errWithCode != nil {
|
if errWithCode := m.processor.AdminMediaPrune(c.Request.Context(), remoteCacheDays); errWithCode != nil {
|
||||||
l.Debugf("error starting prune of remote media: %s", errWithCode.Error())
|
l.Debugf("error starting prune of remote media: %s", errWithCode.Error())
|
||||||
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
|
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
|
||||||
return
|
return
|
||||||
|
|
|
@ -72,3 +72,29 @@ func (m *mediaDB) GetRemoteOlderThan(ctx context.Context, olderThan time.Time, l
|
||||||
}
|
}
|
||||||
return attachments, nil
|
return attachments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mediaDB) GetAvatarsAndHeaders(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, db.Error) {
|
||||||
|
attachments := []*gtsmodel.MediaAttachment{}
|
||||||
|
|
||||||
|
q := m.newMediaQ(&attachments).
|
||||||
|
WhereGroup(" AND ", func(innerQ *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
return innerQ.
|
||||||
|
WhereOr("media_attachment.avatar = true").
|
||||||
|
WhereOr("media_attachment.header = true")
|
||||||
|
}).
|
||||||
|
Order("media_attachment.id DESC")
|
||||||
|
|
||||||
|
if maxID != "" {
|
||||||
|
q = q.Where("media_attachment.id < ?", maxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit != 0 {
|
||||||
|
q = q.Limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Scan(ctx); err != nil {
|
||||||
|
return nil, m.conn.ProcessError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments, nil
|
||||||
|
}
|
||||||
|
|
|
@ -43,6 +43,14 @@ func (suite *MediaTestSuite) TestGetOlder() {
|
||||||
suite.Len(attachments, 2)
|
suite.Len(attachments, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *MediaTestSuite) TestGetAvisAndHeaders() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
attachments, err := suite.db.GetAvatarsAndHeaders(ctx, "", 20)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Len(attachments, 2)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaTestSuite(t *testing.T) {
|
func TestMediaTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(MediaTestSuite))
|
suite.Run(t, new(MediaTestSuite))
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,4 +35,7 @@ type Media interface {
|
||||||
// The selected media attachments will be those with both a URL and a RemoteURL filled in.
|
// The selected media attachments will be those with both a URL and a RemoteURL filled in.
|
||||||
// In other words, media attachments that originated remotely, and that we currently have cached locally.
|
// In other words, media attachments that originated remotely, and that we currently have cached locally.
|
||||||
GetRemoteOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, Error)
|
GetRemoteOlderThan(ctx context.Context, olderThan time.Time, limit int) ([]*gtsmodel.MediaAttachment, Error)
|
||||||
|
// GetAvatarsAndHeaders fetches limit n avatars and headers with an id < maxID. These headers
|
||||||
|
// and avis may be in use or not; the caller should check this if it's important.
|
||||||
|
GetAvatarsAndHeaders(ctx context.Context, maxID string, limit int) ([]*gtsmodel.MediaAttachment, Error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ type MediaAttachment struct {
|
||||||
Type FileType `validate:"oneof=Image Gif Audio Video Unknown" bun:",nullzero,notnull"` // Type of file (image/gif/audio/video)
|
Type FileType `validate:"oneof=Image Gif Audio Video Unknown" bun:",nullzero,notnull"` // Type of file (image/gif/audio/video)
|
||||||
FileMeta FileMeta `validate:"required" bun:",embed:filemeta_,nullzero,notnull"` // Metadata about the file
|
FileMeta FileMeta `validate:"required" bun:",embed:filemeta_,nullzero,notnull"` // Metadata about the file
|
||||||
AccountID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
|
AccountID string `validate:"required,ulid" bun:"type:CHAR(26),nullzero,notnull"` // To which account does this attachment belong
|
||||||
Account *Account `validate:"-" bun:"rel:has-one"` // Account corresponding to accountID
|
Account *Account `validate:"-" bun:"rel:belongs-to,join:account_id=id"` // Account corresponding to accountID
|
||||||
Description string `validate:"-" bun:""` // Description of the attachment (for screenreaders)
|
Description string `validate:"-" bun:""` // Description of the attachment (for screenreaders)
|
||||||
ScheduledStatusID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
|
ScheduledStatusID string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // To which scheduled status does this attachment belong
|
||||||
Blurhash string `validate:"required_if=Type Image,required_if=Type Gif,required_if=Type Video" bun:",nullzero"` // What is the generated blurhash of this attachment
|
Blurhash string `validate:"required_if=Type Image,required_if=Type Gif,required_if=Type Video" bun:",nullzero"` // What is the generated blurhash of this attachment
|
||||||
|
|
|
@ -32,6 +32,9 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// selectPruneLimit is the amount of media entries to select at a time from the db when pruning
|
||||||
|
const selectPruneLimit = 20
|
||||||
|
|
||||||
// Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs.
|
// Manager provides an interface for managing media: parsing, storing, and retrieving media objects like photos, videos, and gifs.
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// ProcessMedia begins the process of decoding and storing the given data as an attachment.
|
// ProcessMedia begins the process of decoding and storing the given data as an attachment.
|
||||||
|
@ -66,10 +69,19 @@ type Manager interface {
|
||||||
ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error)
|
ProcessEmoji(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, shortcode string, id string, uri string, ai *AdditionalEmojiInfo) (*ProcessingEmoji, error)
|
||||||
// RecacheMedia refetches, reprocesses, and recaches an existing attachment that has been uncached via pruneRemote.
|
// RecacheMedia refetches, reprocesses, and recaches an existing attachment that has been uncached via pruneRemote.
|
||||||
RecacheMedia(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, attachmentID string) (*ProcessingMedia, error)
|
RecacheMedia(ctx context.Context, data DataFunc, postData PostDataCallbackFunc, attachmentID string) (*ProcessingMedia, error)
|
||||||
// PruneRemote prunes all remote media cached on this instance that's older than the given amount of days.
|
|
||||||
|
// PruneAllRemote prunes all remote media attachments cached on this instance which are older than the given amount of days.
|
||||||
// 'Pruning' in this context means removing the locally stored data of the attachment (both thumbnail and full size),
|
// 'Pruning' in this context means removing the locally stored data of the attachment (both thumbnail and full size),
|
||||||
// and setting 'cached' to false on the associated attachment.
|
// and setting 'cached' to false on the associated attachment.
|
||||||
PruneRemote(ctx context.Context, olderThanDays int) (int, error)
|
//
|
||||||
|
// The returned int is the amount of media that was pruned by this function.
|
||||||
|
PruneAllRemote(ctx context.Context, olderThanDays int) (int, error)
|
||||||
|
// PruneAllMeta prunes unused meta media -- currently, this means unused avatars + headers, but can also be extended
|
||||||
|
// to include things like attachments that were uploaded on this server but left unused, etc.
|
||||||
|
//
|
||||||
|
// The returned int is the amount of media that was pruned by this function.
|
||||||
|
PruneAllMeta(ctx context.Context) (int, error)
|
||||||
|
|
||||||
// Stop stops the underlying worker pool of the manager. It should be called
|
// Stop stops the underlying worker pool of the manager. It should be called
|
||||||
// when closing GoToSocial in order to cleanly finish any in-progress jobs.
|
// when closing GoToSocial in order to cleanly finish any in-progress jobs.
|
||||||
// It will block until workers are finished processing.
|
// It will block until workers are finished processing.
|
||||||
|
@ -128,53 +140,8 @@ func NewManager(database db.DB, storage *kv.KVStore) (Manager, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// start remote cache cleanup cronjob if configured
|
if err := scheduleCleanupJobs(m); err != nil {
|
||||||
cacheCleanupDays := viper.GetInt(config.Keys.MediaRemoteCacheDays)
|
return nil, err
|
||||||
if cacheCleanupDays != 0 {
|
|
||||||
// we need a way of cancelling running jobs if the media manager is told to stop
|
|
||||||
pruneCtx, pruneCancel := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
// create a new cron instance and add a function to it
|
|
||||||
c := cron.New(cron.WithLogger(&logrusWrapper{}))
|
|
||||||
|
|
||||||
pruneFunc := func() {
|
|
||||||
begin := time.Now()
|
|
||||||
pruned, err := m.PruneRemote(pruneCtx, cacheCleanupDays)
|
|
||||||
if err != nil {
|
|
||||||
logrus.Errorf("media manager: error pruning remote cache: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logrus.Infof("media manager: pruned %d remote cache entries in %s", pruned, time.Since(begin))
|
|
||||||
}
|
|
||||||
|
|
||||||
// run every night
|
|
||||||
entryID, err := c.AddFunc("@midnight", pruneFunc)
|
|
||||||
if err != nil {
|
|
||||||
pruneCancel()
|
|
||||||
return nil, fmt.Errorf("error starting media manager remote cache cleanup job: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// since we're running a cron job, we should define how the manager should stop them
|
|
||||||
m.stopCronJobs = func() error {
|
|
||||||
// try to stop any jobs gracefully by waiting til they're finished
|
|
||||||
cronCtx := c.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-cronCtx.Done():
|
|
||||||
logrus.Infof("media manager: cron finished jobs and stopped gracefully")
|
|
||||||
case <-time.After(1 * time.Minute):
|
|
||||||
logrus.Infof("media manager: cron didn't stop after 60 seconds, will force close")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// whether the job is finished neatly or we had to wait a minute, cancel the context on the prune job
|
|
||||||
pruneCancel()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// now start all the cron stuff we've lined up
|
|
||||||
c.Start()
|
|
||||||
logrus.Infof("media manager: next scheduled remote cache cleanup is %q", c.Entry(entryID).Next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
@ -213,9 +180,7 @@ func (m *manager) Stop() error {
|
||||||
emojiErr := m.emojiWorker.Stop()
|
emojiErr := m.emojiWorker.Stop()
|
||||||
|
|
||||||
var cronErr error
|
var cronErr error
|
||||||
|
|
||||||
if m.stopCronJobs != nil {
|
if m.stopCronJobs != nil {
|
||||||
// only set if cache prune age > 0
|
|
||||||
cronErr = m.stopCronJobs()
|
cronErr = m.stopCronJobs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,5 +189,60 @@ func (m *manager) Stop() error {
|
||||||
} else if emojiErr != nil {
|
} else if emojiErr != nil {
|
||||||
return emojiErr
|
return emojiErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return cronErr
|
return cronErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scheduleCleanupJobs(m *manager) error {
|
||||||
|
// create a new cron instance for scheduling cleanup jobs
|
||||||
|
c := cron.New(cron.WithLogger(&logrusWrapper{}))
|
||||||
|
pruneCtx, pruneCancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
if _, err := c.AddFunc("@midnight", func() {
|
||||||
|
begin := time.Now()
|
||||||
|
pruned, err := m.PruneAllMeta(pruneCtx)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("media manager: error pruning meta: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logrus.Infof("media manager: pruned %d meta entries in %s", pruned, time.Since(begin))
|
||||||
|
}); err != nil {
|
||||||
|
pruneCancel()
|
||||||
|
return fmt.Errorf("error starting media manager meta cleanup job: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start remote cache cleanup cronjob if configured
|
||||||
|
if mediaRemoteCacheDays := viper.GetInt(config.Keys.MediaRemoteCacheDays); mediaRemoteCacheDays > 0 {
|
||||||
|
if _, err := c.AddFunc("@midnight", func() {
|
||||||
|
begin := time.Now()
|
||||||
|
pruned, err := m.PruneAllRemote(pruneCtx, mediaRemoteCacheDays)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("media manager: error pruning remote cache: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logrus.Infof("media manager: pruned %d remote cache entries in %s", pruned, time.Since(begin))
|
||||||
|
}); err != nil {
|
||||||
|
pruneCancel()
|
||||||
|
return fmt.Errorf("error starting media manager remote cache cleanup job: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to stop any jobs gracefully by waiting til they're finished
|
||||||
|
m.stopCronJobs = func() error {
|
||||||
|
cronCtx := c.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cronCtx.Done():
|
||||||
|
logrus.Infof("media manager: cron finished jobs and stopped gracefully")
|
||||||
|
case <-time.After(1 * time.Minute):
|
||||||
|
logrus.Infof("media manager: cron didn't stop after 60 seconds, will force close jobs")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneCancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Start()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ type MediaStandardTestSuite struct {
|
||||||
storage *kv.KVStore
|
storage *kv.KVStore
|
||||||
manager media.Manager
|
manager media.Manager
|
||||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||||
|
testAccounts map[string]*gtsmodel.Account
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *MediaStandardTestSuite) SetupSuite() {
|
func (suite *MediaStandardTestSuite) SetupSuite() {
|
||||||
|
@ -48,6 +49,7 @@ func (suite *MediaStandardTestSuite) SetupTest() {
|
||||||
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
||||||
testrig.StandardDBSetup(suite.db, nil)
|
testrig.StandardDBSetup(suite.db, nil)
|
||||||
suite.testAttachments = testrig.NewTestAttachments()
|
suite.testAttachments = testrig.NewTestAttachments()
|
||||||
|
suite.testAccounts = testrig.NewTestAccounts()
|
||||||
suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage)
|
suite.manager = testrig.NewTestMediaManager(suite.db, suite.storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-store/storage"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *manager) PruneAllMeta(ctx context.Context) (int, error) {
|
||||||
|
var totalPruned int
|
||||||
|
var maxID string
|
||||||
|
var attachments []*gtsmodel.MediaAttachment
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// select 20 attachments at a time and prune them
|
||||||
|
for attachments, err = m.db.GetAvatarsAndHeaders(ctx, maxID, selectPruneLimit); err == nil && len(attachments) != 0; attachments, err = m.db.GetAvatarsAndHeaders(ctx, maxID, selectPruneLimit) {
|
||||||
|
// use the id of the last attachment in the slice as the next 'maxID' value
|
||||||
|
l := len(attachments)
|
||||||
|
logrus.Tracef("PruneAllMeta: got %d attachments with maxID < %s", l, maxID)
|
||||||
|
maxID = attachments[l-1].ID
|
||||||
|
|
||||||
|
// prune each attachment that meets one of the following criteria:
|
||||||
|
// - has no owning account in the database
|
||||||
|
// - is a header but isn't the owning account's current header
|
||||||
|
// - is an avatar but isn't the owning account's current avatar
|
||||||
|
for _, attachment := range attachments {
|
||||||
|
if attachment.Account == nil ||
|
||||||
|
(attachment.Header && attachment.ID != attachment.Account.HeaderMediaAttachmentID) ||
|
||||||
|
(attachment.Avatar && attachment.ID != attachment.Account.AvatarMediaAttachmentID) {
|
||||||
|
if err := m.pruneOneAvatarOrHeader(ctx, attachment); err != nil {
|
||||||
|
return totalPruned, err
|
||||||
|
}
|
||||||
|
totalPruned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure we don't have a real error when we leave the loop
|
||||||
|
if err != nil && err != db.ErrNoEntries {
|
||||||
|
return totalPruned, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.Infof("PruneAllMeta: finished pruning avatars + headers: pruned %d entries", totalPruned)
|
||||||
|
return totalPruned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) pruneOneAvatarOrHeader(ctx context.Context, attachment *gtsmodel.MediaAttachment) error {
|
||||||
|
if attachment.File.Path != "" {
|
||||||
|
// delete the full size attachment from storage
|
||||||
|
logrus.Tracef("pruneOneAvatarOrHeader: deleting %s", attachment.File.Path)
|
||||||
|
if err := m.storage.Delete(attachment.File.Path); err != nil && err != storage.ErrNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment.Thumbnail.Path != "" {
|
||||||
|
// delete the thumbnail from storage
|
||||||
|
logrus.Tracef("pruneOneAvatarOrHeader: deleting %s", attachment.Thumbnail.Path)
|
||||||
|
if err := m.storage.Delete(attachment.Thumbnail.Path); err != nil && err != storage.ErrNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the attachment entry completely
|
||||||
|
return m.db.DeleteByID(ctx, attachment.ID, >smodel.MediaAttachment{})
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package media_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-store/storage"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PruneMetaTestSuite struct {
|
||||||
|
MediaStandardTestSuite
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *PruneMetaTestSuite) TestPruneMeta() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// start by clearing zork's avatar + header
|
||||||
|
zorkOldAvatar := suite.testAttachments["local_account_1_avatar"]
|
||||||
|
zorkOldHeader := suite.testAttachments["local_account_1_avatar"]
|
||||||
|
zork := suite.testAccounts["local_account_1"]
|
||||||
|
zork.AvatarMediaAttachmentID = ""
|
||||||
|
zork.HeaderMediaAttachmentID = ""
|
||||||
|
if err := suite.db.UpdateByPrimaryKey(ctx, zork); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPruned, err := suite.manager.PruneAllMeta(ctx)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
|
// media should no longer be stored
|
||||||
|
_, err = suite.storage.Get(zorkOldAvatar.File.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldAvatar.Thumbnail.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldHeader.File.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldHeader.Thumbnail.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
|
||||||
|
// attachments should no longer be in the db
|
||||||
|
_, err = suite.db.GetAttachmentByID(ctx, zorkOldAvatar.ID)
|
||||||
|
suite.ErrorIs(err, db.ErrNoEntries)
|
||||||
|
_, err = suite.db.GetAttachmentByID(ctx, zorkOldHeader.ID)
|
||||||
|
suite.ErrorIs(err, db.ErrNoEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *PruneMetaTestSuite) TestPruneMetaTwice() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// start by clearing zork's avatar + header
|
||||||
|
zork := suite.testAccounts["local_account_1"]
|
||||||
|
zork.AvatarMediaAttachmentID = ""
|
||||||
|
zork.HeaderMediaAttachmentID = ""
|
||||||
|
if err := suite.db.UpdateByPrimaryKey(ctx, zork); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPruned, err := suite.manager.PruneAllMeta(ctx)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
|
// final prune should prune nothing, since the first prune already happened
|
||||||
|
totalPruned, err = suite.manager.PruneAllMeta(ctx)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(0, totalPruned)
|
||||||
|
}
|
||||||
|
func (suite *PruneMetaTestSuite) TestPruneMetaMultipleAccounts() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// start by clearing zork's avatar + header
|
||||||
|
zorkOldAvatar := suite.testAttachments["local_account_1_avatar"]
|
||||||
|
zorkOldHeader := suite.testAttachments["local_account_1_avatar"]
|
||||||
|
zork := suite.testAccounts["local_account_1"]
|
||||||
|
zork.AvatarMediaAttachmentID = ""
|
||||||
|
zork.HeaderMediaAttachmentID = ""
|
||||||
|
if err := suite.db.UpdateByPrimaryKey(ctx, zork); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set zork's unused header as belonging to turtle
|
||||||
|
turtle := suite.testAccounts["local_account_1"]
|
||||||
|
zorkOldHeader.AccountID = turtle.ID
|
||||||
|
if err := suite.db.UpdateByPrimaryKey(ctx, zorkOldHeader); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPruned, err := suite.manager.PruneAllMeta(ctx)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
|
// media should no longer be stored
|
||||||
|
_, err = suite.storage.Get(zorkOldAvatar.File.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldAvatar.Thumbnail.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldHeader.File.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
_, err = suite.storage.Get(zorkOldHeader.Thumbnail.Path)
|
||||||
|
suite.ErrorIs(err, storage.ErrNotFound)
|
||||||
|
|
||||||
|
// attachments should no longer be in the db
|
||||||
|
_, err = suite.db.GetAttachmentByID(ctx, zorkOldAvatar.ID)
|
||||||
|
suite.ErrorIs(err, db.ErrNoEntries)
|
||||||
|
_, err = suite.db.GetAttachmentByID(ctx, zorkOldHeader.ID)
|
||||||
|
suite.ErrorIs(err, db.ErrNoEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneMetaTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, &PruneMetaTestSuite{})
|
||||||
|
}
|
|
@ -29,10 +29,7 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// amount of media attachments to select at a time from the db when pruning
|
func (m *manager) PruneAllRemote(ctx context.Context, olderThanDays int) (int, error) {
|
||||||
const selectPruneLimit = 20
|
|
||||||
|
|
||||||
func (m *manager) PruneRemote(ctx context.Context, olderThanDays int) (int, error) {
|
|
||||||
var totalPruned int
|
var totalPruned int
|
||||||
|
|
||||||
// convert days into a duration string
|
// convert days into a duration string
|
||||||
|
@ -40,23 +37,23 @@ func (m *manager) PruneRemote(ctx context.Context, olderThanDays int) (int, erro
|
||||||
// parse the duration string into a duration
|
// parse the duration string into a duration
|
||||||
olderThanHours, err := time.ParseDuration(olderThanHoursString)
|
olderThanHours, err := time.ParseDuration(olderThanHoursString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return totalPruned, fmt.Errorf("PruneRemote: %d", err)
|
return totalPruned, fmt.Errorf("PruneAllRemote: %d", err)
|
||||||
}
|
}
|
||||||
// 'subtract' that from the time now to give our threshold
|
// 'subtract' that from the time now to give our threshold
|
||||||
olderThan := time.Now().Add(-olderThanHours)
|
olderThan := time.Now().Add(-olderThanHours)
|
||||||
logrus.Infof("PruneRemote: pruning media older than %s", olderThan)
|
logrus.Infof("PruneAllRemote: pruning media older than %s", olderThan)
|
||||||
|
|
||||||
// select 20 attachments at a time and prune them
|
// select 20 attachments at a time and prune them
|
||||||
for attachments, err := m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit); err == nil && len(attachments) != 0; attachments, err = m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit) {
|
for attachments, err := m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit); err == nil && len(attachments) != 0; attachments, err = m.db.GetRemoteOlderThan(ctx, olderThan, selectPruneLimit) {
|
||||||
|
|
||||||
// use the age of the oldest attachment (the last one in the slice) as the next 'older than' value
|
// use the age of the oldest attachment (the last one in the slice) as the next 'older than' value
|
||||||
l := len(attachments)
|
l := len(attachments)
|
||||||
logrus.Tracef("PruneRemote: got %d attachments older than %s", l, olderThan)
|
logrus.Tracef("PruneAllRemote: got %d attachments older than %s", l, olderThan)
|
||||||
olderThan = attachments[l-1].CreatedAt
|
olderThan = attachments[l-1].CreatedAt
|
||||||
|
|
||||||
// prune each attachment
|
// prune each attachment
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
if err := m.PruneOne(ctx, attachment); err != nil {
|
if err := m.pruneOneRemote(ctx, attachment); err != nil {
|
||||||
return totalPruned, err
|
return totalPruned, err
|
||||||
}
|
}
|
||||||
totalPruned++
|
totalPruned++
|
||||||
|
@ -68,14 +65,14 @@ func (m *manager) PruneRemote(ctx context.Context, olderThanDays int) (int, erro
|
||||||
return totalPruned, err
|
return totalPruned, err
|
||||||
}
|
}
|
||||||
|
|
||||||
logrus.Infof("PruneRemote: finished pruning remote media: pruned %d entries", totalPruned)
|
logrus.Infof("PruneAllRemote: finished pruning remote media: pruned %d entries", totalPruned)
|
||||||
return totalPruned, nil
|
return totalPruned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) PruneOne(ctx context.Context, attachment *gtsmodel.MediaAttachment) error {
|
func (m *manager) pruneOneRemote(ctx context.Context, attachment *gtsmodel.MediaAttachment) error {
|
||||||
if attachment.File.Path != "" {
|
if attachment.File.Path != "" {
|
||||||
// delete the full size attachment from storage
|
// delete the full size attachment from storage
|
||||||
logrus.Tracef("PruneOne: deleting %s", attachment.File.Path)
|
logrus.Tracef("pruneOneRemote: deleting %s", attachment.File.Path)
|
||||||
if err := m.storage.Delete(attachment.File.Path); err != nil && err != storage.ErrNotFound {
|
if err := m.storage.Delete(attachment.File.Path); err != nil && err != storage.ErrNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -84,7 +81,7 @@ func (m *manager) PruneOne(ctx context.Context, attachment *gtsmodel.MediaAttach
|
||||||
|
|
||||||
if attachment.Thumbnail.Path != "" {
|
if attachment.Thumbnail.Path != "" {
|
||||||
// delete the thumbnail from storage
|
// delete the thumbnail from storage
|
||||||
logrus.Tracef("PruneOne: deleting %s", attachment.Thumbnail.Path)
|
logrus.Tracef("pruneOneRemote: deleting %s", attachment.Thumbnail.Path)
|
||||||
if err := m.storage.Delete(attachment.Thumbnail.Path); err != nil && err != storage.ErrNotFound {
|
if err := m.storage.Delete(attachment.Thumbnail.Path); err != nil && err != storage.ErrNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ func (suite *PruneRemoteTestSuite) TestPruneRemote() {
|
||||||
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
|
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
|
||||||
suite.True(testAttachment.Cached)
|
suite.True(testAttachment.Cached)
|
||||||
|
|
||||||
totalPruned, err := suite.manager.PruneRemote(context.Background(), 1)
|
totalPruned, err := suite.manager.PruneAllRemote(context.Background(), 1)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(2, totalPruned)
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
|
@ -49,12 +49,12 @@ func (suite *PruneRemoteTestSuite) TestPruneRemote() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *PruneRemoteTestSuite) TestPruneRemoteTwice() {
|
func (suite *PruneRemoteTestSuite) TestPruneRemoteTwice() {
|
||||||
totalPruned, err := suite.manager.PruneRemote(context.Background(), 1)
|
totalPruned, err := suite.manager.PruneAllRemote(context.Background(), 1)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(2, totalPruned)
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
// final prune should prune nothing, since the first prune already happened
|
// final prune should prune nothing, since the first prune already happened
|
||||||
totalPrunedAgain, err := suite.manager.PruneRemote(context.Background(), 1)
|
totalPrunedAgain, err := suite.manager.PruneAllRemote(context.Background(), 1)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(0, totalPrunedAgain)
|
suite.Equal(0, totalPrunedAgain)
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ func (suite *PruneRemoteTestSuite) TestPruneAndRecache() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
|
testAttachment := suite.testAttachments["remote_account_1_status_1_attachment_1"]
|
||||||
|
|
||||||
totalPruned, err := suite.manager.PruneRemote(ctx, 1)
|
totalPruned, err := suite.manager.PruneAllRemote(ctx, 1)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(2, totalPruned)
|
suite.Equal(2, totalPruned)
|
||||||
|
|
||||||
|
@ -116,7 +116,7 @@ func (suite *PruneRemoteTestSuite) TestPruneOneNonExistent() {
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
|
|
||||||
// Now attempt to prune remote for item with db entry no file
|
// Now attempt to prune remote for item with db entry no file
|
||||||
totalPruned, err := suite.manager.PruneRemote(ctx, 1)
|
totalPruned, err := suite.manager.PruneAllRemote(ctx, 1)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(2, totalPruned)
|
suite.Equal(2, totalPruned)
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,6 @@ func (p *processor) AdminDomainBlockDelete(ctx context.Context, authed *oauth.Au
|
||||||
return p.adminProcessor.DomainBlockDelete(ctx, authed.Account, id)
|
return p.adminProcessor.DomainBlockDelete(ctx, authed.Account, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *processor) AdminMediaRemotePrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode {
|
func (p *processor) AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode {
|
||||||
return p.adminProcessor.MediaRemotePrune(ctx, mediaRemoteCacheDays)
|
return p.adminProcessor.MediaPrune(ctx, mediaRemoteCacheDays)
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ type Processor interface {
|
||||||
DomainBlockDelete(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode)
|
DomainBlockDelete(ctx context.Context, account *gtsmodel.Account, id string) (*apimodel.DomainBlock, gtserror.WithCode)
|
||||||
AccountAction(ctx context.Context, account *gtsmodel.Account, form *apimodel.AdminAccountActionRequest) gtserror.WithCode
|
AccountAction(ctx context.Context, account *gtsmodel.Account, form *apimodel.AdminAccountActionRequest) gtserror.WithCode
|
||||||
EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)
|
EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode)
|
||||||
MediaRemotePrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
|
MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
|
||||||
}
|
}
|
||||||
|
|
||||||
type processor struct {
|
type processor struct {
|
||||||
|
|
|
@ -26,18 +26,27 @@ import (
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *processor) MediaRemotePrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode {
|
func (p *processor) MediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode {
|
||||||
if mediaRemoteCacheDays < 0 {
|
if mediaRemoteCacheDays < 0 {
|
||||||
err := fmt.Errorf("invalid value for mediaRemoteCacheDays prune: value was %d, cannot be less than 0", mediaRemoteCacheDays)
|
err := fmt.Errorf("MediaPrune: invalid value for mediaRemoteCacheDays prune: value was %d, cannot be less than 0", mediaRemoteCacheDays)
|
||||||
return gtserror.NewErrorBadRequest(err, err.Error())
|
return gtserror.NewErrorBadRequest(err, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
pruned, err := p.mediaManager.PruneRemote(ctx, mediaRemoteCacheDays)
|
pruned, err := p.mediaManager.PruneAllRemote(ctx, mediaRemoteCacheDays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("MediaRemotePrune: error pruning: %s", err)
|
logrus.Errorf("MediaPrune: error pruning remote cache: %s", err)
|
||||||
} else {
|
} else {
|
||||||
logrus.Infof("MediaRemotePrune: pruned %d entries", pruned)
|
logrus.Infof("MediaPrune: pruned %d remote cache entries", pruned)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
pruned, err := p.mediaManager.PruneAllMeta(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("MediaPrune: error pruning meta: %s", err)
|
||||||
|
} else {
|
||||||
|
logrus.Infof("MediaPrune: pruned %d meta entries", pruned)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
@ -114,7 +114,7 @@ type Processor interface {
|
||||||
// AdminDomainBlockDelete deletes one domain block, specified by ID, returning the deleted domain block.
|
// AdminDomainBlockDelete deletes one domain block, specified by ID, returning the deleted domain block.
|
||||||
AdminDomainBlockDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.DomainBlock, gtserror.WithCode)
|
AdminDomainBlockDelete(ctx context.Context, authed *oauth.Auth, id string) (*apimodel.DomainBlock, gtserror.WithCode)
|
||||||
// AdminMediaRemotePrune triggers a prune of remote media according to the given number of mediaRemoteCacheDays
|
// AdminMediaRemotePrune triggers a prune of remote media according to the given number of mediaRemoteCacheDays
|
||||||
AdminMediaRemotePrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
|
AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode
|
||||||
|
|
||||||
// AppCreate processes the creation of a new API application
|
// AppCreate processes the creation of a new API application
|
||||||
AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error)
|
AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error)
|
||||||
|
|
Loading…
Reference in New Issue