[performance] rewrite timelines to rely on new timeline cache type (#3941)

* start work rewriting timeline cache type

* further work rewriting timeline caching

* more work integration new timeline code

* remove old code

* add local timeline, fix up merge conflicts

* remove old use of go-bytes

* implement new timeline code into more areas of codebase, pull in latest go-mangler, go-mutexes, go-structr

* remove old timeline package, add local timeline cache

* remove references to old timeline types that needed starting up in tests

* start adding page validation

* fix test-identified timeline cache package issues

* fix up more tests, fix missing required changes, etc

* add exclusion for test.out in gitignore

* clarify some things better in code comments

* tweak cache size limits

* fix list timeline cache fetching

* further list timeline fixes

* linter, ssssssssshhhhhhhhhhhh please

* fix linter hints

* reslice the output if it's beyond length of 'lim'

* remove old timeline initialization code, bump go-structr to v0.9.4

* continued from previous commit

* improved code comments

* don't allow multiple entries for BoostOfID values to prevent repeated boosts of same boosts

* finish writing more code comments

* some variable renaming, for ease of following

* change the way we update lo,hi paging values during timeline load

* improved code comments for updated / returned lo , hi paging values

* finish writing code comments for the StatusTimeline{} type itself

* fill in more code comments

* update go-structr version to latest with changed timeline unique indexing logic

* have a local and public timeline *per user*

* rewrite calls to public / local timeline calls

* remove the zero length check, as lo, hi values might still be set

* simplify timeline cache loading, fix lo/hi returns, fix timeline invalidation side-effects missing for some federated actions

* swap the lo, hi values 🤦

* add (now) missing slice reverse of tag timeline statuses when paging ASC

* remove local / public caches (is out of scope for this work), share more timeline code

* remove unnecessary change

* again, remove more unused code

* remove unused function to appease the linter

* move boost checking to prepare function

* fix use of timeline.lastOrder, fix incorrect range functions used

* remove comments for repeat code

* remove the boost logic from prepare function

* do a maximum of 5 loads, not 10

* add repeat boost filtering logic, update go-structr, general improvements

* more code comments

* add important note

* fix timeline tests now that timelines are returned in page order

* remove unused field

* add StatusTimeline{} tests

* add more status timeline tests

* start adding preloading support

* ensure repeat boosts are marked in preloaded entries

* share a bunch of the database load code in timeline cache, don't clear timelines on relationship change

* add logic to allow dynamic clear / preloading of timelines

* comment-out unused functions, but leave in place as we might end-up using them

* fix timeline preload state check

* much improved status timeline code comments

* more code comments, don't bother inserting statuses if timeline not preloaded

* shift around some logic to make sure things aren't accidentally left set

* finish writing code comments

* remove trim-after-insert behaviour

* fix-up some comments referring to old logic

* remove unsetting of lo, hi

* fix preload repeatBoost checking logic

* don't return on status filter errors, these are usually transient

* better concurrency safety in Clear() and Done()

* fix test broken due to addition of preloader

* fix repeatBoost logic that doesn't account for already-hidden repeatBoosts

* ensure edit submodels are dropped on cache insertion

* update code-comment to expand CAS accronym

* use a plus1hULID() instead of 24h

* remove unused functions

* add note that public / local timeline requester can be nil

* fix incorrect visibility filtering of tag timeline statuses

* ensure we filter home timeline statuses on local only

* some small re-orderings to confirm query params in correct places

* fix the local only home timeline filter func
This commit is contained in:
kim 2025-04-26 09:56:15 +00:00 committed by GitHub
commit 6a6a499333
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 2935 additions and 5213 deletions

View file

@ -1,71 +0,0 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// 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 timeline
import (
"context"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
)
// SkipInsert returns a function that satisifes SkipInsertFunction.
func SkipInsert() timeline.SkipInsertFunction {
// Gap to allow between a status or boost of status,
// and reinsertion of a new boost of that status.
// This is useful to avoid a heavily boosted status
// showing up way too often in a user's timeline.
const boostReinsertionDepth = 50
return func(
ctx context.Context,
newItemID string,
newItemAccountID string,
newItemBoostOfID string,
newItemBoostOfAccountID string,
nextItemID string,
nextItemAccountID string,
nextItemBoostOfID string,
nextItemBoostOfAccountID string,
depth int,
) (bool, error) {
if newItemID == nextItemID {
// Don't insert duplicates.
return true, nil
}
if newItemBoostOfID != "" {
if newItemBoostOfID == nextItemBoostOfID &&
depth < boostReinsertionDepth {
// Don't insert boosts of items
// we've seen boosted recently.
return true, nil
}
if newItemBoostOfID == nextItemID &&
depth < boostReinsertionDepth {
// Don't insert boosts of items when
// we've seen the original recently.
return true, nil
}
}
// Proceed with insertion
// (that's what she said!).
return false, nil
}
}

View file

@ -31,6 +31,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// FavedTimelineGet ...
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {

View file

@ -19,132 +19,85 @@ package timeline
import (
"context"
"errors"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// HomeTimelineGrab returns a function that satisfies GrabFunction for home timelines.
func HomeTimelineGrab(state *state.State) timeline.GrabFunction {
return func(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
statuses, err := state.DB.GetHomeTimeline(ctx, accountID, maxID, sinceID, minID, limit, false)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses from db: %w", err)
return nil, false, err
}
// HomeTimelineGet gets a pageable timeline of statuses
// in the home timeline of the requesting account.
func (p *Processor) HomeTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
page *paging.Page,
local bool,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
count := len(statuses)
if count == 0 {
// We just don't have enough statuses
// left in the db so return stop = true.
return nil, true, nil
var pageQuery url.Values
var postFilter func(*gtsmodel.Status) bool
if local {
// Set local = true query.
pageQuery = localOnlyTrue
postFilter = func(s *gtsmodel.Status) bool {
return !*s.Local
}
items := make([]timeline.Timelineable, count)
for i, s := range statuses {
items[i] = s
}
return items, false, nil
} else {
// Set local = false query.
pageQuery = localOnlyFalse
postFilter = nil
}
}
return p.getStatusTimeline(ctx,
// HomeTimelineFilter returns a function that satisfies FilterFunction for home timelines.
func HomeTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
return func(ctx context.Context, accountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
status, ok := item.(*gtsmodel.Status)
if !ok {
err = gtserror.New("could not convert item to *gtsmodel.Status")
return false, err
}
// Auth'd
// account.
requester,
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
return false, err
}
// Keyed-by-account-ID, home timeline cache.
p.state.Caches.Timelines.Home.MustGet(requester.ID),
timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
if err != nil {
err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, accountID, err)
return false, err
}
// Current
// page.
page,
return timelineable, nil
}
}
// Home timeline endpoint.
"/api/v1/timelines/home",
// HomeTimelineStatusPrepare returns a function that satisfies PrepareFunction for home timelines.
func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
return func(ctx context.Context, accountID string, itemID string) (timeline.Preparable, error) {
status, err := state.DB.GetStatusByID(ctx, itemID)
if err != nil {
err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
return nil, err
}
// Set local-only timeline
// page query flag, (this map
// later gets copied before
// any further usage).
pageQuery,
requestingAccount, err := state.DB.GetAccountByID(ctx, accountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", accountID, err)
return nil, err
}
// Status filter context.
statusfilter.FilterContextHome,
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
// Database load function.
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
return p.state.DB.GetHomeTimeline(ctx, requester.ID, pg)
},
mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
return nil, err
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
// Filtering function,
// i.e. filter before caching.
func(s *gtsmodel.Status) bool {
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
// Check the visibility of passed status to requesting user.
ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
return !ok
},
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *apiutil.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
statuses, err := p.state.Timelines.Home.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(statuses)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
var (
items = make([]interface{}, count)
nextMaxIDValue = statuses[count-1].GetID()
prevMinIDValue = statuses[0].GetID()
// Post filtering funtion,
// i.e. filter after caching.
postFilter,
)
for i := range statuses {
items[i] = statuses[i]
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/home",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
}

View file

@ -23,13 +23,9 @@ import (
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
tlprocessor "github.com/superseriousbusiness/gotosocial/internal/processing/timeline"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -37,25 +33,7 @@ type HomeTestSuite struct {
TimelineStandardTestSuite
}
func (suite *HomeTestSuite) SetupTest() {
suite.TimelineStandardTestSuite.SetupTest()
suite.state.Timelines.Home = timeline.NewManager(
tlprocessor.HomeTimelineGrab(&suite.state),
tlprocessor.HomeTimelineFilter(&suite.state, visibility.NewFilter(&suite.state)),
tlprocessor.HomeTimelineStatusPrepare(&suite.state, typeutils.NewConverter(&suite.state)),
tlprocessor.SkipInsert(),
)
if err := suite.state.Timelines.Home.Start(); err != nil {
suite.FailNow(err.Error())
}
}
func (suite *HomeTestSuite) TearDownTest() {
if err := suite.state.Timelines.Home.Stop(); err != nil {
suite.FailNow(err.Error())
}
suite.TimelineStandardTestSuite.TearDownTest()
}
@ -64,7 +42,6 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
var (
ctx = context.Background()
requester = suite.testAccounts["local_account_1"]
authed = &apiutil.Auth{Account: requester}
maxID = ""
sinceID = ""
minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus
@ -97,11 +74,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline to make sure the status we're going to filter is in that section of it.
resp, errWithCode := suite.timeline.HomeTimelineGet(
ctx,
authed,
maxID,
sinceID,
minID,
limit,
requester,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
suite.NoError(errWithCode)
@ -114,10 +92,9 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
if !filteredStatusFound {
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
}
// Prune the timeline to drop cached prepared statuses, a side effect of this precondition check.
if _, err := suite.state.Timelines.Home.Prune(ctx, requester.ID, 0, 0); err != nil {
suite.FailNow(err.Error())
}
// Clear the timeline to drop all cached statuses.
suite.state.Caches.Timelines.Home.Clear(requester.ID)
// Create a filter to hide one status on the timeline.
if err := suite.db.PutFilter(ctx, filter); err != nil {
@ -127,11 +104,12 @@ func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() {
// Fetch the timeline again with the filter in place.
resp, errWithCode = suite.timeline.HomeTimelineGet(
ctx,
authed,
maxID,
sinceID,
minID,
limit,
requester,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)

View file

@ -22,155 +22,93 @@ import (
"errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// ListTimelineGrab returns a function that satisfies GrabFunction for list timelines.
func ListTimelineGrab(state *state.State) timeline.GrabFunction {
return func(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
statuses, err := state.DB.GetListTimeline(ctx, listID, maxID, sinceID, minID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses from db: %w", err)
return nil, false, err
}
count := len(statuses)
if count == 0 {
// We just don't have enough statuses
// left in the db so return stop = true.
return nil, true, nil
}
items := make([]timeline.Timelineable, count)
for i, s := range statuses {
items[i] = s
}
return items, false, nil
}
}
// ListTimelineFilter returns a function that satisfies FilterFunction for list timelines.
func ListTimelineFilter(state *state.State, visFilter *visibility.Filter) timeline.FilterFunction {
return func(ctx context.Context, listID string, item timeline.Timelineable) (shouldIndex bool, err error) {
status, ok := item.(*gtsmodel.Status)
if !ok {
err = gtserror.New("could not convert item to *gtsmodel.Status")
return false, err
}
list, err := state.DB.GetListByID(ctx, listID)
if err != nil {
err = gtserror.Newf("error getting list with id %s: %w", listID, err)
return false, err
}
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
return false, err
}
timelineable, err := visFilter.StatusHomeTimelineable(ctx, requestingAccount, status)
if err != nil {
err = gtserror.Newf("error checking hometimelineability of status %s for account %s: %w", status.ID, list.AccountID, err)
return false, err
}
return timelineable, nil
}
}
// ListTimelineStatusPrepare returns a function that satisfies PrepareFunction for list timelines.
func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converter) timeline.PrepareFunction {
return func(ctx context.Context, listID string, itemID string) (timeline.Preparable, error) {
status, err := state.DB.GetStatusByID(ctx, itemID)
if err != nil {
err = gtserror.Newf("error getting status with id %s: %w", itemID, err)
return nil, err
}
list, err := state.DB.GetListByID(ctx, listID)
if err != nil {
err = gtserror.Newf("error getting list with id %s: %w", listID, err)
return nil, err
}
requestingAccount, err := state.DB.GetAccountByID(ctx, list.AccountID)
if err != nil {
err = gtserror.Newf("error getting account with id %s: %w", list.AccountID, err)
return nil, err
}
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
mutes, err := state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAccount.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAccount.ID, err)
return nil, err
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
return converter.StatusToAPIStatus(ctx, status, requestingAccount, statusfilter.FilterContextHome, filters, compiledMutes)
}
}
func (p *Processor) ListTimelineGet(ctx context.Context, authed *apiutil.Auth, listID string, maxID string, sinceID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
// Ensure list exists + is owned by this account.
list, err := p.state.DB.GetListByID(ctx, listID)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)
}
// ListTimelineGet gets a pageable timeline of statuses
// in the list timeline of ID by the requesting account.
func (p *Processor) ListTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
listID string,
page *paging.Page,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
// Fetch the requested list with ID.
list, err := p.state.DB.GetListByID(
gtscontext.SetBarebones(ctx),
listID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorInternalError(err)
}
if list.AccountID != authed.Account.ID {
err = gtserror.Newf("list with id %s does not belong to account %s", list.ID, authed.Account.ID)
// Check exists.
if list == nil {
const text = "list not found"
return nil, gtserror.NewErrorNotFound(
errors.New(text),
text,
)
}
// Check list owned by auth'd account.
if list.AccountID != requester.ID {
err := gtserror.New("list does not belong to account")
return nil, gtserror.NewErrorNotFound(err)
}
statuses, err := p.state.Timelines.List.GetTimeline(ctx, listID, maxID, sinceID, minID, limit, false)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Fetch status timeline for list.
return p.getStatusTimeline(ctx,
count := len(statuses)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
// Auth'd
// account.
requester,
var (
items = make([]interface{}, count)
nextMaxIDValue = statuses[count-1].GetID()
prevMinIDValue = statuses[0].GetID()
// Keyed-by-list-ID, list timeline cache.
p.state.Caches.Timelines.List.MustGet(listID),
// Current
// page.
page,
// List timeline ID's endpoint.
"/api/v1/timelines/list/"+listID,
// No page
// query.
nil,
// Status filter context.
statusfilter.FilterContextHome,
// Database load function.
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
return p.state.DB.GetListTimeline(ctx, listID, pg)
},
// Filtering function,
// i.e. filter before caching.
func(s *gtsmodel.Status) bool {
// Check the visibility of passed status to requesting user.
ok, err := p.visFilter.StatusHomeTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
return !ok
},
// Post filtering funtion,
// i.e. filter after caching.
nil,
)
for i := range statuses {
items[i] = statuses[i]
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/list/" + listID,
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
}

View file

@ -36,6 +36,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// NotificationsGet ...
func (p *Processor) NotificationsGet(
ctx context.Context,
authed *apiutil.Auth,

View file

@ -19,152 +19,143 @@ package timeline
import (
"context"
"errors"
"strconv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"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/util"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// PublicTimelineGet gets a pageable timeline of public statuses
// for the given requesting account. It ensures that each status
// in timeline is visible to the account before returning it.
//
// The local argument limits this to local-only statuses.
func (p *Processor) PublicTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
maxID string,
sinceID string,
minID string,
limit int,
page *paging.Page,
local bool,
) (*apimodel.PageableResponse, gtserror.WithCode) {
const maxAttempts = 3
var (
nextMaxIDValue string
prevMinIDValue string
items = make([]any, 0, limit)
)
var filters []*gtsmodel.Filter
var compiledMutes *usermute.CompiledUserMuteList
if requester != nil {
var err error
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requester.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
compiledMutes = usermute.NewCompiledUserMuteList(mutes)
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
if local {
return p.localTimelineGet(ctx, requester, page)
}
// Try a few times to select appropriate public
// statuses from the db, paging up or down to
// reattempt if nothing suitable is found.
outer:
for attempts := 1; ; attempts++ {
// Select slightly more than the limit to try to avoid situations where
// we filter out all the entries, and have to make another db call.
// It's cheaper to select more in 1 query than it is to do multiple queries.
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit+5, local)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(statuses)
if count == 0 {
// Nothing relevant (left) in the db.
return util.EmptyPageableResponse(), nil
}
// Page up from first status in slice
// (ie., one with the highest ID).
prevMinIDValue = statuses[0].ID
inner:
for _, s := range statuses {
// Push back the next page down ID to
// this status, regardless of whether
// we end up filtering it out or not.
nextMaxIDValue = s.ID
timelineable, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error checking status visibility: %v", err)
continue inner
}
if !timelineable {
continue inner
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, statusfilter.FilterContextPublic, filters, compiledMutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue inner
}
// Looks good, add this.
items = append(items, apiStatus)
// We called the db with a little
// more than the desired limit.
//
// Ensure we don't return more
// than the caller asked for.
if len(items) == limit {
break outer
}
}
if len(items) != 0 {
// We've got some items left after
// filtering, happily break + return.
break
}
if attempts >= maxAttempts {
// We reached our attempts limit.
// Be nice + warn about it.
log.Warn(ctx, "reached max attempts to find items in public timeline")
break
}
// We filtered out all items before we
// found anything we could return, but
// we still have attempts left to try
// fetching again. Set paging params
// and allow loop to continue.
if minID != "" {
// Paging up.
minID = prevMinIDValue
} else {
// Paging down.
maxID = nextMaxIDValue
}
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "/api/v1/timelines/public",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
ExtraQueryParams: []string{
"local=" + strconv.FormatBool(local),
},
})
return p.publicTimelineGet(ctx, requester, page)
}
func (p *Processor) publicTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
page *paging.Page,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
return p.getStatusTimeline(ctx,
// Auth acconut,
// can be nil.
requester,
// No cache.
nil,
// Current
// page.
page,
// Public timeline endpoint.
"/api/v1/timelines/public",
// Set local-only timeline
// page query flag, (this map
// later gets copied before
// any further usage).
localOnlyFalse,
// Status filter context.
statusfilter.FilterContextPublic,
// Database load function.
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
return p.state.DB.GetPublicTimeline(ctx, pg)
},
// Pre-filtering function,
// i.e. filter before caching.
func(s *gtsmodel.Status) bool {
// Check the visibility of passed status to requesting user.
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
return !ok
},
// Post filtering funtion,
// i.e. filter after caching.
nil,
)
}
func (p *Processor) localTimelineGet(
ctx context.Context,
requester *gtsmodel.Account,
page *paging.Page,
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
return p.getStatusTimeline(ctx,
// Auth acconut,
// can be nil.
requester,
// No cache.
nil,
// Current
// page.
page,
// Public timeline endpoint.
"/api/v1/timelines/public",
// Set local-only timeline
// page query flag, (this map
// later gets copied before
// any further usage).
localOnlyTrue,
// Status filter context.
statusfilter.FilterContextPublic,
// Database load function.
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
return p.state.DB.GetLocalTimeline(ctx, pg)
},
// Filtering function,
// i.e. filter before caching.
func(s *gtsmodel.Status) bool {
// Check the visibility of passed status to requesting user.
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
return !ok
},
// Post filtering funtion,
// i.e. filter after caching.
nil,
)
}

View file

@ -25,6 +25,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -46,10 +47,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGet() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
@ -79,10 +81,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
@ -90,9 +93,9 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() {
// some other statuses were filtered out.
suite.NoError(errWithCode)
suite.Len(resp.Items, 1)
suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false>; rel="prev"`, resp.LinkHeader)
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&max_id=01F8MHCP5P2NWYQ416SBA0XSEV&local=false`, resp.NextLink)
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false`, resp.PrevLink)
suite.Equal(`<http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV>; rel="next", <http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5>; rel="prev"`, resp.LinkHeader)
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&max_id=01F8MHCP5P2NWYQ416SBA0XSEV`, resp.NextLink)
suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&local=false&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5`, resp.PrevLink)
}
// A timeline containing a status hidden due to filtering should return other statuses with no error.
@ -133,10 +136,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode := suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)
suite.NoError(errWithCode)
@ -149,8 +153,6 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
if !filteredStatusFound {
suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline")
}
// The public timeline has no prepared status cache and doesn't need to be pruned,
// as in the home timeline version of this test.
// Create a filter to hide one status on the timeline.
if err := suite.db.PutFilter(ctx, filter); err != nil {
@ -161,10 +163,11 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
resp, errWithCode = suite.timeline.PublicTimelineGet(
ctx,
requester,
maxID,
sinceID,
minID,
limit,
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
local,
)

View file

@ -20,18 +20,16 @@ package timeline
import (
"context"
"errors"
"fmt"
"net/http"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"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/paging"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// TagTimelineGet gets a pageable timeline for the given
@ -40,37 +38,77 @@ import (
// to requestingAcct before returning it.
func (p *Processor) TagTimelineGet(
ctx context.Context,
requestingAcct *gtsmodel.Account,
requester *gtsmodel.Account,
tagName string,
maxID string,
sinceID string,
minID string,
limit int,
) (*apimodel.PageableResponse, gtserror.WithCode) {
// Fetch the requested tag with name.
tag, errWithCode := p.getTag(ctx, tagName)
if errWithCode != nil {
return nil, errWithCode
}
// Check for a useable returned tag for endpoint.
if tag == nil || !*tag.Useable || !*tag.Listable {
// Obey mastodon API by returning 404 for this.
err := fmt.Errorf("tag was not found, or not useable/listable on this instance")
return nil, gtserror.NewErrorNotFound(err, err.Error())
const text = "tag was not found, or not useable/listable on this instance"
return nil, gtserror.NewWithCode(http.StatusNotFound, text)
}
statuses, err := p.state.DB.GetTagTimeline(ctx, tag.ID, maxID, sinceID, minID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting statuses: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Fetch status timeline for tag.
return p.getStatusTimeline(ctx,
return p.packageTagResponse(
ctx,
requestingAcct,
statuses,
limit,
// Use API URL for tag.
// Auth'd
// account.
requester,
// No
// cache.
nil,
// Current
// page.
&paging.Page{
Min: paging.EitherMinID(minID, sinceID),
Max: paging.MaxID(maxID),
Limit: limit,
},
// Tag timeline name's endpoint.
"/api/v1/timelines/tag/"+tagName,
// No page
// query.
nil,
// Status filter context.
statusfilter.FilterContextPublic,
// Database load function.
func(pg *paging.Page) (statuses []*gtsmodel.Status, err error) {
return p.state.DB.GetTagTimeline(ctx, tag.ID, pg)
},
// Filtering function,
// i.e. filter before caching.
func(s *gtsmodel.Status) bool {
// Check the visibility of passed status to requesting user.
ok, err := p.visFilter.StatusPublicTimelineable(ctx, requester, s)
if err != nil {
log.Errorf(ctx, "error filtering status %s: %v", s.URI, err)
}
return !ok
},
// Post filtering funtion,
// i.e. filter after caching.
nil,
)
}
@ -92,69 +130,3 @@ func (p *Processor) getTag(ctx context.Context, tagName string) (*gtsmodel.Tag,
return tag, nil
}
func (p *Processor) packageTagResponse(
ctx context.Context,
requestingAcct *gtsmodel.Account,
statuses []*gtsmodel.Status,
limit int,
requestPath string,
) (*apimodel.PageableResponse, gtserror.WithCode) {
count := len(statuses)
if count == 0 {
return util.EmptyPageableResponse(), nil
}
var (
items = make([]interface{}, 0, count)
// Set next + prev values before filtering and API
// converting, so caller can still page properly.
nextMaxIDValue = statuses[count-1].ID
prevMinIDValue = statuses[0].ID
)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
mutes, err := p.state.DB.GetAccountMutes(gtscontext.SetBarebones(ctx), requestingAcct.ID, nil)
if err != nil {
err = gtserror.Newf("couldn't retrieve mutes for account %s: %w", requestingAcct.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
for _, s := range statuses {
timelineable, err := p.visFilter.StatusTagTimelineable(ctx, requestingAcct, s)
if err != nil {
log.Errorf(ctx, "error checking status visibility: %v", err)
continue
}
if !timelineable {
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, statusfilter.FilterContextPublic, filters, compiledMutes)
if errors.Is(err, statusfilter.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue
}
items = append(items, apiStatus)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: requestPath,
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
}

View file

@ -18,9 +18,33 @@
package timeline
import (
"context"
"errors"
"net/http"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
timelinepkg "github.com/superseriousbusiness/gotosocial/internal/cache/timeline"
"github.com/superseriousbusiness/gotosocial/internal/db"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
"github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
var (
// pre-prepared URL values to be passed in to
// paging response forms. The paging package always
// copies values before any modifications so it's
// safe to only use a single map variable for these.
localOnlyTrue = url.Values{"local": {"true"}}
localOnlyFalse = url.Values{"local": {"false"}}
)
type Processor struct {
@ -36,3 +60,114 @@ func New(state *state.State, converter *typeutils.Converter, visFilter *visibili
visFilter: visFilter,
}
}
func (p *Processor) getStatusTimeline(
ctx context.Context,
requester *gtsmodel.Account,
timeline *timelinepkg.StatusTimeline,
page *paging.Page,
pagePath string,
pageQuery url.Values,
filterCtx statusfilter.FilterContext,
loadPage func(*paging.Page) (statuses []*gtsmodel.Status, err error),
filter func(*gtsmodel.Status) (delete bool),
postFilter func(*gtsmodel.Status) (remove bool),
) (
*apimodel.PageableResponse,
gtserror.WithCode,
) {
var err error
var filters []*gtsmodel.Filter
var mutes *usermute.CompiledUserMuteList
if requester != nil {
// Fetch all filters relevant for requesting account.
filters, err = p.state.DB.GetFiltersForAccountID(ctx,
requester.ID,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account filters: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Get a list of all account mutes for requester.
allMutes, err := p.state.DB.GetAccountMutes(ctx,
requester.ID,
nil, // i.e. all
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting account mutes: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Compile all account mutes to useable form.
mutes = usermute.NewCompiledUserMuteList(allMutes)
}
// Ensure we have valid
// input paging cursor.
id.ValidatePage(page)
// Load status page via timeline cache, also
// getting lo, hi values for next, prev pages.
//
// NOTE: this safely handles the case of a nil
// input timeline, i.e. uncached timeline type.
apiStatuses, lo, hi, err := timeline.Load(ctx,
// Status page
// to load.
page,
// Caller provided database
// status page loading function.
loadPage,
// Status load function for cached timeline entries.
func(ids []string) ([]*gtsmodel.Status, error) {
return p.state.DB.GetStatusesByIDs(ctx, ids)
},
// Call provided status
// filtering function.
filter,
// Frontend API model preparation function.
func(status *gtsmodel.Status) (*apimodel.Status, error) {
// Check if status needs filtering OUTSIDE of caching stage.
// TODO: this will be moved to separate postFilter hook when
// all filtering has been removed from the type converter.
if postFilter != nil && postFilter(status) {
return nil, nil
}
// Finally, pass status to get converted to API model.
apiStatus, err := p.converter.StatusToAPIStatus(ctx,
status,
requester,
filterCtx,
filters,
mutes,
)
if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
return nil, err
}
return apiStatus, nil
},
)
if err != nil {
err := gtserror.Newf("error loading timeline: %w", err)
return nil, gtserror.WrapWithCode(http.StatusInternalServerError, err)
}
// Package returned API statuses as pageable response.
return paging.PackageResponse(paging.ResponseParams{
Items: xslices.ToAny(apiStatuses),
Path: pagePath,
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: pageQuery,
}), nil
}