diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 2c15d836d..2af5e20ca 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -85,6 +85,8 @@ func (c *Caches) Init() { c.initPollVoteIDs() c.initReport() c.initStatus() + c.initStatusBookmark() + c.initStatusBookmarkIDs() c.initStatusFave() c.initStatusFaveIDs() c.initTag() @@ -101,7 +103,7 @@ func (c *Caches) Init() { func (c *Caches) Start() { log.Infof(nil, "start: %p", c) - tryUntil("starting *gtsmodel.Webfinger cache", 5, func() bool { + tryUntil("starting webfinger cache", 5, func() bool { return c.GTS.Webfinger.Start(5 * time.Minute) }) } @@ -111,7 +113,7 @@ func (c *Caches) Start() { func (c *Caches) Stop() { log.Infof(nil, "stop: %p", c) - tryUntil("stopping *gtsmodel.Webfinger cache", 5, c.GTS.Webfinger.Stop) + tryUntil("stopping webfinger cache", 5, c.GTS.Webfinger.Stop) } // Sweep will sweep all the available caches to ensure none @@ -153,6 +155,8 @@ func (c *Caches) Sweep(threshold float64) { c.GTS.PollVoteIDs.Trim(threshold) c.GTS.Report.Trim(threshold) c.GTS.Status.Trim(threshold) + c.GTS.StatusBookmark.Trim(threshold) + c.GTS.StatusBookmarkIDs.Trim(threshold) c.GTS.StatusFave.Trim(threshold) c.GTS.StatusFaveIDs.Trim(threshold) c.GTS.Tag.Trim(threshold) diff --git a/internal/cache/db.go b/internal/cache/db.go index 8a113bd30..a5325f6ef 100644 --- a/internal/cache/db.go +++ b/internal/cache/db.go @@ -139,6 +139,12 @@ type GTSCaches struct { // Status provides access to the gtsmodel Status database cache. Status StructCache[*gtsmodel.Status] + // StatusBookmark ... + StatusBookmark StructCache[*gtsmodel.StatusBookmark] + + // StatusBookmarkIDs ... + StatusBookmarkIDs SliceCache[string] + // StatusFave provides access to the gtsmodel StatusFave database cache. StatusFave StructCache[*gtsmodel.StatusFave] @@ -1102,6 +1108,54 @@ func (c *Caches) initStatus() { }) } +func (c *Caches) initStatusBookmark() { + // Calculate maximum cache size. + cap := calculateResultCacheMax( + sizeofStatusBookmark(), // model in-mem size. + config.GetCacheStatusBookmarkMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + copyF := func(s1 *gtsmodel.StatusBookmark) *gtsmodel.StatusBookmark { + s2 := new(gtsmodel.StatusBookmark) + *s2 = *s1 + + // Don't include ptr fields that + // will be populated separately. + s2.Account = nil + s2.TargetAccount = nil + s2.Status = nil + + return s2 + } + + c.GTS.StatusBookmark.Init(structr.CacheConfig[*gtsmodel.StatusBookmark]{ + Indices: []structr.IndexConfig{ + {Fields: "ID"}, + {Fields: "AccountID,StatusID"}, + {Fields: "AccountID", Multiple: true}, + {Fields: "TargetAccountID", Multiple: true}, + {Fields: "StatusID", Multiple: true}, + }, + MaxSize: cap, + IgnoreErr: ignoreErrors, + Copy: copyF, + Invalidate: c.OnInvalidateStatusBookmark, + }) +} + +func (c *Caches) initStatusBookmarkIDs() { + // Calculate maximum cache size. + cap := calculateSliceCacheMax( + config.GetCacheStatusBookmarkIDsMemRatio(), + ) + + log.Infof(nil, "cache size = %d", cap) + + c.GTS.StatusBookmarkIDs.Init(0, cap) +} + func (c *Caches) initStatusFave() { // Calculate maximum cache size. cap := calculateResultCacheMax( diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go index 01d332d40..9c626d7a9 100644 --- a/internal/cache/invalidate.go +++ b/internal/cache/invalidate.go @@ -198,6 +198,11 @@ func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) { } } +func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) { + // Invalidate status bookmark ID list for this status. + c.GTS.StatusBookmarkIDs.Invalidate(bookmark.StatusID) +} + func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) { // Invalidate status fave ID list for this status. c.GTS.StatusFaveIDs.Invalidate(fave.StatusID) diff --git a/internal/cache/size.go b/internal/cache/size.go index 758d191be..e205bf023 100644 --- a/internal/cache/size.go +++ b/internal/cache/size.go @@ -201,6 +201,8 @@ func totalOfRatios() float64 { config.GetCachePollVoteMemRatio() + config.GetCacheReportMemRatio() + config.GetCacheStatusMemRatio() + + config.GetCacheStatusBookmarkMemRatio() + + config.GetCacheStatusBookmarkIDsMemRatio() + config.GetCacheStatusFaveMemRatio() + config.GetCacheStatusFaveIDsMemRatio() + config.GetCacheTagMemRatio() + @@ -566,7 +568,7 @@ func sizeofReport() uintptr { func sizeofStatus() uintptr { return uintptr(size.Of(>smodel.Status{ - ID: exampleURI, + ID: exampleID, URI: exampleURI, URL: exampleURI, Content: exampleText, @@ -599,6 +601,20 @@ func sizeofStatus() uintptr { })) } +func sizeofStatusBookmark() uintptr { + return uintptr(size.Of(>smodel.StatusBookmark{ + ID: exampleID, + AccountID: exampleID, + Account: nil, + TargetAccountID: exampleID, + TargetAccount: nil, + StatusID: exampleID, + Status: nil, + CreatedAt: exampleTime, + UpdatedAt: exampleTime, + })) +} + func sizeofStatusFave() uintptr { return uintptr(size.Of(>smodel.StatusFave{ ID: exampleID, diff --git a/internal/cleaner/emoji.go b/internal/cleaner/emoji.go index 62ed0f012..6cf194e40 100644 --- a/internal/cleaner/emoji.go +++ b/internal/cleaner/emoji.go @@ -32,9 +32,7 @@ import ( // Emoji encompasses a set of // emoji cleanup / admin utils. -type Emoji struct { - *Cleaner -} +type Emoji struct{ *Cleaner } // All will execute all cleaner.Emoji utilities synchronously, including output logging. // Context will be checked for `gtscontext.DryRun()` in order to actually perform the action. @@ -381,10 +379,20 @@ func (e *Emoji) uncacheRemote(ctx context.Context, after time.Time, emoji *gtsmo } for _, status := range statuses { + // Check if recently used status. if status.FetchedAt.After(after) { l.Debug("skipping due to recently fetched status") return false, nil } + + // Check whether status is bookmarked by active accounts. + bookmarked, err := e.state.DB.IsStatusBookmarked(ctx, status.ID) + if err != nil { + return false, err + } else if bookmarked { + l.Debug("skipping due to bookmarked status") + return false, nil + } } // This emoji is too old, uncache it. diff --git a/internal/cleaner/media.go b/internal/cleaner/media.go index 185c64fb9..bf4a08699 100644 --- a/internal/cleaner/media.go +++ b/internal/cleaner/media.go @@ -35,9 +35,7 @@ import ( // Media encompasses a set of // media cleanup / admin utils. -type Media struct { - *Cleaner -} +type Media struct{ *Cleaner } // All will execute all cleaner.Media utilities synchronously, including output logging. // Context will be checked for `gtscontext.DryRun()` in order to actually perform the action. @@ -475,9 +473,21 @@ func (m *Media) uncacheRemote(ctx context.Context, after time.Time, media *gtsmo return false, nil } - if status != nil && status.FetchedAt.After(after) { - l.Debug("skipping due to recently fetched status") - return false, nil + if status != nil { + // Check if recently used status. + if status.FetchedAt.After(after) { + l.Debug("skipping due to recently fetched status") + return false, nil + } + + // Check whether status is bookmarked by active accounts. + bookmarked, err := m.state.DB.IsStatusBookmarked(ctx, status.ID) + if err != nil { + return false, err + } else if bookmarked { + l.Debug("skipping due to bookmarked status") + return false, nil + } } } diff --git a/internal/config/config.go b/internal/config/config.go index a738dded4..f738ba797 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -191,48 +191,50 @@ type HTTPClientConfiguration struct { } type CacheConfiguration struct { - MemoryTarget bytesize.Size `name:"memory-target"` - AccountMemRatio float64 `name:"account-mem-ratio"` - AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` - AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` - AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` - ApplicationMemRatio float64 `name:"application-mem-ratio"` - BlockMemRatio float64 `name:"block-mem-ratio"` - BlockIDsMemRatio float64 `name:"block-mem-ratio"` - BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` - ClientMemRatio float64 `name:"client-mem-ratio"` - EmojiMemRatio float64 `name:"emoji-mem-ratio"` - EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` - FilterMemRatio float64 `name:"filter-mem-ratio"` - FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` - FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` - FollowMemRatio float64 `name:"follow-mem-ratio"` - FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` - FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` - FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` - InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` - InstanceMemRatio float64 `name:"instance-mem-ratio"` - ListMemRatio float64 `name:"list-mem-ratio"` - ListEntryMemRatio float64 `name:"list-entry-mem-ratio"` - MarkerMemRatio float64 `name:"marker-mem-ratio"` - MediaMemRatio float64 `name:"media-mem-ratio"` - MentionMemRatio float64 `name:"mention-mem-ratio"` - MoveMemRatio float64 `name:"move-mem-ratio"` - NotificationMemRatio float64 `name:"notification-mem-ratio"` - PollMemRatio float64 `name:"poll-mem-ratio"` - PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` - PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` - ReportMemRatio float64 `name:"report-mem-ratio"` - StatusMemRatio float64 `name:"status-mem-ratio"` - StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` - StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` - TagMemRatio float64 `name:"tag-mem-ratio"` - ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` - TokenMemRatio float64 `name:"token-mem-ratio"` - TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` - UserMemRatio float64 `name:"user-mem-ratio"` - WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` - VisibilityMemRatio float64 `name:"visibility-mem-ratio"` + MemoryTarget bytesize.Size `name:"memory-target"` + AccountMemRatio float64 `name:"account-mem-ratio"` + AccountNoteMemRatio float64 `name:"account-note-mem-ratio"` + AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"` + AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"` + ApplicationMemRatio float64 `name:"application-mem-ratio"` + BlockMemRatio float64 `name:"block-mem-ratio"` + BlockIDsMemRatio float64 `name:"block-mem-ratio"` + BoostOfIDsMemRatio float64 `name:"boost-of-ids-mem-ratio"` + ClientMemRatio float64 `name:"client-mem-ratio"` + EmojiMemRatio float64 `name:"emoji-mem-ratio"` + EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"` + FilterMemRatio float64 `name:"filter-mem-ratio"` + FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"` + FilterStatusMemRatio float64 `name:"filter-status-mem-ratio"` + FollowMemRatio float64 `name:"follow-mem-ratio"` + FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"` + FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"` + FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"` + InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"` + InstanceMemRatio float64 `name:"instance-mem-ratio"` + ListMemRatio float64 `name:"list-mem-ratio"` + ListEntryMemRatio float64 `name:"list-entry-mem-ratio"` + MarkerMemRatio float64 `name:"marker-mem-ratio"` + MediaMemRatio float64 `name:"media-mem-ratio"` + MentionMemRatio float64 `name:"mention-mem-ratio"` + MoveMemRatio float64 `name:"move-mem-ratio"` + NotificationMemRatio float64 `name:"notification-mem-ratio"` + PollMemRatio float64 `name:"poll-mem-ratio"` + PollVoteMemRatio float64 `name:"poll-vote-mem-ratio"` + PollVoteIDsMemRatio float64 `name:"poll-vote-ids-mem-ratio"` + ReportMemRatio float64 `name:"report-mem-ratio"` + StatusMemRatio float64 `name:"status-mem-ratio"` + StatusBookmarkMemRatio float64 `name:"status-bookmark-mem-ratio"` + StatusBookmarkIDsMemRatio float64 `name:"status-bookmark-ids-mem-ratio"` + StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"` + StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"` + TagMemRatio float64 `name:"tag-mem-ratio"` + ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"` + TokenMemRatio float64 `name:"token-mem-ratio"` + TombstoneMemRatio float64 `name:"tombstone-mem-ratio"` + UserMemRatio float64 `name:"user-mem-ratio"` + WebfingerMemRatio float64 `name:"webfinger-mem-ratio"` + VisibilityMemRatio float64 `name:"visibility-mem-ratio"` } // MarshalMap will marshal current Configuration into a map structure (useful for JSON/TOML/YAML). diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e84e619b8..3410dc5e4 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -156,47 +156,49 @@ var Defaults = Configuration{ // when TODO items in the size.go source // file have been addressed, these should // be able to make some more sense :D - AccountMemRatio: 5, - AccountNoteMemRatio: 1, - AccountSettingsMemRatio: 0.1, - AccountStatsMemRatio: 2, - ApplicationMemRatio: 0.1, - BlockMemRatio: 2, - BlockIDsMemRatio: 3, - BoostOfIDsMemRatio: 3, - ClientMemRatio: 0.1, - EmojiMemRatio: 3, - EmojiCategoryMemRatio: 0.1, - FilterMemRatio: 0.5, - FilterKeywordMemRatio: 0.5, - FilterStatusMemRatio: 0.5, - FollowMemRatio: 2, - FollowIDsMemRatio: 4, - FollowRequestMemRatio: 2, - FollowRequestIDsMemRatio: 2, - InReplyToIDsMemRatio: 3, - InstanceMemRatio: 1, - ListMemRatio: 1, - ListEntryMemRatio: 2, - MarkerMemRatio: 0.5, - MediaMemRatio: 4, - MentionMemRatio: 2, - MoveMemRatio: 0.1, - NotificationMemRatio: 2, - PollMemRatio: 1, - PollVoteMemRatio: 2, - PollVoteIDsMemRatio: 2, - ReportMemRatio: 1, - StatusMemRatio: 5, - StatusFaveMemRatio: 2, - StatusFaveIDsMemRatio: 3, - TagMemRatio: 2, - ThreadMuteMemRatio: 0.2, - TokenMemRatio: 0.75, - TombstoneMemRatio: 0.5, - UserMemRatio: 0.25, - WebfingerMemRatio: 0.1, - VisibilityMemRatio: 2, + AccountMemRatio: 5, + AccountNoteMemRatio: 1, + AccountSettingsMemRatio: 0.1, + AccountStatsMemRatio: 2, + ApplicationMemRatio: 0.1, + BlockMemRatio: 2, + BlockIDsMemRatio: 3, + BoostOfIDsMemRatio: 3, + ClientMemRatio: 0.1, + EmojiMemRatio: 3, + EmojiCategoryMemRatio: 0.1, + FilterMemRatio: 0.5, + FilterKeywordMemRatio: 0.5, + FilterStatusMemRatio: 0.5, + FollowMemRatio: 2, + FollowIDsMemRatio: 4, + FollowRequestMemRatio: 2, + FollowRequestIDsMemRatio: 2, + InReplyToIDsMemRatio: 3, + InstanceMemRatio: 1, + ListMemRatio: 1, + ListEntryMemRatio: 2, + MarkerMemRatio: 0.5, + MediaMemRatio: 4, + MentionMemRatio: 2, + MoveMemRatio: 0.1, + NotificationMemRatio: 2, + PollMemRatio: 1, + PollVoteMemRatio: 2, + PollVoteIDsMemRatio: 2, + ReportMemRatio: 1, + StatusMemRatio: 5, + StatusBookmarkMemRatio: 0.5, + StatusBookmarkIDsMemRatio: 2, + StatusFaveMemRatio: 2, + StatusFaveIDsMemRatio: 3, + TagMemRatio: 2, + ThreadMuteMemRatio: 0.2, + TokenMemRatio: 0.75, + TombstoneMemRatio: 0.5, + UserMemRatio: 0.25, + WebfingerMemRatio: 0.1, + VisibilityMemRatio: 2, }, HTTPClient: HTTPClientConfiguration{ diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index c986dd19d..2f37cbacb 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -3550,6 +3550,56 @@ func GetCacheStatusMemRatio() float64 { return global.GetCacheStatusMemRatio() } // SetCacheStatusMemRatio safely sets the value for global configuration 'Cache.StatusMemRatio' field func SetCacheStatusMemRatio(v float64) { global.SetCacheStatusMemRatio(v) } +// GetCacheStatusBookmarkMemRatio safely fetches the Configuration value for state's 'Cache.StatusBookmarkMemRatio' field +func (st *ConfigState) GetCacheStatusBookmarkMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.StatusBookmarkMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheStatusBookmarkMemRatio safely sets the Configuration value for state's 'Cache.StatusBookmarkMemRatio' field +func (st *ConfigState) SetCacheStatusBookmarkMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.StatusBookmarkMemRatio = v + st.reloadToViper() +} + +// CacheStatusBookmarkMemRatioFlag returns the flag name for the 'Cache.StatusBookmarkMemRatio' field +func CacheStatusBookmarkMemRatioFlag() string { return "cache-status-bookmark-mem-ratio" } + +// GetCacheStatusBookmarkMemRatio safely fetches the value for global configuration 'Cache.StatusBookmarkMemRatio' field +func GetCacheStatusBookmarkMemRatio() float64 { return global.GetCacheStatusBookmarkMemRatio() } + +// SetCacheStatusBookmarkMemRatio safely sets the value for global configuration 'Cache.StatusBookmarkMemRatio' field +func SetCacheStatusBookmarkMemRatio(v float64) { global.SetCacheStatusBookmarkMemRatio(v) } + +// GetCacheStatusBookmarkIDsMemRatio safely fetches the Configuration value for state's 'Cache.StatusBookmarkIDsMemRatio' field +func (st *ConfigState) GetCacheStatusBookmarkIDsMemRatio() (v float64) { + st.mutex.RLock() + v = st.config.Cache.StatusBookmarkIDsMemRatio + st.mutex.RUnlock() + return +} + +// SetCacheStatusBookmarkIDsMemRatio safely sets the Configuration value for state's 'Cache.StatusBookmarkIDsMemRatio' field +func (st *ConfigState) SetCacheStatusBookmarkIDsMemRatio(v float64) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.Cache.StatusBookmarkIDsMemRatio = v + st.reloadToViper() +} + +// CacheStatusBookmarkIDsMemRatioFlag returns the flag name for the 'Cache.StatusBookmarkIDsMemRatio' field +func CacheStatusBookmarkIDsMemRatioFlag() string { return "cache-status-bookmark-ids-mem-ratio" } + +// GetCacheStatusBookmarkIDsMemRatio safely fetches the value for global configuration 'Cache.StatusBookmarkIDsMemRatio' field +func GetCacheStatusBookmarkIDsMemRatio() float64 { return global.GetCacheStatusBookmarkIDsMemRatio() } + +// SetCacheStatusBookmarkIDsMemRatio safely sets the value for global configuration 'Cache.StatusBookmarkIDsMemRatio' field +func SetCacheStatusBookmarkIDsMemRatio(v float64) { global.SetCacheStatusBookmarkIDsMemRatio(v) } + // GetCacheStatusFaveMemRatio safely fetches the Configuration value for state's 'Cache.StatusFaveMemRatio' field func (st *ConfigState) GetCacheStatusFaveMemRatio() (v float64) { st.mutex.RLock() diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 4bb9812dc..512730dd5 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -677,12 +677,3 @@ func (s *statusDB) getStatusBoostIDs(ctx context.Context, statusID string) ([]st return statusIDs, nil }) } - -func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) { - q := s.db. - NewSelect(). - TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). - Where("? = ?", bun.Ident("status_bookmark.status_id"), status.ID). - Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID) - return exists(ctx, q) -} diff --git a/internal/db/bundb/statusbookmark.go b/internal/db/bundb/statusbookmark.go index 73fced9c3..25cbb3e27 100644 --- a/internal/db/bundb/statusbookmark.go +++ b/internal/db/bundb/statusbookmark.go @@ -20,11 +20,15 @@ package bundb import ( "context" "errors" - "fmt" + "slices" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/util" "github.com/uptrace/bun" ) @@ -33,52 +37,158 @@ type statusBookmarkDB struct { state *state.State } -func (s *statusBookmarkDB) GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, error) { - bookmark := new(gtsmodel.StatusBookmark) +func (s *statusBookmarkDB) GetStatusBookmarkByID(ctx context.Context, id string) (*gtsmodel.StatusBookmark, error) { + return s.getStatusBookmark( + ctx, + "ID", + func(bookmark *gtsmodel.StatusBookmark) error { + return s.db. + NewSelect(). + Model(bookmark). + Where("? = ?", bun.Ident("id"), id). + Scan(ctx) + }, + id, + ) +} - err := s.db. - NewSelect(). - Model(bookmark). - Where("? = ?", bun.Ident("status_bookmark.id"), id). - Scan(ctx) +func (s *statusBookmarkDB) GetStatusBookmark(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusBookmark, error) { + return s.getStatusBookmark( + ctx, + "AccountID,StatusID", + func(bookmark *gtsmodel.StatusBookmark) error { + return s.db. + NewSelect(). + Model(bookmark). + Where("? = ?", bun.Ident("account_id"), accountID). + Where("? = ?", bun.Ident("status_id"), statusID). + Scan(ctx) + }, + accountID, statusID, + ) +} + +func (s *statusBookmarkDB) GetStatusBookmarksByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusBookmark, error) { + // Load all input bookmark IDs via cache loader callback. + bookmarks, err := s.state.Caches.GTS.StatusBookmark.LoadIDs("ID", + ids, + func(uncached []string) ([]*gtsmodel.StatusBookmark, error) { + // Preallocate expected length of uncached bookmarks. + bookmarks := make([]*gtsmodel.StatusBookmark, 0, len(uncached)) + + // Perform database query scanning + // the remaining (uncached) bookmarks. + if err := s.db.NewSelect(). + Model(&bookmarks). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); err != nil { + return nil, err + } + + return bookmarks, nil + }, + ) if err != nil { return nil, err } - bookmark.Account, err = s.state.DB.GetAccountByID(ctx, bookmark.AccountID) + // Reorder the bookmarks by their + // IDs to ensure in correct order. + getID := func(b *gtsmodel.StatusBookmark) string { return b.ID } + util.OrderBy(bookmarks, ids, getID) + + // Populate all loaded bookmarks, removing those we fail + // to populate (removes needing so many later nil checks). + bookmarks = slices.DeleteFunc(bookmarks, func(bookmark *gtsmodel.StatusBookmark) bool { + if err := s.PopulateStatusBookmark(ctx, bookmark); err != nil { + log.Errorf(ctx, "error populating bookmark %s: %v", bookmark.ID, err) + return true + } + return false + }) + + return bookmarks, nil +} + +func (s *statusBookmarkDB) IsStatusBookmarked(ctx context.Context, statusID string) (bool, error) { + bookmarkIDs, err := s.getStatusBookmarkIDs(ctx, statusID) + return (len(bookmarkIDs) > 0), err +} + +func (s *statusBookmarkDB) IsStatusBookmarkedBy(ctx context.Context, accountID string, statusID string) (bool, error) { + bookmark, err := s.GetStatusBookmark(ctx, accountID, statusID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return false, err + } + return (bookmark != nil), nil +} + +func (s *statusBookmarkDB) getStatusBookmark(ctx context.Context, lookup string, dbQuery func(*gtsmodel.StatusBookmark) error, keyParts ...any) (*gtsmodel.StatusBookmark, error) { + // Fetch bookmark from database cache with loader callback. + bookmark, err := s.state.Caches.GTS.StatusBookmark.LoadOne(lookup, func() (*gtsmodel.StatusBookmark, error) { + var bookmark gtsmodel.StatusBookmark + + // Not cached! Perform database query. + if err := dbQuery(&bookmark); err != nil { + return nil, err + } + + return &bookmark, nil + }, keyParts...) if err != nil { - return nil, fmt.Errorf("error getting status bookmark account %q: %w", bookmark.AccountID, err) + return nil, err } - bookmark.TargetAccount, err = s.state.DB.GetAccountByID(ctx, bookmark.TargetAccountID) - if err != nil { - return nil, fmt.Errorf("error getting status bookmark target account %q: %w", bookmark.TargetAccountID, err) + if gtscontext.Barebones(ctx) { + // no need to fully populate. + return bookmark, nil } - bookmark.Status, err = s.state.DB.GetStatusByID(ctx, bookmark.StatusID) - if err != nil { - return nil, fmt.Errorf("error getting status bookmark status %q: %w", bookmark.StatusID, err) + // Further populate the bookmark fields where applicable. + if err := s.PopulateStatusBookmark(ctx, bookmark); err != nil { + return nil, err } return bookmark, nil } -func (s *statusBookmarkDB) GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, error) { - var id string +func (s *statusBookmarkDB) PopulateStatusBookmark(ctx context.Context, bookmark *gtsmodel.StatusBookmark) (err error) { + var errs gtserror.MultiError - q := s.db. - NewSelect(). - TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). - Column("status_bookmark.id"). - Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID). - Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID). - Limit(1) - - if err := q.Scan(ctx, &id); err != nil { - return "", err + if bookmark.Account == nil { + // Bookmark author is not set, fetch from database. + bookmark.Account, err = s.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + bookmark.AccountID, + ) + if err != nil { + errs.Appendf("error getting bookmark account %s: %w", bookmark.AccountID, err) + } } - return id, nil + if bookmark.TargetAccount == nil { + // Bookmark target account is not set, fetch from database. + bookmark.TargetAccount, err = s.state.DB.GetAccountByID( + gtscontext.SetBarebones(ctx), + bookmark.TargetAccountID, + ) + if err != nil { + errs.Appendf("error getting bookmark target account %s: %w", bookmark.TargetAccountID, err) + } + } + + if bookmark.Status == nil { + // Bookmarked status not set, fetch from database. + bookmark.Status, err = s.state.DB.GetStatusByID( + gtscontext.SetBarebones(ctx), + bookmark.StatusID, + ) + if err != nil { + errs.Appendf("error getting bookmark status %s: %w", bookmark.StatusID, err) + } + } + + return errs.Combine() } func (s *statusBookmarkDB) GetStatusBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, error) { @@ -117,38 +227,46 @@ func (s *statusBookmarkDB) GetStatusBookmarks(ctx context.Context, accountID str return nil, err } - bookmarks := make([]*gtsmodel.StatusBookmark, 0, len(ids)) + return s.GetStatusBookmarksByIDs(ctx, ids) +} - for _, id := range ids { - bookmark, err := s.GetStatusBookmark(ctx, id) - if err != nil { - log.Errorf(ctx, "error getting bookmark %q: %v", id, err) - continue +func (s *statusBookmarkDB) getStatusBookmarkIDs(ctx context.Context, statusID string) ([]string, error) { + return s.state.Caches.GTS.StatusBookmarkIDs.Load(statusID, func() ([]string, error) { + var bookmarkIDs []string + + // Bookmark IDs not cached, + // perform database query. + if err := s.db. + NewSelect(). + Table("status_bookmarks"). + Column("id").Where("? = ?", bun.Ident("status_id"), statusID). + Order("id DESC"). + Scan(ctx, &bookmarkIDs); err != nil { + return nil, err } - bookmarks = append(bookmarks, bookmark) - } - - return bookmarks, nil + return bookmarkIDs, nil + }) } -func (s *statusBookmarkDB) PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) error { - _, err := s.db. - NewInsert(). - Model(statusBookmark). - Exec(ctx) - - return err +func (s *statusBookmarkDB) PutStatusBookmark(ctx context.Context, bookmark *gtsmodel.StatusBookmark) error { + return s.state.Caches.GTS.StatusBookmark.Store(bookmark, func() error { + _, err := s.db.NewInsert().Model(bookmark).Exec(ctx) + return err + }) } -func (s *statusBookmarkDB) DeleteStatusBookmark(ctx context.Context, id string) error { +func (s *statusBookmarkDB) DeleteStatusBookmarkByID(ctx context.Context, id string) error { _, err := s.db. NewDelete(). - TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). - Where("? = ?", bun.Ident("status_bookmark.id"), id). + Table("status_bookmarks"). + Where("? = ?", bun.Ident("id"), id). Exec(ctx) - - return err + if err != nil { + return err + } + s.state.Caches.GTS.StatusBookmark.Invalidate("ID", id) + return nil } func (s *statusBookmarkDB) DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) error { @@ -156,42 +274,43 @@ func (s *statusBookmarkDB) DeleteStatusBookmarks(ctx context.Context, targetAcco return errors.New("DeleteBookmarks: one of targetAccountID or originAccountID must be set") } - // TODO: Capture bookmark IDs in a RETURNING - // statement (when bookmarks have a cache), - // + use the IDs to invalidate cache entries. - q := s.db. NewDelete(). TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")) if targetAccountID != "" { q = q.Where("? = ?", bun.Ident("status_bookmark.target_account_id"), targetAccountID) + defer s.state.Caches.GTS.StatusBookmark.Invalidate("TargetAccountID", targetAccountID) } if originAccountID != "" { q = q.Where("? = ?", bun.Ident("status_bookmark.account_id"), originAccountID) + defer s.state.Caches.GTS.StatusBookmark.Invalidate("AccountID", originAccountID) } if _, err := q.Exec(ctx); err != nil { return err } + if targetAccountID != "" { + s.state.Caches.GTS.StatusBookmark.Invalidate("TargetAccountID", targetAccountID) + } + + if originAccountID != "" { + s.state.Caches.GTS.StatusBookmark.Invalidate("AccountID", originAccountID) + } + return nil } func (s *statusBookmarkDB) DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) error { - // TODO: Capture bookmark IDs in a RETURNING - // statement (when bookmarks have a cache), - // + use the IDs to invalidate cache entries. - q := s.db. NewDelete(). TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID) - if _, err := q.Exec(ctx); err != nil { return err } - + s.state.Caches.GTS.StatusBookmark.Invalidate("StatusID", statusID) return nil } diff --git a/internal/db/bundb/statusbookmark_test.go b/internal/db/bundb/statusbookmark_test.go index 2196cff79..104b090fe 100644 --- a/internal/db/bundb/statusbookmark_test.go +++ b/internal/db/bundb/statusbookmark_test.go @@ -31,23 +31,32 @@ type StatusBookmarkTestSuite struct { BunDBStandardTestSuite } -func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDOK() { +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkOK() { testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] - - id, err := suite.db.GetStatusBookmarkID(context.Background(), testBookmark.AccountID, testBookmark.StatusID) - if err != nil { - suite.FailNow(err.Error()) - } - - suite.Equal(testBookmark.ID, id) + bookmark, err := suite.db.GetStatusBookmark(context.Background(), testBookmark.AccountID, testBookmark.StatusID) + suite.NoError(err) + suite.Equal(testBookmark.ID, bookmark.ID) + suite.Equal(testBookmark.AccountID, bookmark.AccountID) + suite.Equal(testBookmark.StatusID, bookmark.StatusID) } -func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDNonexisting() { - id, err := suite.db.GetStatusBookmarkID(context.Background(), "01GVAVGD06YJ2FSB5GJSMF8M2K", "01GVAVGKGR1MK9ZN7JCJFYSFZV") - suite.Empty(id) +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkNonexisting() { + bookmark, err := suite.db.GetStatusBookmark(context.Background(), "01GVAVGD06YJ2FSB5GJSMF8M2K", "01GVAVGKGR1MK9ZN7JCJFYSFZV") + suite.Nil(bookmark) suite.ErrorIs(err, db.ErrNoEntries) } +func (suite *StatusBookmarkTestSuite) IsStatusBookmarked() { + for _, bookmark := range suite.testBookmarks { + ok, err := suite.db.IsStatusBookmarked( + context.Background(), + bookmark.StatusID, + ) + suite.NoError(err) + suite.True(ok) + } +} + func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksOriginatingFromAccount() { testAccount := suite.testAccounts["local_account_1"] @@ -105,21 +114,21 @@ func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksTargetingStatus() } } -func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmark() { +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarkByID() { testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] ctx := context.Background() - if err := suite.db.DeleteStatusBookmark(ctx, testBookmark.ID); err != nil { + if err := suite.db.DeleteStatusBookmarkByID(ctx, testBookmark.ID); err != nil { suite.FailNow(err.Error()) } - bookmark, err := suite.db.GetStatusBookmark(ctx, testBookmark.ID) + bookmark, err := suite.db.GetStatusBookmarkByID(ctx, testBookmark.ID) suite.ErrorIs(err, db.ErrNoEntries) suite.Nil(bookmark) } -func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarkNonExisting() { - err := suite.db.DeleteStatusBookmark(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarkByIDNonExisting() { + err := suite.db.DeleteStatusBookmarkByID(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") suite.NoError(err) } diff --git a/internal/db/status.go b/internal/db/status.go index 8034d39e7..88ae12a12 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -78,7 +78,4 @@ type Status interface { // GetStatusChildren gets the child statuses of a given status. GetStatusChildren(ctx context.Context, statusID string) ([]*gtsmodel.Status, error) - - // IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID - IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, error) } diff --git a/internal/db/statusbookmark.go b/internal/db/statusbookmark.go index 542e49f17..d16bbcd7c 100644 --- a/internal/db/statusbookmark.go +++ b/internal/db/statusbookmark.go @@ -25,11 +25,16 @@ import ( type StatusBookmark interface { // GetStatusBookmark gets one status bookmark with the given ID. - GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, error) + GetStatusBookmarkByID(ctx context.Context, id string) (*gtsmodel.StatusBookmark, error) - // GetStatusBookmarkID is a shortcut function for returning just the database ID - // of a status bookmark created by the given accountID, targeting the given statusID. - GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, error) + // GetStatusBookmark fetches a status bookmark by the given account ID on the given status ID, if it exists. + GetStatusBookmark(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusBookmark, error) + + // IsStatusBookmarked returns whether status has been bookmarked by any account. + IsStatusBookmarked(ctx context.Context, statusID string) (bool, error) + + // IsStatusBookmarkedBy returns whether status ID is bookmarked by the given account ID. + IsStatusBookmarkedBy(ctx context.Context, accountID string, statusID string) (bool, error) // GetStatusBookmarks retrieves status bookmarks created by the given accountID, // and using the provided parameters. If limit is < 0 then no limit will be set. @@ -42,7 +47,7 @@ type StatusBookmark interface { PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) error // DeleteStatusBookmark deletes one status bookmark with the given ID. - DeleteStatusBookmark(ctx context.Context, id string) error + DeleteStatusBookmarkByID(ctx context.Context, id string) error // DeleteStatusBookmarks mass deletes status bookmarks targeting targetAccountID // and/or originating from originAccountID and/or bookmarking statusID. diff --git a/internal/processing/status/bookmark.go b/internal/processing/status/bookmark.go index 778492c71..ae61696a8 100644 --- a/internal/processing/status/bookmark.go +++ b/internal/processing/status/bookmark.go @@ -20,42 +20,23 @@ package status import ( "context" "errors" - "fmt" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" ) -func (p *Processor) getBookmarkableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, string, gtserror.WithCode) { - targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, - requestingAccount, - targetStatusID, - nil, // default freshness - ) - if errWithCode != nil { - return nil, "", errWithCode - } - - bookmarkID, err := p.state.DB.GetStatusBookmarkID(ctx, requestingAccount.ID, targetStatus.ID) - if err != nil && !errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("getBookmarkTarget: error checking existing bookmark: %w", err) - return nil, "", gtserror.NewErrorInternalError(err) - } - - return targetStatus, bookmarkID, nil -} - // BookmarkCreate adds a bookmark for the requestingAccount, targeting the given status (no-op if bookmark already exists). func (p *Processor) BookmarkCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, existingBookmarkID, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID) + targetStatus, existing, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID) if errWithCode != nil { return nil, errWithCode } - if existingBookmarkID != "" { + if existing != nil { // Status is already bookmarked. return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) } @@ -86,18 +67,18 @@ func (p *Processor) BookmarkCreate(ctx context.Context, requestingAccount *gtsmo // BookmarkRemove removes a bookmark for the requesting account, targeting the given status (no-op if bookmark doesn't exist). func (p *Processor) BookmarkRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { - targetStatus, existingBookmarkID, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID) + targetStatus, existing, errWithCode := p.getBookmarkableStatus(ctx, requestingAccount, targetStatusID) if errWithCode != nil { return nil, errWithCode } - if existingBookmarkID == "" { + if existing == nil { // Status isn't bookmarked. return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) } // We have a bookmark to remove. - if err := p.state.DB.DeleteStatusBookmark(ctx, existingBookmarkID); err != nil { + if err := p.state.DB.DeleteStatusBookmarkByID(ctx, existing.ID); err != nil { err = gtserror.Newf("error removing status bookmark: %w", err) return nil, gtserror.NewErrorInternalError(err) } @@ -109,3 +90,34 @@ func (p *Processor) BookmarkRemove(ctx context.Context, requestingAccount *gtsmo return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) } + +func (p *Processor) getBookmarkableStatus( + ctx context.Context, + requester *gtsmodel.Account, + statusID string, +) ( + *gtsmodel.Status, + *gtsmodel.StatusBookmark, + gtserror.WithCode, +) { + target, errWithCode := p.c.GetVisibleTargetStatus(ctx, + requester, + statusID, + nil, // default freshness + ) + if errWithCode != nil { + return nil, nil, errWithCode + } + + bookmark, err := p.state.DB.GetStatusBookmark( + gtscontext.SetBarebones(ctx), + requester.ID, + statusID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error getting bookmark: %w", err) + return nil, nil, gtserror.NewErrorInternalError(err) + } + + return target, bookmark, nil +} diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go index da4109f67..d674bc150 100644 --- a/internal/typeutils/util.go +++ b/internal/typeutils/util.go @@ -65,7 +65,7 @@ func (c *Converter) interactionsWithStatusForAccount(ctx context.Context, s *gts } si.Muted = muted - bookmarked, err := c.state.DB.IsStatusBookmarkedBy(ctx, s, requestingAccount.ID) + bookmarked, err := c.state.DB.IsStatusBookmarkedBy(ctx, requestingAccount.ID, s.ID) if err != nil { return nil, fmt.Errorf("error checking if requesting account has bookmarked status: %s", err) } diff --git a/test/envparsing.sh b/test/envparsing.sh index 11532b044..95412b00b 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -54,6 +54,8 @@ EXPECT=$(cat << "EOF" "poll-vote-ids-mem-ratio": 2, "poll-vote-mem-ratio": 2, "report-mem-ratio": 1, + "status-bookmark-ids-mem-ratio": 2, + "status-bookmark-mem-ratio": 0.5, "status-fave-ids-mem-ratio": 3, "status-fave-mem-ratio": 2, "status-mem-ratio": 5,