mirror of
1
Fork 0

[feature] Prune timelines once per hour to plug memory leak (#1117)

* export highest/lowest ULIDs as proper const

* add stop + start to timeline manager, other small fixes

* unexport unused interface funcs + tidy up

* add LastGot func

* add timeline Prune function

* test prune

* update lastGot
This commit is contained in:
tobi 2022-11-22 19:38:10 +01:00 committed by GitHub
parent 90bbcf1bcf
commit 50dc179d33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 594 additions and 602 deletions

View File

@ -91,7 +91,7 @@ func (suite *NotificationTestSuite) TestGetNotificationsWithSpam() {
suite.spamNotifs() suite.spamNotifs()
testAccount := suite.testAccounts["local_account_1"] testAccount := suite.testAccounts["local_account_1"]
before := time.Now() before := time.Now()
notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", "00000000000000000000000000") notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest)
suite.NoError(err) suite.NoError(err)
timeTaken := time.Since(before) timeTaken := time.Since(before)
fmt.Printf("\n\n\n withSpam: got %d notifications in %s\n\n\n", len(notifications), timeTaken) fmt.Printf("\n\n\n withSpam: got %d notifications in %s\n\n\n", len(notifications), timeTaken)
@ -105,7 +105,7 @@ func (suite *NotificationTestSuite) TestGetNotificationsWithSpam() {
func (suite *NotificationTestSuite) TestGetNotificationsWithoutSpam() { func (suite *NotificationTestSuite) TestGetNotificationsWithoutSpam() {
testAccount := suite.testAccounts["local_account_1"] testAccount := suite.testAccounts["local_account_1"]
before := time.Now() before := time.Now()
notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", "00000000000000000000000000") notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest)
suite.NoError(err) suite.NoError(err)
timeTaken := time.Since(before) timeTaken := time.Since(before)
fmt.Printf("\n\n\n withoutSpam: got %d notifications in %s\n\n\n", len(notifications), timeTaken) fmt.Printf("\n\n\n withoutSpam: got %d notifications in %s\n\n\n", len(notifications), timeTaken)
@ -122,7 +122,7 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithSpam() {
err := suite.db.ClearNotifications(context.Background(), testAccount.ID) err := suite.db.ClearNotifications(context.Background(), testAccount.ID)
suite.NoError(err) suite.NoError(err)
notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", "00000000000000000000000000") notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest)
suite.NoError(err) suite.NoError(err)
suite.NotNil(notifications) suite.NotNil(notifications)
suite.Empty(notifications) suite.Empty(notifications)
@ -134,7 +134,7 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithTwoAccounts() {
err := suite.db.ClearNotifications(context.Background(), testAccount.ID) err := suite.db.ClearNotifications(context.Background(), testAccount.ID)
suite.NoError(err) suite.NoError(err)
notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", "00000000000000000000000000") notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest)
suite.NoError(err) suite.NoError(err)
suite.NotNil(notifications) suite.NotNil(notifications)
suite.Empty(notifications) suite.Empty(notifications)

View File

@ -1,3 +1,21 @@
/*
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 id package id
import ( import (
@ -8,7 +26,11 @@ import (
"github.com/oklog/ulid" "github.com/oklog/ulid"
) )
const randomRange = 631152381 // ~20 years in seconds const (
Highest = "ZZZZZZZZZZZZZZZZZZZZZZZZZZ" // Highest is the highest possible ULID
Lowest = "00000000000000000000000000" // Lowest is the lowest possible ULID
randomRange = 631152381 // ~20 years in seconds
)
// ULID represents a Universally Unique Lexicographically Sortable Identifier of 26 characters. See https://github.com/oklog/ulid // ULID represents a Universally Unique Lexicographically Sortable Identifier of 26 characters. See https://github.com/oklog/ulid
type ULID string type ULID string

View File

@ -351,6 +351,11 @@ func (p *processor) Start() error {
return err return err
} }
// Start status timelines
if err := p.statusTimelines.Start(); err != nil {
return err
}
return nil return nil
} }
@ -359,8 +364,14 @@ func (p *processor) Stop() error {
if err := p.clientWorker.Stop(); err != nil { if err := p.clientWorker.Stop(); err != nil {
return err return err
} }
if err := p.fedWorker.Stop(); err != nil { if err := p.fedWorker.Stop(); err != nil {
return err return err
} }
if err := p.statusTimelines.Stop(); err != nil {
return err
}
return nil return nil
} }

View File

@ -23,6 +23,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
@ -30,16 +31,27 @@ import (
const retries = 5 const retries = 5
func (t *timeline) LastGot() time.Time {
t.Lock()
defer t.Unlock()
return t.lastGot
}
func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error) { func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"accountID", t.accountID}, {"accountID", t.accountID},
{"amount", amount}, {"amount", amount},
{"maxID", maxID}, {"maxID", maxID},
{"sinceID", sinceID}, {"sinceID", sinceID},
{"minID", minID}, {"minID", minID},
}...) }...)
l.Debug("entering get") l.Debug("entering get and updating t.lastGot")
// regardless of what happens below, update the
// last time Get was called for this timeline
t.Lock()
t.lastGot = time.Now()
t.Unlock()
var items []Preparable var items []Preparable
var err error var err error
@ -47,7 +59,7 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
// 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 items 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 == "" {
items, 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(items) != 0 { if len(items) != 0 {
nextMaxID := items[len(items)-1].GetID() nextMaxID := items[len(items)-1].GetID()
@ -67,7 +79,7 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
// this is equivalent to a user asking for the next x items 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
items, 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(items) != 0 { if len(items) != 0 {
nextMaxID := items[len(items)-1].GetID() nextMaxID := items[len(items)-1].GetID()
@ -86,25 +98,26 @@ 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 items 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 != "" {
items, err = t.GetXBetweenID(ctx, amount, maxID, minID) items, err = t.getXBetweenID(ctx, amount, maxID, minID)
} }
if maxID != "" && minID != "" { if maxID != "" && minID != "" {
items, 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 items 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 != "" {
items, err = t.GetXBeforeID(ctx, amount, sinceID, true) items, err = t.getXBeforeID(ctx, amount, sinceID, true)
} }
if maxID == "" && minID != "" { if maxID == "" && minID != "" {
items, err = t.GetXBeforeID(ctx, amount, minID, true) items, err = t.getXBeforeID(ctx, amount, minID, true)
} }
return items, err return items, err
} }
func (t *timeline) GetXFromTop(ctx context.Context, amount int) ([]Preparable, error) { // getXFromTop returns x amount of items from the top of the timeline, from newest to oldest.
func (t *timeline) getXFromTop(ctx context.Context, amount int) ([]Preparable, error) {
// make a slice of preparedItems with the length we need to return // make a slice of preparedItems with the length we need to return
preparedItems := make([]Preparable, 0, amount) preparedItems := make([]Preparable, 0, amount)
@ -124,7 +137,7 @@ func (t *timeline) GetXFromTop(ctx context.Context, amount int) ([]Preparable, e
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedItemsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return nil, errors.New("GetXFromTop: could not parse e as a preparedItemsEntry") return nil, errors.New("getXFromTop: could not parse e as a preparedItemsEntry")
} }
preparedItems = append(preparedItems, entry.prepared) preparedItems = append(preparedItems, entry.prepared)
served++ served++
@ -136,9 +149,12 @@ func (t *timeline) GetXFromTop(ctx context.Context, amount int) ([]Preparable, e
return preparedItems, nil return preparedItems, nil
} }
func (t *timeline) GetXBehindID(ctx context.Context, amount int, behindID string, attempts *int) ([]Preparable, error) { // getXBehindID returns x amount of items from the given id onwards, from newest to oldest.
// This will NOT include the item with the given ID.
//
// This corresponds to an api call to /timelines/home?max_id=WHATEVER
func (t *timeline) getXBehindID(ctx context.Context, amount int, behindID string, attempts *int) ([]Preparable, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"amount", amount}, {"amount", amount},
{"behindID", behindID}, {"behindID", behindID},
{"attempts", attempts}, {"attempts", attempts},
@ -164,7 +180,7 @@ findMarkLoop:
position++ position++
entry, ok := e.Value.(*preparedItemsEntry) 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.itemID <= behindID { if entry.itemID <= behindID {
@ -177,10 +193,10 @@ 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 items // 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.OldestPreparedItemID(ctx) oldestID, err := t.oldestPreparedItemID(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -196,13 +212,13 @@ findMarkLoop:
l.Tracef("exceeded retries looking for behindID %s", behindID) l.Tracef("exceeded retries looking for behindID %s", behindID)
return items, 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 items 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.preparedItems.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
} }
} }
@ -213,7 +229,7 @@ serveloop:
for e := behindIDMark.Next(); e != nil; e = e.Next() { for e := behindIDMark.Next(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedItemsEntry) 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
@ -227,7 +243,11 @@ serveloop:
return items, nil return items, nil
} }
func (t *timeline) GetXBeforeID(ctx context.Context, amount int, beforeID string, startFromTop bool) ([]Preparable, error) { // getXBeforeID returns x amount of items up to the given id, from newest to oldest.
// This will NOT include the item with the given ID.
//
// This corresponds to an api call to /timelines/home?since_id=WHATEVER
func (t *timeline) getXBeforeID(ctx context.Context, amount int, beforeID string, startFromTop bool) ([]Preparable, error) {
// make a slice of items with the length we need to return // make a slice of items with the length we need to return
items := make([]Preparable, 0, amount) items := make([]Preparable, 0, amount)
@ -241,7 +261,7 @@ findMarkLoop:
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedItemsEntry) 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.itemID >= beforeID { if entry.itemID >= beforeID {
@ -263,7 +283,7 @@ findMarkLoop:
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() { for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedItemsEntry) 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.itemID == beforeID { if entry.itemID == beforeID {
@ -283,7 +303,7 @@ findMarkLoop:
for e := beforeIDMark.Prev(); e != nil; e = e.Prev() { for e := beforeIDMark.Prev(); e != nil; e = e.Prev() {
entry, ok := e.Value.(*preparedItemsEntry) 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
@ -298,7 +318,11 @@ findMarkLoop:
return items, nil return items, nil
} }
func (t *timeline) GetXBetweenID(ctx context.Context, amount int, behindID string, beforeID string) ([]Preparable, error) { // getXBetweenID returns x amount of items from the given maxID, up to the given id, from newest to oldest.
// 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
func (t *timeline) getXBetweenID(ctx context.Context, amount int, behindID string, beforeID string) ([]Preparable, error) {
// make a slice of items with the length we need to return // make a slice of items with the length we need to return
items := make([]Preparable, 0, amount) items := make([]Preparable, 0, amount)
@ -314,7 +338,7 @@ findMarkLoop:
position++ position++
entry, ok := e.Value.(*preparedItemsEntry) 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.itemID == behindID { if entry.itemID == behindID {
@ -325,12 +349,12 @@ 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 item with ID %s", behindID) return nil, fmt.Errorf("getXBetweenID: couldn't find item with ID %s", behindID)
} }
// make sure we have enough items 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.preparedItems.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
} }
} }
@ -341,7 +365,7 @@ serveloop:
for e := behindIDMark.Next(); e != nil; e = e.Next() { for e := behindIDMark.Next(); e != nil; e = e.Next() {
entry, ok := e.Value.(*preparedItemsEntry) 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.itemID == beforeID { if entry.itemID == beforeID {

View File

@ -89,6 +89,9 @@ func (suite *GetTestSuite) TearDownTest() {
} }
func (suite *GetTestSuite) TestGetDefault() { func (suite *GetTestSuite) TestGetDefault() {
// lastGot should be zero
suite.Zero(suite.timeline.LastGot())
// get 10 20 the top and don't prepare the next query // get 10 20 the top and don't prepare the next query
statuses, err := suite.timeline.Get(context.Background(), 20, "", "", "", false) statuses, err := suite.timeline.Get(context.Background(), 20, "", "", "", false)
if err != nil { if err != nil {
@ -108,6 +111,9 @@ func (suite *GetTestSuite) TestGetDefault() {
highest = s.GetID() highest = s.GetID()
} }
} }
// lastGot should be up to date
suite.WithinDuration(time.Now(), suite.timeline.LastGot(), 1*time.Second)
} }
func (suite *GetTestSuite) TestGetDefaultPrepareNext() { func (suite *GetTestSuite) TestGetDefaultPrepareNext() {
@ -297,165 +303,6 @@ func (suite *GetTestSuite) TestGetBetweenIDPrepareNext() {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
} }
func (suite *GetTestSuite) TestGetXFromTop() {
// get 5 from the top
statuses, err := suite.timeline.GetXFromTop(context.Background(), 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.GetID()
} else {
suite.Less(s.GetID(), highest)
highest = s.GetID()
}
}
}
func (suite *GetTestSuite) TestGetXBehindID() {
// get 3 behind the 'middle' id
var attempts *int
a := 0
attempts = &a
statuses, err := suite.timeline.GetXBehindID(context.Background(), 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.GetID()
} else {
suite.Less(s.GetID(), highest)
highest = s.GetID()
}
suite.Less(s.GetID(), "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(context.Background(), 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(context.Background(), 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.GetID()
} else {
suite.Less(s.GetID(), highest)
highest = s.GetID()
}
suite.Less(s.GetID(), "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(context.Background(), 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.GetID()
} else {
suite.Less(s.GetID(), highest)
highest = s.GetID()
}
suite.Less(s.GetID(), "9998MHBQCBTDKN6X5VHGMMN4MA")
}
}
func (suite *GetTestSuite) TestGetXBeforeID() {
// get 3 before the 'middle' id
statuses, err := suite.timeline.GetXBeforeID(context.Background(), 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.GetID()
} else {
suite.Less(s.GetID(), highest)
highest = s.GetID()
}
suite.Greater(s.GetID(), "01F8MHBQCBTDKN6X5VHGMMN4MA")
}
}
func (suite *GetTestSuite) TestGetXBeforeIDNoStartFromTop() {
// get 3 before the 'middle' id
statuses, err := suite.timeline.GetXBeforeID(context.Background(), 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.GetID()
} else {
suite.Greater(s.GetID(), lowest)
lowest = s.GetID()
}
suite.Greater(s.GetID(), "01F8MHBQCBTDKN6X5VHGMMN4MA")
}
}
func TestGetTestSuite(t *testing.T) { func TestGetTestSuite(t *testing.T) {
suite.Run(t, new(GetTestSuite)) suite.Run(t, new(GetTestSuite))
} }

View File

@ -28,79 +28,80 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
func (t *timeline) IndexBefore(ctx context.Context, itemID string, amount int) error { func (t *timeline) ItemIndexLength(ctx context.Context) int {
l := log.WithFields(kv.Fields{ if t.indexedItems == nil || t.indexedItems.data == nil {
return 0
{"amount", amount},
}...)
// lazily initialize index if it hasn't been done already
if t.itemIndex.data == nil {
t.itemIndex.data = &list.List{}
t.itemIndex.data.Init()
} }
return t.indexedItems.data.Len()
toIndex := []Timelineable{}
offsetID := itemID
l.Trace("entering grabloop")
grabloop:
for i := 0; len(toIndex) < amount && i < 5; i++ { // try the grabloop 5 times only
// 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 {
return err
}
if stop {
break grabloop
}
l.Trace("filtering...")
// 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 {
return err
}
if shouldIndex {
toIndex = append(toIndex, item)
}
offsetID = item.GetID()
}
}
l.Trace("left grabloop")
// index the items we got
for _, s := range toIndex {
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
} }
func (t *timeline) IndexBehind(ctx context.Context, itemID string, amount int) error { // func (t *timeline) indexBefore(ctx context.Context, itemID string, amount int) error {
l := log.WithFields(kv.Fields{ // l := log.WithFields(kv.Fields{{"amount", amount}}...)
{"amount", amount}, // // lazily initialize index if it hasn't been done already
}...) // if t.indexedItems.data == nil {
// t.indexedItems.data = &list.List{}
// t.indexedItems.data.Init()
// }
// toIndex := []Timelineable{}
// offsetID := itemID
// l.Trace("entering grabloop")
// grabloop:
// for i := 0; len(toIndex) < amount && i < 5; i++ { // try the grabloop 5 times only
// // 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 {
// return err
// }
// if stop {
// break grabloop
// }
// l.Trace("filtering...")
// // 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 {
// return err
// }
// if shouldIndex {
// toIndex = append(toIndex, item)
// }
// offsetID = item.GetID()
// }
// }
// l.Trace("left grabloop")
// // index the items we got
// for _, s := range toIndex {
// if _, err := t.IndexOne(ctx, s.GetID(), s.GetBoostOfID(), s.GetAccountID(), s.GetBoostOfAccountID()); err != nil {
// return fmt.Errorf("indexBefore: error indexing item with id %s: %s", s.GetID(), err)
// }
// }
// return nil
// }
func (t *timeline) indexBehind(ctx context.Context, itemID string, amount int) error {
l := log.WithFields(kv.Fields{{"amount", amount}}...)
// lazily initialize index if it hasn't been done already // lazily initialize index if it hasn't been done already
if t.itemIndex.data == nil { if t.indexedItems.data == nil {
t.itemIndex.data = &list.List{} t.indexedItems.data = &list.List{}
t.itemIndex.data.Init() t.indexedItems.data.Init()
} }
// If we're already indexedBehind given itemID 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 itemID (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.itemIndex.data.Front(); e != nil; e = e.Next() { for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
if !ok { if !ok {
return errors.New("IndexBehind: could not parse e as an itemIndexEntry") return errors.New("indexBehind: could not parse e as an itemIndexEntry")
} }
if entry.itemID <= itemID { if entry.itemID <= itemID {
@ -111,7 +112,7 @@ positionLoop:
} }
// now check if the length of indexed items exceeds the amount of items required (position of itemID, plus amount of posts requested after that) // 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 { if t.indexedItems.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 items indexed") l.Trace("returning nil since we already have enough items indexed")
return nil return nil
@ -151,7 +152,7 @@ grabloop:
// index the items we got // index the items we got
for _, s := range toIndex { for _, s := range toIndex {
if _, err := t.IndexOne(ctx, s.GetID(), s.GetBoostOfID(), s.GetAccountID(), s.GetBoostOfAccountID()); err != nil { 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 fmt.Errorf("indexBehind: error indexing item with id %s: %s", s.GetID(), err)
} }
} }
@ -162,28 +163,28 @@ func (t *timeline) IndexOne(ctx context.Context, itemID string, boostOfID string
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
postIndexEntry := &itemIndexEntry{ postIndexEntry := &indexedItemsEntry{
itemID: itemID, itemID: itemID,
boostOfID: boostOfID, boostOfID: boostOfID,
accountID: accountID, accountID: accountID,
boostOfAccountID: boostOfAccountID, boostOfAccountID: boostOfAccountID,
} }
return t.itemIndex.insertIndexed(ctx, postIndexEntry) return t.indexedItems.insertIndexed(ctx, postIndexEntry)
} }
func (t *timeline) IndexAndPrepareOne(ctx context.Context, 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 := &itemIndexEntry{ postIndexEntry := &indexedItemsEntry{
itemID: statusID, itemID: statusID,
boostOfID: boostOfID, boostOfID: boostOfID,
accountID: accountID, accountID: accountID,
boostOfAccountID: boostOfAccountID, boostOfAccountID: boostOfAccountID,
} }
inserted, err := t.itemIndex.insertIndexed(ctx, postIndexEntry) inserted, err := t.indexedItems.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)
} }
@ -199,13 +200,13 @@ func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusID string, boos
func (t *timeline) OldestIndexedItemID(ctx context.Context) (string, error) { func (t *timeline) OldestIndexedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.itemIndex == nil || t.itemIndex.data == nil || t.itemIndex.data.Back() == nil { if t.indexedItems == nil || t.indexedItems.data == nil || t.indexedItems.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.itemIndex.data.Back() e := t.indexedItems.data.Back()
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
if !ok { if !ok {
return id, errors.New("OldestIndexedItemID: could not parse e as itemIndexEntry") return id, errors.New("OldestIndexedItemID: could not parse e as itemIndexEntry")
} }
@ -214,13 +215,13 @@ func (t *timeline) OldestIndexedItemID(ctx context.Context) (string, error) {
func (t *timeline) NewestIndexedItemID(ctx context.Context) (string, error) { func (t *timeline) NewestIndexedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.itemIndex == nil || t.itemIndex.data == nil || t.itemIndex.data.Front() == nil { if t.indexedItems == nil || t.indexedItems.data == nil || t.indexedItems.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.itemIndex.data.Front() e := t.indexedItems.data.Front()
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
if !ok { if !ok {
return id, errors.New("NewestIndexedItemID: could not parse e as itemIndexEntry") return id, errors.New("NewestIndexedItemID: could not parse e as itemIndexEntry")
} }

View File

@ -69,63 +69,6 @@ func (suite *IndexTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db) testrig.StandardDBTeardown(suite.db)
} }
func (suite *IndexTestSuite) TestIndexBeforeLowID() {
// index 10 before the lowest status ID possible
err := suite.timeline.IndexBefore(context.Background(), "00000000000000000000000000", 10)
suite.NoError(err)
postID, err := suite.timeline.OldestIndexedItemID(context.Background())
suite.NoError(err)
suite.Equal("01F8MHBQCBTDKN6X5VHGMMN4MA", postID)
indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(10, indexLength)
}
func (suite *IndexTestSuite) TestIndexBeforeHighID() {
// index 10 before the highest status ID possible
err := suite.timeline.IndexBefore(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", 10)
suite.NoError(err)
// the oldest indexed post should be empty
postID, err := suite.timeline.OldestIndexedItemID(context.Background())
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestIndexBehindHighID() {
// index 10 behind the highest status ID possible
err := suite.timeline.IndexBehind(context.Background(), "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", 10)
suite.NoError(err)
// the newest indexed post should be the highest one we have in our testrig
postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err)
suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", postID)
indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(10, indexLength)
}
func (suite *IndexTestSuite) TestIndexBehindLowID() {
// index 10 behind the lowest status ID possible
err := suite.timeline.IndexBehind(context.Background(), "00000000000000000000000000", 10)
suite.NoError(err)
// the newest indexed post should be empty
postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestOldestIndexedItemIDEmpty() { 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.OldestIndexedItemID(context.Background()) postID, err := suite.timeline.OldestIndexedItemID(context.Background())
@ -137,17 +80,6 @@ func (suite *IndexTestSuite) TestOldestIndexedItemIDEmpty() {
suite.Equal(0, indexLength) suite.Equal(0, indexLength)
} }
func (suite *IndexTestSuite) TestNewestIndexedItemIDEmpty() {
// the newest indexed post should be an empty string since there's nothing indexed yet
postID, err := suite.timeline.NewestIndexedItemID(context.Background())
suite.NoError(err)
suite.Empty(postID)
// indexLength should be 0
indexLength := suite.timeline.ItemIndexLength(context.Background())
suite.Equal(0, indexLength)
}
func (suite *IndexTestSuite) TestIndexAlreadyIndexed() { func (suite *IndexTestSuite) TestIndexAlreadyIndexed() {
testStatus := suite.testStatuses["local_account_1_status_1"] testStatus := suite.testStatuses["local_account_1_status_1"]

View File

@ -24,26 +24,26 @@ import (
"errors" "errors"
) )
type itemIndex struct { type indexedItems struct {
data *list.List data *list.List
skipInsert SkipInsertFunction skipInsert SkipInsertFunction
} }
type itemIndexEntry struct { type indexedItemsEntry struct {
itemID string itemID string
boostOfID string boostOfID string
accountID string accountID string
boostOfAccountID string boostOfAccountID string
} }
func (p *itemIndex) insertIndexed(ctx context.Context, i *itemIndexEntry) (bool, error) { func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItemsEntry) (bool, error) {
if p.data == nil { if i.data == nil {
p.data = &list.List{} i.data = &list.List{}
} }
// if we have no entries yet, this is both the newest and oldest entry, so just put it in the front // if we have no entries yet, this is both the newest and oldest entry, so just put it in the front
if p.data.Len() == 0 { if i.data.Len() == 0 {
p.data.PushFront(i) i.data.PushFront(newEntry)
return true, nil return true, nil
} }
@ -51,15 +51,15 @@ func (p *itemIndex) insertIndexed(ctx context.Context, i *itemIndexEntry) (bool,
var position int var position int
// 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 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 item -- 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 := i.data.Front(); e != nil; e = e.Next() {
position++ position++
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
if !ok { if !ok {
return false, errors.New("index: could not parse e as an itemIndexEntry") return false, errors.New("insertIndexed: could not parse e as an indexedItemsEntry")
} }
skip, err := p.skipInsert(ctx, i.itemID, i.accountID, i.boostOfID, i.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position) skip, err := i.skipInsert(ctx, newEntry.itemID, newEntry.accountID, newEntry.boostOfID, newEntry.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -69,18 +69,18 @@ func (p *itemIndex) insertIndexed(ctx context.Context, i *itemIndexEntry) (bool,
// if the item 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.itemID > entry.itemID { if newEntry.itemID > entry.itemID {
insertMark = e insertMark = e
} }
} }
} }
if insertMark != nil { if insertMark != nil {
p.data.InsertBefore(i, insertMark) i.data.InsertBefore(newEntry, insertMark)
return true, nil return true, nil
} }
// if we reach this point it's the oldest item 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) i.data.PushBack(newEntry)
return true, nil return true, nil
} }

View File

@ -23,15 +23,12 @@ import (
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"time"
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
const (
desiredPostIndexLength = 400
)
// 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 timelineable hits the manager interface, it should already have been filtered and it should be established that the item 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
@ -65,8 +62,6 @@ type Manager interface {
GetTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) GetTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error)
// GetIndexedLength returns the amount of items 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 items that we, ideally, index for each user.
GetDesiredIndexLength(ctx context.Context) int
// GetOldestIndexedID returns the id ID for the oldest item 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 items, 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.
@ -77,6 +72,10 @@ type Manager interface {
WipeItemFromAllTimelines(ctx context.Context, itemID string) error WipeItemFromAllTimelines(ctx context.Context, itemID string) error
// WipeStatusesFromAccountID removes all items by the given accountID from the timelineAccountID's timelines. // WipeStatusesFromAccountID removes all items by the given accountID from the timelineAccountID's timelines.
WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error
// Start starts hourly cleanup jobs for this timeline manager.
Start() error
// Stop stops the timeline manager (currently a stub, doesn't do anything).
Stop() error
} }
// NewManager returns a new timeline manager. // NewManager returns a new timeline manager.
@ -98,9 +97,44 @@ type manager struct {
skipInsertFunction SkipInsertFunction skipInsertFunction SkipInsertFunction
} }
func (m *manager) Start() error {
// range through all timelines in the sync map once per hour + prune as necessary
go func() {
for now := range time.NewTicker(1 * time.Hour).C {
m.accountTimelines.Range(func(key any, value any) bool {
timelineAccountID, ok := key.(string)
if !ok {
panic("couldn't parse timeline manager sync map key as string, this should never happen so panic")
}
t, ok := value.(Timeline)
if !ok {
panic("couldn't parse timeline manager sync map value as Timeline, this should never happen so panic")
}
anHourAgo := now.Add(-1 * time.Hour)
if lastGot := t.LastGot(); lastGot.Before(anHourAgo) {
amountPruned := t.Prune(defaultDesiredPreparedItemsLength, defaultDesiredIndexedItemsLength)
log.WithFields(kv.Fields{
{"timelineAccountID", timelineAccountID},
{"amountPruned", amountPruned},
}...).Info("pruned indexed and prepared items from timeline")
}
return true
})
}
}()
return nil
}
func (m *manager) Stop() error {
return nil
}
func (m *manager) Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) { func (m *manager) Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"timelineAccountID", timelineAccountID}, {"timelineAccountID", timelineAccountID},
{"itemID", item.GetID()}, {"itemID", item.GetID()},
}...) }...)
@ -116,7 +150,6 @@ func (m *manager) Ingest(ctx context.Context, item Timelineable, timelineAccount
func (m *manager) IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) { func (m *manager) IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"timelineAccountID", timelineAccountID}, {"timelineAccountID", timelineAccountID},
{"itemID", item.GetID()}, {"itemID", item.GetID()},
}...) }...)
@ -132,7 +165,6 @@ func (m *manager) IngestAndPrepare(ctx context.Context, item Timelineable, timel
func (m *manager) Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error) { func (m *manager) Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"timelineAccountID", timelineAccountID}, {"timelineAccountID", timelineAccountID},
{"itemID", itemID}, {"itemID", itemID},
}...) }...)
@ -147,10 +179,7 @@ func (m *manager) Remove(ctx context.Context, timelineAccountID string, itemID s
} }
func (m *manager) GetTimeline(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) { func (m *manager) GetTimeline(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{{"timelineAccountID", timelineAccountID}}...)
{"timelineAccountID", timelineAccountID},
}...)
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
if err != nil { if err != nil {
@ -173,10 +202,6 @@ func (m *manager) GetIndexedLength(ctx context.Context, timelineAccountID string
return t.ItemIndexLength(ctx) return t.ItemIndexLength(ctx)
} }
func (m *manager) GetDesiredIndexLength(ctx context.Context) int {
return desiredPostIndexLength
}
func (m *manager) GetOldestIndexedID(ctx context.Context, timelineAccountID string) (string, error) { func (m *manager) GetOldestIndexedID(ctx context.Context, timelineAccountID string) (string, error) {
t, err := m.getOrCreateTimeline(ctx, timelineAccountID) t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
if err != nil { if err != nil {

View File

@ -26,154 +26,12 @@ import (
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID string, sinceID string, minID string) error {
l := log.WithFields(kv.Fields{
{"amount", amount},
{"maxID", maxID},
{"sinceID", sinceID},
{"minID", minID},
}...)
var err error
// maxID is defined but sinceID isn't so take from behind
if maxID != "" && sinceID == "" {
l.Debug("preparing behind maxID")
err = t.PrepareBehind(ctx, maxID, amount)
}
// maxID isn't defined, but sinceID || minID are, so take x before
if maxID == "" && sinceID != "" {
l.Debug("preparing before sinceID")
err = t.PrepareBefore(ctx, sinceID, false, amount)
}
if maxID == "" && minID != "" {
l.Debug("preparing before minID")
err = t.PrepareBefore(ctx, minID, false, amount)
}
return err
}
func (t *timeline) PrepareBehind(ctx context.Context, itemID string, amount int) error {
// lazily initialize prepared items if it hasn't been done already
if t.preparedItems.data == nil {
t.preparedItems.data = &list.List{}
t.preparedItems.data.Init()
}
if err := t.IndexBehind(ctx, itemID, amount); err != nil {
return fmt.Errorf("PrepareBehind: error indexing behind id %s: %s", itemID, err)
}
// if the itemindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.itemIndex.data == nil {
return nil
}
var prepared int
var preparing bool
t.Lock()
defer t.Unlock()
prepareloop:
for e := t.itemIndex.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*itemIndexEntry)
if !ok {
return errors.New("PrepareBehind: could not parse e as itemIndexEntry")
}
if !preparing {
// we haven't hit the position we need to prepare from yet
if entry.itemID == itemID {
preparing = true
}
}
if preparing {
if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error
if err != db.ErrNoEntries {
// it's a real error
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
continue
}
if prepared == amount {
// we're done
break prepareloop
}
prepared++
}
}
return nil
}
func (t *timeline) PrepareBefore(ctx context.Context, statusID string, include bool, amount int) error {
t.Lock()
defer t.Unlock()
// lazily initialize prepared posts if it hasn't been done already
if t.preparedItems.data == nil {
t.preparedItems.data = &list.List{}
t.preparedItems.data.Init()
}
// if the postindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.itemIndex.data == nil {
return nil
}
var prepared int
var preparing bool
prepareloop:
for e := t.itemIndex.data.Back(); e != nil; e = e.Prev() {
entry, ok := e.Value.(*itemIndexEntry)
if !ok {
return errors.New("PrepareBefore: could not parse e as a postIndexEntry")
}
if !preparing {
// we haven't hit the position we need to prepare from yet
if entry.itemID == statusID {
preparing = true
if !include {
continue
}
}
}
if preparing {
if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error
if err != db.ErrNoEntries {
// it's a real error
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
continue
}
if prepared == amount {
// we're done
break prepareloop
}
prepared++
}
}
return nil
}
func (t *timeline) PrepareFromTop(ctx context.Context, amount int) error { func (t *timeline) PrepareFromTop(ctx context.Context, amount int) error {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{{"amount", amount}}...)
{"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.preparedItems.data == nil { if t.preparedItems.data == nil {
@ -182,10 +40,10 @@ func (t *timeline) PrepareFromTop(ctx context.Context, amount int) error {
} }
// 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.itemIndex.data == nil { if t.indexedItems.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", amount); err != nil { if err := t.indexBehind(ctx, id.Highest, 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", id.Highest, err)
} }
} }
@ -194,12 +52,12 @@ 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.itemIndex.data.Front(); e != nil; e = e.Next() { for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
if e == nil { if e == nil {
continue continue
} }
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
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")
} }
@ -226,6 +84,142 @@ prepareloop:
return nil return nil
} }
func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID string, sinceID string, minID string) error {
l := log.WithFields(kv.Fields{
{"amount", amount},
{"maxID", maxID},
{"sinceID", sinceID},
{"minID", minID},
}...)
var err error
switch {
case maxID != "" && sinceID == "":
l.Debug("preparing behind maxID")
err = t.prepareBehind(ctx, maxID, amount)
case maxID == "" && sinceID != "":
l.Debug("preparing before sinceID")
err = t.prepareBefore(ctx, sinceID, false, amount)
case maxID == "" && minID != "":
l.Debug("preparing before minID")
err = t.prepareBefore(ctx, minID, false, amount)
}
return err
}
// prepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards.
// If include is true, then the given item ID will also be prepared, otherwise only entries behind it will be prepared.
func (t *timeline) prepareBehind(ctx context.Context, itemID string, amount int) error {
// lazily initialize prepared items if it hasn't been done already
if t.preparedItems.data == nil {
t.preparedItems.data = &list.List{}
t.preparedItems.data.Init()
}
if err := t.indexBehind(ctx, itemID, amount); err != nil {
return fmt.Errorf("prepareBehind: error indexing behind id %s: %s", itemID, err)
}
// if the itemindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.indexedItems.data == nil {
return nil
}
var prepared int
var preparing bool
t.Lock()
defer t.Unlock()
prepareloop:
for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*indexedItemsEntry)
if !ok {
return errors.New("prepareBehind: could not parse e as itemIndexEntry")
}
if !preparing {
// we haven't hit the position we need to prepare from yet
if entry.itemID == itemID {
preparing = true
}
}
if preparing {
if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error
if err != db.ErrNoEntries {
// it's a real error
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
continue
}
if prepared == amount {
// we're done
break prepareloop
}
prepared++
}
}
return nil
}
func (t *timeline) prepareBefore(ctx context.Context, statusID string, include bool, amount int) error {
t.Lock()
defer t.Unlock()
// lazily initialize prepared posts if it hasn't been done already
if t.preparedItems.data == nil {
t.preparedItems.data = &list.List{}
t.preparedItems.data.Init()
}
// if the postindex is nil, nothing has been indexed yet so there's nothing to prepare
if t.indexedItems.data == nil {
return nil
}
var prepared int
var preparing bool
prepareloop:
for e := t.indexedItems.data.Back(); e != nil; e = e.Prev() {
entry, ok := e.Value.(*indexedItemsEntry)
if !ok {
return errors.New("prepareBefore: could not parse e as a postIndexEntry")
}
if !preparing {
// we haven't hit the position we need to prepare from yet
if entry.itemID == statusID {
preparing = true
if !include {
continue
}
}
}
if preparing {
if err := t.prepare(ctx, entry.itemID); err != nil {
// there's been an error
if err != db.ErrNoEntries {
// it's a real error
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
continue
}
if prepared == amount {
// we're done
break prepareloop
}
prepared++
}
}
return nil
}
func (t *timeline) prepare(ctx context.Context, itemID string) error { func (t *timeline) prepare(ctx context.Context, itemID string) error {
// trigger the caller-provided prepare function // trigger the caller-provided prepare function
prepared, err := t.prepareFunction(ctx, t.accountID, itemID) prepared, err := t.prepareFunction(ctx, t.accountID, itemID)
@ -245,7 +239,9 @@ func (t *timeline) prepare(ctx context.Context, itemID string) error {
return t.preparedItems.insertPrepared(ctx, preparedItemsEntry) return t.preparedItems.insertPrepared(ctx, preparedItemsEntry)
} }
func (t *timeline) OldestPreparedItemID(ctx context.Context) (string, error) { // 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 item, an empty string will be returned so make sure to check for this.
func (t *timeline) oldestPreparedItemID(ctx context.Context) (string, error) {
var id string var id string
if t.preparedItems == nil || t.preparedItems.data == nil { if t.preparedItems == nil || t.preparedItems.data == nil {
// return an empty string if prepared items hasn't been initialized yet // return an empty string if prepared items hasn't been initialized yet
@ -260,7 +256,7 @@ func (t *timeline) OldestPreparedItemID(ctx context.Context) (string, error) {
entry, ok := e.Value.(*preparedItemsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return id, errors.New("OldestPreparedItemID: could not parse e as a preparedItemsEntry") return id, errors.New("oldestPreparedItemID: could not parse e as a preparedItemsEntry")
} }
return entry.itemID, nil return entry.itemID, nil

View File

@ -37,30 +37,30 @@ type preparedItemsEntry struct {
prepared Preparable prepared Preparable
} }
func (p *preparedItems) insertPrepared(ctx context.Context, i *preparedItemsEntry) error { func (p *preparedItems) insertPrepared(ctx context.Context, newEntry *preparedItemsEntry) error {
if p.data == nil { if p.data == nil {
p.data = &list.List{} p.data = &list.List{}
} }
// if we have no entries yet, this is both the newest and oldest entry, so just put it in the front // if we have no entries yet, this is both the newest and oldest entry, so just put it in the front
if p.data.Len() == 0 { if p.data.Len() == 0 {
p.data.PushFront(i) p.data.PushFront(newEntry)
return nil return nil
} }
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 entry 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 entry -- 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.(*preparedItemsEntry) entry, ok := e.Value.(*preparedItemsEntry)
if !ok { if !ok {
return errors.New("index: could not parse e as a preparedPostsEntry") return errors.New("insertPrepared: could not parse e as a preparedItemsEntry")
} }
skip, err := p.skipInsert(ctx, i.itemID, i.accountID, i.boostOfID, i.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position) skip, err := p.skipInsert(ctx, newEntry.itemID, newEntry.accountID, newEntry.boostOfID, newEntry.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position)
if err != nil { if err != nil {
return err return err
} }
@ -68,25 +68,25 @@ func (p *preparedItems) insertPrepared(ctx context.Context, i *preparedItemsEntr
return nil return nil
} }
// if the post to index is newer than e, insert it before e in the list // if the entry to index is newer than e, insert it before e in the list
if insertMark == nil { if insertMark == nil {
if i.itemID > entry.itemID { if newEntry.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.itemID == i.itemID { if entry.itemID == newEntry.itemID {
return nil return nil
} }
} }
if insertMark != nil { if insertMark != nil {
p.data.InsertBefore(i, insertMark) p.data.InsertBefore(newEntry, insertMark)
return nil return 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 entry we've seen so put it at the back
p.data.PushBack(i) p.data.PushBack(newEntry)
return nil return nil
} }

View File

@ -0,0 +1,68 @@
/*
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
import (
"container/list"
)
const (
defaultDesiredIndexedItemsLength = 400
defaultDesiredPreparedItemsLength = 50
)
func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int {
t.Lock()
defer t.Unlock()
pruneList := func(pruneTo int, listToPrune *list.List) int {
if listToPrune == nil {
// no need to prune
return 0
}
unprunedLength := listToPrune.Len()
if unprunedLength <= pruneTo {
// no need to prune
return 0
}
// work from the back + assemble a slice of entries that we will prune
amountStillToPrune := unprunedLength - pruneTo
itemsToPrune := make([]*list.Element, 0, amountStillToPrune)
for e := listToPrune.Back(); amountStillToPrune > 0; e = e.Prev() {
itemsToPrune = append(itemsToPrune, e)
amountStillToPrune--
}
// remove the entries we found
var totalPruned int
for _, e := range itemsToPrune {
listToPrune.Remove(e)
totalPruned++
}
return totalPruned
}
prunedPrepared := pruneList(desiredPreparedItemsLength, t.preparedItems.data)
prunedIndexed := pruneList(desiredIndexedItemsLength, t.indexedItems.data)
return prunedPrepared + prunedIndexed
}

View File

@ -0,0 +1,110 @@
/*
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_test
import (
"context"
"sort"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/timeline"
"github.com/superseriousbusiness/gotosocial/internal/visibility"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type PruneTestSuite struct {
TimelineStandardTestSuite
}
func (suite *PruneTestSuite) SetupSuite() {
suite.testAccounts = testrig.NewTestAccounts()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *PruneTestSuite) SetupTest() {
testrig.InitTestLog()
testrig.InitTestConfig()
suite.db = testrig.NewTestDB()
suite.tc = testrig.NewTestTypeConverter(suite.db)
suite.filter = visibility.NewFilter(suite.db)
testrig.StandardDBSetup(suite.db, nil)
// let's take local_account_1 as the timeline owner
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 {
suite.FailNow(err.Error())
}
// put the status IDs in a determinate order since we can't trust a map to keep its order
statuses := []*gtsmodel.Status{}
for _, s := range suite.testStatuses {
statuses = append(statuses, s)
}
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].ID > statuses[j].ID
})
// prepare the timeline by just shoving all test statuses in it -- let's not be fussy about who sees what
for _, s := range statuses {
_, err := tl.IndexAndPrepareOne(context.Background(), s.GetID(), s.BoostOfID, s.AccountID, s.BoostOfAccountID)
if err != nil {
suite.FailNow(err.Error())
}
}
suite.timeline = tl
}
func (suite *PruneTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
}
func (suite *PruneTestSuite) TestPrune() {
// prune down to 5 prepared + 5 indexed
suite.Equal(24, suite.timeline.Prune(5, 5))
suite.Equal(5, suite.timeline.ItemIndexLength(context.Background()))
}
func (suite *PruneTestSuite) TestPruneTo0() {
// prune down to 0 prepared + 0 indexed
suite.Equal(34, suite.timeline.Prune(0, 0))
suite.Equal(0, suite.timeline.ItemIndexLength(context.Background()))
}
func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() {
// prune to 99999, this should result in no entries being pruned
suite.Equal(0, suite.timeline.Prune(99999, 99999))
suite.Equal(17, suite.timeline.ItemIndexLength(context.Background()))
}
func TestPruneTestSuite(t *testing.T) {
suite.Run(t, new(PruneTestSuite))
}

View File

@ -29,7 +29,6 @@ import (
func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) { func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"accountTimeline", t.accountID}, {"accountTimeline", t.accountID},
{"statusID", statusID}, {"statusID", statusID},
}...) }...)
@ -40,9 +39,9 @@ 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.itemIndex != nil && t.itemIndex.data != nil { if t.indexedItems != nil && t.indexedItems.data != nil {
for e := t.itemIndex.data.Front(); e != nil; e = e.Next() { for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
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")
} }
@ -53,7 +52,7 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
} }
} }
for _, e := range removeIndexes { for _, e := range removeIndexes {
t.itemIndex.data.Remove(e) t.indexedItems.data.Remove(e)
removed++ removed++
} }
@ -82,19 +81,19 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, error) { func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, error) {
l := log.WithFields(kv.Fields{ l := log.WithFields(kv.Fields{
{"accountTimeline", t.accountID}, {"accountTimeline", t.accountID},
{"accountID", accountID}, {"accountID", accountID},
}...) }...)
t.Lock() t.Lock()
defer t.Unlock() defer t.Unlock()
var removed int var removed int
// remove entr(ies) from the post index // remove entr(ies) from the post index
removeIndexes := []*list.Element{} removeIndexes := []*list.Element{}
if t.itemIndex != nil && t.itemIndex.data != nil { if t.indexedItems != nil && t.indexedItems.data != nil {
for e := t.itemIndex.data.Front(); e != nil; e = e.Next() { for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
entry, ok := e.Value.(*itemIndexEntry) entry, ok := e.Value.(*indexedItemsEntry)
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")
} }
@ -105,7 +104,7 @@ func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, erro
} }
} }
for _, e := range removeIndexes { for _, e := range removeIndexes {
t.itemIndex.data.Remove(e) t.indexedItems.data.Remove(e)
removed++ removed++
} }

View File

@ -21,6 +21,7 @@ package timeline
import ( import (
"context" "context"
"sync" "sync"
"time"
) )
// GrabFunction is used by a Timeline to grab more items to index. // GrabFunction is used by a Timeline to grab more items to index.
@ -73,26 +74,9 @@ type Timeline interface {
// 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) ([]Preparable, error) Get(ctx context.Context, amount int, maxID string, sinceID string, minID string, prepareNext bool) ([]Preparable, error)
// GetXFromTop returns x amount of items from the top of the timeline, from newest to oldest.
GetXFromTop(ctx context.Context, amount int) ([]Preparable, error)
// GetXBehindID returns x amount of items from the given id onwards, from newest to oldest.
// This will NOT include the item with the given ID.
//
// This corresponds to an api call to /timelines/home?max_id=WHATEVER
GetXBehindID(ctx context.Context, amount int, fromID string, attempts *int) ([]Preparable, error)
// GetXBeforeID returns x amount of items up to the given id, from newest to oldest.
// This will NOT include the item with the given ID.
//
// This corresponds to an api call to /timelines/home?since_id=WHATEVER
GetXBeforeID(ctx context.Context, amount int, sinceID string, startFromTop bool) ([]Preparable, error)
// GetXBetweenID returns x amount of items from the given maxID, up to the given id, from newest to oldest.
// 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
GetXBetweenID(ctx context.Context, amount int, maxID string, sinceID string) ([]Preparable, error)
/* /*
INDEXING FUNCTIONS INDEXING + PREPARATION FUNCTIONS
*/ */
// IndexOne puts a item 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.
@ -100,35 +84,14 @@ type Timeline interface {
// The returned bool indicates whether or not the item 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 item is a boost and the original item 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, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) IndexOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error)
// IndexAndPrepareOne puts a item into the timeline at the appropriate place according to its 'createdAt' property,
// 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 item, an empty string will be returned so make sure to check for this.
OldestIndexedItemID(ctx context.Context) (string, error)
// 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 item, an empty string will be returned so make sure to check for this.
NewestIndexedItemID(ctx context.Context) (string, error)
IndexBefore(ctx context.Context, itemID string, amount int) error
IndexBehind(ctx context.Context, itemID string, amount int) error
/*
PREPARATION FUNCTIONS
*/
// PrepareXFromTop instructs the timeline to prepare x amount of items from the top of the timeline.
PrepareFromTop(ctx context.Context, amount int) error
// PrepareBehind instructs the timeline to prepare the next amount of entries for serialization, from position onwards.
// 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, itemID string, amount int) error
// 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 item 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 item is a boost and the original item 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, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) IndexAndPrepareOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error)
// OldestPreparedItemID returns the id of the rearmost (ie., the oldest) prepared item, or an error if something goes wrong. // PrepareXFromTop instructs the timeline to prepare x amount of items from the top of the timeline, useful during init.
// If nothing goes wrong but there's no oldest item, an empty string will be returned so make sure to check for this. PrepareFromTop(ctx context.Context, amount int) error
OldestPreparedItemID(ctx context.Context) (string, error)
/* /*
INFO FUNCTIONS INFO FUNCTIONS
@ -136,13 +99,24 @@ type Timeline interface {
// ActualPostIndexLength returns the actual length of the item index at this point in time. // ActualPostIndexLength returns the actual length of the item index at this point in time.
ItemIndexLength(ctx context.Context) int ItemIndexLength(ctx context.Context) int
// 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 item, an empty string will be returned so make sure to check for this.
OldestIndexedItemID(ctx context.Context) (string, error)
// 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 item, an empty string will be returned so make sure to check for this.
NewestIndexedItemID(ctx context.Context) (string, error)
/* /*
UTILITY FUNCTIONS UTILITY FUNCTIONS
*/ */
// Reset instructs the timeline to reset to its base state -- cache only the minimum amount of items. // LastGot returns the time that Get was last called.
Reset() error LastGot() time.Time
// Prune prunes preparedItems and indexedItems in this timeline to the desired lengths.
// This will be a no-op if the lengths are already < the desired values.
// Prune acquires a lock on the timeline before pruning.
// The return value is the combined total of items pruned from preparedItems and indexedItems.
Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int
// Remove removes a item from both the index and prepared items. // Remove removes a item from both the index and prepared items.
// //
// If a item 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.
@ -157,12 +131,13 @@ type Timeline interface {
// timeline fulfils the Timeline interface // timeline fulfils the Timeline interface
type timeline struct { type timeline struct {
itemIndex *itemIndex indexedItems *indexedItems
preparedItems *preparedItems preparedItems *preparedItems
grabFunction GrabFunction grabFunction GrabFunction
filterFunction FilterFunction filterFunction FilterFunction
prepareFunction PrepareFunction prepareFunction PrepareFunction
accountID string accountID string
lastGot time.Time
sync.Mutex sync.Mutex
} }
@ -175,7 +150,7 @@ func NewTimeline(
prepareFunction PrepareFunction, prepareFunction PrepareFunction,
skipInsertFunction SkipInsertFunction) (Timeline, error) { skipInsertFunction SkipInsertFunction) (Timeline, error) {
return &timeline{ return &timeline{
itemIndex: &itemIndex{ indexedItems: &indexedItems{
skipInsert: skipInsertFunction, skipInsert: skipInsertFunction,
}, },
preparedItems: &preparedItems{ preparedItems: &preparedItems{
@ -185,17 +160,6 @@ func NewTimeline(
filterFunction: filterFunction, filterFunction: filterFunction,
prepareFunction: prepareFunction, prepareFunction: prepareFunction,
accountID: timelineAccountID, accountID: timelineAccountID,
lastGot: time.Time{},
}, nil }, nil
} }
func (t *timeline) Reset() error {
return nil
}
func (t *timeline) ItemIndexLength(ctx context.Context) int {
if t.itemIndex == nil || t.itemIndex.data == nil {
return 0
}
return t.itemIndex.data.Len()
}

View File

@ -34,13 +34,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
// const (
// // highestID is the highest possible ULID
// highestID = "ZZZZZZZZZZZZZZZZZZZZZZZZZZ"
// // lowestID is the lowest possible ULID
// lowestID = "00000000000000000000000000"
// )
// Converts a gts model account into an Activity Streams person type. // Converts a gts model account into an Activity Streams person type.
func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) { func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
person := streams.NewActivityStreamsPerson() person := streams.NewActivityStreamsPerson()