[feature] Rework timeline code to make it useful for more than just statuses (#373)

* add preparable and timelineable interfaces

* initialize timeline manager within the processor

* generic renaming

* move status-specific timeline logic into the processor

* refactor timeline to make it useful for more than statuses
This commit is contained in:
tobi 2022-02-05 12:47:38 +01:00 committed by GitHub
parent 98341a1d4d
commit 1b36e85840
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 801 additions and 566 deletions

View file

@ -65,7 +65,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/oidc" "github.com/superseriousbusiness/gotosocial/internal/oidc"
"github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/router"
timelineprocessing "github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/web" "github.com/superseriousbusiness/gotosocial/internal/web"
@ -95,7 +94,6 @@ var Start action.GTSAction = func(ctx context.Context) error {
// build converters and util // build converters and util
typeConverter := typeutils.NewConverter(dbService) typeConverter := typeutils.NewConverter(dbService)
timelineManager := timelineprocessing.NewManager(dbService, typeConverter)
// Open the storage backend // Open the storage backend
storageBasePath := viper.GetString(config.Keys.StorageLocalBasePath) storageBasePath := viper.GetString(config.Keys.StorageLocalBasePath)
@ -128,7 +126,7 @@ var Start action.GTSAction = func(ctx context.Context) error {
} }
// create and start the message processor using the other services we've created so far // create and start the message processor using the other services we've created so far
processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaHandler, storage, timelineManager, dbService, emailSender) processor := processing.NewProcessor(typeConverter, federator, oauthServer, mediaHandler, storage, dbService, emailSender)
if err := processor.Start(ctx); err != nil { if err := processor.Start(ctx); err != nil {
return fmt.Errorf("error starting processor: %s", err) return fmt.Errorf("error starting processor: %s", err)
} }

View file

@ -96,6 +96,36 @@ type Status struct {
Text string `json:"text"` Text string `json:"text"`
} }
/*
** The below functions are added onto the API model status so that it satisfies
** the Preparable interface in internal/timeline.
*/
func (s *Status) GetID() string {
return s.ID
}
func (s *Status) GetAccountID() string {
if s.Account != nil {
return s.Account.ID
}
return ""
}
func (s *Status) GetBoostOfID() string {
if s.Reblog != nil {
return s.Reblog.ID
}
return ""
}
func (s *Status) GetBoostOfAccountID() string {
if s.Reblog != nil && s.Reblog.Account != nil {
return s.Reblog.Account.ID
}
return ""
}
// StatusReblogged represents a reblogged status. // StatusReblogged represents a reblogged status.
// //
// swagger:model statusReblogged // swagger:model statusReblogged

View file

@ -69,7 +69,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUser() {
func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() { func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHost() {
viper.Set(config.Keys.Host, "gts.example.org") viper.Set(config.Keys.Host, "gts.example.org")
viper.Set(config.Keys.AccountDomain, "example.org") viper.Set(config.Keys.AccountDomain, "example.org")
suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db, suite.emailSender) suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender)
suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module)
targetAccount := accountDomainAccount() targetAccount := accountDomainAccount()
@ -103,7 +103,7 @@ func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByHo
func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() { func (suite *WebfingerGetTestSuite) TestFingerUserWithDifferentAccountDomainByAccountDomain() {
viper.Set(config.Keys.Host, "gts.example.org") viper.Set(config.Keys.Host, "gts.example.org")
viper.Set(config.Keys.AccountDomain, "example.org") viper.Set(config.Keys.AccountDomain, "example.org")
suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, testrig.NewTestTimelineManager(suite.db), suite.db, suite.emailSender) suite.processor = processing.NewProcessor(suite.tc, suite.federator, testrig.NewTestOauthServer(suite.db), testrig.NewTestMediaHandler(suite.db, suite.storage), suite.storage, suite.db, suite.emailSender)
suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module) suite.webfingerModule = webfinger.New(suite.processor).(*webfinger.Module)
targetAccount := accountDomainAccount() targetAccount := accountDomainAccount()

View file

@ -66,6 +66,27 @@ type Status struct {
Likeable bool `validate:"-" bun:",notnull"` // This status can be liked/faved Likeable bool `validate:"-" bun:",notnull"` // This status can be liked/faved
} }
/*
The below functions are added onto the gtsmodel status so that it satisfies
the Timelineable interface in internal/timeline.
*/
func (s *Status) GetID() string {
return s.ID
}
func (s *Status) GetAccountID() string {
return s.AccountID
}
func (s *Status) GetBoostOfID() string {
return s.BoostOfID
}
func (s *Status) GetBoostOfAccountID() string {
return s.BoostOfAccountID
}
// StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags. // StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags.
type StatusToTag struct { type StatusToTag struct {
StatusID string `validate:"ulid,required" bun:"type:CHAR(26),unique:statustag,nullzero,notnull"` StatusID string `validate:"ulid,required" bun:"type:CHAR(26),unique:statustag,nullzero,notnull"`

View file

@ -192,10 +192,10 @@ func (p *processor) processCreateBlockFromClientAPI(ctx context.Context, clientM
} }
// remove any of the blocking account's statuses from the blocked account's timeline, and vice versa // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa
if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
return err return err
} }
if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
return err return err
} }

View file

@ -413,7 +413,7 @@ func (p *processor) timelineStatusForAccount(ctx context.Context, status *gtsmod
} }
// stick the status in the timeline for the account and then immediately prepare it so they can see it right away // stick the status in the timeline for the account and then immediately prepare it so they can see it right away
inserted, err := p.timelineManager.IngestAndPrepare(ctx, status, timelineAccount.ID) inserted, err := p.statusTimelines.IngestAndPrepare(ctx, status, timelineAccount.ID)
if err != nil { if err != nil {
errors <- fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %s", status.ID, err) errors <- fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %s", status.ID, err)
return return
@ -436,7 +436,7 @@ func (p *processor) timelineStatusForAccount(ctx context.Context, status *gtsmod
// deleteStatusFromTimelines completely removes the given status from all timelines. // deleteStatusFromTimelines completely removes the given status from all timelines.
// It will also stream deletion of the status to all open streams. // It will also stream deletion of the status to all open streams.
func (p *processor) deleteStatusFromTimelines(ctx context.Context, status *gtsmodel.Status) error { func (p *processor) deleteStatusFromTimelines(ctx context.Context, status *gtsmodel.Status) error {
if err := p.timelineManager.WipeStatusFromAllTimelines(ctx, status.ID); err != nil { if err := p.statusTimelines.WipeItemFromAllTimelines(ctx, status.ID); err != nil {
return err return err
} }

View file

@ -213,10 +213,10 @@ func (p *processor) processCreateBlockFromFederator(ctx context.Context, federat
} }
// remove any of the blocking account's statuses from the blocked account's timeline, and vice versa // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa
if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil { if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.AccountID, block.TargetAccountID); err != nil {
return err return err
} }
if err := p.timelineManager.WipeStatusesFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil { if err := p.statusTimelines.WipeItemsFromAccountID(ctx, block.TargetAccountID, block.AccountID); err != nil {
return err return err
} }
// TODO: same with notifications // TODO: same with notifications

View file

@ -237,7 +237,7 @@ type processor struct {
oauthServer oauth.Server oauthServer oauth.Server
mediaHandler media.Handler mediaHandler media.Handler
storage *kv.KVStore storage *kv.KVStore
timelineManager timeline.Manager statusTimelines timeline.Manager
db db.DB db db.DB
filter visibility.Filter filter visibility.Filter
@ -261,7 +261,6 @@ func NewProcessor(
oauthServer oauth.Server, oauthServer oauth.Server,
mediaHandler media.Handler, mediaHandler media.Handler,
storage *kv.KVStore, storage *kv.KVStore,
timelineManager timeline.Manager,
db db.DB, db db.DB,
emailSender email.Sender) Processor { emailSender email.Sender) Processor {
fromClientAPI := make(chan messages.FromClientAPI, 1000) fromClientAPI := make(chan messages.FromClientAPI, 1000)
@ -274,6 +273,7 @@ func NewProcessor(
mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage) mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage)
userProcessor := user.New(db, emailSender) userProcessor := user.New(db, emailSender)
federationProcessor := federationProcessor.New(db, tc, federator, fromFederator) federationProcessor := federationProcessor.New(db, tc, federator, fromFederator)
filter := visibility.NewFilter(db)
return &processor{ return &processor{
fromClientAPI: fromClientAPI, fromClientAPI: fromClientAPI,
@ -284,7 +284,7 @@ func NewProcessor(
oauthServer: oauthServer, oauthServer: oauthServer,
mediaHandler: mediaHandler, mediaHandler: mediaHandler,
storage: storage, storage: storage,
timelineManager: timelineManager, statusTimelines: timeline.NewManager(StatusGrabFunction(db), StatusFilterFunction(db, filter), StatusPrepareFunction(db, tc), StatusSkipInsertFunction()),
db: db, db: db,
filter: visibility.NewFilter(db), filter: visibility.NewFilter(db),

View file

@ -219,10 +219,9 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage) suite.federator = testrig.NewTestFederator(suite.db, suite.transportController, suite.storage)
suite.oauthServer = testrig.NewTestOauthServer(suite.db) suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage) suite.mediaHandler = testrig.NewTestMediaHandler(suite.db, suite.storage)
suite.timelineManager = testrig.NewTestTimelineManager(suite.db)
suite.emailSender = testrig.NewEmailSender("../../web/template/", nil) suite.emailSender = testrig.NewEmailSender("../../web/template/", nil)
suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaHandler, suite.storage, suite.timelineManager, suite.db, suite.emailSender) suite.processor = processing.NewProcessor(suite.typeconverter, suite.federator, suite.oauthServer, suite.mediaHandler, suite.storage, suite.db, suite.emailSender)
testrig.StandardDBSetup(suite.db, suite.testAccounts) testrig.StandardDBSetup(suite.db, suite.testAccounts)
testrig.StandardStorageSetup(suite.storage, "../../testrig/media") testrig.StandardStorageSetup(suite.storage, "../../testrig/media")

View file

@ -20,6 +20,7 @@ package processing
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
@ -32,8 +33,113 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
) )
const boostReinsertionDepth = 50
// StatusGrabFunction returns a function that satisfies the GrabFunction interface in internal/timeline.
func StatusGrabFunction(database db.DB) timeline.GrabFunction {
return func(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
statuses, err := database.GetHomeTimeline(ctx, timelineAccountID, maxID, sinceID, minID, limit, false)
if err != nil {
if err == db.ErrNoEntries {
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
}
return nil, false, fmt.Errorf("statusGrabFunction: error getting statuses from db: %s", err)
}
items := []timeline.Timelineable{}
for _, s := range statuses {
items = append(items, s)
}
return items, false, nil
}
}
// StatusFilterFunction returns a function that satisfies the FilterFunction interface in internal/timeline.
func StatusFilterFunction(database db.DB, filter visibility.Filter) timeline.FilterFunction {
return func(ctx context.Context, timelineAccountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
status, ok := item.(*gtsmodel.Status)
if !ok {
return false, errors.New("statusFilterFunction: could not convert item to *gtsmodel.Status")
}
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
if err != nil {
return false, fmt.Errorf("statusFilterFunction: error getting account with id %s", timelineAccountID)
}
timelineable, err := filter.StatusHometimelineable(ctx, status, requestingAccount)
if err != nil {
logrus.Warnf("error checking hometimelineability of status %s for account %s: %s", status.ID, timelineAccountID, err)
}
return timelineable, nil // we don't return the error here because we want to just skip this item if something goes wrong
}
}
// StatusPrepareFunction returns a function that satisfies the PrepareFunction interface in internal/timeline.
func StatusPrepareFunction(database db.DB, tc typeutils.TypeConverter) timeline.PrepareFunction {
return func(ctx context.Context, timelineAccountID string, itemID string) (timeline.Preparable, error) {
status, err := database.GetStatusByID(ctx, itemID)
if err != nil {
return nil, fmt.Errorf("statusPrepareFunction: error getting status with id %s", itemID)
}
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
if err != nil {
return nil, fmt.Errorf("statusPrepareFunction: error getting account with id %s", timelineAccountID)
}
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
}
}
// StatusSkipInsertFunction returns a function that satisifes the SkipInsertFunction interface in internal/timeline.
func StatusSkipInsertFunction() timeline.SkipInsertFunction {
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) {
// make sure we don't insert a duplicate
if newItemID == nextItemID {
return true, nil
}
// check if it's a boost
if newItemBoostOfID != "" {
// skip if we've recently put another boost of this status in the timeline
if newItemBoostOfID == nextItemBoostOfID {
if depth < boostReinsertionDepth {
return true, nil
}
}
// skip if we've recently put the original status in the timeline
if newItemBoostOfID == nextItemID {
if depth < boostReinsertionDepth {
return true, nil
}
}
}
// insert the item
return false, nil
}
}
func (p *processor) packageStatusResponse(statuses []*apimodel.Status, path string, nextMaxID string, prevMinID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { func (p *processor) packageStatusResponse(statuses []*apimodel.Status, path string, nextMaxID string, prevMinID string, limit int) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
resp := &apimodel.StatusTimelineResponse{ resp := &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{}, Statuses: []*apimodel.Status{},
@ -67,18 +173,27 @@ func (p *processor) packageStatusResponse(statuses []*apimodel.Status, path stri
} }
func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { func (p *processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {
statuses, err := p.timelineManager.HomeTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local) preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
if len(statuses) == 0 { if len(preparedItems) == 0 {
return &apimodel.StatusTimelineResponse{ return &apimodel.StatusTimelineResponse{
Statuses: []*apimodel.Status{}, Statuses: []*apimodel.Status{},
}, nil }, nil
} }
return p.packageStatusResponse(statuses, "api/v1/timelines/home", statuses[len(statuses)-1].ID, statuses[0].ID, limit) statuses := []*apimodel.Status{}
for _, i := range preparedItems {
status, ok := i.(*apimodel.Status)
if !ok {
return nil, gtserror.NewErrorInternalError(errors.New("error converting prepared timeline entry to api status"))
}
statuses = append(statuses, status)
}
return p.packageStatusResponse(statuses, "api/v1/timelines/home", statuses[len(preparedItems)-1].ID, statuses[0].ID, limit)
} }
func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) { func (p *processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.StatusTimelineResponse, gtserror.WithCode) {

View file

@ -25,12 +25,11 @@ import (
"fmt" "fmt"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
) )
const retries = 5 const retries = 5
func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]*apimodel.Status, error) { func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "Get", "func": "Get",
"accountID": t.accountID, "accountID": t.accountID,
@ -41,16 +40,16 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
}) })
l.Debug("entering get") l.Debug("entering get")
var statuses []*apimodel.Status var items []Preparable
var err error var err error
// no params are defined to just fetch from the top // no params are defined to just fetch from the top
// this is equivalent to a user asking for the top x posts from their timeline // this is equivalent to a user asking for the top x items from their timeline
if maxID == "" && sinceID == "" && minID == "" { if maxID == "" && sinceID == "" && minID == "" {
statuses, err = t.GetXFromTop(ctx, amount) items, err = t.GetXFromTop(ctx, amount)
// aysnchronously prepare the next predicted query so it's ready when the user asks for it // aysnchronously prepare the next predicted query so it's ready when the user asks for it
if len(statuses) != 0 { if len(items) != 0 {
nextMaxID := statuses[len(statuses)-1].ID nextMaxID := items[len(items)-1].GetID()
if prepareNext { if prepareNext {
// already cache the next query to speed up scrolling // already cache the next query to speed up scrolling
go func() { go func() {
@ -64,13 +63,13 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
} }
// maxID is defined but sinceID isn't so take from behind // maxID is defined but sinceID isn't so take from behind
// this is equivalent to a user asking for the next x posts from their timeline, starting from maxID // this is equivalent to a user asking for the next x items from their timeline, starting from maxID
if maxID != "" && sinceID == "" { if maxID != "" && sinceID == "" {
attempts := 0 attempts := 0
statuses, err = t.GetXBehindID(ctx, amount, maxID, &attempts) items, err = t.GetXBehindID(ctx, amount, maxID, &attempts)
// aysnchronously prepare the next predicted query so it's ready when the user asks for it // aysnchronously prepare the next predicted query so it's ready when the user asks for it
if len(statuses) != 0 { if len(items) != 0 {
nextMaxID := statuses[len(statuses)-1].ID nextMaxID := items[len(items)-1].GetID()
if prepareNext { if prepareNext {
// already cache the next query to speed up scrolling // already cache the next query to speed up scrolling
go func() { go func() {
@ -84,59 +83,59 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
} }
// maxID is defined and sinceID || minID are as well, so take a slice between them // maxID is defined and sinceID || minID are as well, so take a slice between them
// this is equivalent to a user asking for posts older than x but newer than y // this is equivalent to a user asking for items older than x but newer than y
if maxID != "" && sinceID != "" { if maxID != "" && sinceID != "" {
statuses, err = t.GetXBetweenID(ctx, amount, maxID, minID) items, err = t.GetXBetweenID(ctx, amount, maxID, minID)
} }
if maxID != "" && minID != "" { if maxID != "" && minID != "" {
statuses, err = t.GetXBetweenID(ctx, amount, maxID, minID) items, err = t.GetXBetweenID(ctx, amount, maxID, minID)
} }
// maxID isn't defined, but sinceID || minID are, so take x before // maxID isn't defined, but sinceID || minID are, so take x before
// this is equivalent to a user asking for posts newer than x (eg., refreshing the top of their timeline) // this is equivalent to a user asking for items newer than x (eg., refreshing the top of their timeline)
if maxID == "" && sinceID != "" { if maxID == "" && sinceID != "" {
statuses, err = t.GetXBeforeID(ctx, amount, sinceID, true) items, err = t.GetXBeforeID(ctx, amount, sinceID, true)
} }
if maxID == "" && minID != "" { if maxID == "" && minID != "" {
statuses, err = t.GetXBeforeID(ctx, amount, minID, true) items, err = t.GetXBeforeID(ctx, amount, minID, true)
} }
return statuses, err return items, err
} }
func (t *timeline) GetXFromTop(ctx context.Context, amount int) ([]*apimodel.Status, error) { func (t *timeline) GetXFromTop(ctx context.Context, amount int) ([]Preparable, error) {
// make a slice of statuses with the length we need to return // make a slice of preparedItems with the length we need to return
statuses := make([]*apimodel.Status, 0, amount) preparedItems := make([]Preparable, 0, amount)
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
} }
// make sure we have enough posts prepared to return // make sure we have enough items prepared to return
if t.preparedPosts.data.Len() < amount { if t.preparedItems.data.Len() < amount {
if err := t.PrepareFromTop(ctx, amount); err != nil { if err := t.PrepareFromTop(ctx, amount); err != nil {
return nil, err return nil, err
} }
} }
// work through the prepared posts from the top and return // work through the prepared items from the top and return
var served int var served int
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXFromTop: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXFromTop: could not parse e as a preparedItemsEntry")
} }
statuses = append(statuses, entry.prepared) preparedItems = append(preparedItems, entry.prepared)
served++ served++
if served >= amount { if served >= amount {
break break
} }
} }
return statuses, nil return preparedItems, nil
} }
func (t *timeline) GetXBehindID(ctx context.Context, amount int, behindID string, attempts *int) ([]*apimodel.Status, error) { func (t *timeline) GetXBehindID(ctx context.Context, amount int, behindID string, attempts *int) ([]Preparable, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "GetXBehindID", "func": "GetXBehindID",
"amount": amount, "amount": amount,
@ -148,11 +147,11 @@ func (t *timeline) GetXBehindID(ctx context.Context, amount int, behindID string
newAttempts++ newAttempts++
attempts = &newAttempts attempts = &newAttempts
// make a slice of statuses with the length we need to return // make a slice of items with the length we need to return
statuses := make([]*apimodel.Status, 0, amount) items := make([]Preparable, 0, amount)
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
} }
// iterate through the modified list until we hit the mark we're looking for // iterate through the modified list until we hit the mark we're looking for
@ -160,14 +159,14 @@ func (t *timeline) GetXBehindID(ctx context.Context, amount int, behindID string
var behindIDMark *list.Element var behindIDMark *list.Element
findMarkLoop: findMarkLoop:
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
position++ position++
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry")
} }
if entry.statusID <= behindID { if entry.itemID <= behindID {
l.Trace("found behindID mark") l.Trace("found behindID mark")
behindIDMark = e behindIDMark = e
break findMarkLoop break findMarkLoop
@ -175,33 +174,33 @@ findMarkLoop:
} }
// we didn't find it, so we need to make sure it's indexed and prepared and then try again // we didn't find it, so we need to make sure it's indexed and prepared and then try again
// this can happen when a user asks for really old posts // this can happen when a user asks for really old items
if behindIDMark == nil { if behindIDMark == nil {
if err := t.PrepareBehind(ctx, behindID, amount); err != nil { if err := t.PrepareBehind(ctx, behindID, amount); err != nil {
return nil, fmt.Errorf("GetXBehindID: error preparing behind and including ID %s", behindID) return nil, fmt.Errorf("GetXBehindID: error preparing behind and including ID %s", behindID)
} }
oldestID, err := t.OldestPreparedPostID(ctx) oldestID, err := t.OldestPreparedItemID(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if oldestID == "" { if oldestID == "" {
l.Tracef("oldestID is empty so we can't return behindID %s", behindID) l.Tracef("oldestID is empty so we can't return behindID %s", behindID)
return statuses, nil return items, nil
} }
if oldestID == behindID { if oldestID == behindID {
l.Tracef("given behindID %s is the same as oldestID %s so there's nothing to return behind it", behindID, oldestID) l.Tracef("given behindID %s is the same as oldestID %s so there's nothing to return behind it", behindID, oldestID)
return statuses, nil return items, nil
} }
if *attempts > retries { if *attempts > retries {
l.Tracef("exceeded retries looking for behindID %s", behindID) l.Tracef("exceeded retries looking for behindID %s", behindID)
return statuses, nil return items, nil
} }
l.Trace("trying GetXBehindID again") l.Trace("trying GetXBehindID again")
return t.GetXBehindID(ctx, amount, behindID, attempts) return t.GetXBehindID(ctx, amount, behindID, attempts)
} }
// make sure we have enough posts prepared behind it to return what we're being asked for // make sure we have enough items prepared behind it to return what we're being asked for
if t.preparedPosts.data.Len() < amount+position { if t.preparedItems.data.Len() < amount+position {
if err := t.PrepareBehind(ctx, behindID, amount); err != nil { if err := t.PrepareBehind(ctx, behindID, amount); err != nil {
return nil, err return nil, err
} }
@ -211,40 +210,40 @@ findMarkLoop:
var served int var served int
serveloop: serveloop:
for e := behindIDMark.Next(); e != nil; e = e.Next() { for e := behindIDMark.Next(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry")
} }
// serve up to the amount requested // serve up to the amount requested
statuses = append(statuses, entry.prepared) items = append(items, entry.prepared)
served++ served++
if served >= amount { if served >= amount {
break serveloop break serveloop
} }
} }
return statuses, nil return items, nil
} }
func (t *timeline) GetXBeforeID(ctx context.Context, amount int, beforeID string, startFromTop bool) ([]*apimodel.Status, error) { func (t *timeline) GetXBeforeID(ctx context.Context, amount int, beforeID string, startFromTop bool) ([]Preparable, error) {
// make a slice of statuses with the length we need to return // make a slice of items with the length we need to return
statuses := make([]*apimodel.Status, 0, amount) items := make([]Preparable, 0, amount)
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
} }
// iterate through the modified list until we hit the mark we're looking for, or as close as possible to it // iterate through the modified list until we hit the mark we're looking for, or as close as possible to it
var beforeIDMark *list.Element var beforeIDMark *list.Element
findMarkLoop: findMarkLoop:
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry")
} }
if entry.statusID >= beforeID { if entry.itemID >= beforeID {
beforeIDMark = e beforeIDMark = e
} else { } else {
break findMarkLoop break findMarkLoop
@ -252,26 +251,26 @@ findMarkLoop:
} }
if beforeIDMark == nil { if beforeIDMark == nil {
return statuses, nil return items, nil
} }
var served int var served int
if startFromTop { if startFromTop {
// start serving from the front/top and keep going until we hit mark or get x amount statuses // start serving from the front/top and keep going until we hit mark or get x amount items
serveloopFromTop: serveloopFromTop:
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry")
} }
if entry.statusID == beforeID { if entry.itemID == beforeID {
break serveloopFromTop break serveloopFromTop
} }
// serve up to the amount requested // serve up to the amount requested
statuses = append(statuses, entry.prepared) items = append(items, entry.prepared)
served++ served++
if served >= amount { if served >= amount {
break serveloopFromTop break serveloopFromTop
@ -281,13 +280,13 @@ findMarkLoop:
// start serving from the entry right before the mark // start serving from the entry right before the mark
serveloopFromBottom: serveloopFromBottom:
for e := beforeIDMark.Prev(); e != nil; e = e.Prev() { for e := beforeIDMark.Prev(); e != nil; e = e.Prev() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry")
} }
// serve up to the amount requested // serve up to the amount requested
statuses = append(statuses, entry.prepared) items = append(items, entry.prepared)
served++ served++
if served >= amount { if served >= amount {
break serveloopFromBottom break serveloopFromBottom
@ -295,29 +294,29 @@ findMarkLoop:
} }
} }
return statuses, nil return items, nil
} }
func (t *timeline) GetXBetweenID(ctx context.Context, amount int, behindID string, beforeID string) ([]*apimodel.Status, error) { func (t *timeline) GetXBetweenID(ctx context.Context, amount int, behindID string, beforeID string) ([]Preparable, error) {
// make a slice of statuses with the length we need to return // make a slice of items with the length we need to return
statuses := make([]*apimodel.Status, 0, amount) items := make([]Preparable, 0, amount)
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
} }
// iterate through the modified list until we hit the mark we're looking for // iterate through the modified list until we hit the mark we're looking for
var position int var position int
var behindIDMark *list.Element var behindIDMark *list.Element
findMarkLoop: findMarkLoop:
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
position++ position++
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry")
} }
if entry.statusID == behindID { if entry.itemID == behindID {
behindIDMark = e behindIDMark = e
break findMarkLoop break findMarkLoop
} }
@ -325,11 +324,11 @@ findMarkLoop:
// we didn't find it // we didn't find it
if behindIDMark == nil { if behindIDMark == nil {
return nil, fmt.Errorf("GetXBetweenID: couldn't find status with ID %s", behindID) return nil, fmt.Errorf("GetXBetweenID: couldn't find item with ID %s", behindID)
} }
// make sure we have enough posts prepared behind it to return what we're being asked for // make sure we have enough items prepared behind it to return what we're being asked for
if t.preparedPosts.data.Len() < amount+position { if t.preparedItems.data.Len() < amount+position {
if err := t.PrepareBehind(ctx, behindID, amount); err != nil { if err := t.PrepareBehind(ctx, behindID, amount); err != nil {
return nil, err return nil, err
} }
@ -339,22 +338,22 @@ findMarkLoop:
var served int var served int
serveloop: serveloop:
for e := behindIDMark.Next(); e != nil; e = e.Next() { for e := behindIDMark.Next(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry") return nil, errors.New("GetXBetweenID: could not parse e as a preparedPostsEntry")
} }
if entry.statusID == beforeID { if entry.itemID == beforeID {
break serveloop break serveloop
} }
// serve up to the amount requested // serve up to the amount requested
statuses = append(statuses, entry.prepared) items = append(items, entry.prepared)
served++ served++
if served >= amount { if served >= amount {
break serveloop break serveloop
} }
} }
return statuses, nil return items, nil
} }

View file

@ -24,7 +24,9 @@ import (
"time" "time"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -43,18 +45,26 @@ func (suite *GetTestSuite) SetupTest() {
suite.db = testrig.NewTestDB() suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db) suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.filter = visibility.NewFilter(suite.db)
testrig.StandardDBSetup(suite.db, nil) testrig.StandardDBSetup(suite.db, nil)
// let's take local_account_1 as the timeline owner // let's take local_account_1 as the timeline owner
tl, err := timeline.NewTimeline(context.Background(), suite.testAccounts["local_account_1"].ID, suite.db, suite.tc) tl, err := timeline.NewTimeline(
context.Background(),
suite.testAccounts["local_account_1"].ID,
processing.StatusGrabFunction(suite.db),
processing.StatusFilterFunction(suite.db, suite.filter),
processing.StatusPrepareFunction(suite.db, suite.tc),
processing.StatusSkipInsertFunction(),
)
if err != nil { if err != nil {
suite.FailNow(err.Error()) suite.FailNow(err.Error())
} }
// prepare the timeline by just shoving all test statuses in it -- let's not be fussy about who sees what // prepare the timeline by just shoving all test statuses in it -- let's not be fussy about who sees what
for _, s := range suite.testStatuses { for _, s := range suite.testStatuses {
_, err := tl.IndexAndPrepareOne(context.Background(), s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID) _, err := tl.IndexAndPrepareOne(context.Background(), s.GetID(), s.BoostOfID, s.AccountID, s.BoostOfAccountID)
if err != nil { if err != nil {
suite.FailNow(err.Error()) suite.FailNow(err.Error())
} }
@ -81,10 +91,10 @@ func (suite *GetTestSuite) TestGetDefault() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -102,10 +112,10 @@ func (suite *GetTestSuite) TestGetDefaultPrepareNext() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
@ -127,10 +137,10 @@ func (suite *GetTestSuite) TestGetMaxID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -149,10 +159,10 @@ func (suite *GetTestSuite) TestGetMaxIDPrepareNext() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
@ -174,10 +184,10 @@ func (suite *GetTestSuite) TestGetMinID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -196,10 +206,10 @@ func (suite *GetTestSuite) TestGetSinceID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -218,10 +228,10 @@ func (suite *GetTestSuite) TestGetSinceIDPrepareNext() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
@ -243,10 +253,10 @@ func (suite *GetTestSuite) TestGetBetweenID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -265,10 +275,10 @@ func (suite *GetTestSuite) TestGetBetweenIDPrepareNext() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
@ -289,10 +299,10 @@ func (suite *GetTestSuite) TestGetXFromTop() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
} }
} }
@ -314,12 +324,12 @@ func (suite *GetTestSuite) TestGetXBehindID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
suite.Less(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA") suite.Less(s.GetID(), "01F8MHBQCBTDKN6X5VHGMMN4MA")
} }
} }
@ -353,12 +363,12 @@ func (suite *GetTestSuite) TestGetXBehindNonexistentReasonableID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
suite.Less(s.ID, "01F8MHBCN8120SYH7D5S050MGK") suite.Less(s.GetID(), "01F8MHBCN8120SYH7D5S050MGK")
} }
} }
@ -380,12 +390,12 @@ func (suite *GetTestSuite) TestGetXBehindVeryHighID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
suite.Less(s.ID, "9998MHBQCBTDKN6X5VHGMMN4MA") suite.Less(s.GetID(), "9998MHBQCBTDKN6X5VHGMMN4MA")
} }
} }
@ -403,12 +413,12 @@ func (suite *GetTestSuite) TestGetXBeforeID() {
var highest string var highest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
highest = s.ID highest = s.GetID()
} else { } else {
suite.Less(s.ID, highest) suite.Less(s.GetID(), highest)
highest = s.ID highest = s.GetID()
} }
suite.Greater(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA") suite.Greater(s.GetID(), "01F8MHBQCBTDKN6X5VHGMMN4MA")
} }
} }
@ -426,12 +436,12 @@ func (suite *GetTestSuite) TestGetXBeforeIDNoStartFromTop() {
var lowest string var lowest string
for i, s := range statuses { for i, s := range statuses {
if i == 0 { if i == 0 {
lowest = s.ID lowest = s.GetID()
} else { } else {
suite.Greater(s.ID, lowest) suite.Greater(s.GetID(), lowest)
lowest = s.ID lowest = s.GetID()
} }
suite.Greater(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA") suite.Greater(s.GetID(), "01F8MHBQCBTDKN6X5VHGMMN4MA")
} }
} }

View file

@ -23,173 +23,166 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
func (t *timeline) IndexBefore(ctx context.Context, statusID string, include bool, amount int) error { func (t *timeline) IndexBefore(ctx context.Context, itemID string, amount int) error {
l := logrus.WithFields(logrus.Fields{
"func": "IndexBefore",
"amount": amount,
})
// lazily initialize index if it hasn't been done already // lazily initialize index if it hasn't been done already
if t.postIndex.data == nil { if t.itemIndex.data == nil {
t.postIndex.data = &list.List{} t.itemIndex.data = &list.List{}
t.postIndex.data.Init() t.itemIndex.data.Init()
} }
filtered := []*gtsmodel.Status{} toIndex := []Timelineable{}
offsetStatus := statusID offsetID := itemID
if include { l.Trace("entering grabloop")
// if we have the status with given statusID in the database, include it in the results set as well
s := &gtsmodel.Status{}
if err := t.db.GetByID(ctx, statusID, s); err == nil {
filtered = append(filtered, s)
}
}
i := 0
grabloop: grabloop:
for ; len(filtered) < amount && i < 5; i++ { // try the grabloop 5 times only for i := 0; len(toIndex) < amount && i < 5; i++ { // try the grabloop 5 times only
statuses, err := t.db.GetHomeTimeline(ctx, t.accountID, "", "", offsetStatus, amount, false) // first grab items using the caller-provided grab function
l.Trace("grabbing...")
items, stop, err := t.grabFunction(ctx, t.accountID, "", "", offsetID, amount)
if err != nil { if err != nil {
if err == db.ErrNoEntries { return err
break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail }
} if stop {
return fmt.Errorf("IndexBefore: error getting statuses from db: %s", err) break grabloop
} }
for _, s := range statuses { l.Trace("filtering...")
timelineable, err := t.filter.StatusHometimelineable(ctx, s, t.account) // now filter each item using the caller-provided filter function
for _, item := range items {
shouldIndex, err := t.filterFunction(ctx, t.accountID, item)
if err != nil { if err != nil {
continue return err
} }
if timelineable { if shouldIndex {
filtered = append(filtered, s) toIndex = append(toIndex, item)
} }
offsetStatus = s.ID offsetID = item.GetID()
} }
} }
l.Trace("left grabloop")
for _, s := range filtered { // index the items we got
if _, err := t.IndexOne(ctx, s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil { for _, s := range toIndex {
return fmt.Errorf("IndexBefore: error indexing status with id %s: %s", s.ID, err) if _, err := t.IndexOne(ctx, s.GetID(), s.GetBoostOfID(), s.GetAccountID(), s.GetBoostOfAccountID()); err != nil {
return fmt.Errorf("IndexBehind: error indexing item with id %s: %s", s.GetID(), err)
} }
} }
return nil return nil
} }
func (t *timeline) IndexBehind(ctx context.Context, statusID string, include bool, amount int) error { func (t *timeline) IndexBehind(ctx context.Context, itemID string, amount int) error {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "IndexBehind", "func": "IndexBehind",
"include": include, "amount": amount,
"amount": amount,
}) })
// lazily initialize index if it hasn't been done already // lazily initialize index if it hasn't been done already
if t.postIndex.data == nil { if t.itemIndex.data == nil {
t.postIndex.data = &list.List{} t.itemIndex.data = &list.List{}
t.postIndex.data.Init() t.itemIndex.data.Init()
} }
// If we're already indexedBehind given statusID by the required amount, we can return nil. // If we're already indexedBehind given itemID by the required amount, we can return nil.
// First find position of statusID (or as near as possible). // First find position of itemID (or as near as possible).
var position int var position int
positionLoop: positionLoop:
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return errors.New("IndexBehind: could not parse e as a postIndexEntry") return errors.New("IndexBehind: could not parse e as an itemIndexEntry")
} }
if entry.statusID <= statusID { if entry.itemID <= itemID {
// we've found it // we've found it
break positionLoop break positionLoop
} }
position++ position++
} }
// now check if the length of indexed posts exceeds the amount of posts required (position of statusID, plus amount of posts requested after that)
if t.postIndex.data.Len() > position+amount { // now check if the length of indexed items exceeds the amount of items required (position of itemID, plus amount of posts requested after that)
if t.itemIndex.data.Len() > position+amount {
// we have enough indexed behind already to satisfy amount, so don't need to make db calls // we have enough indexed behind already to satisfy amount, so don't need to make db calls
l.Trace("returning nil since we already have enough posts indexed") l.Trace("returning nil since we already have enough items indexed")
return nil return nil
} }
filtered := []*gtsmodel.Status{} toIndex := []Timelineable{}
offsetStatus := statusID offsetID := itemID
if include { l.Trace("entering grabloop")
// if we have the status with given statusID in the database, include it in the results set as well
s := &gtsmodel.Status{}
if err := t.db.GetByID(ctx, statusID, s); err == nil {
filtered = append(filtered, s)
}
}
i := 0
grabloop: grabloop:
for ; len(filtered) < amount && i < 5; i++ { // try the grabloop 5 times only for i := 0; len(toIndex) < amount && i < 5; i++ { // try the grabloop 5 times only
l.Tracef("entering grabloop; i is %d; len(filtered) is %d", i, len(filtered)) // first grab items using the caller-provided grab function
statuses, err := t.db.GetHomeTimeline(ctx, t.accountID, offsetStatus, "", "", amount, false) l.Trace("grabbing...")
items, stop, err := t.grabFunction(ctx, t.accountID, offsetID, "", "", amount)
if err != nil { if err != nil {
if err == db.ErrNoEntries { return err
break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail }
} if stop {
return fmt.Errorf("IndexBehind: error getting statuses from db: %s", err) break grabloop
} }
l.Tracef("got %d statuses", len(statuses))
for _, s := range statuses { l.Trace("filtering...")
timelineable, err := t.filter.StatusHometimelineable(ctx, s, t.account) // now filter each item using the caller-provided filter function
for _, item := range items {
shouldIndex, err := t.filterFunction(ctx, t.accountID, item)
if err != nil { if err != nil {
l.Tracef("status was not hometimelineable: %s", err) return err
continue
} }
if timelineable { if shouldIndex {
filtered = append(filtered, s) toIndex = append(toIndex, item)
} }
offsetStatus = s.ID offsetID = item.GetID()
} }
} }
l.Trace("left grabloop") l.Trace("left grabloop")
for _, s := range filtered { // index the items we got
if _, err := t.IndexOne(ctx, s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil { for _, s := range toIndex {
return fmt.Errorf("IndexBehind: error indexing status with id %s: %s", s.ID, err) if _, err := t.IndexOne(ctx, s.GetID(), s.GetBoostOfID(), s.GetAccountID(), s.GetBoostOfAccountID()); err != nil {
return fmt.Errorf("IndexBehind: error indexing item with id %s: %s", s.GetID(), err)
} }
} }
l.Trace("exiting function")
return nil return nil
} }
func (t *timeline) IndexOne(ctx context.Context, statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) { func (t *timeline) IndexOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) {
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
postIndexEntry := &postIndexEntry{ postIndexEntry := &itemIndexEntry{
statusID: statusID, itemID: itemID,
boostOfID: boostOfID, boostOfID: boostOfID,
accountID: accountID, accountID: accountID,
boostOfAccountID: boostOfAccountID, boostOfAccountID: boostOfAccountID,
} }
return t.postIndex.insertIndexed(postIndexEntry) return t.itemIndex.insertIndexed(ctx, postIndexEntry)
} }
func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) { func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) {
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
postIndexEntry := &postIndexEntry{ postIndexEntry := &itemIndexEntry{
statusID: statusID, itemID: statusID,
boostOfID: boostOfID, boostOfID: boostOfID,
accountID: accountID, accountID: accountID,
boostOfAccountID: boostOfAccountID, boostOfAccountID: boostOfAccountID,
} }
inserted, err := t.postIndex.insertIndexed(postIndexEntry) inserted, err := t.itemIndex.insertIndexed(ctx, postIndexEntry)
if err != nil { if err != nil {
return inserted, fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err) return inserted, fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err)
} }
@ -203,32 +196,32 @@ func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusCreatedAt time.
return inserted, nil return inserted, nil
} }
func (t *timeline) OldestIndexedPostID(ctx context.Context) (string, error) { func (t *timeline) OldestIndexedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.postIndex == nil || t.postIndex.data == nil || t.postIndex.data.Back() == nil { if t.itemIndex == nil || t.itemIndex.data == nil || t.itemIndex.data.Back() == nil {
// return an empty string if postindex hasn't been initialized yet // return an empty string if postindex hasn't been initialized yet
return id, nil return id, nil
} }
e := t.postIndex.data.Back() e := t.itemIndex.data.Back()
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry") return id, errors.New("OldestIndexedItemID: could not parse e as itemIndexEntry")
} }
return entry.statusID, nil return entry.itemID, nil
} }
func (t *timeline) NewestIndexedPostID(ctx context.Context) (string, error) { func (t *timeline) NewestIndexedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.postIndex == nil || t.postIndex.data == nil || t.postIndex.data.Front() == nil { if t.itemIndex == nil || t.itemIndex.data == nil || t.itemIndex.data.Front() == nil {
// return an empty string if postindex hasn't been initialized yet // return an empty string if postindex hasn't been initialized yet
return id, nil return id, nil
} }
e := t.postIndex.data.Front() e := t.itemIndex.data.Front()
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return id, errors.New("NewestIndexedPostID: could not parse e as a postIndexEntry") return id, errors.New("NewestIndexedItemID: could not parse e as itemIndexEntry")
} }
return entry.statusID, nil return entry.itemID, nil
} }

View file

@ -25,7 +25,9 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -44,11 +46,19 @@ func (suite *IndexTestSuite) SetupTest() {
suite.db = testrig.NewTestDB() suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db) suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.filter = visibility.NewFilter(suite.db)
testrig.StandardDBSetup(suite.db, nil) testrig.StandardDBSetup(suite.db, nil)
// let's take local_account_1 as the timeline owner, and start with an empty timeline // let's take local_account_1 as the timeline owner, and start with an empty timeline
tl, err := timeline.NewTimeline(context.Background(), suite.testAccounts["local_account_1"].ID, suite.db, suite.tc) tl, err := timeline.NewTimeline(
context.Background(),
suite.testAccounts["local_account_1"].ID,
processing.StatusGrabFunction(suite.db),
processing.StatusFilterFunction(suite.db, suite.filter),
processing.StatusPrepareFunction(suite.db, suite.tc),
processing.StatusSkipInsertFunction(),
)
if err != nil { if err != nil {
suite.FailNow(err.Error()) suite.FailNow(err.Error())
} }
@ -61,82 +71,82 @@ func (suite *IndexTestSuite) TearDownTest() {
func (suite *IndexTestSuite) TestIndexBeforeLowID() { func (suite *IndexTestSuite) TestIndexBeforeLowID() {
// index 10 before the lowest status ID possible // index 10 before the lowest status ID possible
err := suite.timeline.IndexBefore(context.Background(), "00000000000000000000000000", true, 10) err := suite.timeline.IndexBefore(context.Background(), "00000000000000000000000000", 10)
suite.NoError(err) suite.NoError(err)
// the oldest indexed post should be the lowest one we have in our testrig // the oldest indexed post should be the lowest one we have in our testrig
postID, err := suite.timeline.OldestIndexedPostID(context.Background()) postID, err := suite.timeline.OldestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Equal("01F8MHAYFKS4KMXF8K5Y1C0KRN", postID) suite.Equal("01F8MHAYFKS4KMXF8K5Y1C0KRN", postID)
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(10, indexLength) suite.Equal(10, indexLength)
} }
func (suite *IndexTestSuite) TestIndexBeforeHighID() { func (suite *IndexTestSuite) TestIndexBeforeHighID() {
// index 10 before the highest status ID possible // index 10 before the highest status ID possible
err := suite.timeline.IndexBefore(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", true, 10) err := suite.timeline.IndexBefore(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", 10)
suite.NoError(err) suite.NoError(err)
// the oldest indexed post should be empty // the oldest indexed post should be empty
postID, err := suite.timeline.OldestIndexedPostID(context.Background()) postID, err := suite.timeline.OldestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Empty(postID) suite.Empty(postID)
// indexLength should be 0 // indexLength should be 0
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength) suite.Equal(0, indexLength)
} }
func (suite *IndexTestSuite) TestIndexBehindHighID() { func (suite *IndexTestSuite) TestIndexBehindHighID() {
// index 10 behind the highest status ID possible // index 10 behind the highest status ID possible
err := suite.timeline.IndexBehind(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", true, 10) err := suite.timeline.IndexBehind(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", 10)
suite.NoError(err) suite.NoError(err)
// the newest indexed post should be the highest one we have in our testrig // the newest indexed post should be the highest one we have in our testrig
postID, err := suite.timeline.NewestIndexedPostID(context.Background()) postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", postID) suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", postID)
// indexLength should be 10 because that's all this user has hometimelineable // indexLength should be 10 because that's all this user has hometimelineable
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(10, indexLength) suite.Equal(10, indexLength)
} }
func (suite *IndexTestSuite) TestIndexBehindLowID() { func (suite *IndexTestSuite) TestIndexBehindLowID() {
// index 10 behind the lowest status ID possible // index 10 behind the lowest status ID possible
err := suite.timeline.IndexBehind(context.Background(), "00000000000000000000000000", true, 10) err := suite.timeline.IndexBehind(context.Background(), "00000000000000000000000000", 10)
suite.NoError(err) suite.NoError(err)
// the newest indexed post should be empty // the newest indexed post should be empty
postID, err := suite.timeline.NewestIndexedPostID(context.Background()) postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Empty(postID) suite.Empty(postID)
// indexLength should be 0 // indexLength should be 0
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength) suite.Equal(0, indexLength)
} }
func (suite *IndexTestSuite) TestOldestIndexedPostIDEmpty() { func (suite *IndexTestSuite) TestOldestIndexedItemIDEmpty() {
// the oldest indexed post should be an empty string since there's nothing indexed yet // the oldest indexed post should be an empty string since there's nothing indexed yet
postID, err := suite.timeline.OldestIndexedPostID(context.Background()) postID, err := suite.timeline.OldestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Empty(postID) suite.Empty(postID)
// indexLength should be 0 // indexLength should be 0
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength) suite.Equal(0, indexLength)
} }
func (suite *IndexTestSuite) TestNewestIndexedPostIDEmpty() { func (suite *IndexTestSuite) TestNewestIndexedItemIDEmpty() {
// the newest indexed post should be an empty string since there's nothing indexed yet // the newest indexed post should be an empty string since there's nothing indexed yet
postID, err := suite.timeline.NewestIndexedPostID(context.Background()) postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err) suite.NoError(err)
suite.Empty(postID) suite.Empty(postID)
// indexLength should be 0 // indexLength should be 0
indexLength := suite.timeline.PostIndexLength(context.Background()) indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength) suite.Equal(0, indexLength)
} }
@ -144,12 +154,12 @@ func (suite *IndexTestSuite) TestIndexAlreadyIndexed() {
testStatus := suite.testStatuses["local_account_1_status_1"] testStatus := suite.testStatuses["local_account_1_status_1"]
// index one post -- it should be indexed // index one post -- it should be indexed
indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID) indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.True(indexed) suite.True(indexed)
// try to index the same post again -- it should not be indexed // try to index the same post again -- it should not be indexed
indexed, err = suite.timeline.IndexOne(context.Background(), testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID) indexed, err = suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.False(indexed) suite.False(indexed)
} }
@ -158,12 +168,12 @@ func (suite *IndexTestSuite) TestIndexAndPrepareAlreadyIndexedAndPrepared() {
testStatus := suite.testStatuses["local_account_1_status_1"] testStatus := suite.testStatuses["local_account_1_status_1"]
// index and prepare one post -- it should be indexed // index and prepare one post -- it should be indexed
indexed, err := suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID) indexed, err := suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.True(indexed) suite.True(indexed)
// try to index and prepare the same post again -- it should not be indexed // try to index and prepare the same post again -- it should not be indexed
indexed, err = suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID) indexed, err = suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.False(indexed) suite.False(indexed)
} }
@ -179,12 +189,12 @@ func (suite *IndexTestSuite) TestIndexBoostOfAlreadyIndexed() {
} }
// index one post -- it should be indexed // index one post -- it should be indexed
indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID) indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.True(indexed) suite.True(indexed)
// try to index the a boost of that post -- it should not be indexed // try to index the a boost of that post -- it should not be indexed
indexed, err = suite.timeline.IndexOne(context.Background(), boostOfTestStatus.CreatedAt, boostOfTestStatus.ID, boostOfTestStatus.BoostOfID, boostOfTestStatus.AccountID, boostOfTestStatus.BoostOfAccountID) indexed, err = suite.timeline.IndexOne(context.Background(), boostOfTestStatus.ID, boostOfTestStatus.BoostOfID, boostOfTestStatus.AccountID, boostOfTestStatus.BoostOfAccountID)
suite.NoError(err) suite.NoError(err)
suite.False(indexed) suite.False(indexed)
} }

View file

@ -20,21 +20,23 @@ package timeline
import ( import (
"container/list" "container/list"
"context"
"errors" "errors"
) )
type postIndex struct { type itemIndex struct {
data *list.List data *list.List
skipInsert SkipInsertFunction
} }
type postIndexEntry struct { type itemIndexEntry struct {
statusID string itemID string
boostOfID string boostOfID string
accountID string accountID string
boostOfAccountID string boostOfAccountID string
} }
func (p *postIndex) insertIndexed(i *postIndexEntry) (bool, error) { func (p *itemIndex) insertIndexed(ctx context.Context, i *itemIndexEntry) (bool, error) {
if p.data == nil { if p.data == nil {
p.data = &list.List{} p.data = &list.List{}
} }
@ -47,36 +49,30 @@ func (p *postIndex) insertIndexed(i *postIndexEntry) (bool, error) {
var insertMark *list.Element var insertMark *list.Element
var position int var position int
// We need to iterate through the index to make sure we put this post in the appropriate place according to when it was created. // We need to iterate through the index to make sure we put this item in the appropriate place according to when it was created.
// We also need to make sure we're not inserting a duplicate post -- this can happen sometimes and it's not nice UX (*shudder*). // We also need to make sure we're not inserting a duplicate item -- this can happen sometimes and it's not nice UX (*shudder*).
for e := p.data.Front(); e != nil; e = e.Next() { for e := p.data.Front(); e != nil; e = e.Next() {
position++ position++
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return false, errors.New("index: could not parse e as a postIndexEntry") return false, errors.New("index: could not parse e as an itemIndexEntry")
} }
// don't insert this if it's a boost of a status we've seen recently skip, err := p.skipInsert(ctx, i.itemID, i.accountID, i.boostOfID, i.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position)
if i.boostOfID != "" { if err != nil {
if i.boostOfID == entry.boostOfID || i.boostOfID == entry.statusID { return false, err
if position < boostReinsertionDepth { }
return false, nil if skip {
} return false, nil
}
} }
// if the post to index is newer than e, insert it before e in the list // if the item to index is newer than e, insert it before e in the list
if insertMark == nil { if insertMark == nil {
if i.statusID > entry.statusID { if i.itemID > entry.itemID {
insertMark = e insertMark = e
} }
} }
// make sure we don't insert a duplicate
if entry.statusID == i.statusID {
return false, nil
}
} }
if insertMark != nil { if insertMark != nil {
@ -84,7 +80,7 @@ func (p *postIndex) insertIndexed(i *postIndexEntry) (bool, error) {
return true, nil return true, nil
} }
// if we reach this point it's the oldest post we've seen so put it at the back // if we reach this point it's the oldest item we've seen so put it at the back
p.data.PushBack(i) p.data.PushBack(i)
return true, nil return true, nil
} }

View file

@ -25,10 +25,6 @@ import (
"sync" "sync"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
) )
const ( const (
@ -37,71 +33,75 @@ const (
// Manager abstracts functions for creating timelines for multiple accounts, and adding, removing, and fetching entries from those timelines. // Manager abstracts functions for creating timelines for multiple accounts, and adding, removing, and fetching entries from those timelines.
// //
// By the time a status hits the manager interface, it should already have been filtered and it should be established that the status indeed // By the time a timelineable hits the manager interface, it should already have been filtered and it should be established that the item indeed
// belongs in the home timeline of the given account ID. // belongs in the timeline of the given account ID.
// //
// The manager makes a distinction between *indexed* posts and *prepared* posts. // The manager makes a distinction between *indexed* items and *prepared* items.
// //
// Indexed posts consist of just that post's ID (in the database) and the time it was created. An indexed post takes up very little memory, so // Indexed items consist of just that item's ID (in the database) and the time it was created. An indexed item takes up very little memory, so
// it's not a huge priority to keep trimming the indexed posts list. // it's not a huge priority to keep trimming the indexed items list.
// //
// Prepared posts consist of the post's database ID, the time it was created, AND the apimodel representation of that post, for quick serialization. // Prepared items consist of the item's database ID, the time it was created, AND the apimodel representation of that item, for quick serialization.
// Prepared posts of course take up more memory than indexed posts, so they should be regularly pruned if they're not being actively served. // Prepared items of course take up more memory than indexed items, so they should be regularly pruned if they're not being actively served.
type Manager interface { type Manager interface {
// Ingest takes one status and indexes it into the timeline for the given account ID. // Ingest takes one item and indexes it into the timeline for the given account ID.
// //
// It should already be established before calling this function that the status/post actually belongs in the timeline! // It should already be established before calling this function that the item actually belongs in the timeline!
// //
// The returned bool indicates whether the status was actually put in the timeline. This could be false in cases where // The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
// the status is a boost, but a boost of the original post or the post itself already exists recently in the timeline. // the item is a boosted status, but a boost of the original status or the status itself already exists recently in the timeline.
Ingest(ctx context.Context, status *gtsmodel.Status, timelineAccountID string) (bool, error) Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error)
// IngestAndPrepare takes one status and indexes it into the timeline for the given account ID, and then immediately prepares it for serving. // IngestAndPrepare takes one timelineable and indexes it into the timeline for the given account ID, and then immediately prepares it for serving.
// This is useful in cases where we know the status will need to be shown at the top of a user's timeline immediately (eg., a new status is created). // This is useful in cases where we know the item will need to be shown at the top of a user's timeline immediately (eg., a new status is created).
// //
// It should already be established before calling this function that the status/post actually belongs in the timeline! // It should already be established before calling this function that the item actually belongs in the timeline!
// //
// The returned bool indicates whether the status was actually put in the timeline. This could be false in cases where // The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
// the status is a boost, but a boost of the original post or the post itself already exists recently in the timeline. // a status is a boost, but a boost of the original status or the status itself already exists recently in the timeline.
IngestAndPrepare(ctx context.Context, status *gtsmodel.Status, timelineAccountID string) (bool, error) IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error)
// HomeTimeline returns limit n amount of entries from the home timeline of the given account ID, in descending chronological order. // GetTimeline returns limit n amount of prepared entries from the timeline of the given account ID, in descending chronological order.
// If maxID is provided, it will return entries from that maxID onwards, inclusive. // If maxID is provided, it will return prepared entries from that maxID onwards, inclusive.
HomeTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) GetTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error)
// GetIndexedLength returns the amount of posts/statuses that have been *indexed* for the given account ID. // GetIndexedLength returns the amount of items that have been *indexed* for the given account ID.
GetIndexedLength(ctx context.Context, timelineAccountID string) int GetIndexedLength(ctx context.Context, timelineAccountID string) int
// GetDesiredIndexLength returns the amount of posts that we, ideally, index for each user. // GetDesiredIndexLength returns the amount of items that we, ideally, index for each user.
GetDesiredIndexLength(ctx context.Context) int GetDesiredIndexLength(ctx context.Context) int
// GetOldestIndexedID returns the status ID for the oldest post that we have indexed for the given account. // GetOldestIndexedID returns the id ID for the oldest item that we have indexed for the given account.
GetOldestIndexedID(ctx context.Context, timelineAccountID string) (string, error) GetOldestIndexedID(ctx context.Context, timelineAccountID string) (string, error)
// PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index. // PrepareXFromTop prepares limit n amount of items, based on their indexed representations, from the top of the index.
PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error
// Remove removes one status from the timeline of the given timelineAccountID // Remove removes one item from the timeline of the given timelineAccountID
Remove(ctx context.Context, timelineAccountID string, statusID string) (int, error) Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error)
// WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines // WipeItemFromAllTimelines removes one item from the index and prepared items of all timelines
WipeStatusFromAllTimelines(ctx context.Context, statusID string) error WipeItemFromAllTimelines(ctx context.Context, itemID string) error
// WipeStatusesFromAccountID removes all statuses by the given accountID from the timelineAccountID's timelines. // WipeStatusesFromAccountID removes all items by the given accountID from the timelineAccountID's timelines.
WipeStatusesFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error
} }
// NewManager returns a new timeline manager with the given database, typeconverter, config, and log. // NewManager returns a new timeline manager.
func NewManager(db db.DB, tc typeutils.TypeConverter) Manager { func NewManager(grabFunction GrabFunction, filterFunction FilterFunction, prepareFunction PrepareFunction, skipInsertFunction SkipInsertFunction) Manager {
return &manager{ return &manager{
accountTimelines: sync.Map{}, accountTimelines: sync.Map{},
db: db, grabFunction: grabFunction,
tc: tc, filterFunction: filterFunction,
prepareFunction: prepareFunction,
skipInsertFunction: skipInsertFunction,
} }
} }
type manager struct { type manager struct {
accountTimelines sync.Map accountTimelines sync.Map
db db.DB grabFunction GrabFunction
tc typeutils.TypeConverter filterFunction FilterFunction
prepareFunction PrepareFunction
skipInsertFunction SkipInsertFunction
} }
func (m *manager) Ingest(ctx context.Context, status *gtsmodel.Status, timelineAccountID string) (bool, error) { func (m *manager) Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "Ingest", "func": "Ingest",
"timelineAccountID": timelineAccountID, "timelineAccountID": timelineAccountID,
"statusID": status.ID, "itemID": item.GetID(),
}) })
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
@ -109,15 +109,15 @@ func (m *manager) Ingest(ctx context.Context, status *gtsmodel.Status, timelineA
return false, err return false, err
} }
l.Trace("ingesting status") l.Trace("ingesting item")
return t.IndexOne(ctx, status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID) return t.IndexOne(ctx, item.GetID(), item.GetBoostOfID(), item.GetAccountID(), item.GetBoostOfAccountID())
} }
func (m *manager) IngestAndPrepare(ctx context.Context, status *gtsmodel.Status, timelineAccountID string) (bool, error) { func (m *manager) IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "IngestAndPrepare", "func": "IngestAndPrepare",
"timelineAccountID": timelineAccountID, "timelineAccountID": timelineAccountID,
"statusID": status.ID, "itemID": item.GetID(),
}) })
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
@ -125,15 +125,15 @@ func (m *manager) IngestAndPrepare(ctx context.Context, status *gtsmodel.Status,
return false, err return false, err
} }
l.Trace("ingesting status") l.Trace("ingesting item")
return t.IndexAndPrepareOne(ctx, status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID) return t.IndexAndPrepareOne(ctx, item.GetID(), item.GetBoostOfID(), item.GetAccountID(), item.GetBoostOfAccountID())
} }
func (m *manager) Remove(ctx context.Context, timelineAccountID string, statusID string) (int, error) { func (m *manager) Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "Remove", "func": "Remove",
"timelineAccountID": timelineAccountID, "timelineAccountID": timelineAccountID,
"statusID": statusID, "itemID": itemID,
}) })
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
@ -141,13 +141,13 @@ func (m *manager) Remove(ctx context.Context, timelineAccountID string, statusID
return 0, err return 0, err
} }
l.Trace("removing status") l.Trace("removing item")
return t.Remove(ctx, statusID) return t.Remove(ctx, itemID)
} }
func (m *manager) HomeTimeline(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*apimodel.Status, error) { func (m *manager) GetTimeline(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) {
l := logrus.WithFields(logrus.Fields{ l := logrus.WithFields(logrus.Fields{
"func": "HomeTimelineGet", "func": "GetTimeline",
"timelineAccountID": timelineAccountID, "timelineAccountID": timelineAccountID,
}) })
@ -156,11 +156,11 @@ func (m *manager) HomeTimeline(ctx context.Context, timelineAccountID string, ma
return nil, err return nil, err
} }
statuses, err := t.Get(ctx, limit, maxID, sinceID, minID, true) items, err := t.Get(ctx, limit, maxID, sinceID, minID, true)
if err != nil { if err != nil {
l.Errorf("error getting statuses: %s", err) l.Errorf("error getting statuses: %s", err)
} }
return statuses, nil return items, nil
} }
func (m *manager) GetIndexedLength(ctx context.Context, timelineAccountID string) int { func (m *manager) GetIndexedLength(ctx context.Context, timelineAccountID string) int {
@ -169,7 +169,7 @@ func (m *manager) GetIndexedLength(ctx context.Context, timelineAccountID string
return 0 return 0
} }
return t.PostIndexLength(ctx) return t.ItemIndexLength(ctx)
} }
func (m *manager) GetDesiredIndexLength(ctx context.Context) int { func (m *manager) GetDesiredIndexLength(ctx context.Context) int {
@ -182,7 +182,7 @@ func (m *manager) GetOldestIndexedID(ctx context.Context, timelineAccountID stri
return "", err return "", err
} }
return t.OldestIndexedPostID(ctx) return t.OldestIndexedItemID(ctx)
} }
func (m *manager) PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error { func (m *manager) PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error {
@ -194,7 +194,7 @@ func (m *manager) PrepareXFromTop(ctx context.Context, timelineAccountID string,
return t.PrepareFromTop(ctx, limit) return t.PrepareFromTop(ctx, limit)
} }
func (m *manager) WipeStatusFromAllTimelines(ctx context.Context, statusID string) error { func (m *manager) WipeItemFromAllTimelines(ctx context.Context, statusID string) error {
errors := []string{} errors := []string{}
m.accountTimelines.Range(func(k interface{}, i interface{}) bool { m.accountTimelines.Range(func(k interface{}, i interface{}) bool {
t, ok := i.(Timeline) t, ok := i.(Timeline)
@ -217,7 +217,7 @@ func (m *manager) WipeStatusFromAllTimelines(ctx context.Context, statusID strin
return err return err
} }
func (m *manager) WipeStatusesFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error { func (m *manager) WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error {
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
if err != nil { if err != nil {
return err return err
@ -232,7 +232,7 @@ func (m *manager) getOrCreateTimeline(ctx context.Context, timelineAccountID str
i, ok := m.accountTimelines.Load(timelineAccountID) i, ok := m.accountTimelines.Load(timelineAccountID)
if !ok { if !ok {
var err error var err error
t, err = NewTimeline(ctx, timelineAccountID, m.db, m.tc) t, err = NewTimeline(ctx, timelineAccountID, m.grabFunction, m.filterFunction, m.prepareFunction, m.skipInsertFunction)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -23,6 +23,9 @@ import (
"testing" "testing"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
"github.com/superseriousbusiness/gotosocial/testrig" "github.com/superseriousbusiness/gotosocial/testrig"
) )
@ -41,10 +44,16 @@ func (suite *ManagerTestSuite) SetupTest() {
suite.db = testrig.NewTestDB() suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db) suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.filter = visibility.NewFilter(suite.db)
testrig.StandardDBSetup(suite.db, nil) testrig.StandardDBSetup(suite.db, nil)
manager := testrig.NewTestTimelineManager(suite.db) manager := timeline.NewManager(
processing.StatusGrabFunction(suite.db),
processing.StatusFilterFunction(suite.db, suite.filter),
processing.StatusPrepareFunction(suite.db, suite.tc),
processing.StatusSkipInsertFunction(),
)
suite.manager = manager suite.manager = manager
} }
@ -78,12 +87,12 @@ func (suite *ManagerTestSuite) TestManagerIntegration() {
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", oldestIndexed) suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", oldestIndexed)
// get hometimeline // get hometimeline
statuses, err := suite.manager.HomeTimeline(context.Background(), testAccount.ID, "", "", "", 20, false) statuses, err := suite.manager.GetTimeline(context.Background(), testAccount.ID, "", "", "", 20, false)
suite.NoError(err) suite.NoError(err)
suite.Len(statuses, 14) suite.Len(statuses, 14)
// now wipe the last status from all timelines, as though it had been deleted by the owner // now wipe the last status from all timelines, as though it had been deleted by the owner
err = suite.manager.WipeStatusFromAllTimelines(context.Background(), "01F8MH75CBF9JFX4ZAD54N0W0R") err = suite.manager.WipeItemFromAllTimelines(context.Background(), "01F8MH75CBF9JFX4ZAD54N0W0R")
suite.NoError(err) suite.NoError(err)
// timeline should be shorter // timeline should be shorter
@ -110,7 +119,7 @@ func (suite *ManagerTestSuite) TestManagerIntegration() {
suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", oldestIndexed) suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", oldestIndexed)
// now remove all entries by local_account_2 from the timeline // now remove all entries by local_account_2 from the timeline
err = suite.manager.WipeStatusesFromAccountID(context.Background(), testAccount.ID, suite.testAccounts["local_account_2"].ID) err = suite.manager.WipeItemsFromAccountID(context.Background(), testAccount.ID, suite.testAccounts["local_account_2"].ID)
suite.NoError(err) suite.NoError(err)
// timeline should be shorter // timeline should be shorter

View file

@ -0,0 +1,26 @@
/*
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 timeline
type Preparable interface {
GetID() string
GetAccountID() string
GetBoostOfID() string
GetBoostOfAccountID() string
}

View file

@ -26,7 +26,6 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID string, sinceID string, minID string) error { func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID string, sinceID string, minID string) error {
@ -59,19 +58,19 @@ func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID strin
return err return err
} }
func (t *timeline) PrepareBehind(ctx context.Context, statusID string, amount int) error { func (t *timeline) PrepareBehind(ctx context.Context, itemID string, amount int) error {
// lazily initialize prepared posts if it hasn't been done already // lazily initialize prepared items if it hasn't been done already
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
t.preparedPosts.data.Init() t.preparedItems.data.Init()
} }
if err := t.IndexBehind(ctx, statusID, true, amount); err != nil { if err := t.IndexBehind(ctx, itemID, amount); err != nil {
return fmt.Errorf("PrepareBehind: error indexing behind id %s: %s", statusID, err) return fmt.Errorf("PrepareBehind: error indexing behind id %s: %s", itemID, err)
} }
// if the postindex is nil, nothing has been indexed yet so there's nothing to prepare // if the itemindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.postIndex.data == nil { if t.itemIndex.data == nil {
return nil return nil
} }
@ -80,25 +79,25 @@ func (t *timeline) PrepareBehind(ctx context.Context, statusID string, amount in
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
prepareloop: prepareloop:
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return errors.New("PrepareBehind: could not parse e as a postIndexEntry") return errors.New("PrepareBehind: could not parse e as itemIndexEntry")
} }
if !preparing { if !preparing {
// we haven't hit the position we need to prepare from yet // we haven't hit the position we need to prepare from yet
if entry.statusID == statusID { if entry.itemID == itemID {
preparing = true preparing = true
} }
} }
if preparing { if preparing {
if err := t.prepare(ctx, entry.statusID); err != nil { if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error // there's been an error
if err != db.ErrNoEntries { if err != db.ErrNoEntries {
// it's a real error // it's a real error
return fmt.Errorf("PrepareBehind: error preparing status with id %s: %s", entry.statusID, err) return fmt.Errorf("PrepareBehind: error preparing item with id %s: %s", entry.itemID, err)
} }
// the status just doesn't exist (anymore) so continue to the next one // the status just doesn't exist (anymore) so continue to the next one
continue continue
@ -119,28 +118,28 @@ func (t *timeline) PrepareBefore(ctx context.Context, statusID string, include b
defer t.Unlock() defer t.Unlock()
// lazily initialize prepared posts if it hasn't been done already // lazily initialize prepared posts if it hasn't been done already
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
t.preparedPosts.data.Init() t.preparedItems.data.Init()
} }
// if the postindex is nil, nothing has been indexed yet so there's nothing to prepare // if the postindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.postIndex.data == nil { if t.itemIndex.data == nil {
return nil return nil
} }
var prepared int var prepared int
var preparing bool var preparing bool
prepareloop: prepareloop:
for e := t.postIndex.data.Back(); e != nil; e = e.Prev() { for e := t.itemIndex.data.Back(); e != nil; e = e.Prev() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return errors.New("PrepareBefore: could not parse e as a postIndexEntry") return errors.New("PrepareBefore: could not parse e as a postIndexEntry")
} }
if !preparing { if !preparing {
// we haven't hit the position we need to prepare from yet // we haven't hit the position we need to prepare from yet
if entry.statusID == statusID { if entry.itemID == statusID {
preparing = true preparing = true
if !include { if !include {
continue continue
@ -149,11 +148,11 @@ prepareloop:
} }
if preparing { if preparing {
if err := t.prepare(ctx, entry.statusID); err != nil { if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error // there's been an error
if err != db.ErrNoEntries { if err != db.ErrNoEntries {
// it's a real error // it's a real error
return fmt.Errorf("PrepareBefore: error preparing status with id %s: %s", entry.statusID, err) return fmt.Errorf("PrepareBefore: error preparing status with id %s: %s", entry.itemID, err)
} }
// the status just doesn't exist (anymore) so continue to the next one // the status just doesn't exist (anymore) so continue to the next one
continue continue
@ -176,15 +175,15 @@ func (t *timeline) PrepareFromTop(ctx context.Context, amount int) error {
}) })
// lazily initialize prepared posts if it hasn't been done already // lazily initialize prepared posts if it hasn't been done already
if t.preparedPosts.data == nil { if t.preparedItems.data == nil {
t.preparedPosts.data = &list.List{} t.preparedItems.data = &list.List{}
t.preparedPosts.data.Init() t.preparedItems.data.Init()
} }
// if the postindex is nil, nothing has been indexed yet so index from the highest ID possible // if the postindex is nil, nothing has been indexed yet so index from the highest ID possible
if t.postIndex.data == nil { if t.itemIndex.data == nil {
l.Debug("postindex.data was nil, indexing behind highest possible ID") l.Debug("postindex.data was nil, indexing behind highest possible ID")
if err := t.IndexBehind(ctx, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", false, amount); err != nil { if err := t.IndexBehind(ctx, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", amount); err != nil {
return fmt.Errorf("PrepareFromTop: error indexing behind id %s: %s", "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", err) return fmt.Errorf("PrepareFromTop: error indexing behind id %s: %s", "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", err)
} }
} }
@ -194,21 +193,21 @@ func (t *timeline) PrepareFromTop(ctx context.Context, amount int) error {
defer t.Unlock() defer t.Unlock()
var prepared int var prepared int
prepareloop: prepareloop:
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
if e == nil { if e == nil {
continue continue
} }
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return errors.New("PrepareFromTop: could not parse e as a postIndexEntry") return errors.New("PrepareFromTop: could not parse e as a postIndexEntry")
} }
if err := t.prepare(ctx, entry.statusID); err != nil { if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error // there's been an error
if err != db.ErrNoEntries { if err != db.ErrNoEntries {
// it's a real error // it's a real error
return fmt.Errorf("PrepareFromTop: error preparing status with id %s: %s", entry.statusID, err) return fmt.Errorf("PrepareFromTop: error preparing status with id %s: %s", entry.itemID, err)
} }
// the status just doesn't exist (anymore) so continue to the next one // the status just doesn't exist (anymore) so continue to the next one
continue continue
@ -226,57 +225,42 @@ prepareloop:
return nil return nil
} }
func (t *timeline) prepare(ctx context.Context, statusID string) error { func (t *timeline) prepare(ctx context.Context, itemID string) error {
// trigger the caller-provided prepare function
// start by getting the status out of the database according to its indexed ID prepared, err := t.prepareFunction(ctx, t.accountID, itemID)
gtsStatus := &gtsmodel.Status{}
if err := t.db.GetByID(ctx, statusID, gtsStatus); err != nil {
return err
}
// if the account pointer hasn't been set on this timeline already, set it lazily here
if t.account == nil {
timelineOwnerAccount := &gtsmodel.Account{}
if err := t.db.GetByID(ctx, t.accountID, timelineOwnerAccount); err != nil {
return err
}
t.account = timelineOwnerAccount
}
// serialize the status (or, at least, convert it to a form that's ready to be serialized)
apiModelStatus, err := t.tc.StatusToAPIStatus(ctx, gtsStatus, t.account)
if err != nil { if err != nil {
return err return err
} }
// shove it in prepared posts as a prepared posts entry // shove it in prepared items as a prepared items entry
preparedPostsEntry := &preparedPostsEntry{ preparedItemsEntry := &preparedItemsEntry{
statusID: gtsStatus.ID, itemID: prepared.GetID(),
boostOfID: gtsStatus.BoostOfID, boostOfID: prepared.GetBoostOfID(),
accountID: gtsStatus.AccountID, accountID: prepared.GetAccountID(),
boostOfAccountID: gtsStatus.BoostOfAccountID, boostOfAccountID: prepared.GetBoostOfAccountID(),
prepared: apiModelStatus, prepared: prepared,
} }
return t.preparedPosts.insertPrepared(preparedPostsEntry) return t.preparedItems.insertPrepared(ctx, preparedItemsEntry)
} }
func (t *timeline) OldestPreparedPostID(ctx context.Context) (string, error) { func (t *timeline) OldestPreparedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.preparedPosts == nil || t.preparedPosts.data == nil { if t.preparedItems == nil || t.preparedItems.data == nil {
// return an empty string if prepared posts hasn't been initialized yet // return an empty string if prepared items hasn't been initialized yet
return id, nil return id, nil
} }
e := t.preparedPosts.data.Back() e := t.preparedItems.data.Back()
if e == nil { if e == nil {
// return an empty string if there's no back entry (ie., the index list hasn't been initialized yet) // return an empty string if there's no back entry (ie., the index list hasn't been initialized yet)
return id, nil return id, nil
} }
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return id, errors.New("OldestPreparedPostID: could not parse e as a preparedPostsEntry") return id, errors.New("OldestPreparedItemID: could not parse e as a preparedItemsEntry")
} }
return entry.statusID, nil
return entry.itemID, nil
} }

View file

@ -20,24 +20,24 @@ package timeline
import ( import (
"container/list" "container/list"
"context"
"errors" "errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
) )
type preparedPosts struct { type preparedItems struct {
data *list.List data *list.List
skipInsert SkipInsertFunction
} }
type preparedPostsEntry struct { type preparedItemsEntry struct {
statusID string itemID string
boostOfID string boostOfID string
accountID string accountID string
boostOfAccountID string boostOfAccountID string
prepared *apimodel.Status prepared Preparable
} }
func (p *preparedPosts) insertPrepared(i *preparedPostsEntry) error { func (p *preparedItems) insertPrepared(ctx context.Context, i *preparedItemsEntry) error {
if p.data == nil { if p.data == nil {
p.data = &list.List{} p.data = &list.List{}
} }
@ -55,35 +55,28 @@ func (p *preparedPosts) insertPrepared(i *preparedPostsEntry) error {
for e := p.data.Front(); e != nil; e = e.Next() { for e := p.data.Front(); e != nil; e = e.Next() {
position++ position++
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return errors.New("index: could not parse e as a preparedPostsEntry") return errors.New("index: could not parse e as a preparedPostsEntry")
} }
// don't insert this if it's a boost of a status we've seen recently skip, err := p.skipInsert(ctx, i.itemID, i.accountID, i.boostOfID, i.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position)
if i.prepared.Reblog != nil { if err != nil {
if entry.prepared.Reblog != nil && i.prepared.Reblog.ID == entry.prepared.Reblog.ID { return err
if position < boostReinsertionDepth { }
return nil if skip {
} return nil
}
if i.prepared.Reblog.ID == entry.statusID {
if position < boostReinsertionDepth {
return nil
}
}
} }
// if the post to index is newer than e, insert it before e in the list // if the post to index is newer than e, insert it before e in the list
if insertMark == nil { if insertMark == nil {
if i.statusID > entry.statusID { if i.itemID > entry.itemID {
insertMark = e insertMark = e
} }
} }
// make sure we don't insert a duplicate // make sure we don't insert a duplicate
if entry.statusID == i.statusID { if entry.itemID == i.itemID {
return nil return nil
} }
} }

View file

@ -38,39 +38,39 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
// remove entr(ies) from the post index // remove entr(ies) from the post index
removeIndexes := []*list.Element{} removeIndexes := []*list.Element{}
if t.postIndex != nil && t.postIndex.data != nil { if t.itemIndex != nil && t.itemIndex.data != nil {
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return removed, errors.New("Remove: could not parse e as a postIndexEntry") return removed, errors.New("Remove: could not parse e as a postIndexEntry")
} }
if entry.statusID == statusID { if entry.itemID == statusID {
l.Debug("found status in postIndex") l.Debug("found status in postIndex")
removeIndexes = append(removeIndexes, e) removeIndexes = append(removeIndexes, e)
} }
} }
} }
for _, e := range removeIndexes { for _, e := range removeIndexes {
t.postIndex.data.Remove(e) t.itemIndex.data.Remove(e)
removed++ removed++
} }
// remove entr(ies) from prepared posts // remove entr(ies) from prepared posts
removePrepared := []*list.Element{} removePrepared := []*list.Element{}
if t.preparedPosts != nil && t.preparedPosts.data != nil { if t.preparedItems != nil && t.preparedItems.data != nil {
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") return removed, errors.New("Remove: could not parse e as a preparedPostsEntry")
} }
if entry.statusID == statusID { if entry.itemID == statusID {
l.Debug("found status in preparedPosts") l.Debug("found status in preparedPosts")
removePrepared = append(removePrepared, e) removePrepared = append(removePrepared, e)
} }
} }
} }
for _, e := range removePrepared { for _, e := range removePrepared {
t.preparedPosts.data.Remove(e) t.preparedItems.data.Remove(e)
removed++ removed++
} }
@ -90,9 +90,9 @@ func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, erro
// remove entr(ies) from the post index // remove entr(ies) from the post index
removeIndexes := []*list.Element{} removeIndexes := []*list.Element{}
if t.postIndex != nil && t.postIndex.data != nil { if t.itemIndex != nil && t.itemIndex.data != nil {
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*itemIndexEntry)
if !ok { if !ok {
return removed, errors.New("Remove: could not parse e as a postIndexEntry") return removed, errors.New("Remove: could not parse e as a postIndexEntry")
} }
@ -103,15 +103,15 @@ func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, erro
} }
} }
for _, e := range removeIndexes { for _, e := range removeIndexes {
t.postIndex.data.Remove(e) t.itemIndex.data.Remove(e)
removed++ removed++
} }
// remove entr(ies) from prepared posts // remove entr(ies) from prepared posts
removePrepared := []*list.Element{} removePrepared := []*list.Element{}
if t.preparedPosts != nil && t.preparedPosts.data != nil { if t.preparedItems != nil && t.preparedItems.data != nil {
for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedPostsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") return removed, errors.New("Remove: could not parse e as a preparedPostsEntry")
} }
@ -122,7 +122,7 @@ func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, erro
} }
} }
for _, e := range removePrepared { for _, e := range removePrepared {
t.preparedPosts.data.Remove(e) t.preparedItems.data.Remove(e)
removed++ removed++
} }

View file

@ -21,104 +21,135 @@ package timeline
import ( import (
"context" "context"
"sync" "sync"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
) )
const boostReinsertionDepth = 50 // GrabFunction is used by a Timeline to grab more items to index.
//
// It should be provided to NewTimeline when the caller is creating a timeline
// (of statuses, notifications, etc).
//
// timelineAccountID: the owner of the timeline
// maxID: the maximum item ID desired.
// sinceID: the minimum item ID desired.
// minID: see sinceID
// limit: the maximum amount of items to be returned
//
// If an error is returned, the timeline will stop processing whatever request called GrabFunction,
// and return the error. If no error is returned, but stop = true, this indicates to the caller of GrabFunction
// that there are no more items to return, and processing should continue with the items already grabbed.
type GrabFunction func(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int) (items []Timelineable, stop bool, err error)
// Timeline represents a timeline for one account, and contains indexed and prepared posts. // FilterFunction is used by a Timeline to filter whether or not a grabbed item should be indexed.
type FilterFunction func(ctx context.Context, timelineAccountID string, item Timelineable) (shouldIndex bool, err error)
// PrepareFunction converts a Timelineable into a Preparable.
//
// For example, this might result in the converstion of a *gtsmodel.Status with the given itemID into a serializable *apimodel.Status.
type PrepareFunction func(ctx context.Context, timelineAccountID string, itemID string) (Preparable, error)
// SkipInsertFunction indicates whether a new item about to be inserted in the prepared list should be skipped,
// based on the item itself, the next item in the timeline, and the depth at which nextItem has been found in the list.
//
// This will be called for every item found while iterating through a timeline, so callers should be very careful
// not to do anything expensive here.
type SkipInsertFunction func(ctx context.Context,
newItemID string,
newItemAccountID string,
newItemBoostOfID string,
newItemBoostOfAccountID string,
nextItemID string,
nextItemAccountID string,
nextItemBoostOfID string,
nextItemBoostOfAccountID string,
depth int) (bool, error)
// Timeline represents a timeline for one account, and contains indexed and prepared items.
type Timeline interface { type Timeline interface {
/* /*
RETRIEVAL FUNCTIONS RETRIEVAL FUNCTIONS
*/ */
// Get returns an amount of statuses with the given parameters. // Get returns an amount of prepared items with the given parameters.
// If prepareNext is true, then the next predicted query will be prepared already in a goroutine, // If prepareNext is true, then the next predicted query will be prepared already in a goroutine,
// to make the next call to Get faster. // to make the next call to Get faster.
Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]*apimodel.Status, error) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error)
// GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest. // GetXFromTop returns x amount of items from the top of the timeline, from newest to oldest.
GetXFromTop(ctx context.Context, amount int) ([]*apimodel.Status, error) GetXFromTop(ctx context.Context, amount int) ([]Preparable, error)
// GetXBehindID returns x amount of posts from the given id onwards, from newest to oldest. // GetXBehindID returns x amount of items from the given id onwards, from newest to oldest.
// This will NOT include the status with the given ID. // This will NOT include the item with the given ID.
// //
// This corresponds to an api call to /timelines/home?max_id=WHATEVER // This corresponds to an api call to /timelines/home?max_id=WHATEVER
GetXBehindID(ctx context.Context, amount int, fromID string, attempts *int) ([]*apimodel.Status, error) GetXBehindID(ctx context.Context, amount int, fromID string, attempts *int) ([]Preparable, error)
// GetXBeforeID returns x amount of posts up to the given id, from newest to oldest. // GetXBeforeID returns x amount of items up to the given id, from newest to oldest.
// This will NOT include the status with the given ID. // This will NOT include the item with the given ID.
// //
// This corresponds to an api call to /timelines/home?since_id=WHATEVER // This corresponds to an api call to /timelines/home?since_id=WHATEVER
GetXBeforeID(ctx context.Context, amount int, sinceID string, startFromTop bool) ([]*apimodel.Status, error) GetXBeforeID(ctx context.Context, amount int, sinceID string, startFromTop bool) ([]Preparable, error)
// GetXBetweenID returns x amount of posts from the given maxID, up to the given id, from newest to oldest. // GetXBetweenID returns x amount of items from the given maxID, up to the given id, from newest to oldest.
// This will NOT include the status with the given IDs. // This will NOT include the item with the given IDs.
// //
// This corresponds to an api call to /timelines/home?since_id=WHATEVER&max_id=WHATEVER_ELSE // This corresponds to an api call to /timelines/home?since_id=WHATEVER&max_id=WHATEVER_ELSE
GetXBetweenID(ctx context.Context, amount int, maxID string, sinceID string) ([]*apimodel.Status, error) GetXBetweenID(ctx context.Context, amount int, maxID string, sinceID string) ([]Preparable, error)
/* /*
INDEXING FUNCTIONS INDEXING FUNCTIONS
*/ */
// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property. // IndexOne puts a item into the timeline at the appropriate place according to its 'createdAt' property.
// //
// The returned bool indicates whether or not the status was actually inserted into the timeline. This will be false // The returned bool indicates whether or not the item was actually inserted into the timeline. This will be false
// if the status is a boost and the original post or another boost of it already exists < boostReinsertionDepth back in the timeline. // if the item is a boost and the original item or another boost of it already exists < boostReinsertionDepth back in the timeline.
IndexOne(ctx context.Context, statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) IndexOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error)
// OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong. // OldestIndexedItemID returns the id of the rearmost (ie., the oldest) indexed item, or an error if something goes wrong.
// If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. // If nothing goes wrong but there's no oldest item, an empty string will be returned so make sure to check for this.
OldestIndexedPostID(ctx context.Context) (string, error) OldestIndexedItemID(ctx context.Context) (string, error)
// NewestIndexedPostID returns the id of the frontmost (ie., the newest) indexed post, or an error if something goes wrong. // NewestIndexedItemID returns the id of the frontmost (ie., the newest) indexed item, or an error if something goes wrong.
// If nothing goes wrong but there's no newest post, an empty string will be returned so make sure to check for this. // If nothing goes wrong but there's no newest item, an empty string will be returned so make sure to check for this.
NewestIndexedPostID(ctx context.Context) (string, error) NewestIndexedItemID(ctx context.Context) (string, error)
IndexBefore(ctx context.Context, statusID string, include bool, amount int) error IndexBefore(ctx context.Context, itemID string, amount int) error
IndexBehind(ctx context.Context, statusID string, include bool, amount int) error IndexBehind(ctx context.Context, itemID string, amount int) error
/* /*
PREPARATION FUNCTIONS PREPARATION FUNCTIONS
*/ */
// PrepareXFromTop instructs the timeline to prepare x amount of posts from the top of the timeline. // PrepareXFromTop instructs the timeline to prepare x amount of items from the top of the timeline.
PrepareFromTop(ctx context.Context, amount int) error PrepareFromTop(ctx context.Context, amount int) error
// PrepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards. // PrepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards.
// If include is true, then the given status ID will also be prepared, otherwise only entries behind it will be prepared. // If include is true, then the given item ID will also be prepared, otherwise only entries behind it will be prepared.
PrepareBehind(ctx context.Context, statusID string, amount int) error PrepareBehind(ctx context.Context, itemID string, amount int) error
// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property, // IndexOne puts a item into the timeline at the appropriate place according to its 'createdAt' property,
// and then immediately prepares it. // and then immediately prepares it.
// //
// The returned bool indicates whether or not the status was actually inserted into the timeline. This will be false // The returned bool indicates whether or not the item was actually inserted into the timeline. This will be false
// if the status is a boost and the original post or another boost of it already exists < boostReinsertionDepth back in the timeline. // if the item is a boost and the original item or another boost of it already exists < boostReinsertionDepth back in the timeline.
IndexAndPrepareOne(ctx context.Context, statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) IndexAndPrepareOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error)
// OldestPreparedPostID returns the id of the rearmost (ie., the oldest) prepared post, or an error if something goes wrong. // OldestPreparedItemID returns the id of the rearmost (ie., the oldest) prepared item, or an error if something goes wrong.
// If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. // If nothing goes wrong but there's no oldest item, an empty string will be returned so make sure to check for this.
OldestPreparedPostID(ctx context.Context) (string, error) OldestPreparedItemID(ctx context.Context) (string, error)
/* /*
INFO FUNCTIONS INFO FUNCTIONS
*/ */
// ActualPostIndexLength returns the actual length of the post index at this point in time. // ActualPostIndexLength returns the actual length of the item index at this point in time.
PostIndexLength(ctx context.Context) int ItemIndexLength(ctx context.Context) int
/* /*
UTILITY FUNCTIONS UTILITY FUNCTIONS
*/ */
// Reset instructs the timeline to reset to its base state -- cache only the minimum amount of posts. // Reset instructs the timeline to reset to its base state -- cache only the minimum amount of items.
Reset() error Reset() error
// Remove removes a status from both the index and prepared posts. // Remove removes a item from both the index and prepared items.
// //
// If a status has multiple entries in a timeline, they will all be removed. // If a item has multiple entries in a timeline, they will all be removed.
// //
// The returned int indicates the amount of entries that were removed. // The returned int indicates the amount of entries that were removed.
Remove(ctx context.Context, statusID string) (int, error) Remove(ctx context.Context, itemID string) (int, error)
// RemoveAllBy removes all statuses by the given accountID, from both the index and prepared posts. // RemoveAllBy removes all items by the given accountID, from both the index and prepared items.
// //
// The returned int indicates the amount of entries that were removed. // The returned int indicates the amount of entries that were removed.
RemoveAllBy(ctx context.Context, accountID string) (int, error) RemoveAllBy(ctx context.Context, accountID string) (int, error)
@ -126,31 +157,34 @@ type Timeline interface {
// timeline fulfils the Timeline interface // timeline fulfils the Timeline interface
type timeline struct { type timeline struct {
postIndex *postIndex itemIndex *itemIndex
preparedPosts *preparedPosts preparedItems *preparedItems
accountID string grabFunction GrabFunction
account *gtsmodel.Account filterFunction FilterFunction
db db.DB prepareFunction PrepareFunction
filter visibility.Filter accountID string
tc typeutils.TypeConverter
sync.Mutex sync.Mutex
} }
// NewTimeline returns a new Timeline for the given account ID // NewTimeline returns a new Timeline for the given account ID
func NewTimeline(ctx context.Context, accountID string, db db.DB, typeConverter typeutils.TypeConverter) (Timeline, error) { func NewTimeline(
timelineOwnerAccount := &gtsmodel.Account{} ctx context.Context,
if err := db.GetByID(ctx, accountID, timelineOwnerAccount); err != nil { timelineAccountID string,
return nil, err grabFunction GrabFunction,
} filterFunction FilterFunction,
prepareFunction PrepareFunction,
skipInsertFunction SkipInsertFunction) (Timeline, error) {
return &timeline{ return &timeline{
postIndex: &postIndex{}, itemIndex: &itemIndex{
preparedPosts: &preparedPosts{}, skipInsert: skipInsertFunction,
accountID: accountID, },
account: timelineOwnerAccount, preparedItems: &preparedItems{
db: db, skipInsert: skipInsertFunction,
filter: visibility.NewFilter(db), },
tc: typeConverter, grabFunction: grabFunction,
filterFunction: filterFunction,
prepareFunction: prepareFunction,
accountID: timelineAccountID,
}, nil }, nil
} }
@ -158,10 +192,10 @@ func (t *timeline) Reset() error {
return nil return nil
} }
func (t *timeline) PostIndexLength(ctx context.Context) int { func (t *timeline) ItemIndexLength(ctx context.Context) int {
if t.postIndex == nil || t.postIndex.data == nil { if t.itemIndex == nil || t.itemIndex.data == nil {
return 0 return 0
} }
return t.postIndex.data.Len() return t.itemIndex.data.Len()
} }

View file

@ -24,12 +24,14 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/timeline" "github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
) )
type TimelineStandardTestSuite struct { type TimelineStandardTestSuite struct {
suite.Suite suite.Suite
db db.DB db db.DB
tc typeutils.TypeConverter tc typeutils.TypeConverter
filter visibility.Filter
testAccounts map[string]*gtsmodel.Account testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status testStatuses map[string]*gtsmodel.Status

View file

@ -0,0 +1,27 @@
/*
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 timeline
// Timelineable represents any item that can be put in a timeline.
type Timelineable interface {
GetID() string
GetAccountID() string
GetBoostOfID() string
GetBoostOfAccountID() string
}

View file

@ -28,5 +28,5 @@ import (
// NewTestProcessor returns a Processor suitable for testing purposes // NewTestProcessor returns a Processor suitable for testing purposes
func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator, emailSender email.Sender) processing.Processor { func NewTestProcessor(db db.DB, storage *kv.KVStore, federator federation.Federator, emailSender email.Sender) processing.Processor {
return processing.NewProcessor(NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, NewTestTimelineManager(db), db, emailSender) return processing.NewProcessor(NewTestTypeConverter(db), federator, NewTestOauthServer(db), NewTestMediaHandler(db, storage), storage, db, emailSender)
} }

View file

@ -1,11 +0,0 @@
package testrig
import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
)
// NewTestTimelineManager retuts a new timeline.Manager, suitable for testing, using the given db.
func NewTestTimelineManager(db db.DB) timeline.Manager {
return timeline.NewManager(db, NewTestTypeConverter(db))
}