Timeline loop fix (#140)

* uwu we made a fucky wucky

* uwu we made a fucky wucky

* work on timeline fixes a little

* fiddle with tests some more

* bleep bloop more tests

* more tests

* update drone yml

* update some sturf

* make the timeline code a bit lazier

* go fmt

* fix drone.yml
This commit is contained in:
Tobi Smethurst 2021-08-15 18:43:08 +02:00 committed by GitHub
parent a4a33b9ad9
commit ff406be68f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1035 additions and 180 deletions

View file

@ -14,6 +14,11 @@ steps:
# See: https://golangci-lint.run/ # See: https://golangci-lint.run/
- name: lint - name: lint
image: golangci/golangci-lint:v1.41.1 image: golangci/golangci-lint:v1.41.1
volumes:
- name: go-build-cache
path: /root/.cache/go-build
- name: golangci-lint-cache
path: /root/.cache/golangci-lint
commands: commands:
- golangci-lint run --timeout 5m0s --tests=false --verbose - golangci-lint run --timeout 5m0s --tests=false --verbose
when: when:
@ -23,6 +28,9 @@ steps:
- name: test - name: test
image: golang:1.16.4 image: golang:1.16.4
volumes:
- name: go-build-cache
path: /root/.cache/go-build
environment: environment:
GTS_DB_ADDRESS: postgres GTS_DB_ADDRESS: postgres
commands: commands:
@ -49,15 +57,30 @@ steps:
exclude: exclude:
- pull_request - pull_request
services: # We need a postgres service running for the test step.
# We need this postgres service running for the test step.
# See: https://docs.drone.io/pipeline/docker/syntax/services/ # See: https://docs.drone.io/pipeline/docker/syntax/services/
services:
- name: postgres - name: postgres
image: postgres image: postgres
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
when:
event:
include:
- pull_request
# We can speed up builds significantly by caching build artifacts between runs.
# See: https://docs.drone.io/pipeline/docker/syntax/volumes/host/
volumes:
- name: go-build-cache
host:
path: /drone/gotosocial/go-build
- name: golangci-lint-cache
host:
path: /drone/gotosocial/golangci-lint
--- ---
kind: signature kind: signature
hmac: 888b0a9964be9bddad325a8eab0f54350f3cd36c333564ad4333811b4841b640 hmac: 9134975e238ab9f92a7f75ccc11279e8d5edddb87f10165ed3c7d23fdd9c8a11
... ...

View file

@ -28,31 +28,46 @@ import (
func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) { func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) {
statuses := []*gtsmodel.Status{} statuses := []*gtsmodel.Status{}
q := ps.conn.Model(&statuses) q := ps.conn.Model(&statuses)
q = q.ColumnExpr("status.*"). q = q.ColumnExpr("status.*").
// Find out who accountID follows.
Join("LEFT JOIN follows AS f ON f.target_account_id = status.account_id"). Join("LEFT JOIN follows AS f ON f.target_account_id = status.account_id").
Where("f.account_id = ?", accountID). // Use a WhereGroup here to specify that we want EITHER statuses posted by accounts that accountID follows,
// OR statuses posted by accountID itself (since a user should be able to see their own statuses).
//
// This is equivalent to something like WHERE ... AND (... OR ...)
// See: https://pg.uptrace.dev/queries/#select
WhereGroup(func(q *pg.Query) (*pg.Query, error) {
q = q.WhereOr("f.account_id = ?", accountID).
WhereOr("status.account_id = ?", accountID)
return q, nil
}).
// Sort by highest ID (newest) to lowest ID (oldest)
Order("status.id DESC") Order("status.id DESC")
if maxID != "" { if maxID != "" {
// return only statuses LOWER (ie., older) than maxID
q = q.Where("status.id < ?", maxID) q = q.Where("status.id < ?", maxID)
} }
if sinceID != "" { if sinceID != "" {
// return only statuses HIGHER (ie., newer) than sinceID
q = q.Where("status.id > ?", sinceID) q = q.Where("status.id > ?", sinceID)
} }
if minID != "" { if minID != "" {
// return only statuses HIGHER (ie., newer) than minID
q = q.Where("status.id > ?", minID) q = q.Where("status.id > ?", minID)
} }
if local { if local {
// return only statuses posted by local account havers
q = q.Where("status.local = ?", local) q = q.Where("status.local = ?", local)
} }
if limit > 0 { if limit > 0 {
// limit amount of statuses returned
q = q.Limit(limit) q = q.Limit(limit)
} }

View file

@ -304,7 +304,7 @@ func (p *processor) Start() error {
} }
} }
}() }()
return p.initTimelines() return nil
} }
// Stop stops the processor cleanly, finishing handling any remaining messages before closing down. // Stop stops the processor cleanly, finishing handling any remaining messages before closing down.

View file

@ -21,9 +21,7 @@ package processing
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"sync"
"github.com/sirupsen/logrus"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -186,103 +184,3 @@ func (p *processor) filterFavedStatuses(authed *oauth.Auth, statuses []*gtsmodel
return apiStatuses, nil return apiStatuses, nil
} }
func (p *processor) initTimelines() error {
// get all local accounts (ie., domain = nil) that aren't suspended (suspended_at = nil)
localAccounts := []*gtsmodel.Account{}
where := []db.Where{
{
Key: "domain", Value: nil,
},
{
Key: "suspended_at", Value: nil,
},
}
if err := p.db.GetWhere(where, &localAccounts); err != nil {
if _, ok := err.(db.ErrNoEntries); ok {
return nil
}
return fmt.Errorf("initTimelines: db error initializing timelines: %s", err)
}
// we want to wait until all timelines are populated so created a waitgroup here
wg := &sync.WaitGroup{}
wg.Add(len(localAccounts))
for _, localAccount := range localAccounts {
// to save time we can populate the timelines asynchronously
// this will go heavy on the database, but since we're not actually serving yet it doesn't really matter
go p.initTimelineFor(localAccount, wg)
}
// wait for all timelines to be populated before we exit
wg.Wait()
return nil
}
func (p *processor) initTimelineFor(account *gtsmodel.Account, wg *sync.WaitGroup) {
defer wg.Done()
l := p.log.WithFields(logrus.Fields{
"func": "initTimelineFor",
"accountID": account.ID,
})
desiredIndexLength := p.timelineManager.GetDesiredIndexLength()
statuses, err := p.db.GetHomeTimelineForAccount(account.ID, "", "", "", desiredIndexLength, false)
if err != nil {
if _, ok := err.(db.ErrNoEntries); !ok {
l.Error(fmt.Errorf("initTimelineFor: error getting statuses: %s", err))
}
return
}
p.indexAndIngest(statuses, account, desiredIndexLength)
lengthNow := p.timelineManager.GetIndexedLength(account.ID)
if lengthNow < desiredIndexLength {
// try and get more posts from the last ID onwards
rearmostStatusID, err := p.timelineManager.GetOldestIndexedID(account.ID)
if err != nil {
l.Error(fmt.Errorf("initTimelineFor: error getting id of rearmost status: %s", err))
return
}
if rearmostStatusID != "" {
moreStatuses, err := p.db.GetHomeTimelineForAccount(account.ID, rearmostStatusID, "", "", desiredIndexLength/2, false)
if err != nil {
l.Error(fmt.Errorf("initTimelineFor: error getting more statuses: %s", err))
return
}
p.indexAndIngest(moreStatuses, account, desiredIndexLength)
}
}
l.Debugf("prepared timeline of length %d for account %s", lengthNow, account.ID)
}
func (p *processor) indexAndIngest(statuses []*gtsmodel.Status, timelineAccount *gtsmodel.Account, desiredIndexLength int) {
l := p.log.WithFields(logrus.Fields{
"func": "indexAndIngest",
"accountID": timelineAccount.ID,
})
for _, s := range statuses {
timelineable, err := p.filter.StatusHometimelineable(s, timelineAccount)
if err != nil {
l.Error(fmt.Errorf("initTimelineFor: error checking home timelineability of status %s: %s", s.ID, err))
continue
}
if timelineable {
if _, err := p.timelineManager.Ingest(s, timelineAccount.ID); err != nil {
l.Error(fmt.Errorf("initTimelineFor: error ingesting status %s: %s", s.ID, err))
continue
}
// check if we have enough posts now and return if we do
if p.timelineManager.GetIndexedLength(timelineAccount.ID) >= desiredIndexLength {
return
}
}
}
}

View file

@ -29,7 +29,7 @@ import (
const retries = 5 const retries = 5
func (t *timeline) Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) { func (t *timeline) Get(amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]*apimodel.Status, error) {
l := t.log.WithFields(logrus.Fields{ l := t.log.WithFields(logrus.Fields{
"func": "Get", "func": "Get",
"accountID": t.accountID, "accountID": t.accountID,
@ -44,35 +44,44 @@ func (t *timeline) Get(amount int, maxID string, sinceID string, minID string) (
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
if maxID == "" && sinceID == "" && minID == "" { if maxID == "" && sinceID == "" && minID == "" {
statuses, err = t.GetXFromTop(amount) statuses, err = t.GetXFromTop(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(statuses) != 0 {
nextMaxID := statuses[len(statuses)-1].ID nextMaxID := statuses[len(statuses)-1].ID
go func() { if prepareNext {
if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { // already cache the next query to speed up scrolling
l.Errorf("error preparing next query: %s", err) go func() {
} if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil {
}() l.Errorf("error preparing next query: %s", err)
}
}()
}
} }
} }
// 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
if maxID != "" && sinceID == "" { if maxID != "" && sinceID == "" {
attempts := 0 attempts := 0
statuses, err = t.GetXBehindID(amount, maxID, &attempts) statuses, err = t.GetXBehindID(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(statuses) != 0 {
nextMaxID := statuses[len(statuses)-1].ID nextMaxID := statuses[len(statuses)-1].ID
go func() { if prepareNext {
if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil { // already cache the next query to speed up scrolling
l.Errorf("error preparing next query: %s", err) go func() {
} if err := t.prepareNextQuery(amount, nextMaxID, "", ""); err != nil {
}() l.Errorf("error preparing next query: %s", err)
}
}()
}
} }
} }
// 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
if maxID != "" && sinceID != "" { if maxID != "" && sinceID != "" {
statuses, err = t.GetXBetweenID(amount, maxID, minID) statuses, err = t.GetXBetweenID(amount, maxID, minID)
} }
@ -81,13 +90,12 @@ func (t *timeline) Get(amount int, maxID string, sinceID string, minID string) (
} }
// 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)
if maxID == "" && sinceID != "" { if maxID == "" && sinceID != "" {
attempts := 0 statuses, err = t.GetXBeforeID(amount, sinceID, true)
statuses, err = t.GetXBeforeID(amount, sinceID, true, &attempts)
} }
if maxID == "" && minID != "" { if maxID == "" && minID != "" {
attempts := 0 statuses, err = t.GetXBeforeID(amount, minID, true)
statuses, err = t.GetXBeforeID(amount, minID, true, &attempts)
} }
return statuses, err return statuses, err
@ -126,6 +134,13 @@ func (t *timeline) GetXFromTop(amount int) ([]*apimodel.Status, error) {
} }
func (t *timeline) GetXBehindID(amount int, behindID string, attempts *int) ([]*apimodel.Status, error) { func (t *timeline) GetXBehindID(amount int, behindID string, attempts *int) ([]*apimodel.Status, error) {
l := t.log.WithFields(logrus.Fields{
"func": "GetXBehindID",
"amount": amount,
"behindID": behindID,
"attempts": *attempts,
})
newAttempts := *attempts newAttempts := *attempts
newAttempts = newAttempts + 1 newAttempts = newAttempts + 1
attempts = &newAttempts attempts = &newAttempts
@ -149,17 +164,16 @@ findMarkLoop:
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.statusID <= behindID {
l.Trace("found behindID mark")
behindIDMark = e behindIDMark = e
break findMarkLoop break 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
if behindIDMark == nil { if behindIDMark == nil {
if err := t.IndexBehind(behindID, amount); err != nil {
return nil, fmt.Errorf("GetXBehindID: error indexing behind and including ID %s", behindID)
}
if err := t.PrepareBehind(behindID, amount); err != nil { if err := t.PrepareBehind(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)
} }
@ -167,12 +181,19 @@ findMarkLoop:
if err != nil { if err != nil {
return nil, err return nil, err
} }
if oldestID == "" || oldestID == behindID || *attempts > retries { if oldestID == "" {
// There is no oldest prepared post, or the oldest prepared post is still the post we're looking for entries after, l.Tracef("oldestID is empty so we can't return behindID %s", behindID)
// or we've tried this loop too many times.
// This means we should just return the empty statuses slice since we don't have any more posts to offer.
return statuses, nil return statuses, nil
} }
if oldestID == behindID {
l.Tracef("given behindID %s is the same as oldestID %s so there's nothing to return behind it", behindID, oldestID)
return statuses, nil
}
if *attempts > retries {
l.Tracef("exceeded retries looking for behindID %s", behindID)
return statuses, nil
}
l.Trace("trying GetXBehindID again")
return t.GetXBehindID(amount, behindID, attempts) return t.GetXBehindID(amount, behindID, attempts)
} }
@ -203,11 +224,7 @@ serveloop:
return statuses, nil return statuses, nil
} }
func (t *timeline) GetXBeforeID(amount int, beforeID string, startFromTop bool, attempts *int) ([]*apimodel.Status, error) { func (t *timeline) GetXBeforeID(amount int, beforeID string, startFromTop bool) ([]*apimodel.Status, error) {
newAttempts := *attempts
newAttempts = newAttempts + 1
attempts = &newAttempts
// make a slice of statuses with the length we need to return // make a slice of statuses with the length we need to return
statuses := make([]*apimodel.Status, 0, amount) statuses := make([]*apimodel.Status, 0, amount)
@ -215,7 +232,7 @@ func (t *timeline) GetXBeforeID(amount int, beforeID string, startFromTop bool,
t.preparedPosts.data = &list.List{} t.preparedPosts.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, 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.preparedPosts.data.Front(); e != nil; e = e.Next() {
@ -224,24 +241,15 @@ findMarkLoop:
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.statusID >= beforeID {
beforeIDMark = e beforeIDMark = e
} else {
break findMarkLoop break findMarkLoop
} }
} }
// we didn't find it, so we need to make sure it's indexed and prepared and then try again
if beforeIDMark == nil { if beforeIDMark == nil {
if err := t.IndexBefore(beforeID, true, amount); err != nil { return statuses, nil
return nil, fmt.Errorf("GetXBeforeID: error indexing before and including ID %s", beforeID)
}
if err := t.PrepareBefore(beforeID, true, amount); err != nil {
return nil, fmt.Errorf("GetXBeforeID: error preparing before and including ID %s", beforeID)
}
if *attempts > retries {
return statuses, nil
}
return t.GetXBeforeID(amount, beforeID, startFromTop, attempts)
} }
var served int var served int

View file

@ -0,0 +1,438 @@
/*
GoToSocial
Copyright (C) 2021 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_test
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type GetTestSuite struct {
TimelineStandardTestSuite
}
func (suite *GetTestSuite) SetupSuite() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *GetTestSuite) SetupTest() {
suite.config = testrig.NewTestConfig()
suite.db = testrig.NewTestDB()
suite.log = testrig.NewTestLog()
suite.tc = testrig.NewTestTypeConverter(suite.db)
testrig.StandardDBSetup(suite.db, nil)
// let's take local_account_1 as the timeline owner
tl, err := timeline.NewTimeline(suite.testAccounts["local_account_1"].ID, suite.db, suite.tc, suite.log)
if err != nil {
suite.FailNow(err.Error())
}
// 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 {
_, err := tl.IndexAndPrepareOne(s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID)
if err != nil {
suite.FailNow(err.Error())
}
}
suite.timeline = tl
}
func (suite *GetTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
}
func (suite *GetTestSuite) TestGetDefault() {
// get 10 20 the top and don't prepare the next query
statuses, err := suite.timeline.Get(20, "", "", "", false)
if err != nil {
suite.FailNow(err.Error())
}
// we only have 12 statuses in the test suite
suite.Len(statuses, 12)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetDefaultPrepareNext() {
// get 10 from the top and prepare the next query
statuses, err := suite.timeline.Get(10, "", "", "", true)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 10)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
// sleep a second so the next query can run
time.Sleep(1 * time.Second)
}
func (suite *GetTestSuite) TestGetMaxID() {
// ask for 10 with a max ID somewhere in the middle of the stack
statuses, err := suite.timeline.Get(10, "01F8MHBQCBTDKN6X5VHGMMN4MA", "", "", false)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 6 statuses back, since we asked for a max ID that excludes some of our entries
suite.Len(statuses, 6)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetMaxIDPrepareNext() {
// ask for 10 with a max ID somewhere in the middle of the stack
statuses, err := suite.timeline.Get(10, "01F8MHBQCBTDKN6X5VHGMMN4MA", "", "", true)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 6 statuses back, since we asked for a max ID that excludes some of our entries
suite.Len(statuses, 6)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
// sleep a second so the next query can run
time.Sleep(1 * time.Second)
}
func (suite *GetTestSuite) TestGetMinID() {
// ask for 10 with a min ID somewhere in the middle of the stack
statuses, err := suite.timeline.Get(10, "", "01F8MHBQCBTDKN6X5VHGMMN4MA", "", false)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 5 statuses back, since we asked for a min ID that excludes some of our entries
suite.Len(statuses, 5)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetSinceID() {
// ask for 10 with a since ID somewhere in the middle of the stack
statuses, err := suite.timeline.Get(10, "", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", false)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 5 statuses back, since we asked for a since ID that excludes some of our entries
suite.Len(statuses, 5)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetSinceIDPrepareNext() {
// ask for 10 with a since ID somewhere in the middle of the stack
statuses, err := suite.timeline.Get(10, "", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", true)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 5 statuses back, since we asked for a since ID that excludes some of our entries
suite.Len(statuses, 5)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
// sleep a second so the next query can run
time.Sleep(1 * time.Second)
}
func (suite *GetTestSuite) TestGetBetweenID() {
// ask for 10 between these two IDs
statuses, err := suite.timeline.Get(10, "01F8MHCP5P2NWYQ416SBA0XSEV", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", false)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 2 statuses back, since there are only two statuses between the given IDs
suite.Len(statuses, 2)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetBetweenIDPrepareNext() {
// ask for 10 between these two IDs
statuses, err := suite.timeline.Get(10, "01F8MHCP5P2NWYQ416SBA0XSEV", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", true)
if err != nil {
suite.FailNow(err.Error())
}
// we should only get 2 statuses back, since there are only two statuses between the given IDs
suite.Len(statuses, 2)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
// sleep a second so the next query can run
time.Sleep(1 * time.Second)
}
func (suite *GetTestSuite) TestGetXFromTop() {
// get 5 from the top
statuses, err := suite.timeline.GetXFromTop(5)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 5)
// statuses should be sorted highest to lowest ID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
}
}
func (suite *GetTestSuite) TestGetXBehindID() {
// get 3 behind the 'middle' id
var attempts *int
a := 0
attempts = &a
statuses, err := suite.timeline.GetXBehindID(3, "01F8MHBQCBTDKN6X5VHGMMN4MA", attempts)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 3)
// statuses should be sorted highest to lowest ID
// all status IDs should be less than the behindID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
suite.Less(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA")
}
}
func (suite *GetTestSuite) TestGetXBehindID0() {
// try to get behind 0, the lowest possible ID
var attempts *int
a := 0
attempts = &a
statuses, err := suite.timeline.GetXBehindID(3, "0", attempts)
if err != nil {
suite.FailNow(err.Error())
}
// there's nothing beyond it so len should be 0
suite.Len(statuses, 0)
}
func (suite *GetTestSuite) TestGetXBehindNonexistentReasonableID() {
// try to get behind an id that doesn't exist, but is close to one that does so we should still get statuses back
var attempts *int
a := 0
attempts = &a
statuses, err := suite.timeline.GetXBehindID(3, "01F8MHBQCBTDKN6X5VHGMMN4MB", attempts) // change the last A to a B
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 3)
// statuses should be sorted highest to lowest ID
// all status IDs should be less than the behindID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
suite.Less(s.ID, "01F8MHBCN8120SYH7D5S050MGK")
}
}
func (suite *GetTestSuite) TestGetXBehindVeryHighID() {
// try to get behind an id that doesn't exist, and is higher than any other ID we could possibly have
var attempts *int
a := 0
attempts = &a
statuses, err := suite.timeline.GetXBehindID(7, "9998MHBQCBTDKN6X5VHGMMN4MA", attempts)
if err != nil {
suite.FailNow(err.Error())
}
// we should get all 7 statuses we asked for because they all have lower IDs than the very high ID given in the query
suite.Len(statuses, 7)
// statuses should be sorted highest to lowest ID
// all status IDs should be less than the behindID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
suite.Less(s.ID, "9998MHBQCBTDKN6X5VHGMMN4MA")
}
}
func (suite *GetTestSuite) TestGetXBeforeID() {
// get 3 before the 'middle' id
statuses, err := suite.timeline.GetXBeforeID(3, "01F8MHBQCBTDKN6X5VHGMMN4MA", true)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 3)
// statuses should be sorted highest to lowest ID
// all status IDs should be greater than the beforeID
var highest string
for i, s := range statuses {
if i == 0 {
highest = s.ID
} else {
suite.Less(s.ID, highest)
highest = s.ID
}
suite.Greater(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA")
}
}
func (suite *GetTestSuite) TestGetXBeforeIDNoStartFromTop() {
// get 3 before the 'middle' id
statuses, err := suite.timeline.GetXBeforeID(3, "01F8MHBQCBTDKN6X5VHGMMN4MA", false)
if err != nil {
suite.FailNow(err.Error())
}
suite.Len(statuses, 3)
// statuses should be sorted lowest to highest ID
// all status IDs should be greater than the beforeID
var lowest string
for i, s := range statuses {
if i == 0 {
lowest = s.ID
} else {
suite.Greater(s.ID, lowest)
lowest = s.ID
}
suite.Greater(s.ID, "01F8MHBQCBTDKN6X5VHGMMN4MA")
}
}
func TestGetTestSuite(t *testing.T) {
suite.Run(t, new(GetTestSuite))
}

View file

@ -19,30 +19,38 @@
package timeline package timeline
import ( import (
"container/list"
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
func (t *timeline) IndexBefore(statusID string, include bool, amount int) error { func (t *timeline) IndexBefore(statusID string, include bool, amount int) error {
// lazily initialize index if it hasn't been done already
if t.postIndex.data == nil {
t.postIndex.data = &list.List{}
t.postIndex.data.Init()
}
filtered := []*gtsmodel.Status{} filtered := []*gtsmodel.Status{}
offsetStatus := statusID offsetStatus := statusID
if include { if include {
// if we have the status with given statusID in the database, include it in the results set as well
s := &gtsmodel.Status{} s := &gtsmodel.Status{}
if err := t.db.GetByID(statusID, s); err != nil { if err := t.db.GetByID(statusID, s); err == nil {
return fmt.Errorf("IndexBefore: error getting initial status with id %s: %s", statusID, err) filtered = append(filtered, s)
} }
filtered = append(filtered, s)
} }
i := 0 i := 0
grabloop: grabloop:
for ; len(filtered) < amount && i < 5; i = i + 1 { // try the grabloop 5 times only for ; len(filtered) < amount && i < 5; i = i + 1 { // try the grabloop 5 times only
statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, "", offsetStatus, "", amount, false) statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, "", "", offsetStatus, amount, false)
if err != nil { if err != nil {
if _, ok := err.(db.ErrNoEntries); ok { if _, ok := err.(db.ErrNoEntries); ok {
break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail
@ -71,24 +79,70 @@ grabloop:
return nil return nil
} }
func (t *timeline) IndexBehind(statusID string, amount int) error { func (t *timeline) IndexBehind(statusID string, include bool, amount int) error {
l := t.log.WithFields(logrus.Fields{
"func": "IndexBehind",
"include": include,
"amount": amount,
})
// lazily initialize index if it hasn't been done already
if t.postIndex.data == nil {
t.postIndex.data = &list.List{}
t.postIndex.data.Init()
}
// If we're already indexedBehind given statusID by the required amount, we can return nil.
// First find position of statusID (or as near as possible).
var position int
positionLoop:
for e := t.postIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry)
if !ok {
return errors.New("IndexBehind: could not parse e as a postIndexEntry")
}
if entry.statusID <= statusID {
// we've found it
break positionLoop
}
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 {
// 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")
return nil
}
filtered := []*gtsmodel.Status{} filtered := []*gtsmodel.Status{}
offsetStatus := statusID offsetStatus := statusID
if include {
// 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(statusID, s); err == nil {
filtered = append(filtered, s)
}
}
i := 0 i := 0
grabloop: grabloop:
for ; len(filtered) < amount && i < 5; i = i + 1 { // try the grabloop 5 times only for ; len(filtered) < amount && i < 5; i = i + 1 { // try the grabloop 5 times only
l.Tracef("entering grabloop; i is %d; len(filtered) is %d", i, len(filtered))
statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, offsetStatus, "", "", amount, false) statuses, err := t.db.GetHomeTimelineForAccount(t.accountID, offsetStatus, "", "", amount, false)
if err != nil { if err != nil {
if _, ok := err.(db.ErrNoEntries); ok { if _, ok := err.(db.ErrNoEntries); ok {
break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail break grabloop // we just don't have enough statuses left in the db so index what we've got and then bail
} }
return fmt.Errorf("IndexBehindAndIncluding: error getting statuses from db: %s", err) return fmt.Errorf("IndexBehind: error getting statuses from db: %s", err)
} }
l.Tracef("got %d statuses", len(statuses))
for _, s := range statuses { for _, s := range statuses {
timelineable, err := t.filter.StatusHometimelineable(s, t.account) timelineable, err := t.filter.StatusHometimelineable(s, t.account)
if err != nil { if err != nil {
l.Tracef("status was not hometimelineable: %s", err)
continue continue
} }
if timelineable { if timelineable {
@ -97,6 +151,7 @@ grabloop:
offsetStatus = s.ID offsetStatus = s.ID
} }
} }
l.Trace("left grabloop")
for _, s := range filtered { for _, s := range filtered {
if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil { if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil {
@ -104,10 +159,7 @@ grabloop:
} }
} }
return nil l.Trace("exiting function")
}
func (t *timeline) IndexOneByID(statusID string) error {
return nil return nil
} }
@ -152,21 +204,30 @@ func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string
func (t *timeline) OldestIndexedPostID() (string, error) { func (t *timeline) OldestIndexedPostID() (string, error) {
var id string var id string
if t.postIndex == nil || t.postIndex.data == nil { if t.postIndex == nil || t.postIndex.data == nil || t.postIndex.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.postIndex.data.Back()
if e == nil {
// return an empty string if there's no back entry (ie., the index list hasn't been initialized yet)
return id, nil
}
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*postIndexEntry)
if !ok { if !ok {
return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry") return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry")
} }
return entry.statusID, nil return entry.statusID, nil
} }
func (t *timeline) NewestIndexedPostID() (string, error) {
var id string
if t.postIndex == nil || t.postIndex.data == nil || t.postIndex.data.Front() == nil {
// return an empty string if postindex hasn't been initialized yet
return id, nil
}
e := t.postIndex.data.Front()
entry, ok := e.Value.(*postIndexEntry)
if !ok {
return id, errors.New("NewestIndexedPostID: could not parse e as a postIndexEntry")
}
return entry.statusID, nil
}

View file

@ -0,0 +1,193 @@
/*
GoToSocial
Copyright (C) 2021 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_test
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type IndexTestSuite struct {
TimelineStandardTestSuite
}
func (suite *IndexTestSuite) SetupSuite() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *IndexTestSuite) SetupTest() {
suite.config = testrig.NewTestConfig()
suite.db = testrig.NewTestDB()
suite.log = testrig.NewTestLog()
suite.tc = testrig.NewTestTypeConverter(suite.db)
testrig.StandardDBSetup(suite.db, nil)
// let's take local_account_1 as the timeline owner, and start with an empty timeline
tl, err := timeline.NewTimeline(suite.testAccounts["local_account_1"].ID, suite.db, suite.tc, suite.log)
if err != nil {
suite.FailNow(err.Error())
}
suite.timeline = tl
}
func (suite *IndexTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
}
func (suite *IndexTestSuite) TestIndexBeforeLowID() {
// index 10 before the lowest status ID possible
err := suite.timeline.IndexBefore("00000000000000000000000000", true, 10)
suite.NoError(err)
// the oldest indexed post should be the lowest one we have in our testrig
postID, err := suite.timeline.OldestIndexedPostID()
suite.NoError(err)
suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", postID)
// indexLength should only be 9 because that's all this user has hometimelineable
indexLength := suite.timeline.PostIndexLength()
suite.Equal(9, indexLength)
}
func (suite *IndexTestSuite) TestIndexBeforeHighID() {
// index 10 before the highest status ID possible
err := suite.timeline.IndexBefore("ZZZZZZZZZZZZZZZZZZZZZZZZZZ", true, 10)
suite.NoError(err)
// the oldest indexed post should be empty
postID, err := suite.timeline.OldestIndexedPostID()
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.PostIndexLength()
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestIndexBehindHighID() {
// index 10 behind the highest status ID possible
err := suite.timeline.IndexBehind("ZZZZZZZZZZZZZZZZZZZZZZZZZZ", true, 10)
suite.NoError(err)
// the newest indexed post should be the highest one we have in our testrig
postID, err := suite.timeline.NewestIndexedPostID()
suite.NoError(err)
suite.Equal("01FCTA44PW9H1TB328S9AQXKDS", postID)
// indexLength should only be 11 because that's all this user has hometimelineable
indexLength := suite.timeline.PostIndexLength()
suite.Equal(11, indexLength)
}
func (suite *IndexTestSuite) TestIndexBehindLowID() {
// index 10 behind the lowest status ID possible
err := suite.timeline.IndexBehind("00000000000000000000000000", true, 10)
suite.NoError(err)
// the newest indexed post should be empty
postID, err := suite.timeline.NewestIndexedPostID()
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.PostIndexLength()
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestOldestIndexedPostIDEmpty() {
// the oldest indexed post should be an empty string since there's nothing indexed yet
postID, err := suite.timeline.OldestIndexedPostID()
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.PostIndexLength()
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestNewestIndexedPostIDEmpty() {
// the newest indexed post should be an empty string since there's nothing indexed yet
postID, err := suite.timeline.NewestIndexedPostID()
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.PostIndexLength()
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestIndexAlreadyIndexed() {
testStatus := suite.testStatuses["local_account_1_status_1"]
// index one post -- it should be indexed
indexed, err := suite.timeline.IndexOne(testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err)
suite.True(indexed)
// try to index the same post again -- it should not be indexed
indexed, err = suite.timeline.IndexOne(testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err)
suite.False(indexed)
}
func (suite *IndexTestSuite) TestIndexAndPrepareAlreadyIndexedAndPrepared() {
testStatus := suite.testStatuses["local_account_1_status_1"]
// index and prepare one post -- it should be indexed
indexed, err := suite.timeline.IndexAndPrepareOne(testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err)
suite.True(indexed)
// try to index and prepare the same post again -- it should not be indexed
indexed, err = suite.timeline.IndexAndPrepareOne(testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err)
suite.False(indexed)
}
func (suite *IndexTestSuite) TestIndexBoostOfAlreadyIndexed() {
testStatus := suite.testStatuses["local_account_1_status_1"]
boostOfTestStatus := &gtsmodel.Status{
CreatedAt: time.Now(),
ID: "01FD4TA6G2Z6M7W8NJQ3K5WXYD",
BoostOfID: testStatus.ID,
AccountID: "01FD4TAY1C0NGEJVE9CCCX7QKS",
BoostOfAccountID: testStatus.AccountID,
}
// index one post -- it should be indexed
indexed, err := suite.timeline.IndexOne(testStatus.CreatedAt, testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
suite.NoError(err)
suite.True(indexed)
// try to index the a boost of that post -- it should not be indexed
indexed, err = suite.timeline.IndexOne(boostOfTestStatus.CreatedAt, boostOfTestStatus.ID, boostOfTestStatus.BoostOfID, boostOfTestStatus.AccountID, boostOfTestStatus.BoostOfAccountID)
suite.NoError(err)
suite.False(indexed)
}
func TestIndexTestSuite(t *testing.T) {
suite.Run(t, new(IndexTestSuite))
}

View file

@ -75,11 +75,11 @@ type Manager interface {
// PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index. // PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index.
PrepareXFromTop(timelineAccountID string, limit int) error PrepareXFromTop(timelineAccountID string, limit int) error
// Remove removes one status from the timeline of the given timelineAccountID // Remove removes one status from the timeline of the given timelineAccountID
Remove(statusID string, timelineAccountID string) (int, error) Remove(timelineAccountID string, statusID string) (int, error)
// WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines // WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines
WipeStatusFromAllTimelines(statusID string) error WipeStatusFromAllTimelines(statusID string) error
// WipeStatusesFromAccountID removes all statuses by the given accountID from the timelineAccountID's timelines. // WipeStatusesFromAccountID removes all statuses by the given accountID from the timelineAccountID's timelines.
WipeStatusesFromAccountID(accountID string, timelineAccountID string) error WipeStatusesFromAccountID(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 with the given database, typeconverter, config, and log.
@ -133,7 +133,7 @@ func (m *manager) IngestAndPrepare(status *gtsmodel.Status, timelineAccountID st
return t.IndexAndPrepareOne(status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID) return t.IndexAndPrepareOne(status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID)
} }
func (m *manager) Remove(statusID string, timelineAccountID string) (int, error) { func (m *manager) Remove(timelineAccountID string, statusID string) (int, error) {
l := m.log.WithFields(logrus.Fields{ l := m.log.WithFields(logrus.Fields{
"func": "Remove", "func": "Remove",
"timelineAccountID": timelineAccountID, "timelineAccountID": timelineAccountID,
@ -160,7 +160,7 @@ func (m *manager) HomeTimeline(timelineAccountID string, maxID string, sinceID s
return nil, err return nil, err
} }
statuses, err := t.Get(limit, maxID, sinceID, minID) statuses, err := t.Get(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)
} }
@ -221,7 +221,7 @@ func (m *manager) WipeStatusFromAllTimelines(statusID string) error {
return err return err
} }
func (m *manager) WipeStatusesFromAccountID(accountID string, timelineAccountID string) error { func (m *manager) WipeStatusesFromAccountID(timelineAccountID string, accountID string) error {
t, err := m.getOrCreateTimeline(timelineAccountID) t, err := m.getOrCreateTimeline(timelineAccountID)
if err != nil { if err != nil {
return err return err

View file

@ -0,0 +1,142 @@
/*
GoToSocial
Copyright (C) 2021 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_test
import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type ManagerTestSuite struct {
TimelineStandardTestSuite
}
func (suite *ManagerTestSuite) SetupSuite() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *ManagerTestSuite) SetupTest() {
suite.config = testrig.NewTestConfig()
suite.db = testrig.NewTestDB()
suite.log = testrig.NewTestLog()
suite.tc = testrig.NewTestTypeConverter(suite.db)
testrig.StandardDBSetup(suite.db, nil)
manager := testrig.NewTestTimelineManager(suite.db)
suite.manager = manager
}
func (suite *ManagerTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
}
func (suite *ManagerTestSuite) TestManagerIntegration() {
testAccount := suite.testAccounts["local_account_1"]
// should start at 0
indexedLen := suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(0, indexedLen)
// oldestIndexed should be empty string since there's nothing indexed
oldestIndexed, err := suite.manager.GetOldestIndexedID(testAccount.ID)
suite.NoError(err)
suite.Empty(oldestIndexed)
// trigger status preparation
err = suite.manager.PrepareXFromTop(testAccount.ID, 20)
suite.NoError(err)
// local_account_1 can see 11 statuses out of the testrig statuses in its home timeline
indexedLen = suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(11, indexedLen)
// oldest should now be set
oldestIndexed, err = suite.manager.GetOldestIndexedID(testAccount.ID)
suite.NoError(err)
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", oldestIndexed)
// get hometimeline
statuses, err := suite.manager.HomeTimeline(testAccount.ID, "", "", "", 20, false)
suite.NoError(err)
suite.Len(statuses, 11)
// now wipe the last status from all timelines, as though it had been deleted by the owner
err = suite.manager.WipeStatusFromAllTimelines("01F8MH75CBF9JFX4ZAD54N0W0R")
suite.NoError(err)
// timeline should be shorter
indexedLen = suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(10, indexedLen)
// oldest should now be different
oldestIndexed, err = suite.manager.GetOldestIndexedID(testAccount.ID)
suite.NoError(err)
suite.Equal("01F8MH82FYRXD2RC6108DAJ5HB", oldestIndexed)
// delete the new oldest status specifically from this timeline, as though local_account_1 had muted or blocked it
removed, err := suite.manager.Remove(testAccount.ID, "01F8MH82FYRXD2RC6108DAJ5HB")
suite.NoError(err)
suite.Equal(2, removed) // 1 status should be removed, but from both indexed and prepared, so 2 removals total
// timeline should be shorter
indexedLen = suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(9, indexedLen)
// oldest should now be different
oldestIndexed, err = suite.manager.GetOldestIndexedID(testAccount.ID)
suite.NoError(err)
suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", oldestIndexed)
// now remove all entries by local_account_2 from the timeline
err = suite.manager.WipeStatusesFromAccountID(testAccount.ID, suite.testAccounts["local_account_2"].ID)
suite.NoError(err)
// timeline should be empty now
indexedLen = suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(5, indexedLen)
// ingest 1 into the timeline
status1 := suite.testStatuses["admin_account_status_1"]
ingested, err := suite.manager.Ingest(status1, testAccount.ID)
suite.NoError(err)
suite.True(ingested)
// ingest and prepare another one into the timeline
status2 := suite.testStatuses["local_account_2_status_1"]
ingested, err = suite.manager.IngestAndPrepare(status2, testAccount.ID)
suite.NoError(err)
suite.True(ingested)
// timeline should be longer now
indexedLen = suite.manager.GetIndexedLength(testAccount.ID)
suite.Equal(7, indexedLen)
// try to ingest status 2 again
ingested, err = suite.manager.IngestAndPrepare(status2, testAccount.ID)
suite.NoError(err)
suite.False(ingested) // should be false since it's a duplicate
}
func TestManagerTestSuite(t *testing.T) {
suite.Run(t, new(ManagerTestSuite))
}

View file

@ -23,23 +23,35 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) error { func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) error {
l := t.log.WithFields(logrus.Fields{
"func": "prepareNextQuery",
"amount": amount,
"maxID": maxID,
"sinceID": sinceID,
"minID": minID,
})
var err error var err error
// maxID is defined but sinceID isn't so take from behind // maxID is defined but sinceID isn't so take from behind
if maxID != "" && sinceID == "" { if maxID != "" && sinceID == "" {
l.Debug("preparing behind maxID")
err = t.PrepareBehind(maxID, amount) err = t.PrepareBehind(maxID, amount)
} }
// maxID isn't defined, but sinceID || minID are, so take x before // maxID isn't defined, but sinceID || minID are, so take x before
if maxID == "" && sinceID != "" { if maxID == "" && sinceID != "" {
l.Debug("preparing before sinceID")
err = t.PrepareBefore(sinceID, false, amount) err = t.PrepareBefore(sinceID, false, amount)
} }
if maxID == "" && minID != "" { if maxID == "" && minID != "" {
l.Debug("preparing before minID")
err = t.PrepareBefore(minID, false, amount) err = t.PrepareBefore(minID, false, amount)
} }
@ -47,15 +59,16 @@ func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, mi
} }
func (t *timeline) PrepareBehind(statusID string, amount int) error { func (t *timeline) PrepareBehind(statusID string, amount int) error {
t.Lock()
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.preparedPosts.data == nil {
t.preparedPosts.data = &list.List{} t.preparedPosts.data = &list.List{}
t.preparedPosts.data.Init() t.preparedPosts.data.Init()
} }
if err := t.IndexBehind(statusID, true, amount); err != nil {
return fmt.Errorf("PrepareBehind: error indexing behind id %s: %s", statusID, err)
}
// 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.postIndex.data == nil {
return nil return nil
@ -63,6 +76,8 @@ func (t *timeline) PrepareBehind(statusID string, amount int) error {
var prepared int var prepared int
var preparing bool var preparing bool
t.Lock()
defer t.Unlock()
prepareloop: prepareloop:
for e := t.postIndex.data.Front(); e != nil; e = e.Next() { for e := t.postIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*postIndexEntry) entry, ok := e.Value.(*postIndexEntry)
@ -154,8 +169,10 @@ prepareloop:
} }
func (t *timeline) PrepareFromTop(amount int) error { func (t *timeline) PrepareFromTop(amount int) error {
t.Lock() l := t.log.WithFields(logrus.Fields{
defer t.Unlock() "func": "PrepareFromTop",
"amount": amount,
})
// 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.preparedPosts.data == nil {
@ -163,11 +180,17 @@ func (t *timeline) PrepareFromTop(amount int) error {
t.preparedPosts.data.Init() t.preparedPosts.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 index from the highest ID possible
if t.postIndex.data == nil { if t.postIndex.data == nil {
return nil l.Debug("postindex.data was nil, indexing behind highest possible ID")
if err := t.IndexBehind("ZZZZZZZZZZZZZZZZZZZZZZZZZZ", false, amount); err != nil {
return fmt.Errorf("PrepareFromTop: error indexing behind id %s: %s", "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", err)
}
} }
l.Trace("entering prepareloop")
t.Lock()
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.postIndex.data.Front(); e != nil; e = e.Next() {
@ -193,10 +216,12 @@ prepareloop:
prepared = prepared + 1 prepared = prepared + 1
if prepared == amount { if prepared == amount {
// we're done // we're done
l.Trace("leaving prepareloop")
break prepareloop break prepareloop
} }
} }
l.Trace("leaving function")
return nil return nil
} }

View file

@ -38,7 +38,10 @@ type Timeline interface {
RETRIEVAL FUNCTIONS RETRIEVAL FUNCTIONS
*/ */
Get(amount int, maxID string, sinceID string, minID string) ([]*apimodel.Status, error) // Get returns an amount of statuses with the given parameters.
// If prepareNext is true, then the next predicted query will be prepared already in a goroutine,
// to make the next call to Get faster.
Get(amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]*apimodel.Status, error)
// GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest. // GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest.
GetXFromTop(amount int) ([]*apimodel.Status, error) GetXFromTop(amount int) ([]*apimodel.Status, error)
// GetXBehindID returns x amount of posts from the given id onwards, from newest to oldest. // GetXBehindID returns x amount of posts from the given id onwards, from newest to oldest.
@ -50,7 +53,7 @@ type Timeline interface {
// This will NOT include the status with the given ID. // This will NOT include the status 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(amount int, sinceID string, startFromTop bool, attempts *int) ([]*apimodel.Status, error) GetXBeforeID(amount int, sinceID string, startFromTop bool) ([]*apimodel.Status, 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 posts 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 status with the given IDs.
// //
@ -70,6 +73,12 @@ type Timeline interface {
// OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong. // OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, 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 post, an empty string will be returned so make sure to check for this.
OldestIndexedPostID() (string, error) OldestIndexedPostID() (string, error)
// NewestIndexedPostID returns the id of the frontmost (ie., the newest) indexed post, 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.
NewestIndexedPostID() (string, error)
IndexBefore(statusID string, include bool, amount int) error
IndexBehind(statusID string, include bool, amount int) error
/* /*
PREPARATION FUNCTIONS PREPARATION FUNCTIONS

View file

@ -0,0 +1,43 @@
/*
GoToSocial
Copyright (C) 2021 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_test
import (
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
type TimelineStandardTestSuite struct {
suite.Suite
config *config.Config
db db.DB
log *logrus.Logger
tc typeutils.TypeConverter
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
timeline timeline.Timeline
manager timeline.Manager
}