[bugfix/chore] Refactor timeline code (#1656)
* start poking timelines * OK yes we're refactoring, but it's nothing like the last time so don't worry * more fiddling * update tests, simplify Get * thanks linter, you're the best, mwah mwah kisses * do a bit more tidying up * start buggering about with the prepare function * fix little oopsie * start merging lists into 1 * ik heb een heel zwaar leven nee nee echt waar * hey it works we did it reddit * regenerate swagger docs * tidy up a wee bit * adjust paging * fix little error, remove unused functions
This commit is contained in:
parent
c54510bc74
commit
3510454768
|
@ -5582,11 +5582,11 @@ paths:
|
||||||
in: query
|
in: query
|
||||||
name: max_id
|
name: max_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only statuses *NEWER* than the given since status ID. The status with the specified ID will not be included in the response.
|
- description: Return only statuses *newer* than the given since status ID. The status with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: since_id
|
name: since_id
|
||||||
type: string
|
type: string
|
||||||
- description: Return only statuses *NEWER* than the given since status ID. The status with the specified ID will not be included in the response.
|
- description: Return only statuses *immediately newer* than the given since status ID. The status with the specified ID will not be included in the response.
|
||||||
in: query
|
in: query
|
||||||
name: min_id
|
name: min_id
|
||||||
type: string
|
type: string
|
||||||
|
|
|
@ -62,14 +62,14 @@ import (
|
||||||
// name: since_id
|
// name: since_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only statuses *NEWER* than the given since status ID.
|
// Return only statuses *newer* than the given since status ID.
|
||||||
// The status with the specified ID will not be included in the response.
|
// The status with the specified ID will not be included in the response.
|
||||||
// in: query
|
// in: query
|
||||||
// -
|
// -
|
||||||
// name: min_id
|
// name: min_id
|
||||||
// type: string
|
// type: string
|
||||||
// description: >-
|
// description: >-
|
||||||
// Return only statuses *NEWER* than the given since status ID.
|
// Return only statuses *immediately newer* than the given since status ID.
|
||||||
// The status with the specified ID will not be included in the response.
|
// The status with the specified ID will not be included in the response.
|
||||||
// in: query
|
// in: query
|
||||||
// required: false
|
// required: false
|
||||||
|
|
|
@ -42,7 +42,10 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make educated guess for slice size
|
// Make educated guess for slice size
|
||||||
statusIDs := make([]string, 0, limit)
|
var (
|
||||||
|
statusIDs = make([]string, 0, limit)
|
||||||
|
frontToBack = true
|
||||||
|
)
|
||||||
|
|
||||||
q := t.conn.
|
q := t.conn.
|
||||||
NewSelect().
|
NewSelect().
|
||||||
|
@ -56,11 +59,9 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
||||||
bun.Ident("follow.target_account_id"),
|
bun.Ident("follow.target_account_id"),
|
||||||
bun.Ident("status.account_id"),
|
bun.Ident("status.account_id"),
|
||||||
bun.Ident("follow.account_id"),
|
bun.Ident("follow.account_id"),
|
||||||
accountID).
|
accountID)
|
||||||
// Sort by highest ID (newest) to lowest ID (oldest)
|
|
||||||
Order("status.id DESC")
|
|
||||||
|
|
||||||
if maxID == "" {
|
if maxID == "" || maxID == id.Highest {
|
||||||
const future = 24 * time.Hour
|
const future = 24 * time.Hour
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
@ -83,6 +84,9 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
||||||
if minID != "" {
|
if minID != "" {
|
||||||
// return only statuses HIGHER (ie., newer) than minID
|
// return only statuses HIGHER (ie., newer) than minID
|
||||||
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
q = q.Where("? > ?", bun.Ident("status.id"), minID)
|
||||||
|
|
||||||
|
// page up
|
||||||
|
frontToBack = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if local {
|
if local {
|
||||||
|
@ -95,6 +99,14 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
||||||
q = q.Limit(limit)
|
q = q.Limit(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if frontToBack {
|
||||||
|
// Page down.
|
||||||
|
q = q.Order("status.id DESC")
|
||||||
|
} else {
|
||||||
|
// Page up.
|
||||||
|
q = q.Order("status.id ASC")
|
||||||
|
}
|
||||||
|
|
||||||
// Use a WhereGroup here to specify that we want EITHER statuses posted by accounts that accountID follows,
|
// 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).
|
// OR statuses posted by accountID itself (since a user should be able to see their own statuses).
|
||||||
//
|
//
|
||||||
|
@ -110,8 +122,20 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
||||||
return nil, t.conn.ProcessError(err)
|
return nil, t.conn.ProcessError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
|
if len(statusIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're paging up, we still want statuses
|
||||||
|
// to be sorted by ID desc, so reverse ids slice.
|
||||||
|
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||||
|
if !frontToBack {
|
||||||
|
for l, r := 0, len(statusIDs)-1; l < r; l, r = l+1, r-1 {
|
||||||
|
statusIDs[l], statusIDs[r] = statusIDs[r], statusIDs[l]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses := make([]*gtsmodel.Status, 0, len(statusIDs))
|
||||||
for _, id := range statusIDs {
|
for _, id := range statusIDs {
|
||||||
// Fetch status from db for ID
|
// Fetch status from db for ID
|
||||||
status, err := t.state.DB.GetStatusByID(ctx, id)
|
status, err := t.state.DB.GetStatusByID(ctx, id)
|
||||||
|
|
|
@ -100,6 +100,32 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() {
|
||||||
suite.Len(s, 16)
|
suite.Len(s, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *TimelineTestSuite) TestGetHomeTimelineBackToFront() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
viewingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
|
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, "", "", id.Lowest, 5, false)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Len(s, 5)
|
||||||
|
suite.Equal("01F8MHAYFKS4KMXF8K5Y1C0KRN", s[0].ID)
|
||||||
|
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", s[len(s)-1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
viewingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
|
s, err := suite.db.GetHomeTimeline(ctx, viewingAccount.ID, id.Highest, "", "", 5, false)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Len(s, 5)
|
||||||
|
suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[0].ID)
|
||||||
|
suite.Equal("01FCTA44PW9H1TB328S9AQXKDS", s[len(s)-1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
func getFutureStatus() *gtsmodel.Status {
|
func getFutureStatus() *gtsmodel.Status {
|
||||||
theDistantFuture := time.Now().Add(876600 * time.Hour)
|
theDistantFuture := time.Now().Add(876600 * time.Hour)
|
||||||
id, err := id.NewULIDFromTime(theDistantFuture)
|
id, err := id.NewULIDFromTime(theDistantFuture)
|
||||||
|
|
|
@ -455,8 +455,8 @@ func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmo
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stick the status in the timeline for the account and then immediately prepare it so they can see it right away
|
// stick the status in the timeline for the account
|
||||||
if inserted, err := p.statusTimelines.IngestAndPrepare(ctx, status, account.ID); err != nil {
|
if inserted, err := p.statusTimelines.IngestOne(ctx, account.ID, status); err != nil {
|
||||||
return fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err)
|
return fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err)
|
||||||
} else if !inserted {
|
} else if !inserted {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -41,10 +41,10 @@ func StatusGrabFunction(database db.DB) timeline.GrabFunction {
|
||||||
return func(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
|
return func(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int) ([]timeline.Timelineable, bool, error) {
|
||||||
statuses, err := database.GetHomeTimeline(ctx, timelineAccountID, maxID, sinceID, minID, limit, false)
|
statuses, err := database.GetHomeTimeline(ctx, timelineAccountID, maxID, sinceID, minID, limit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNoEntries {
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
|
return nil, true, nil // we just don't have enough statuses left in the db so return stop = true
|
||||||
}
|
}
|
||||||
return nil, false, fmt.Errorf("statusGrabFunction: error getting statuses from db: %s", err)
|
return nil, false, fmt.Errorf("statusGrabFunction: error getting statuses from db: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]timeline.Timelineable, len(statuses))
|
items := make([]timeline.Timelineable, len(statuses))
|
||||||
|
@ -61,20 +61,20 @@ func StatusFilterFunction(database db.DB, filter *visibility.Filter) timeline.Fi
|
||||||
return func(ctx context.Context, timelineAccountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
|
return func(ctx context.Context, timelineAccountID string, item timeline.Timelineable) (shouldIndex bool, err error) {
|
||||||
status, ok := item.(*gtsmodel.Status)
|
status, ok := item.(*gtsmodel.Status)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, errors.New("statusFilterFunction: could not convert item to *gtsmodel.Status")
|
return false, errors.New("StatusFilterFunction: could not convert item to *gtsmodel.Status")
|
||||||
}
|
}
|
||||||
|
|
||||||
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("statusFilterFunction: error getting account with id %s", timelineAccountID)
|
return false, fmt.Errorf("StatusFilterFunction: error getting account with id %s: %w", timelineAccountID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
timelineable, err := filter.StatusHomeTimelineable(ctx, requestingAccount, status)
|
timelineable, err := filter.StatusHomeTimelineable(ctx, requestingAccount, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf(ctx, "error checking hometimelineability of status %s for account %s: %s", status.ID, timelineAccountID, err)
|
return false, fmt.Errorf("StatusFilterFunction: error checking hometimelineability of status %s for account %s: %w", status.ID, timelineAccountID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return timelineable, nil // we don't return the error here because we want to just skip this item if something goes wrong
|
return timelineable, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,12 +83,12 @@ func StatusPrepareFunction(database db.DB, tc typeutils.TypeConverter) timeline.
|
||||||
return func(ctx context.Context, timelineAccountID string, itemID string) (timeline.Preparable, error) {
|
return func(ctx context.Context, timelineAccountID string, itemID string) (timeline.Preparable, error) {
|
||||||
status, err := database.GetStatusByID(ctx, itemID)
|
status, err := database.GetStatusByID(ctx, itemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("statusPrepareFunction: error getting status with id %s", itemID)
|
return nil, fmt.Errorf("StatusPrepareFunction: error getting status with id %s: %w", itemID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
requestingAccount, err := database.GetAccountByID(ctx, timelineAccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("statusPrepareFunction: error getting account with id %s", timelineAccountID)
|
return nil, fmt.Errorf("StatusPrepareFunction: error getting account with id %s: %w", timelineAccountID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
|
return tc.StatusToAPIStatus(ctx, status, requestingAccount)
|
||||||
|
@ -137,21 +137,24 @@ func StatusSkipInsertFunction() timeline.SkipInsertFunction {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
preparedItems, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
|
statuses, err := p.statusTimelines.GetTimeline(ctx, authed.Account.ID, maxID, sinceID, minID, limit, local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = fmt.Errorf("HomeTimelineGet: error getting statuses: %w", err)
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count := len(preparedItems)
|
count := len(statuses)
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return util.EmptyPageableResponse(), nil
|
return util.EmptyPageableResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items := []interface{}{}
|
var (
|
||||||
nextMaxIDValue := ""
|
items = make([]interface{}, count)
|
||||||
prevMinIDValue := ""
|
nextMaxIDValue string
|
||||||
for i, item := range preparedItems {
|
prevMinIDValue string
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, item := range statuses {
|
||||||
if i == count-1 {
|
if i == count-1 {
|
||||||
nextMaxIDValue = item.GetID()
|
nextMaxIDValue = item.GetID()
|
||||||
}
|
}
|
||||||
|
@ -159,7 +162,8 @@ func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, max
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
prevMinIDValue = item.GetID()
|
prevMinIDValue = item.GetID()
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
|
||||||
|
items[i] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||||
|
@ -174,37 +178,54 @@ func (p *Processor) HomeTimelineGet(ctx context.Context, authed *oauth.Auth, max
|
||||||
func (p *Processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
func (p *Processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit, local)
|
statuses, err := p.state.DB.GetPublicTimeline(ctx, maxID, sinceID, minID, limit, local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNoEntries {
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
// there are just no entries left
|
// No statuses (left) in public timeline.
|
||||||
return util.EmptyPageableResponse(), nil
|
return util.EmptyPageableResponse(), nil
|
||||||
}
|
}
|
||||||
// there's an actual error
|
// An actual error has occurred.
|
||||||
|
err = fmt.Errorf("PublicTimelineGet: db error getting statuses: %w", err)
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered, err := p.filterPublicStatuses(ctx, authed, statuses)
|
count := len(statuses)
|
||||||
if err != nil {
|
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count := len(filtered)
|
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return util.EmptyPageableResponse(), nil
|
return util.EmptyPageableResponse(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
items := []interface{}{}
|
var (
|
||||||
nextMaxIDValue := ""
|
items = make([]interface{}, 0, count)
|
||||||
prevMinIDValue := ""
|
nextMaxIDValue string
|
||||||
for i, item := range filtered {
|
prevMinIDValue string
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, s := range statuses {
|
||||||
|
// Set next + prev values before filtering and API
|
||||||
|
// converting, so caller can still page properly.
|
||||||
if i == count-1 {
|
if i == count-1 {
|
||||||
nextMaxIDValue = item.GetID()
|
nextMaxIDValue = s.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
prevMinIDValue = item.GetID()
|
prevMinIDValue = s.ID
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
|
||||||
|
timelineable, err := p.filter.StatusPublicTimelineable(ctx, authed.Account, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf(ctx, "skipping status %s because of an error checking StatusPublicTimelineable: %s", s.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timelineable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, apiStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||||
|
@ -219,26 +240,29 @@ func (p *Processor) PublicTimelineGet(ctx context.Context, authed *oauth.Auth, m
|
||||||
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
|
statuses, nextMaxID, prevMinID, err := p.state.DB.GetFavedTimeline(ctx, authed.Account.ID, maxID, minID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNoEntries {
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
// there are just no entries left
|
// There are just no entries (left).
|
||||||
return util.EmptyPageableResponse(), nil
|
return util.EmptyPageableResponse(), nil
|
||||||
}
|
}
|
||||||
// there's an actual error
|
// An actual error has occurred.
|
||||||
|
err = fmt.Errorf("FavedTimelineGet: db error getting statuses: %w", err)
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
count := len(statuses)
|
||||||
|
if count == 0 {
|
||||||
|
return util.EmptyPageableResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
filtered, err := p.filterFavedStatuses(ctx, authed, statuses)
|
filtered, err := p.filterFavedStatuses(ctx, authed, statuses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = fmt.Errorf("FavedTimelineGet: error filtering statuses: %w", err)
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(filtered) == 0 {
|
items := make([]interface{}, len(filtered))
|
||||||
return util.EmptyPageableResponse(), nil
|
for i, item := range filtered {
|
||||||
}
|
items[i] = item
|
||||||
|
|
||||||
items := []interface{}{}
|
|
||||||
for _, item := range filtered {
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||||
|
@ -250,47 +274,17 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Processor) filterPublicStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) {
|
|
||||||
apiStatuses := []*apimodel.Status{}
|
|
||||||
for _, s := range statuses {
|
|
||||||
if _, err := p.state.DB.GetAccountByID(ctx, s.AccountID); err != nil {
|
|
||||||
if err == db.ErrNoEntries {
|
|
||||||
log.Debugf(ctx, "skipping status %s because account %s can't be found in the db", s.ID, s.AccountID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("filterPublicStatuses: error getting status author: %s", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
timelineable, err := p.filter.StatusPublicTimelineable(ctx, authed.Account, s)
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf(ctx, "skipping status %s because of an error checking status visibility: %s", s.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !timelineable {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, s, authed.Account)
|
|
||||||
if err != nil {
|
|
||||||
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", s.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
apiStatuses = append(apiStatuses, apiStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiStatuses, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Processor) filterFavedStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) {
|
func (p *Processor) filterFavedStatuses(ctx context.Context, authed *oauth.Auth, statuses []*gtsmodel.Status) ([]*apimodel.Status, error) {
|
||||||
apiStatuses := []*apimodel.Status{}
|
apiStatuses := make([]*apimodel.Status, 0, len(statuses))
|
||||||
|
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
if _, err := p.state.DB.GetAccountByID(ctx, s.AccountID); err != nil {
|
if _, err := p.state.DB.GetAccountByID(ctx, s.AccountID); err != nil {
|
||||||
if err == db.ErrNoEntries {
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
log.Debugf(ctx, "skipping status %s because account %s can't be found in the db", s.ID, s.AccountID)
|
log.Debugf(ctx, "skipping status %s because account %s can't be found in the db", s.ID, s.AccountID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("filterPublicStatuses: error getting status author: %s", err))
|
err = fmt.Errorf("filterFavedStatuses: db error getting status author: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
timelineable, err := p.filter.StatusVisible(ctx, authed.Account, s)
|
timelineable, err := p.filter.StatusVisible(ctx, authed.Account, s)
|
||||||
|
|
|
@ -25,11 +25,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/gruf/go-kv"
|
"codeberg.org/gruf/go-kv"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const retries = 5
|
|
||||||
|
|
||||||
func (t *timeline) LastGot() time.Time {
|
func (t *timeline) LastGot() time.Time {
|
||||||
t.Lock()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
|
@ -47,339 +47,368 @@ func (t *timeline) Get(ctx context.Context, amount int, maxID string, sinceID st
|
||||||
}...)
|
}...)
|
||||||
l.Trace("entering get and updating t.lastGot")
|
l.Trace("entering get and updating t.lastGot")
|
||||||
|
|
||||||
// regardless of what happens below, update the
|
// Regardless of what happens below, update the
|
||||||
// last time Get was called for this timeline
|
// last time Get was called for this timeline.
|
||||||
t.Lock()
|
t.Lock()
|
||||||
t.lastGot = time.Now()
|
t.lastGot = time.Now()
|
||||||
t.Unlock()
|
t.Unlock()
|
||||||
|
|
||||||
var items []Preparable
|
var (
|
||||||
var err error
|
items []Preparable
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
// no params are defined to just fetch from the top
|
switch {
|
||||||
// this is equivalent to a user asking for the top x items from their timeline
|
case maxID == "" && sinceID == "" && minID == "":
|
||||||
if maxID == "" && sinceID == "" && minID == "" {
|
// No params are defined so just fetch from the top.
|
||||||
items, err = t.getXFromTop(ctx, amount)
|
// This is equivalent to a user starting to view
|
||||||
// aysnchronously prepare the next predicted query so it's ready when the user asks for it
|
// their timeline from newest -> older posts.
|
||||||
if len(items) != 0 {
|
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, id.Lowest, true)
|
||||||
|
|
||||||
|
// Cache expected next query to speed up scrolling.
|
||||||
|
// Assume the user will be scrolling downwards from
|
||||||
|
// the final ID in items.
|
||||||
|
if prepareNext && err == nil && len(items) != 0 {
|
||||||
nextMaxID := items[len(items)-1].GetID()
|
nextMaxID := items[len(items)-1].GetID()
|
||||||
if prepareNext {
|
t.prepareNextQuery(amount, nextMaxID, "", "")
|
||||||
// already cache the next query to speed up scrolling
|
|
||||||
go func() {
|
|
||||||
// use context.Background() because we don't want the query to abort when the request finishes
|
|
||||||
if err := t.prepareNextQuery(context.Background(), amount, nextMaxID, "", ""); err != nil {
|
|
||||||
l.Errorf("error preparing next query: %s", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxID is defined but sinceID isn't so take from behind
|
case maxID != "" && sinceID == "" && minID == "":
|
||||||
// this is equivalent to a user asking for the next x items from their timeline, starting from maxID
|
// Only maxID is defined, so fetch from maxID onwards.
|
||||||
if maxID != "" && sinceID == "" {
|
// This is equivalent to a user paging further down
|
||||||
attempts := 0
|
// their timeline from newer -> older posts.
|
||||||
items, err = t.getXBehindID(ctx, amount, maxID, &attempts)
|
items, err = t.getXBetweenIDs(ctx, amount, maxID, id.Lowest, true)
|
||||||
// aysnchronously prepare the next predicted query so it's ready when the user asks for it
|
|
||||||
if len(items) != 0 {
|
// Cache expected next query to speed up scrolling.
|
||||||
|
// Assume the user will be scrolling downwards from
|
||||||
|
// the final ID in items.
|
||||||
|
if prepareNext && err == nil && len(items) != 0 {
|
||||||
nextMaxID := items[len(items)-1].GetID()
|
nextMaxID := items[len(items)-1].GetID()
|
||||||
if prepareNext {
|
t.prepareNextQuery(amount, nextMaxID, "", "")
|
||||||
// already cache the next query to speed up scrolling
|
|
||||||
go func() {
|
|
||||||
// use context.Background() because we don't want the query to abort when the request finishes
|
|
||||||
if err := t.prepareNextQuery(context.Background(), 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
|
// In the next cases, maxID is defined, and so are
|
||||||
// this is equivalent to a user asking for items older than x but newer than y
|
// either sinceID or minID. This is equivalent to
|
||||||
if maxID != "" && sinceID != "" {
|
// a user opening an in-progress timeline and asking
|
||||||
items, err = t.getXBetweenID(ctx, amount, maxID, minID)
|
// for a slice of posts somewhere in the middle, or
|
||||||
}
|
// trying to "fill in the blanks" between two points,
|
||||||
if maxID != "" && minID != "" {
|
// paging either up or down.
|
||||||
items, err = t.getXBetweenID(ctx, amount, maxID, minID)
|
case maxID != "" && sinceID != "":
|
||||||
|
items, err = t.getXBetweenIDs(ctx, amount, maxID, sinceID, true)
|
||||||
|
|
||||||
|
// Cache expected next query to speed up scrolling.
|
||||||
|
// We can assume the caller is scrolling downwards.
|
||||||
|
// Guess id.Lowest as sinceID, since we don't actually
|
||||||
|
// know what the next sinceID would be.
|
||||||
|
if prepareNext && err == nil && len(items) != 0 {
|
||||||
|
nextMaxID := items[len(items)-1].GetID()
|
||||||
|
t.prepareNextQuery(amount, nextMaxID, id.Lowest, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxID isn't defined, but sinceID || minID are, so take x before
|
case maxID != "" && minID != "":
|
||||||
// this is equivalent to a user asking for items newer than x (eg., refreshing the top of their timeline)
|
items, err = t.getXBetweenIDs(ctx, amount, maxID, minID, false)
|
||||||
if maxID == "" && sinceID != "" {
|
|
||||||
items, err = t.getXBeforeID(ctx, amount, sinceID, true)
|
// Cache expected next query to speed up scrolling.
|
||||||
|
// We can assume the caller is scrolling upwards.
|
||||||
|
// Guess id.Highest as maxID, since we don't actually
|
||||||
|
// know what the next maxID would be.
|
||||||
|
if prepareNext && err == nil && len(items) != 0 {
|
||||||
|
prevMinID := items[0].GetID()
|
||||||
|
t.prepareNextQuery(amount, id.Highest, "", prevMinID)
|
||||||
}
|
}
|
||||||
if maxID == "" && minID != "" {
|
|
||||||
items, err = t.getXBeforeID(ctx, amount, minID, true)
|
// In the final cases, maxID is not defined, but
|
||||||
|
// either sinceID or minID are. This is equivalent to
|
||||||
|
// a user either "pulling up" at the top of their timeline
|
||||||
|
// to refresh it and check if newer posts have come in, or
|
||||||
|
// trying to scroll upwards from an old post to see what
|
||||||
|
// they missed since then.
|
||||||
|
//
|
||||||
|
// In these calls, we use the highest possible ulid as
|
||||||
|
// behindID because we don't have a cap for newest that
|
||||||
|
// we're interested in.
|
||||||
|
case maxID == "" && sinceID != "":
|
||||||
|
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, sinceID, true)
|
||||||
|
|
||||||
|
// We can't cache an expected next query for this one,
|
||||||
|
// since presumably the caller is at the top of their
|
||||||
|
// timeline already.
|
||||||
|
|
||||||
|
case maxID == "" && minID != "":
|
||||||
|
items, err = t.getXBetweenIDs(ctx, amount, id.Highest, minID, false)
|
||||||
|
|
||||||
|
// Cache expected next query to speed up scrolling.
|
||||||
|
// We can assume the caller is scrolling upwards.
|
||||||
|
// Guess id.Highest as maxID, since we don't actually
|
||||||
|
// know what the next maxID would be.
|
||||||
|
if prepareNext && err == nil && len(items) != 0 {
|
||||||
|
prevMinID := items[0].GetID()
|
||||||
|
t.prepareNextQuery(amount, id.Highest, "", prevMinID)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
err = errors.New("Get: switch statement exhausted with no results")
|
||||||
}
|
}
|
||||||
|
|
||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getXFromTop returns x amount of items from the top of the timeline, from newest to oldest.
|
// getXBetweenIDs returns x amount of items somewhere between (not including) the given IDs.
|
||||||
func (t *timeline) getXFromTop(ctx context.Context, amount int) ([]Preparable, error) {
|
//
|
||||||
// make a slice of preparedItems with the length we need to return
|
// If frontToBack is true, items will be served paging down from behindID.
|
||||||
preparedItems := make([]Preparable, 0, amount)
|
// This corresponds to an api call to /timelines/home?max_id=WHATEVER&since_id=WHATEVER
|
||||||
|
//
|
||||||
|
// If frontToBack is false, items will be served paging up from beforeID.
|
||||||
|
// This corresponds to an api call to /timelines/home?max_id=WHATEVER&min_id=WHATEVER
|
||||||
|
func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) ([]Preparable, error) {
|
||||||
|
l := log.
|
||||||
|
WithContext(ctx).
|
||||||
|
WithFields(kv.Fields{
|
||||||
|
{"amount", amount},
|
||||||
|
{"behindID", behindID},
|
||||||
|
{"beforeID", beforeID},
|
||||||
|
{"frontToBack", frontToBack},
|
||||||
|
}...)
|
||||||
|
l.Trace("entering getXBetweenID")
|
||||||
|
|
||||||
if t.preparedItems.data == nil {
|
// Assume length we need to return.
|
||||||
t.preparedItems.data = &list.List{}
|
items := make([]Preparable, 0, amount)
|
||||||
|
|
||||||
|
if beforeID >= behindID {
|
||||||
|
// This is an impossible situation, we
|
||||||
|
// can't serve anything between these.
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure we have enough items prepared to return
|
// Try to ensure we have enough items prepared.
|
||||||
if t.preparedItems.data.Len() < amount {
|
if err := t.prepareXBetweenIDs(ctx, amount, behindID, beforeID, frontToBack); err != nil {
|
||||||
if err := t.PrepareFromTop(ctx, amount); err != nil {
|
// An error here doesn't necessarily mean we
|
||||||
|
// can't serve anything, so log + keep going.
|
||||||
|
l.Debugf("error calling prepareXBetweenIDs: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
beforeIDMark *list.Element
|
||||||
|
served int
|
||||||
|
// Our behavior while ranging through the
|
||||||
|
// list changes depending on if we're
|
||||||
|
// going front-to-back or back-to-front.
|
||||||
|
//
|
||||||
|
// To avoid checking which one we're doing
|
||||||
|
// in each loop iteration, define our range
|
||||||
|
// function here outside the loop.
|
||||||
|
//
|
||||||
|
// The bool indicates to the caller whether
|
||||||
|
// iteration should continue (true) or stop
|
||||||
|
// (false).
|
||||||
|
rangeF func(e *list.Element) (bool, error)
|
||||||
|
// If we get certain errors on entries as we're
|
||||||
|
// looking through, we might want to cheekily
|
||||||
|
// remove their elements from the timeline.
|
||||||
|
// Everything added to this slice will be removed.
|
||||||
|
removeElements = []*list.Element{}
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for _, e := range removeElements {
|
||||||
|
t.items.data.Remove(e)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if frontToBack {
|
||||||
|
// We're going front-to-back, which means we
|
||||||
|
// don't need to look for a mark per se, we
|
||||||
|
// just keep serving items until we've reached
|
||||||
|
// a point where the items are out of the range
|
||||||
|
// we're interested in.
|
||||||
|
rangeF = func(e *list.Element) (bool, error) {
|
||||||
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
|
||||||
|
if entry.itemID >= behindID {
|
||||||
|
// ID of this item is too high,
|
||||||
|
// just keep iterating.
|
||||||
|
l.Trace("item is too new, continuing")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.itemID <= beforeID {
|
||||||
|
// We've gone as far as we can through
|
||||||
|
// the list and reached entries that are
|
||||||
|
// now too old for us, stop here.
|
||||||
|
l.Trace("reached older items, breaking")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Trace("entry is just right")
|
||||||
|
|
||||||
|
if entry.prepared == nil {
|
||||||
|
// Whoops, this entry isn't prepared yet; some
|
||||||
|
// race condition? That's OK, we can do it now.
|
||||||
|
prepared, err := t.prepareFunction(ctx, t.accountID, entry.itemID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
|
// ErrNoEntries means something has been deleted,
|
||||||
|
// so we'll likely not be able to ever prepare this.
|
||||||
|
// This means we can remove it and skip past it.
|
||||||
|
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||||
|
removeElements = append(removeElements, e)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// We've got a proper db error.
|
||||||
|
return false, fmt.Errorf("getXBetweenIDs: db error while trying to prepare %s: %w", entry.itemID, err)
|
||||||
|
}
|
||||||
|
entry.prepared = prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, entry.prepared)
|
||||||
|
|
||||||
|
served++
|
||||||
|
return served < amount, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Iterate through the list from the top, until
|
||||||
|
// we reach an item with id smaller than beforeID;
|
||||||
|
// ie., an item OLDER than beforeID. At that point,
|
||||||
|
// we can stop looking because we're not interested
|
||||||
|
// in older entries.
|
||||||
|
rangeF = func(e *list.Element) (bool, error) {
|
||||||
|
// Move the mark back one place each loop.
|
||||||
|
beforeIDMark = e
|
||||||
|
|
||||||
|
//nolint:forcetypeassert
|
||||||
|
if entry := e.Value.(*indexedItemsEntry); entry.itemID <= beforeID {
|
||||||
|
// We've gone as far as we can through
|
||||||
|
// the list and reached entries that are
|
||||||
|
// now too old for us, stop here.
|
||||||
|
l.Trace("reached older items, breaking")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through the list until the function
|
||||||
|
// we defined above instructs us to stop.
|
||||||
|
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||||
|
keepGoing, err := rangeF(e)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !keepGoing {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// work through the prepared items from the top and return
|
if frontToBack || beforeIDMark == nil {
|
||||||
var served int
|
// If we're serving front to back, then
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
// items should be populated by now. If
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
// we're serving back to front but didn't
|
||||||
if !ok {
|
// find any items newer than beforeID,
|
||||||
return nil, errors.New("getXFromTop: could not parse e as a preparedItemsEntry")
|
// we can just return empty items.
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
preparedItems = append(preparedItems, entry.prepared)
|
|
||||||
|
// We're serving back to front, so iterate upwards
|
||||||
|
// towards the front of the list from the mark we found,
|
||||||
|
// until we either get to the front, serve enough
|
||||||
|
// items, or reach behindID.
|
||||||
|
//
|
||||||
|
// To preserve ordering, we need to reverse the slice
|
||||||
|
// when we're finished.
|
||||||
|
for e := beforeIDMark; e != nil; e = e.Prev() {
|
||||||
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
|
||||||
|
if entry.itemID == beforeID {
|
||||||
|
// Don't include the beforeID
|
||||||
|
// entry itself, just continue.
|
||||||
|
l.Trace("entry item ID is equal to beforeID, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.itemID >= behindID {
|
||||||
|
// We've reached items that are
|
||||||
|
// newer than what we're looking
|
||||||
|
// for, just stop here.
|
||||||
|
l.Trace("reached newer items, breaking")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.prepared == nil {
|
||||||
|
// Whoops, this entry isn't prepared yet; some
|
||||||
|
// race condition? That's OK, we can do it now.
|
||||||
|
prepared, err := t.prepareFunction(ctx, t.accountID, entry.itemID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
|
// ErrNoEntries means something has been deleted,
|
||||||
|
// so we'll likely not be able to ever prepare this.
|
||||||
|
// This means we can remove it and skip past it.
|
||||||
|
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||||
|
removeElements = append(removeElements, e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// We've got a proper db error.
|
||||||
|
return nil, fmt.Errorf("getXBetweenIDs: db error while trying to prepare %s: %w", entry.itemID, err)
|
||||||
|
}
|
||||||
|
entry.prepared = prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, entry.prepared)
|
||||||
|
|
||||||
served++
|
served++
|
||||||
if served >= amount {
|
if served >= amount {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return preparedItems, nil
|
// Reverse order of items.
|
||||||
|
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||||
|
for l, r := 0, len(items)-1; l < r; l, r = l+1, r-1 {
|
||||||
|
items[l], items[r] = items[r], items[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getXBehindID returns x amount of items from the given id onwards, from newest to oldest.
|
func (t *timeline) prepareNextQuery(amount int, maxID string, sinceID string, minID string) {
|
||||||
// This will NOT include the item with the given ID.
|
var (
|
||||||
//
|
// We explicitly use context.Background() rather than
|
||||||
// This corresponds to an api call to /timelines/home?max_id=WHATEVER
|
// accepting a context param because we don't want this
|
||||||
func (t *timeline) getXBehindID(ctx context.Context, amount int, behindID string, attempts *int) ([]Preparable, error) {
|
// to stop/break when the calling context finishes.
|
||||||
l := log.WithContext(ctx).
|
ctx = context.Background()
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always perform this async so caller doesn't have to wait.
|
||||||
|
go func() {
|
||||||
|
switch {
|
||||||
|
case maxID == "" && sinceID == "" && minID == "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, id.Lowest, true)
|
||||||
|
case maxID != "" && sinceID == "" && minID == "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, maxID, id.Lowest, true)
|
||||||
|
case maxID != "" && sinceID != "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, maxID, sinceID, true)
|
||||||
|
case maxID != "" && minID != "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, maxID, minID, false)
|
||||||
|
case maxID == "" && sinceID != "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, sinceID, true)
|
||||||
|
case maxID == "" && minID != "":
|
||||||
|
err = t.prepareXBetweenIDs(ctx, amount, id.Highest, minID, false)
|
||||||
|
default:
|
||||||
|
err = errors.New("Get: switch statement exhausted with no results")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.
|
||||||
|
WithContext(ctx).
|
||||||
WithFields(kv.Fields{
|
WithFields(kv.Fields{
|
||||||
{"amount", amount},
|
{"amount", amount},
|
||||||
{"behindID", behindID},
|
{"maxID", maxID},
|
||||||
{"attempts", attempts},
|
{"sinceID", sinceID},
|
||||||
}...)
|
{"minID", minID},
|
||||||
|
}...).
|
||||||
newAttempts := *attempts
|
Warnf("error preparing next query: %s", err)
|
||||||
newAttempts++
|
|
||||||
attempts = &newAttempts
|
|
||||||
|
|
||||||
// make a slice of items with the length we need to return
|
|
||||||
items := make([]Preparable, 0, amount)
|
|
||||||
|
|
||||||
if t.preparedItems.data == nil {
|
|
||||||
t.preparedItems.data = &list.List{}
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
// iterate through the modified list until we hit the mark we're looking for
|
|
||||||
var position int
|
|
||||||
var behindIDMark *list.Element
|
|
||||||
|
|
||||||
findMarkLoop:
|
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
position++
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBehindID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID <= behindID {
|
|
||||||
l.Trace("found behindID mark")
|
|
||||||
behindIDMark = e
|
|
||||||
break findMarkLoop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
if behindIDMark == nil {
|
|
||||||
if err := t.prepareBehind(ctx, behindID, amount); err != nil {
|
|
||||||
return nil, fmt.Errorf("getXBehindID: error preparing behind and including ID %s", behindID)
|
|
||||||
}
|
|
||||||
oldestID, err := t.oldestPreparedItemID(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if oldestID == "" {
|
|
||||||
l.Tracef("oldestID is empty so we can't return behindID %s", behindID)
|
|
||||||
return items, 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 items, nil
|
|
||||||
}
|
|
||||||
if *attempts > retries {
|
|
||||||
l.Tracef("exceeded retries looking for behindID %s", behindID)
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
l.Trace("trying getXBehindID again")
|
|
||||||
return t.getXBehindID(ctx, amount, behindID, attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 err := t.prepareBehind(ctx, behindID, amount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start serving from the entry right after the mark
|
|
||||||
var served int
|
|
||||||
serveloop:
|
|
||||||
for e := behindIDMark.Next(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBehindID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve up to the amount requested
|
|
||||||
items = append(items, entry.prepared)
|
|
||||||
served++
|
|
||||||
if served >= amount {
|
|
||||||
break serveloop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
items := make([]Preparable, 0, amount)
|
|
||||||
|
|
||||||
if t.preparedItems.data == nil {
|
|
||||||
t.preparedItems.data = &list.List{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// iterate through the modified list until we hit the mark we're looking for, or as close as possible to it
|
|
||||||
var beforeIDMark *list.Element
|
|
||||||
findMarkLoop:
|
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBeforeID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID >= beforeID {
|
|
||||||
beforeIDMark = e
|
|
||||||
} else {
|
|
||||||
break findMarkLoop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if beforeIDMark == nil {
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var served int
|
|
||||||
|
|
||||||
if startFromTop {
|
|
||||||
// start serving from the front/top and keep going until we hit mark or get x amount items
|
|
||||||
serveloopFromTop:
|
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBeforeID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID == beforeID {
|
|
||||||
break serveloopFromTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve up to the amount requested
|
|
||||||
items = append(items, entry.prepared)
|
|
||||||
served++
|
|
||||||
if served >= amount {
|
|
||||||
break serveloopFromTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !startFromTop {
|
|
||||||
// start serving from the entry right before the mark
|
|
||||||
serveloopFromBottom:
|
|
||||||
for e := beforeIDMark.Prev(); e != nil; e = e.Prev() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBeforeID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve up to the amount requested
|
|
||||||
items = append(items, entry.prepared)
|
|
||||||
served++
|
|
||||||
if served >= amount {
|
|
||||||
break serveloopFromBottom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
items := make([]Preparable, 0, amount)
|
|
||||||
|
|
||||||
if t.preparedItems.data == nil {
|
|
||||||
t.preparedItems.data = &list.List{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// iterate through the modified list until we hit the mark we're looking for
|
|
||||||
var position int
|
|
||||||
var behindIDMark *list.Element
|
|
||||||
findMarkLoop:
|
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
position++
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBetweenID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID == behindID {
|
|
||||||
behindIDMark = e
|
|
||||||
break findMarkLoop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// we didn't find it
|
|
||||||
if behindIDMark == nil {
|
|
||||||
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
|
|
||||||
if t.preparedItems.data.Len() < amount+position {
|
|
||||||
if err := t.prepareBehind(ctx, behindID, amount); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start serving from the entry right after the mark
|
|
||||||
var served int
|
|
||||||
serveloop:
|
|
||||||
for e := behindIDMark.Next(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("getXBetweenID: could not parse e as a preparedPostsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID == beforeID {
|
|
||||||
break serveloop
|
|
||||||
}
|
|
||||||
|
|
||||||
// serve up to the amount requested
|
|
||||||
items = append(items, entry.prepared)
|
|
||||||
served++
|
|
||||||
if served >= amount {
|
|
||||||
break serveloop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
"github.com/superseriousbusiness/gotosocial/internal/timeline"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||||
|
@ -43,8 +44,8 @@ func (suite *GetTestSuite) SetupSuite() {
|
||||||
func (suite *GetTestSuite) SetupTest() {
|
func (suite *GetTestSuite) SetupTest() {
|
||||||
suite.state.Caches.Init()
|
suite.state.Caches.Init()
|
||||||
|
|
||||||
testrig.InitTestLog()
|
|
||||||
testrig.InitTestConfig()
|
testrig.InitTestConfig()
|
||||||
|
testrig.InitTestLog()
|
||||||
|
|
||||||
suite.db = testrig.NewTestDB(&suite.state)
|
suite.db = testrig.NewTestDB(&suite.state)
|
||||||
suite.tc = testrig.NewTestTypeConverter(suite.db)
|
suite.tc = testrig.NewTestTypeConverter(suite.db)
|
||||||
|
@ -52,8 +53,9 @@ func (suite *GetTestSuite) SetupTest() {
|
||||||
|
|
||||||
testrig.StandardDBSetup(suite.db, nil)
|
testrig.StandardDBSetup(suite.db, nil)
|
||||||
|
|
||||||
// let's take local_account_1 as the timeline owner
|
// Take local_account_1 as the timeline owner, it
|
||||||
tl, err := timeline.NewTimeline(
|
// doesn't really matter too much for these tests.
|
||||||
|
tl := timeline.NewTimeline(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
suite.testAccounts["local_account_1"].ID,
|
suite.testAccounts["local_account_1"].ID,
|
||||||
processing.StatusGrabFunction(suite.db),
|
processing.StatusGrabFunction(suite.db),
|
||||||
|
@ -61,20 +63,27 @@ func (suite *GetTestSuite) SetupTest() {
|
||||||
processing.StatusPrepareFunction(suite.db, suite.tc),
|
processing.StatusPrepareFunction(suite.db, suite.tc),
|
||||||
processing.StatusSkipInsertFunction(),
|
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
|
// Put testrig statuses in a determinate order
|
||||||
|
// since we can't trust a map to keep order.
|
||||||
statuses := []*gtsmodel.Status{}
|
statuses := []*gtsmodel.Status{}
|
||||||
for _, s := range suite.testStatuses {
|
for _, s := range suite.testStatuses {
|
||||||
statuses = append(statuses, s)
|
statuses = append(statuses, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(statuses, func(i, j int) bool {
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
return statuses[i].ID > statuses[j].ID
|
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
|
// Statuses are now highest -> lowest.
|
||||||
|
suite.highestStatusID = statuses[0].ID
|
||||||
|
suite.lowestStatusID = statuses[len(statuses)-1].ID
|
||||||
|
if suite.highestStatusID < suite.lowestStatusID {
|
||||||
|
suite.FailNow("", "statuses weren't ordered properly by sort")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put all test statuses into the timeline; we don't
|
||||||
|
// need to be fussy about who sees what for these tests.
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
_, err := tl.IndexAndPrepareOne(context.Background(), s.GetID(), s.BoostOfID, s.AccountID, s.BoostOfAccountID)
|
_, err := tl.IndexAndPrepareOne(context.Background(), s.GetID(), s.BoostOfID, s.AccountID, s.BoostOfAccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -89,219 +98,292 @@ func (suite *GetTestSuite) TearDownTest() {
|
||||||
testrig.StandardDBTeardown(suite.db)
|
testrig.StandardDBTeardown(suite.db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetDefault() {
|
func (suite *GetTestSuite) checkStatuses(statuses []timeline.Preparable, maxID string, minID string, expectedLength int) {
|
||||||
// lastGot should be zero
|
if l := len(statuses); l != expectedLength {
|
||||||
suite.Zero(suite.timeline.LastGot())
|
suite.FailNow("", "expected %d statuses in slice, got %d", expectedLength, l)
|
||||||
|
} else if l == 0 {
|
||||||
// get 10 20 the top and don't prepare the next query
|
// Can't test empty slice.
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 20, "", "", "", false)
|
return
|
||||||
if err != nil {
|
|
||||||
suite.FailNow(err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we only have 16 statuses in the test suite
|
// Check ordering + bounds of statuses.
|
||||||
suite.Len(statuses, 17)
|
highest := statuses[0].GetID()
|
||||||
|
for _, status := range statuses {
|
||||||
|
id := status.GetID()
|
||||||
|
|
||||||
// statuses should be sorted highest to lowest ID
|
if id >= maxID {
|
||||||
var highest string
|
suite.FailNow("", "%s greater than maxID %s", id, maxID)
|
||||||
for i, s := range statuses {
|
|
||||||
if i == 0 {
|
|
||||||
highest = s.GetID()
|
|
||||||
} else {
|
|
||||||
suite.Less(s.GetID(), highest)
|
|
||||||
highest = s.GetID()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lastGot should be up to date
|
if id <= minID {
|
||||||
suite.WithinDuration(time.Now(), suite.timeline.LastGot(), 1*time.Second)
|
suite.FailNow("", "%s smaller than minID %s", id, minID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id > highest {
|
||||||
|
suite.FailNow("", "statuses in slice were not ordered highest -> lowest ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
highest = id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetDefaultPrepareNext() {
|
func (suite *GetTestSuite) TestGetNewTimelinePageDown() {
|
||||||
// get 10 from the top and prepare the next query
|
// Take a fresh timeline for this test.
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 10, "", "", "", true)
|
// This tests whether indexing works
|
||||||
|
// properly against uninitialized timelines.
|
||||||
|
tl := 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get 5 from the top.
|
||||||
|
statuses, err := tl.Get(context.Background(), 5, "", "", "", true)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, id.Highest, id.Lowest, 5)
|
||||||
|
|
||||||
|
// Get 5 from next maxID.
|
||||||
|
nextMaxID := statuses[len(statuses)-1].GetID()
|
||||||
|
statuses, err = tl.Get(context.Background(), 5, nextMaxID, "", "", false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, nextMaxID, id.Lowest, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetNewTimelinePageUp() {
|
||||||
|
// Take a fresh timeline for this test.
|
||||||
|
// This tests whether indexing works
|
||||||
|
// properly against uninitialized timelines.
|
||||||
|
tl := 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get 5 from the back.
|
||||||
|
statuses, err := tl.Get(context.Background(), 5, "", "", id.Lowest, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, id.Highest, id.Lowest, 5)
|
||||||
|
|
||||||
|
// Page upwards.
|
||||||
|
nextMinID := statuses[len(statuses)-1].GetID()
|
||||||
|
statuses, err = tl.Get(context.Background(), 5, "", "", nextMinID, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, id.Highest, nextMinID, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossible() {
|
||||||
|
// Take a fresh timeline for this test.
|
||||||
|
// This tests whether indexing works
|
||||||
|
// properly against uninitialized timelines.
|
||||||
|
tl := 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get 100 from the top.
|
||||||
|
statuses, err := tl.Get(context.Background(), 100, id.Highest, "", "", false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, id.Highest, id.Lowest, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossiblePageUp() {
|
||||||
|
// Take a fresh timeline for this test.
|
||||||
|
// This tests whether indexing works
|
||||||
|
// properly against uninitialized timelines.
|
||||||
|
tl := 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get 100 from the back.
|
||||||
|
statuses, err := tl.Get(context.Background(), 100, "", "", id.Lowest, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
suite.checkStatuses(statuses, id.Highest, id.Lowest, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetNoParams() {
|
||||||
|
// Get 10 statuses from the top (no params).
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 10, "", "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
suite.Len(statuses, 10)
|
suite.checkStatuses(statuses, id.Highest, id.Lowest, 10)
|
||||||
|
|
||||||
// statuses should be sorted highest to lowest ID
|
// First status should have the highest ID in the testrig.
|
||||||
var highest string
|
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||||
for i, s := range statuses {
|
|
||||||
if i == 0 {
|
|
||||||
highest = s.GetID()
|
|
||||||
} else {
|
|
||||||
suite.Less(s.GetID(), highest)
|
|
||||||
highest = s.GetID()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sleep a second so the next query can run
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetMaxID() {
|
func (suite *GetTestSuite) TestGetMaxID() {
|
||||||
// ask for 10 with a max ID somewhere in the middle of the stack
|
// Ask for 10 with a max ID somewhere in the middle of the stack.
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 10, "01F8MHBQCBTDKN6X5VHGMMN4MA", "", "", false)
|
maxID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
|
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 10, maxID, "", "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should only get 6 statuses back, since we asked for a max ID that excludes some of our entries
|
// We'll only get 6 statuses back.
|
||||||
suite.Len(statuses, 6)
|
suite.checkStatuses(statuses, maxID, id.Lowest, 6)
|
||||||
|
|
||||||
// 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) TestGetMaxIDPrepareNext() {
|
|
||||||
// ask for 10 with a max ID somewhere in the middle of the stack
|
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 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.GetID()
|
|
||||||
} else {
|
|
||||||
suite.Less(s.GetID(), highest)
|
|
||||||
highest = s.GetID()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sleep a second so the next query can run
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetMinID() {
|
|
||||||
// ask for 15 with a min ID somewhere in the middle of the stack
|
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 10, "", "01F8MHBQCBTDKN6X5VHGMMN4MA", "", false)
|
|
||||||
if err != nil {
|
|
||||||
suite.FailNow(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// we should only get 10 statuses back, since we asked for a min ID that excludes some of our entries
|
|
||||||
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.GetID()
|
|
||||||
} else {
|
|
||||||
suite.Less(s.GetID(), highest)
|
|
||||||
highest = s.GetID()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetSinceID() {
|
func (suite *GetTestSuite) TestGetSinceID() {
|
||||||
// ask for 15 with a since ID somewhere in the middle of the stack
|
// Ask for 10 with a since ID somewhere in the middle of the stack.
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 15, "", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", false)
|
sinceID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 10, "", sinceID, "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should only get 10 statuses back, since we asked for a since ID that excludes some of our entries
|
suite.checkStatuses(statuses, id.Highest, sinceID, 10)
|
||||||
suite.Len(statuses, 10)
|
|
||||||
|
|
||||||
// statuses should be sorted highest to lowest ID
|
// The first status in the stack should have the highest ID of all
|
||||||
var highest string
|
// in the testrig, because we're paging down.
|
||||||
for i, s := range statuses {
|
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||||
if i == 0 {
|
|
||||||
highest = s.GetID()
|
|
||||||
} else {
|
|
||||||
suite.Less(s.GetID(), highest)
|
|
||||||
highest = s.GetID()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetSinceIDPrepareNext() {
|
func (suite *GetTestSuite) TestGetSinceIDOneOnly() {
|
||||||
// ask for 15 with a since ID somewhere in the middle of the stack
|
// Ask for 1 with a since ID somewhere in the middle of the stack.
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 15, "", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", true)
|
sinceID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 1, "", sinceID, "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should only get 10 statuses back, since we asked for a since ID that excludes some of our entries
|
suite.checkStatuses(statuses, id.Highest, sinceID, 1)
|
||||||
suite.Len(statuses, 10)
|
|
||||||
|
|
||||||
// statuses should be sorted highest to lowest ID
|
// The one status we got back should have the highest ID of all in
|
||||||
var highest string
|
// the testrig, because using sinceID means we're paging down.
|
||||||
for i, s := range statuses {
|
suite.Equal(suite.highestStatusID, statuses[0].GetID())
|
||||||
if i == 0 {
|
}
|
||||||
highest = s.GetID()
|
|
||||||
} else {
|
func (suite *GetTestSuite) TestGetMinID() {
|
||||||
suite.Less(s.GetID(), highest)
|
// Ask for 5 with a min ID somewhere in the middle of the stack.
|
||||||
highest = s.GetID()
|
minID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
}
|
statuses, err := suite.timeline.Get(context.Background(), 5, "", "", minID, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// sleep a second so the next query can run
|
suite.checkStatuses(statuses, id.Highest, minID, 5)
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
|
// We're paging up so even the highest status ID in the pile
|
||||||
|
// shouldn't be the highest ID we have.
|
||||||
|
suite.NotEqual(suite.highestStatusID, statuses[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetMinIDOneOnly() {
|
||||||
|
// Ask for 1 with a min ID somewhere in the middle of the stack.
|
||||||
|
minID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 1, "", "", minID, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||||
|
|
||||||
|
// The one status we got back should have the an ID equal to the
|
||||||
|
// one ID immediately newer than it.
|
||||||
|
suite.Equal("01F8MHC0H0A7XHTVH5F596ZKBM", statuses[0].GetID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetMinIDFromLowestInTestrig() {
|
||||||
|
// Ask for 1 with minID equal to the lowest status in the testrig.
|
||||||
|
minID := suite.lowestStatusID
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 1, "", "", minID, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||||
|
|
||||||
|
// The one status we got back should have an id higher than
|
||||||
|
// the lowest status in the testrig, since minID is not inclusive.
|
||||||
|
suite.Greater(statuses[0].GetID(), suite.lowestStatusID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *GetTestSuite) TestGetMinIDFromLowestPossible() {
|
||||||
|
// Ask for 1 with the lowest possible min ID.
|
||||||
|
minID := id.Lowest
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 1, "", "", minID, false)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.checkStatuses(statuses, id.Highest, minID, 1)
|
||||||
|
|
||||||
|
// The one status we got back should have the an ID equal to the
|
||||||
|
// lowest ID status in the test rig.
|
||||||
|
suite.Equal(suite.lowestStatusID, statuses[0].GetID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *GetTestSuite) TestGetBetweenID() {
|
func (suite *GetTestSuite) TestGetBetweenID() {
|
||||||
// ask for 10 between these two IDs
|
// Ask for 10 between these two IDs
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 10, "01F8MHCP5P2NWYQ416SBA0XSEV", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", false)
|
maxID := "01F8MHCP5P2NWYQ416SBA0XSEV"
|
||||||
|
minID := "01F8MHBQCBTDKN6X5VHGMMN4MA"
|
||||||
|
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 10, maxID, "", minID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should only get 2 statuses back, since there are only two statuses between the given IDs
|
// There's only two statuses between these two IDs.
|
||||||
suite.Len(statuses, 2)
|
suite.checkStatuses(statuses, maxID, minID, 2)
|
||||||
|
|
||||||
// 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) TestGetBetweenIDPrepareNext() {
|
func (suite *GetTestSuite) TestGetBetweenIDImpossible() {
|
||||||
// ask for 10 between these two IDs
|
// Ask for 10 between these two IDs which present
|
||||||
statuses, err := suite.timeline.Get(context.Background(), 10, "01F8MHCP5P2NWYQ416SBA0XSEV", "", "01F8MHBQCBTDKN6X5VHGMMN4MA", true)
|
// an impossible query.
|
||||||
|
maxID := id.Lowest
|
||||||
|
minID := id.Highest
|
||||||
|
|
||||||
|
statuses, err := suite.timeline.Get(context.Background(), 10, maxID, "", minID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should only get 2 statuses back, since there are only two statuses between the given IDs
|
// We should have nothing back.
|
||||||
suite.Len(statuses, 2)
|
suite.checkStatuses(statuses, maxID, minID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// statuses should be sorted highest to lowest ID
|
func (suite *GetTestSuite) TestLastGot() {
|
||||||
var highest string
|
// LastGot should be zero
|
||||||
for i, s := range statuses {
|
suite.Zero(suite.timeline.LastGot())
|
||||||
if i == 0 {
|
|
||||||
highest = s.GetID()
|
// Get some from the top
|
||||||
} else {
|
_, err := suite.timeline.Get(context.Background(), 10, "", "", "", false)
|
||||||
suite.Less(s.GetID(), highest)
|
if err != nil {
|
||||||
highest = s.GetID()
|
suite.FailNow(err.Error())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sleep a second so the next query can run
|
// LastGot should be updated
|
||||||
time.Sleep(1 * time.Second)
|
suite.WithinDuration(time.Now(), suite.timeline.LastGot(), 1*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetTestSuite(t *testing.T) {
|
func TestGetTestSuite(t *testing.T) {
|
||||||
|
|
|
@ -24,103 +24,205 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"codeberg.org/gruf/go-kv"
|
"codeberg.org/gruf/go-kv"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t *timeline) ItemIndexLength(ctx context.Context) int {
|
func (t *timeline) indexXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) error {
|
||||||
if t.indexedItems == nil || t.indexedItems.data == nil {
|
l := log.
|
||||||
return 0
|
WithContext(ctx).
|
||||||
}
|
WithFields(kv.Fields{
|
||||||
return t.indexedItems.data.Len()
|
{"amount", amount},
|
||||||
}
|
{"behindID", behindID},
|
||||||
|
{"beforeID", beforeID},
|
||||||
|
{"frontToBack", frontToBack},
|
||||||
|
}...)
|
||||||
|
l.Trace("entering indexXBetweenIDs")
|
||||||
|
|
||||||
func (t *timeline) indexBehind(ctx context.Context, itemID string, amount int) error {
|
if beforeID >= behindID {
|
||||||
l := log.WithContext(ctx).
|
// This is an impossible situation, we
|
||||||
WithFields(kv.Fields{{"amount", amount}}...)
|
// can't index anything between these.
|
||||||
|
|
||||||
// lazily initialize index if it hasn't been done already
|
|
||||||
if t.indexedItems.data == nil {
|
|
||||||
t.indexedItems.data = &list.List{}
|
|
||||||
t.indexedItems.data.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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).
|
|
||||||
var position int
|
|
||||||
positionLoop:
|
|
||||||
for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("indexBehind: could not parse e as an itemIndexEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.itemID <= itemID {
|
|
||||||
// we've found it
|
|
||||||
break positionLoop
|
|
||||||
}
|
|
||||||
position++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.indexedItems.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 items indexed")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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) IndexOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) {
|
|
||||||
t.Lock()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
|
|
||||||
postIndexEntry := &indexedItemsEntry{
|
// Lazily init indexed items.
|
||||||
itemID: itemID,
|
if t.items.data == nil {
|
||||||
boostOfID: boostOfID,
|
t.items.data = &list.List{}
|
||||||
accountID: accountID,
|
t.items.data.Init()
|
||||||
boostOfAccountID: boostOfAccountID,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.indexedItems.insertIndexed(ctx, postIndexEntry)
|
// Start by mapping out the list so we know what
|
||||||
|
// we have to do. Depending on the current state
|
||||||
|
// of the list we might not have to do *anything*.
|
||||||
|
var (
|
||||||
|
position int
|
||||||
|
listLen = t.items.data.Len()
|
||||||
|
behindIDPosition int
|
||||||
|
beforeIDPosition int
|
||||||
|
)
|
||||||
|
|
||||||
|
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||||
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
|
||||||
|
position++
|
||||||
|
|
||||||
|
if entry.itemID > behindID {
|
||||||
|
l.Trace("item is too new, continuing")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if behindIDPosition == 0 {
|
||||||
|
// Gone far enough through the list
|
||||||
|
// and found our behindID mark.
|
||||||
|
// We only need to set this once.
|
||||||
|
l.Tracef("found behindID mark %s at position %d", entry.itemID, position)
|
||||||
|
behindIDPosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.itemID >= beforeID {
|
||||||
|
// Push the beforeID mark back
|
||||||
|
// one place every iteration.
|
||||||
|
l.Tracef("setting beforeID mark %s at position %d", entry.itemID, position)
|
||||||
|
beforeIDPosition = position
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.itemID <= beforeID {
|
||||||
|
// We've gone beyond the bounds of
|
||||||
|
// items we're interested in; stop.
|
||||||
|
l.Trace("reached older items, breaking")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can now figure out if we need to make db calls.
|
||||||
|
var grabMore bool
|
||||||
|
switch {
|
||||||
|
case listLen < amount:
|
||||||
|
// The whole list is shorter than the
|
||||||
|
// amount we're being asked to return,
|
||||||
|
// make up the difference.
|
||||||
|
grabMore = true
|
||||||
|
amount -= listLen
|
||||||
|
case beforeIDPosition-behindIDPosition < amount:
|
||||||
|
// Not enough items between behindID and
|
||||||
|
// beforeID to return amount required,
|
||||||
|
// try to get more.
|
||||||
|
grabMore = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !grabMore {
|
||||||
|
// We're good!
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch additional items.
|
||||||
|
items, err := t.grab(ctx, amount, behindID, beforeID, frontToBack)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index all the items we got. We already have
|
||||||
|
// a lock on the timeline, so don't call IndexOne
|
||||||
|
// here, since that will also try to get a lock!
|
||||||
|
for _, item := range items {
|
||||||
|
entry := &indexedItemsEntry{
|
||||||
|
itemID: item.GetID(),
|
||||||
|
boostOfID: item.GetBoostOfID(),
|
||||||
|
accountID: item.GetAccountID(),
|
||||||
|
boostOfAccountID: item.GetBoostOfAccountID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := t.items.insertIndexed(ctx, entry); err != nil {
|
||||||
|
return fmt.Errorf("error inserting entry with itemID %s into index: %w", entry.itemID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grab wraps the timeline's grabFunction in paging + filtering logic.
|
||||||
|
func (t *timeline) grab(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) ([]Timelineable, error) {
|
||||||
|
var (
|
||||||
|
sinceID string
|
||||||
|
minID string
|
||||||
|
grabbed int
|
||||||
|
maxID = behindID
|
||||||
|
filtered = make([]Timelineable, 0, amount)
|
||||||
|
)
|
||||||
|
|
||||||
|
if frontToBack {
|
||||||
|
sinceID = beforeID
|
||||||
|
} else {
|
||||||
|
minID = beforeID
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempts := 0; attempts < 5; attempts++ {
|
||||||
|
if grabbed >= amount {
|
||||||
|
// We got everything we needed.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
items, stop, err := t.grabFunction(
|
||||||
|
ctx,
|
||||||
|
t.accountID,
|
||||||
|
maxID,
|
||||||
|
sinceID,
|
||||||
|
minID,
|
||||||
|
// Don't grab more than we need to.
|
||||||
|
amount-grabbed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Grab function already checks for
|
||||||
|
// db.ErrNoEntries, so if an error
|
||||||
|
// is returned then it's a real one.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stop || len(items) == 0 {
|
||||||
|
// No items left.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set next query parameters.
|
||||||
|
if frontToBack {
|
||||||
|
// Page down.
|
||||||
|
maxID = items[len(items)-1].GetID()
|
||||||
|
if maxID <= beforeID {
|
||||||
|
// Can't go any further.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Page up.
|
||||||
|
minID = items[0].GetID()
|
||||||
|
if minID >= behindID {
|
||||||
|
// Can't go any further.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
ok, err := t.filterFunction(ctx, t.accountID, item)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
// Real error here.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Warnf(ctx, "errNoEntries while filtering item %s: %s", item.GetID(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
grabbed++ // count this as grabbed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
@ -134,46 +236,49 @@ func (t *timeline) IndexAndPrepareOne(ctx context.Context, statusID string, boos
|
||||||
boostOfAccountID: boostOfAccountID,
|
boostOfAccountID: boostOfAccountID,
|
||||||
}
|
}
|
||||||
|
|
||||||
inserted, err := t.indexedItems.insertIndexed(ctx, postIndexEntry)
|
if inserted, err := t.items.insertIndexed(ctx, postIndexEntry); err != nil {
|
||||||
|
return false, fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %w", err)
|
||||||
|
} else if !inserted {
|
||||||
|
// Entry wasn't inserted, so
|
||||||
|
// don't bother preparing it.
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
preparable, err := t.prepareFunction(ctx, t.accountID, statusID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return inserted, fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err)
|
return true, fmt.Errorf("IndexAndPrepareOne: error preparing: %w", err)
|
||||||
}
|
}
|
||||||
|
postIndexEntry.prepared = preparable
|
||||||
|
|
||||||
if inserted {
|
return true, nil
|
||||||
if err := t.prepare(ctx, statusID); err != nil {
|
|
||||||
return inserted, fmt.Errorf("IndexAndPrepareOne: error preparing: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inserted, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeline) OldestIndexedItemID(ctx context.Context) (string, error) {
|
func (t *timeline) Len() int {
|
||||||
var id string
|
t.Lock()
|
||||||
if t.indexedItems == nil || t.indexedItems.data == nil || t.indexedItems.data.Back() == nil {
|
defer t.Unlock()
|
||||||
// return an empty string if postindex hasn't been initialized yet
|
|
||||||
return id, nil
|
if t.items == nil || t.items.data == nil {
|
||||||
|
// indexedItems hasnt been initialized yet.
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
e := t.indexedItems.data.Back()
|
return t.items.data.Len()
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return id, errors.New("OldestIndexedItemID: could not parse e as itemIndexEntry")
|
|
||||||
}
|
|
||||||
return entry.itemID, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeline) NewestIndexedItemID(ctx context.Context) (string, error) {
|
func (t *timeline) OldestIndexedItemID() string {
|
||||||
var id string
|
t.Lock()
|
||||||
if t.indexedItems == nil || t.indexedItems.data == nil || t.indexedItems.data.Front() == nil {
|
defer t.Unlock()
|
||||||
// return an empty string if postindex hasn't been initialized yet
|
|
||||||
return id, nil
|
if t.items == nil || t.items.data == nil {
|
||||||
|
// indexedItems hasnt been initialized yet.
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
e := t.indexedItems.data.Front()
|
e := t.items.data.Back()
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
if e == nil {
|
||||||
if !ok {
|
// List was empty.
|
||||||
return id, errors.New("NewestIndexedItemID: could not parse e as itemIndexEntry")
|
return ""
|
||||||
}
|
}
|
||||||
return entry.itemID, nil
|
|
||||||
|
return e.Value.(*indexedItemsEntry).itemID //nolint:forcetypeassert
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ func (suite *IndexTestSuite) SetupTest() {
|
||||||
testrig.StandardDBSetup(suite.db, nil)
|
testrig.StandardDBSetup(suite.db, nil)
|
||||||
|
|
||||||
// let's take local_account_1 as the timeline owner, and start with an empty timeline
|
// let's take local_account_1 as the timeline owner, and start with an empty timeline
|
||||||
tl, err := timeline.NewTimeline(
|
suite.timeline = timeline.NewTimeline(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
suite.testAccounts["local_account_1"].ID,
|
suite.testAccounts["local_account_1"].ID,
|
||||||
processing.StatusGrabFunction(suite.db),
|
processing.StatusGrabFunction(suite.db),
|
||||||
|
@ -60,10 +60,6 @@ func (suite *IndexTestSuite) SetupTest() {
|
||||||
processing.StatusPrepareFunction(suite.db, suite.tc),
|
processing.StatusPrepareFunction(suite.db, suite.tc),
|
||||||
processing.StatusSkipInsertFunction(),
|
processing.StatusSkipInsertFunction(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
suite.FailNow(err.Error())
|
|
||||||
}
|
|
||||||
suite.timeline = tl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *IndexTestSuite) TearDownTest() {
|
func (suite *IndexTestSuite) TearDownTest() {
|
||||||
|
@ -72,12 +68,11 @@ func (suite *IndexTestSuite) TearDownTest() {
|
||||||
|
|
||||||
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 := suite.timeline.OldestIndexedItemID()
|
||||||
suite.NoError(err)
|
|
||||||
suite.Empty(postID)
|
suite.Empty(postID)
|
||||||
|
|
||||||
// indexLength should be 0
|
// indexLength should be 0
|
||||||
indexLength := suite.timeline.ItemIndexLength(context.Background())
|
indexLength := suite.timeline.Len()
|
||||||
suite.Equal(0, indexLength)
|
suite.Equal(0, indexLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,12 +80,12 @@ func (suite *IndexTestSuite) TestIndexAlreadyIndexed() {
|
||||||
testStatus := suite.testStatuses["local_account_1_status_1"]
|
testStatus := suite.testStatuses["local_account_1_status_1"]
|
||||||
|
|
||||||
// index one post -- it should be indexed
|
// index one post -- it should be indexed
|
||||||
indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
indexed, err := suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.True(indexed)
|
suite.True(indexed)
|
||||||
|
|
||||||
// try to index the same post again -- it should not be indexed
|
// try to index the same post again -- it should not be indexed
|
||||||
indexed, err = suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
indexed, err = suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.False(indexed)
|
suite.False(indexed)
|
||||||
}
|
}
|
||||||
|
@ -120,12 +115,12 @@ func (suite *IndexTestSuite) TestIndexBoostOfAlreadyIndexed() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// index one post -- it should be indexed
|
// index one post -- it should be indexed
|
||||||
indexed, err := suite.timeline.IndexOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
indexed, err := suite.timeline.IndexAndPrepareOne(context.Background(), testStatus.ID, testStatus.BoostOfID, testStatus.AccountID, testStatus.BoostOfAccountID)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.True(indexed)
|
suite.True(indexed)
|
||||||
|
|
||||||
// try to index the a boost of that post -- it should not be indexed
|
// try to index the a boost of that post -- it should not be indexed
|
||||||
indexed, err = suite.timeline.IndexOne(context.Background(), boostOfTestStatus.ID, boostOfTestStatus.BoostOfID, boostOfTestStatus.AccountID, boostOfTestStatus.BoostOfAccountID)
|
indexed, err = suite.timeline.IndexAndPrepareOne(context.Background(), boostOfTestStatus.ID, boostOfTestStatus.BoostOfID, boostOfTestStatus.AccountID, boostOfTestStatus.BoostOfAccountID)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.False(indexed)
|
suite.False(indexed)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ package timeline
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type indexedItems struct {
|
type indexedItems struct {
|
||||||
|
@ -33,53 +33,87 @@ type indexedItemsEntry struct {
|
||||||
boostOfID string
|
boostOfID string
|
||||||
accountID string
|
accountID string
|
||||||
boostOfAccountID string
|
boostOfAccountID string
|
||||||
|
prepared Preparable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WARNING: ONLY CALL THIS FUNCTION IF YOU ALREADY HAVE
|
||||||
|
// A LOCK ON THE TIMELINE CONTAINING THIS INDEXEDITEMS!
|
||||||
func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItemsEntry) (bool, error) {
|
func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItemsEntry) (bool, error) {
|
||||||
|
// Lazily init indexed items.
|
||||||
if i.data == nil {
|
if i.data == nil {
|
||||||
i.data = &list.List{}
|
i.data = &list.List{}
|
||||||
|
i.data.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we have no entries yet, this is both the newest and oldest entry, so just put it in the front
|
|
||||||
if i.data.Len() == 0 {
|
if i.data.Len() == 0 {
|
||||||
|
// We have no entries yet, meaning this is both the
|
||||||
|
// newest + oldest entry, so just put it in the front.
|
||||||
i.data.PushFront(newEntry)
|
i.data.PushFront(newEntry)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var insertMark *list.Element
|
var (
|
||||||
var position int
|
insertMark *list.Element
|
||||||
// We need to iterate through the index to make sure we put this item in the appropriate place according to when it was created.
|
currentPosition int
|
||||||
// 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 need to iterate through the index to make sure we put
|
||||||
|
// this item in the appropriate place according to its id.
|
||||||
|
// We also need to make sure we're not inserting a duplicate
|
||||||
|
// item -- this can happen sometimes and it's sucky UX.
|
||||||
for e := i.data.Front(); e != nil; e = e.Next() {
|
for e := i.data.Front(); e != nil; e = e.Next() {
|
||||||
position++
|
currentPosition++
|
||||||
|
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
currentEntry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
if !ok {
|
|
||||||
return false, errors.New("insertIndexed: could not parse e as an indexedItemsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
skip, err := i.skipInsert(ctx, newEntry.itemID, newEntry.accountID, newEntry.boostOfID, newEntry.boostOfAccountID, entry.itemID, entry.accountID, entry.boostOfID, entry.boostOfAccountID, position)
|
// Check if we need to skip inserting this item based on
|
||||||
if err != nil {
|
// the current item.
|
||||||
return false, err
|
//
|
||||||
}
|
// For example, if the new item is a boost, and the current
|
||||||
if skip {
|
// item is the original, we may not want to insert the boost
|
||||||
|
// if it would appear very shortly after the original.
|
||||||
|
if skip, err := i.skipInsert(
|
||||||
|
ctx,
|
||||||
|
newEntry.itemID,
|
||||||
|
newEntry.accountID,
|
||||||
|
newEntry.boostOfID,
|
||||||
|
newEntry.boostOfAccountID,
|
||||||
|
currentEntry.itemID,
|
||||||
|
currentEntry.accountID,
|
||||||
|
currentEntry.boostOfID,
|
||||||
|
currentEntry.boostOfAccountID,
|
||||||
|
currentPosition,
|
||||||
|
); err != nil {
|
||||||
|
return false, fmt.Errorf("insertIndexed: error calling skipInsert: %w", err)
|
||||||
|
} else if skip {
|
||||||
|
// We don't need to insert this at all,
|
||||||
|
// so we can safely bail.
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the item to index is newer than e, insert it before e in the list
|
if insertMark != nil {
|
||||||
if insertMark == nil {
|
// We already found our mark.
|
||||||
if newEntry.itemID > entry.itemID {
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentEntry.itemID > newEntry.itemID {
|
||||||
|
// We're still in items newer than
|
||||||
|
// the one we're trying to insert.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found our spot!
|
||||||
insertMark = e
|
insertMark = e
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if insertMark != nil {
|
if insertMark == nil {
|
||||||
i.data.InsertBefore(newEntry, insertMark)
|
// We looked through the whole timeline and didn't find
|
||||||
return true, nil
|
// a mark, so the new item is the oldest item we've seen;
|
||||||
}
|
// insert it at the back.
|
||||||
|
|
||||||
// if we reach this point it's the oldest item we've seen so put it at the back
|
|
||||||
i.data.PushBack(newEntry)
|
i.data.PushBack(newEntry)
|
||||||
return true, nil
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
i.data.InsertBefore(newEntry, insertMark)
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,14 +20,18 @@ package timeline
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/gruf/go-kv"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pruneLengthIndexed = 400
|
||||||
|
pruneLengthPrepared = 50
|
||||||
|
)
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -41,38 +45,37 @@ import (
|
||||||
// Prepared items consist of the item's database ID, the time it was created, AND the apimodel representation of that item, for quick serialization.
|
// Prepared items consist of the item's database ID, the time it was created, AND the apimodel representation of that item, for quick serialization.
|
||||||
// Prepared items of course take up more memory than indexed items, so they should be regularly pruned if they're not being actively served.
|
// Prepared items of course take up more memory than indexed items, so they should be regularly pruned if they're not being actively served.
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// Ingest takes one item and indexes it into the timeline for the given account ID.
|
// IngestOne takes one timelineable and indexes it into the timeline for the given account ID, and then immediately prepares it for serving.
|
||||||
//
|
|
||||||
// It should already be established before calling this function that the item actually belongs in the timeline!
|
|
||||||
//
|
|
||||||
// The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
|
|
||||||
// the item is a boosted status, but a boost of the original status or the status itself already exists recently in the timeline.
|
|
||||||
Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error)
|
|
||||||
// IngestAndPrepare takes one timelineable and indexes it into the timeline for the given account ID, and then immediately prepares it for serving.
|
|
||||||
// This is useful in cases where we know the item will need to be shown at the top of a user's timeline immediately (eg., a new status is created).
|
// This is useful in cases where we know the item will need to be shown at the top of a user's timeline immediately (eg., a new status is created).
|
||||||
//
|
//
|
||||||
// It should already be established before calling this function that the item actually belongs in the timeline!
|
// It should already be established before calling this function that the item actually belongs in the timeline!
|
||||||
//
|
//
|
||||||
// The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
|
// The returned bool indicates whether the item was actually put in the timeline. This could be false in cases where
|
||||||
// a status is a boost, but a boost of the original status or the status itself already exists recently in the timeline.
|
// a status is a boost, but a boost of the original status or the status itself already exists recently in the timeline.
|
||||||
IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error)
|
IngestOne(ctx context.Context, accountID string, item Timelineable) (bool, error)
|
||||||
|
|
||||||
// GetTimeline returns limit n amount of prepared entries from the timeline of the given account ID, in descending chronological order.
|
// GetTimeline returns limit n amount of prepared entries from the timeline of the given account ID, in descending chronological order.
|
||||||
// If maxID is provided, it will return prepared entries from that maxID onwards, inclusive.
|
|
||||||
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(ctx context.Context, timelineAccountID string) int
|
// GetIndexedLength returns the amount of items that have been indexed for the given account ID.
|
||||||
|
GetIndexedLength(ctx context.Context, accountID string) 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)
|
// Will be an empty string if nothing is (yet) indexed.
|
||||||
// PrepareXFromTop prepares limit n amount of items, based on their indexed representations, from the top of the index.
|
GetOldestIndexedID(ctx context.Context, accountID string) string
|
||||||
PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error
|
|
||||||
// Remove removes one item from the timeline of the given timelineAccountID
|
// Remove removes one item from the timeline of the given timelineAccountID
|
||||||
Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error)
|
Remove(ctx context.Context, accountID string, itemID string) (int, error)
|
||||||
|
|
||||||
// WipeItemFromAllTimelines removes one item from the index and prepared items of all timelines
|
// WipeItemFromAllTimelines removes one item from the index and prepared items of all timelines
|
||||||
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 starts hourly cleanup jobs for this timeline manager.
|
||||||
Start() error
|
Start() error
|
||||||
|
|
||||||
// Stop stops the timeline manager (currently a stub, doesn't do anything).
|
// Stop stops the timeline manager (currently a stub, doesn't do anything).
|
||||||
Stop() error
|
Stop() error
|
||||||
}
|
}
|
||||||
|
@ -97,31 +100,41 @@ type manager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) Start() error {
|
func (m *manager) Start() error {
|
||||||
// range through all timelines in the sync map once per hour + prune as necessary
|
// Start a background goroutine which iterates
|
||||||
|
// through all stored timelines once per hour,
|
||||||
|
// and cleans up old entries if that timeline
|
||||||
|
// hasn't been accessed in the last hour.
|
||||||
go func() {
|
go func() {
|
||||||
for now := range time.NewTicker(1 * time.Hour).C {
|
for now := range time.NewTicker(1 * time.Hour).C {
|
||||||
m.accountTimelines.Range(func(key any, value any) bool {
|
// Define the range function inside here,
|
||||||
timelineAccountID, ok := key.(string)
|
// so that we can use the 'now' returned
|
||||||
|
// by the ticker, instead of having to call
|
||||||
|
// time.Now() multiple times.
|
||||||
|
//
|
||||||
|
// Unless it panics, this function always
|
||||||
|
// returns 'true', to continue the Range
|
||||||
|
// call through the sync.Map.
|
||||||
|
f := func(_ any, v any) bool {
|
||||||
|
timeline, ok := v.(Timeline)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic("couldn't parse timeline manager sync map key as string, this should never happen so panic")
|
log.Panic(nil, "couldn't parse timeline manager sync map value as Timeline, this should never happen so panic")
|
||||||
}
|
}
|
||||||
|
|
||||||
t, ok := value.(Timeline)
|
if now.Sub(timeline.LastGot()) < 1*time.Hour {
|
||||||
if !ok {
|
// Timeline has been fetched in the
|
||||||
panic("couldn't parse timeline manager sync map value as Timeline, this should never happen so panic")
|
// last hour, move on to the next one.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
anHourAgo := now.Add(-1 * time.Hour)
|
if amountPruned := timeline.Prune(pruneLengthPrepared, pruneLengthIndexed); amountPruned > 0 {
|
||||||
if lastGot := t.LastGot(); lastGot.Before(anHourAgo) {
|
log.WithField("accountID", timeline.AccountID()).Infof("pruned %d indexed and prepared items from timeline", amountPruned)
|
||||||
amountPruned := t.Prune(defaultDesiredPreparedItemsLength, defaultDesiredIndexedItemsLength)
|
|
||||||
log.WithFields(kv.Fields{
|
|
||||||
{"timelineAccountID", timelineAccountID},
|
|
||||||
{"amountPruned", amountPruned},
|
|
||||||
}...).Info("pruned indexed and prepared items from timeline")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Execute the function for each timeline.
|
||||||
|
m.accountTimelines.Range(f)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -132,146 +145,69 @@ func (m *manager) Stop() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) Ingest(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
|
func (m *manager) IngestOne(ctx context.Context, accountID string, item Timelineable) (bool, error) {
|
||||||
l := log.WithContext(ctx).
|
return m.getOrCreateTimeline(ctx, accountID).IndexAndPrepareOne(
|
||||||
WithFields(kv.Fields{
|
ctx,
|
||||||
{"timelineAccountID", timelineAccountID},
|
item.GetID(),
|
||||||
{"itemID", item.GetID()},
|
item.GetBoostOfID(),
|
||||||
}...)
|
item.GetAccountID(),
|
||||||
|
item.GetBoostOfAccountID(),
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
)
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Trace("ingesting item")
|
|
||||||
return t.IndexOne(ctx, item.GetID(), item.GetBoostOfID(), item.GetAccountID(), item.GetBoostOfAccountID())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) IngestAndPrepare(ctx context.Context, item Timelineable, timelineAccountID string) (bool, error) {
|
func (m *manager) Remove(ctx context.Context, accountID string, itemID string) (int, error) {
|
||||||
l := log.WithContext(ctx).
|
return m.getOrCreateTimeline(ctx, accountID).Remove(ctx, itemID)
|
||||||
WithFields(kv.Fields{
|
|
||||||
{"timelineAccountID", timelineAccountID},
|
|
||||||
{"itemID", item.GetID()},
|
|
||||||
}...)
|
|
||||||
|
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Trace("ingesting item")
|
|
||||||
return t.IndexAndPrepareOne(ctx, item.GetID(), item.GetBoostOfID(), item.GetAccountID(), item.GetBoostOfAccountID())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) Remove(ctx context.Context, timelineAccountID string, itemID string) (int, error) {
|
func (m *manager) GetTimeline(ctx context.Context, accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) {
|
||||||
l := log.WithContext(ctx).
|
return m.getOrCreateTimeline(ctx, accountID).Get(ctx, limit, maxID, sinceID, minID, true)
|
||||||
WithFields(kv.Fields{
|
|
||||||
{"timelineAccountID", timelineAccountID},
|
|
||||||
{"itemID", itemID},
|
|
||||||
}...)
|
|
||||||
|
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Trace("removing item")
|
|
||||||
return t.Remove(ctx, itemID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) GetTimeline(ctx context.Context, timelineAccountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]Preparable, error) {
|
func (m *manager) GetIndexedLength(ctx context.Context, accountID string) int {
|
||||||
l := log.WithContext(ctx).
|
return m.getOrCreateTimeline(ctx, accountID).Len()
|
||||||
WithFields(kv.Fields{{"timelineAccountID", timelineAccountID}}...)
|
|
||||||
|
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
items, err := t.Get(ctx, limit, maxID, sinceID, minID, true)
|
|
||||||
if err != nil {
|
|
||||||
l.Errorf("error getting statuses: %s", err)
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) GetIndexedLength(ctx context.Context, timelineAccountID string) int {
|
func (m *manager) GetOldestIndexedID(ctx context.Context, accountID string) string {
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
return m.getOrCreateTimeline(ctx, accountID).OldestIndexedItemID()
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.ItemIndexLength(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *manager) GetOldestIndexedID(ctx context.Context, timelineAccountID string) (string, error) {
|
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.OldestIndexedItemID(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *manager) PrepareXFromTop(ctx context.Context, timelineAccountID string, limit int) error {
|
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.PrepareFromTop(ctx, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) WipeItemFromAllTimelines(ctx context.Context, statusID string) error {
|
func (m *manager) WipeItemFromAllTimelines(ctx context.Context, statusID string) error {
|
||||||
errors := []string{}
|
errors := gtserror.MultiError{}
|
||||||
m.accountTimelines.Range(func(k interface{}, i interface{}) bool {
|
|
||||||
t, ok := i.(Timeline)
|
m.accountTimelines.Range(func(_ any, v any) bool {
|
||||||
if !ok {
|
if _, err := v.(Timeline).Remove(ctx, statusID); err != nil {
|
||||||
panic("couldn't parse entry as Timeline, this should never happen so panic")
|
errors.Append(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := t.Remove(ctx, statusID); err != nil {
|
return true // always continue range
|
||||||
errors = append(errors, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var err error
|
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
err = fmt.Errorf("one or more errors removing status %s from all timelines: %s", statusID, strings.Join(errors, ";"))
|
return fmt.Errorf("WipeItemFromAllTimelines: one or more errors wiping status %s: %w", statusID, errors.Combine())
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error {
|
func (m *manager) WipeItemsFromAccountID(ctx context.Context, timelineAccountID string, accountID string) error {
|
||||||
t, err := m.getOrCreateTimeline(ctx, timelineAccountID)
|
_, err := m.getOrCreateTimeline(ctx, timelineAccountID).RemoveAllByOrBoosting(ctx, accountID)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = t.RemoveAllBy(ctx, accountID)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) getOrCreateTimeline(ctx context.Context, timelineAccountID string) (Timeline, error) {
|
// getOrCreateTimeline returns a timeline for the given
|
||||||
var t Timeline
|
// accountID. If a timeline does not yet exist in the
|
||||||
i, ok := m.accountTimelines.Load(timelineAccountID)
|
// manager's sync.Map, it will be created and stored.
|
||||||
if !ok {
|
func (m *manager) getOrCreateTimeline(ctx context.Context, accountID string) Timeline {
|
||||||
var err error
|
i, ok := m.accountTimelines.Load(accountID)
|
||||||
t, err = NewTimeline(ctx, timelineAccountID, m.grabFunction, m.filterFunction, m.prepareFunction, m.skipInsertFunction)
|
if ok {
|
||||||
if err != nil {
|
// Timeline already existed in sync.Map.
|
||||||
return nil, err
|
return i.(Timeline) //nolint:forcetypeassert
|
||||||
}
|
|
||||||
m.accountTimelines.Store(timelineAccountID, t)
|
|
||||||
} else {
|
|
||||||
t, ok = i.(Timeline)
|
|
||||||
if !ok {
|
|
||||||
panic("couldn't parse entry as Timeline, this should never happen so panic")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return t, nil
|
// Timeline did not yet exist in sync.Map.
|
||||||
|
// Create + store it.
|
||||||
|
timeline := NewTimeline(ctx, accountID, m.grabFunction, m.filterFunction, m.prepareFunction, m.skipInsertFunction)
|
||||||
|
m.accountTimelines.Store(accountID, timeline)
|
||||||
|
|
||||||
|
return timeline
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,23 +72,9 @@ func (suite *ManagerTestSuite) TestManagerIntegration() {
|
||||||
suite.Equal(0, indexedLen)
|
suite.Equal(0, indexedLen)
|
||||||
|
|
||||||
// oldestIndexed should be empty string since there's nothing indexed
|
// oldestIndexed should be empty string since there's nothing indexed
|
||||||
oldestIndexed, err := suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
oldestIndexed := suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
||||||
suite.NoError(err)
|
|
||||||
suite.Empty(oldestIndexed)
|
suite.Empty(oldestIndexed)
|
||||||
|
|
||||||
// trigger status preparation
|
|
||||||
err = suite.manager.PrepareXFromTop(ctx, testAccount.ID, 20)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// local_account_1 can see 16 statuses out of the testrig statuses in its home timeline
|
|
||||||
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
|
||||||
suite.Equal(16, indexedLen)
|
|
||||||
|
|
||||||
// oldest should now be set
|
|
||||||
oldestIndexed, err = suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal("01F8MH75CBF9JFX4ZAD54N0W0R", oldestIndexed)
|
|
||||||
|
|
||||||
// get hometimeline
|
// get hometimeline
|
||||||
statuses, err := suite.manager.GetTimeline(ctx, testAccount.ID, "", "", "", 20, false)
|
statuses, err := suite.manager.GetTimeline(ctx, testAccount.ID, "", "", "", 20, false)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
|
@ -103,22 +89,20 @@ func (suite *ManagerTestSuite) TestManagerIntegration() {
|
||||||
suite.Equal(15, indexedLen)
|
suite.Equal(15, indexedLen)
|
||||||
|
|
||||||
// oldest should now be different
|
// oldest should now be different
|
||||||
oldestIndexed, err = suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
oldestIndexed = suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal("01F8MH82FYRXD2RC6108DAJ5HB", oldestIndexed)
|
suite.Equal("01F8MH82FYRXD2RC6108DAJ5HB", oldestIndexed)
|
||||||
|
|
||||||
// delete the new oldest status specifically from this timeline, as though local_account_1 had muted or blocked it
|
// delete the new oldest status specifically from this timeline, as though local_account_1 had muted or blocked it
|
||||||
removed, err := suite.manager.Remove(ctx, testAccount.ID, "01F8MH82FYRXD2RC6108DAJ5HB")
|
removed, err := suite.manager.Remove(ctx, testAccount.ID, "01F8MH82FYRXD2RC6108DAJ5HB")
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.Equal(2, removed) // 1 status should be removed, but from both indexed and prepared, so 2 removals total
|
suite.Equal(1, removed) // 1 status should be removed
|
||||||
|
|
||||||
// timeline should be shorter
|
// timeline should be shorter
|
||||||
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
||||||
suite.Equal(14, indexedLen)
|
suite.Equal(14, indexedLen)
|
||||||
|
|
||||||
// oldest should now be different
|
// oldest should now be different
|
||||||
oldestIndexed, err = suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
oldestIndexed = suite.manager.GetOldestIndexedID(ctx, testAccount.ID)
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", oldestIndexed)
|
suite.Equal("01F8MHAAY43M6RJ473VQFCVH37", oldestIndexed)
|
||||||
|
|
||||||
// now remove all entries by local_account_2 from the timeline
|
// now remove all entries by local_account_2 from the timeline
|
||||||
|
@ -129,24 +113,18 @@ func (suite *ManagerTestSuite) TestManagerIntegration() {
|
||||||
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
||||||
suite.Equal(7, indexedLen)
|
suite.Equal(7, indexedLen)
|
||||||
|
|
||||||
// ingest 1 into the timeline
|
|
||||||
status1 := suite.testStatuses["admin_account_status_1"]
|
|
||||||
ingested, err := suite.manager.Ingest(ctx, status1, testAccount.ID)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(ingested)
|
|
||||||
|
|
||||||
// ingest and prepare another one into the timeline
|
// ingest and prepare another one into the timeline
|
||||||
status2 := suite.testStatuses["local_account_2_status_1"]
|
status := suite.testStatuses["local_account_2_status_1"]
|
||||||
ingested, err = suite.manager.IngestAndPrepare(ctx, status2, testAccount.ID)
|
ingested, err := suite.manager.IngestOne(ctx, testAccount.ID, status)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.True(ingested)
|
suite.True(ingested)
|
||||||
|
|
||||||
// timeline should be longer now
|
// timeline should be longer now
|
||||||
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
indexedLen = suite.manager.GetIndexedLength(ctx, testAccount.ID)
|
||||||
suite.Equal(9, indexedLen)
|
suite.Equal(8, indexedLen)
|
||||||
|
|
||||||
// try to ingest status 2 again
|
// try to ingest same status again
|
||||||
ingested, err = suite.manager.IngestAndPrepare(ctx, status2, testAccount.ID)
|
ingested, err = suite.manager.IngestOne(ctx, testAccount.ID, status)
|
||||||
suite.NoError(err)
|
suite.NoError(err)
|
||||||
suite.False(ingested) // should be false since it's a duplicate
|
suite.False(ingested) // should be false since it's a duplicate
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
// GoToSocial
|
|
||||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package timeline
|
|
||||||
|
|
||||||
type Preparable interface {
|
|
||||||
GetID() string
|
|
||||||
GetAccountID() string
|
|
||||||
GetBoostOfID() string
|
|
||||||
GetBoostOfAccountID() string
|
|
||||||
}
|
|
|
@ -25,240 +25,114 @@ 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) PrepareFromTop(ctx context.Context, amount int) error {
|
func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID string, beforeID string, frontToBack bool) error {
|
||||||
l := log.WithContext(ctx).
|
l := log.
|
||||||
WithFields(kv.Fields{{"amount", amount}}...)
|
WithContext(ctx).
|
||||||
|
|
||||||
// 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 index from the highest ID possible
|
|
||||||
if t.indexedItems.data == nil {
|
|
||||||
l.Debug("postindex.data was nil, indexing behind highest possible ID")
|
|
||||||
if err := t.indexBehind(ctx, id.Highest, amount); err != nil {
|
|
||||||
return fmt.Errorf("PrepareFromTop: error indexing behind id %s: %s", id.Highest, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Trace("entering prepareloop")
|
|
||||||
t.Lock()
|
|
||||||
defer t.Unlock()
|
|
||||||
var prepared int
|
|
||||||
prepareloop:
|
|
||||||
for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
if e == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("PrepareFromTop: could not parse e as a postIndexEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
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("PrepareFromTop: error preparing status with id %s: %s", entry.itemID, err)
|
|
||||||
}
|
|
||||||
// the status just doesn't exist (anymore) so continue to the next one
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prepared++
|
|
||||||
if prepared == amount {
|
|
||||||
// we're done
|
|
||||||
l.Trace("leaving prepareloop")
|
|
||||||
break prepareloop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Trace("leaving function")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *timeline) prepareNextQuery(ctx context.Context, amount int, maxID string, sinceID string, minID string) error {
|
|
||||||
l := log.WithContext(ctx).
|
|
||||||
WithFields(kv.Fields{
|
WithFields(kv.Fields{
|
||||||
{"amount", amount},
|
{"amount", amount},
|
||||||
{"maxID", maxID},
|
{"behindID", behindID},
|
||||||
{"sinceID", sinceID},
|
{"beforeID", beforeID},
|
||||||
{"minID", minID},
|
{"frontToBack", frontToBack},
|
||||||
}...)
|
}...)
|
||||||
|
l.Trace("entering prepareXBetweenIDs")
|
||||||
|
|
||||||
var err error
|
if beforeID >= behindID {
|
||||||
switch {
|
// This is an impossible situation, we
|
||||||
case maxID != "" && sinceID == "":
|
// can't prepare anything between these.
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var prepared int
|
if err := t.indexXBetweenIDs(ctx, amount, behindID, beforeID, frontToBack); err != nil {
|
||||||
var preparing bool
|
// An error here doesn't necessarily mean we
|
||||||
t.Lock()
|
// can't prepare anything, so log + keep going.
|
||||||
defer t.Unlock()
|
l.Debugf("error calling prepareXBetweenIDs: %s", err)
|
||||||
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()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
|
|
||||||
// lazily initialize prepared posts if it hasn't been done already
|
// Try to prepare everything between (and including) the two points.
|
||||||
if t.preparedItems.data == nil {
|
var (
|
||||||
t.preparedItems.data = &list.List{}
|
toPrepare = make(map[*list.Element]*indexedItemsEntry)
|
||||||
t.preparedItems.data.Init()
|
foundToPrepare int
|
||||||
}
|
)
|
||||||
|
|
||||||
// if the postindex is nil, nothing has been indexed yet so there's nothing to prepare
|
if frontToBack {
|
||||||
if t.indexedItems.data == nil {
|
// Paging forwards / down.
|
||||||
return nil
|
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||||
}
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
|
||||||
var prepared int
|
if entry.itemID > behindID {
|
||||||
var preparing bool
|
l.Trace("item is too new, continuing")
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if entry.itemID < beforeID {
|
||||||
|
// We've gone beyond the bounds of
|
||||||
|
// items we're interested in; stop.
|
||||||
|
l.Trace("reached older items, breaking")
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if preparing {
|
// Only prepare entry if it's not
|
||||||
if err := t.prepare(ctx, entry.itemID); err != nil {
|
// already prepared, save db calls.
|
||||||
// there's been an error
|
if entry.prepared == nil {
|
||||||
if err != db.ErrNoEntries {
|
toPrepare[e] = entry
|
||||||
// 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
|
|
||||||
|
foundToPrepare++
|
||||||
|
if foundToPrepare >= amount {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Paging backwards / up.
|
||||||
|
for e := t.items.data.Back(); e != nil; e = e.Prev() {
|
||||||
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
|
||||||
|
if entry.itemID < beforeID {
|
||||||
|
l.Trace("item is too old, continuing")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if prepared == amount {
|
|
||||||
// we're done
|
if entry.itemID > behindID {
|
||||||
break prepareloop
|
// We've gone beyond the bounds of
|
||||||
|
// items we're interested in; stop.
|
||||||
|
l.Trace("reached newer items, breaking")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.prepared == nil {
|
||||||
|
toPrepare[e] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only prepare entry if it's not
|
||||||
|
// already prepared, save db calls.
|
||||||
|
foundToPrepare++
|
||||||
|
if foundToPrepare >= amount {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
prepared++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
for e, entry := range toPrepare {
|
||||||
}
|
prepared, err := t.prepareFunction(ctx, t.accountID, entry.itemID)
|
||||||
|
|
||||||
func (t *timeline) prepare(ctx context.Context, itemID string) error {
|
|
||||||
// trigger the caller-provided prepare function
|
|
||||||
prepared, err := t.prepareFunction(ctx, t.accountID, itemID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if errors.Is(err, db.ErrNoEntries) {
|
||||||
|
// ErrNoEntries means something has been deleted,
|
||||||
|
// so we'll likely not be able to ever prepare this.
|
||||||
|
// This means we can remove it and skip past it.
|
||||||
|
l.Debugf("db.ErrNoEntries while trying to prepare %s; will remove from timeline", entry.itemID)
|
||||||
|
t.items.data.Remove(e)
|
||||||
|
}
|
||||||
|
// We've got a proper db error.
|
||||||
|
return fmt.Errorf("prepareXBetweenIDs: db error while trying to prepare %s: %w", entry.itemID, err)
|
||||||
|
}
|
||||||
|
entry.prepared = prepared
|
||||||
}
|
}
|
||||||
|
|
||||||
// shove it in prepared items as a prepared items entry
|
return nil
|
||||||
preparedItemsEntry := &preparedItemsEntry{
|
|
||||||
itemID: prepared.GetID(),
|
|
||||||
boostOfID: prepared.GetBoostOfID(),
|
|
||||||
accountID: prepared.GetAccountID(),
|
|
||||||
boostOfAccountID: prepared.GetBoostOfAccountID(),
|
|
||||||
prepared: prepared,
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.preparedItems.insertPrepared(ctx, preparedItemsEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
if t.preparedItems == nil || t.preparedItems.data == nil {
|
|
||||||
// return an empty string if prepared items hasn't been initialized yet
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
e := t.preparedItems.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.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return id, errors.New("oldestPreparedItemID: could not parse e as a preparedItemsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry.itemID, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,91 +0,0 @@
|
||||||
// GoToSocial
|
|
||||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
package timeline
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type preparedItems struct {
|
|
||||||
data *list.List
|
|
||||||
skipInsert SkipInsertFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
type preparedItemsEntry struct {
|
|
||||||
itemID string
|
|
||||||
boostOfID string
|
|
||||||
accountID string
|
|
||||||
boostOfAccountID string
|
|
||||||
prepared Preparable
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *preparedItems) insertPrepared(ctx context.Context, newEntry *preparedItemsEntry) error {
|
|
||||||
if p.data == nil {
|
|
||||||
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 p.data.Len() == 0 {
|
|
||||||
p.data.PushFront(newEntry)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var insertMark *list.Element
|
|
||||||
var position int
|
|
||||||
// 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 entry -- this can happen sometimes and it's not nice UX (*shudder*).
|
|
||||||
for e := p.data.Front(); e != nil; e = e.Next() {
|
|
||||||
position++
|
|
||||||
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("insertPrepared: could not parse e as a preparedItemsEntry")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if skip {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the entry to index is newer than e, insert it before e in the list
|
|
||||||
if insertMark == nil {
|
|
||||||
if newEntry.itemID > entry.itemID {
|
|
||||||
insertMark = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure we don't insert a duplicate
|
|
||||||
if entry.itemID == newEntry.itemID {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if insertMark != nil {
|
|
||||||
p.data.InsertBefore(newEntry, insertMark)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we reach this point it's the oldest entry we've seen so put it at the back
|
|
||||||
p.data.PushBack(newEntry)
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -21,47 +21,63 @@ import (
|
||||||
"container/list"
|
"container/list"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
defaultDesiredIndexedItemsLength = 400
|
|
||||||
defaultDesiredPreparedItemsLength = 50
|
|
||||||
)
|
|
||||||
|
|
||||||
func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int {
|
func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int {
|
||||||
t.Lock()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
|
|
||||||
pruneList := func(pruneTo int, listToPrune *list.List) int {
|
l := t.items.data
|
||||||
if listToPrune == nil {
|
if l == nil {
|
||||||
// no need to prune
|
// Nothing to prune.
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
unprunedLength := listToPrune.Len()
|
var (
|
||||||
if unprunedLength <= pruneTo {
|
position int
|
||||||
// no need to prune
|
totalPruned int
|
||||||
return 0
|
toRemove *[]*list.Element
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only initialize toRemove if we know we're
|
||||||
|
// going to need it, otherwise skiperino.
|
||||||
|
if toRemoveLen := t.items.data.Len() - desiredIndexedItemsLength; toRemoveLen > 0 {
|
||||||
|
toRemove = func() *[]*list.Element { tr := make([]*list.Element, 0, toRemoveLen); return &tr }()
|
||||||
}
|
}
|
||||||
|
|
||||||
// work from the back + assemble a slice of entries that we will prune
|
// Work from the front of the list until we get
|
||||||
amountStillToPrune := unprunedLength - pruneTo
|
// to the point where we need to start pruning.
|
||||||
itemsToPrune := make([]*list.Element, 0, amountStillToPrune)
|
for e := l.Front(); e != nil; e = e.Next() {
|
||||||
for e := listToPrune.Back(); amountStillToPrune > 0; e = e.Prev() {
|
position++
|
||||||
itemsToPrune = append(itemsToPrune, e)
|
|
||||||
amountStillToPrune--
|
if position <= desiredPreparedItemsLength {
|
||||||
|
// We're still within our allotted
|
||||||
|
// prepped length, nothing to do yet.
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the entries we found
|
// We need to *at least* unprepare this entry.
|
||||||
var totalPruned int
|
// If we're beyond our indexed length already,
|
||||||
for _, e := range itemsToPrune {
|
// we can just remove the item completely.
|
||||||
listToPrune.Remove(e)
|
if position > desiredIndexedItemsLength {
|
||||||
|
*toRemove = append(*toRemove, e)
|
||||||
|
totalPruned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert
|
||||||
|
if entry.prepared == nil {
|
||||||
|
// It's already unprepared (mood).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.prepared = nil // <- eat this up please garbage collector nom nom nom
|
||||||
totalPruned++
|
totalPruned++
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalPruned
|
if toRemove != nil {
|
||||||
|
for _, e := range *toRemove {
|
||||||
|
l.Remove(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prunedPrepared := pruneList(desiredPreparedItemsLength, t.preparedItems.data)
|
return totalPruned
|
||||||
prunedIndexed := pruneList(desiredIndexedItemsLength, t.indexedItems.data)
|
|
||||||
|
|
||||||
return prunedPrepared + prunedIndexed
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,7 @@ func (suite *PruneTestSuite) SetupTest() {
|
||||||
testrig.StandardDBSetup(suite.db, nil)
|
testrig.StandardDBSetup(suite.db, nil)
|
||||||
|
|
||||||
// let's take local_account_1 as the timeline owner
|
// let's take local_account_1 as the timeline owner
|
||||||
tl, err := timeline.NewTimeline(
|
tl := timeline.NewTimeline(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
suite.testAccounts["local_account_1"].ID,
|
suite.testAccounts["local_account_1"].ID,
|
||||||
processing.StatusGrabFunction(suite.db),
|
processing.StatusGrabFunction(suite.db),
|
||||||
|
@ -60,9 +60,6 @@ func (suite *PruneTestSuite) SetupTest() {
|
||||||
processing.StatusPrepareFunction(suite.db, suite.tc),
|
processing.StatusPrepareFunction(suite.db, suite.tc),
|
||||||
processing.StatusSkipInsertFunction(),
|
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
|
// put the status IDs in a determinate order since we can't trust a map to keep its order
|
||||||
statuses := []*gtsmodel.Status{}
|
statuses := []*gtsmodel.Status{}
|
||||||
|
@ -90,20 +87,30 @@ func (suite *PruneTestSuite) TearDownTest() {
|
||||||
|
|
||||||
func (suite *PruneTestSuite) TestPrune() {
|
func (suite *PruneTestSuite) TestPrune() {
|
||||||
// prune down to 5 prepared + 5 indexed
|
// prune down to 5 prepared + 5 indexed
|
||||||
suite.Equal(24, suite.timeline.Prune(5, 5))
|
suite.Equal(12, suite.timeline.Prune(5, 5))
|
||||||
suite.Equal(5, suite.timeline.ItemIndexLength(context.Background()))
|
suite.Equal(5, suite.timeline.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *PruneTestSuite) TestPruneTwice() {
|
||||||
|
// prune down to 5 prepared + 10 indexed
|
||||||
|
suite.Equal(12, suite.timeline.Prune(5, 10))
|
||||||
|
suite.Equal(10, suite.timeline.Len())
|
||||||
|
|
||||||
|
// Prune same again, nothing should be pruned this time.
|
||||||
|
suite.Zero(suite.timeline.Prune(5, 10))
|
||||||
|
suite.Equal(10, suite.timeline.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *PruneTestSuite) TestPruneTo0() {
|
func (suite *PruneTestSuite) TestPruneTo0() {
|
||||||
// prune down to 0 prepared + 0 indexed
|
// prune down to 0 prepared + 0 indexed
|
||||||
suite.Equal(34, suite.timeline.Prune(0, 0))
|
suite.Equal(17, suite.timeline.Prune(0, 0))
|
||||||
suite.Equal(0, suite.timeline.ItemIndexLength(context.Background()))
|
suite.Equal(0, suite.timeline.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() {
|
func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() {
|
||||||
// prune to 99999, this should result in no entries being pruned
|
// prune to 99999, this should result in no entries being pruned
|
||||||
suite.Equal(0, suite.timeline.Prune(99999, 99999))
|
suite.Equal(0, suite.timeline.Prune(99999, 99999))
|
||||||
suite.Equal(17, suite.timeline.ItemIndexLength(context.Background()))
|
suite.Equal(17, suite.timeline.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPruneTestSuite(t *testing.T) {
|
func TestPruneTestSuite(t *testing.T) {
|
||||||
|
|
|
@ -20,7 +20,6 @@ package timeline
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
|
|
||||||
"codeberg.org/gruf/go-kv"
|
"codeberg.org/gruf/go-kv"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
@ -35,52 +34,35 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
|
||||||
|
|
||||||
t.Lock()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
var removed int
|
|
||||||
|
|
||||||
// remove entr(ies) from the post index
|
if t.items == nil || t.items.data == nil {
|
||||||
removeIndexes := []*list.Element{}
|
// Nothing to do.
|
||||||
if t.indexedItems != nil && t.indexedItems.data != nil {
|
return 0, nil
|
||||||
for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return removed, errors.New("Remove: could not parse e as a postIndexEntry")
|
|
||||||
}
|
|
||||||
if entry.itemID == statusID {
|
|
||||||
l.Debug("found status in postIndex")
|
|
||||||
removeIndexes = append(removeIndexes, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range removeIndexes {
|
|
||||||
t.indexedItems.data.Remove(e)
|
|
||||||
removed++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove entr(ies) from prepared posts
|
var toRemove []*list.Element
|
||||||
removePrepared := []*list.Element{}
|
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||||
if t.preparedItems != nil && t.preparedItems.data != nil {
|
entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
if entry.itemID != statusID {
|
||||||
if !ok {
|
// Not relevant.
|
||||||
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry")
|
continue
|
||||||
}
|
|
||||||
if entry.itemID == statusID {
|
|
||||||
l.Debug("found status in preparedPosts")
|
|
||||||
removePrepared = append(removePrepared, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range removePrepared {
|
|
||||||
t.preparedItems.data.Remove(e)
|
|
||||||
removed++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Debugf("removed %d entries", removed)
|
l.Debug("removing item")
|
||||||
return removed, nil
|
toRemove = append(toRemove, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range toRemove {
|
||||||
|
t.items.data.Remove(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(toRemove), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, error) {
|
func (t *timeline) RemoveAllByOrBoosting(ctx context.Context, accountID string) (int, error) {
|
||||||
l := log.WithContext(ctx).
|
l := log.
|
||||||
|
WithContext(ctx).
|
||||||
WithFields(kv.Fields{
|
WithFields(kv.Fields{
|
||||||
{"accountTimeline", t.accountID},
|
{"accountTimeline", t.accountID},
|
||||||
{"accountID", accountID},
|
{"accountID", accountID},
|
||||||
|
@ -88,46 +70,28 @@ func (t *timeline) RemoveAllBy(ctx context.Context, accountID string) (int, erro
|
||||||
|
|
||||||
t.Lock()
|
t.Lock()
|
||||||
defer t.Unlock()
|
defer t.Unlock()
|
||||||
var removed int
|
|
||||||
|
|
||||||
// remove entr(ies) from the post index
|
if t.items == nil || t.items.data == nil {
|
||||||
removeIndexes := []*list.Element{}
|
// Nothing to do.
|
||||||
if t.indexedItems != nil && t.indexedItems.data != nil {
|
return 0, nil
|
||||||
for e := t.indexedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*indexedItemsEntry)
|
|
||||||
if !ok {
|
|
||||||
return removed, errors.New("Remove: could not parse e as a postIndexEntry")
|
|
||||||
}
|
|
||||||
if entry.accountID == accountID || entry.boostOfAccountID == accountID {
|
|
||||||
l.Debug("found status in postIndex")
|
|
||||||
removeIndexes = append(removeIndexes, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range removeIndexes {
|
|
||||||
t.indexedItems.data.Remove(e)
|
|
||||||
removed++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove entr(ies) from prepared posts
|
var toRemove []*list.Element
|
||||||
removePrepared := []*list.Element{}
|
for e := t.items.data.Front(); e != nil; e = e.Next() {
|
||||||
if t.preparedItems != nil && t.preparedItems.data != nil {
|
entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert
|
||||||
for e := t.preparedItems.data.Front(); e != nil; e = e.Next() {
|
|
||||||
entry, ok := e.Value.(*preparedItemsEntry)
|
if entry.accountID != accountID && entry.boostOfAccountID != accountID {
|
||||||
if !ok {
|
// Not relevant.
|
||||||
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry")
|
continue
|
||||||
}
|
|
||||||
if entry.accountID == accountID || entry.boostOfAccountID == accountID {
|
|
||||||
l.Debug("found status in preparedPosts")
|
|
||||||
removePrepared = append(removePrepared, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range removePrepared {
|
|
||||||
t.preparedItems.data.Remove(e)
|
|
||||||
removed++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.Debugf("removed %d entries", removed)
|
l.Debug("removing item")
|
||||||
return removed, nil
|
toRemove = append(toRemove, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range toRemove {
|
||||||
|
t.items.data.Remove(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(toRemove), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,32 +78,25 @@ type Timeline interface {
|
||||||
INDEXING + PREPARATION FUNCTIONS
|
INDEXING + PREPARATION FUNCTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// IndexOne puts a item into the timeline at the appropriate place according to its 'createdAt' property.
|
// IndexAndPrepareOne puts a item into the timeline at the appropriate place according to its id, and then immediately prepares it.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
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,
|
|
||||||
// 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)
|
||||||
// PrepareXFromTop instructs the timeline to prepare x amount of items from the top of the timeline, useful during init.
|
|
||||||
PrepareFromTop(ctx context.Context, amount int) error
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
INFO FUNCTIONS
|
INFO FUNCTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ActualPostIndexLength returns the actual length of the item index at this point in time.
|
// AccountID returns the id of the account this timeline belongs to.
|
||||||
ItemIndexLength(ctx context.Context) int
|
AccountID() string
|
||||||
// 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.
|
// Len returns the length of the item index at this point in time.
|
||||||
OldestIndexedItemID(ctx context.Context) (string, error)
|
Len() int
|
||||||
// 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.
|
// OldestIndexedItemID returns the id of the rearmost (ie., the oldest) indexed item.
|
||||||
NewestIndexedItemID(ctx context.Context) (string, error)
|
// If there's no oldest item, an empty string will be returned so make sure to check for this.
|
||||||
|
OldestIndexedItemID() string
|
||||||
|
|
||||||
/*
|
/*
|
||||||
UTILITY FUNCTIONS
|
UTILITY FUNCTIONS
|
||||||
|
@ -111,27 +104,29 @@ type Timeline interface {
|
||||||
|
|
||||||
// LastGot returns the time that Get was last called.
|
// LastGot returns the time that Get was last called.
|
||||||
LastGot() time.Time
|
LastGot() time.Time
|
||||||
// Prune prunes preparedItems and indexedItems in this timeline to the desired lengths.
|
|
||||||
|
// Prune prunes prepared and indexed items in this timeline to the desired lengths.
|
||||||
// This will be a no-op if the lengths are already < the desired values.
|
// 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.
|
// The returned int indicates the amount of entries that were removed or unprepared.
|
||||||
Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int
|
Prune(desiredPreparedItemsLength int, desiredIndexedItemsLength int) int
|
||||||
// Remove removes a item from both the index and prepared items.
|
|
||||||
|
// Remove removes an item with the given ID.
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
//
|
//
|
||||||
// The returned int indicates the amount of entries that were removed.
|
// The returned int indicates the amount of entries that were removed.
|
||||||
Remove(ctx context.Context, itemID string) (int, error)
|
Remove(ctx context.Context, itemID string) (int, error)
|
||||||
// RemoveAllBy removes all items by the given accountID, from both the index and prepared items.
|
|
||||||
|
// RemoveAllByOrBoosting removes all items created by or boosting the given accountID.
|
||||||
//
|
//
|
||||||
// The returned int indicates the amount of entries that were removed.
|
// The returned int indicates the amount of entries that were removed.
|
||||||
RemoveAllBy(ctx context.Context, accountID string) (int, error)
|
RemoveAllByOrBoosting(ctx context.Context, accountID string) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeline fulfils the Timeline interface
|
// timeline fulfils the Timeline interface
|
||||||
type timeline struct {
|
type timeline struct {
|
||||||
indexedItems *indexedItems
|
items *indexedItems
|
||||||
preparedItems *preparedItems
|
|
||||||
grabFunction GrabFunction
|
grabFunction GrabFunction
|
||||||
filterFunction FilterFunction
|
filterFunction FilterFunction
|
||||||
prepareFunction PrepareFunction
|
prepareFunction PrepareFunction
|
||||||
|
@ -140,6 +135,10 @@ type timeline struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *timeline) AccountID() string {
|
||||||
|
return t.accountID
|
||||||
|
}
|
||||||
|
|
||||||
// NewTimeline returns a new Timeline for the given account ID
|
// NewTimeline returns a new Timeline for the given account ID
|
||||||
func NewTimeline(
|
func NewTimeline(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
@ -148,12 +147,9 @@ func NewTimeline(
|
||||||
filterFunction FilterFunction,
|
filterFunction FilterFunction,
|
||||||
prepareFunction PrepareFunction,
|
prepareFunction PrepareFunction,
|
||||||
skipInsertFunction SkipInsertFunction,
|
skipInsertFunction SkipInsertFunction,
|
||||||
) (Timeline, error) {
|
) Timeline {
|
||||||
return &timeline{
|
return &timeline{
|
||||||
indexedItems: &indexedItems{
|
items: &indexedItems{
|
||||||
skipInsert: skipInsertFunction,
|
|
||||||
},
|
|
||||||
preparedItems: &preparedItems{
|
|
||||||
skipInsert: skipInsertFunction,
|
skipInsert: skipInsertFunction,
|
||||||
},
|
},
|
||||||
grabFunction: grabFunction,
|
grabFunction: grabFunction,
|
||||||
|
@ -161,5 +157,5 @@ func NewTimeline(
|
||||||
prepareFunction: prepareFunction,
|
prepareFunction: prepareFunction,
|
||||||
accountID: timelineAccountID,
|
accountID: timelineAccountID,
|
||||||
lastGot: time.Time{},
|
lastGot: time.Time{},
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,8 @@ type TimelineStandardTestSuite struct {
|
||||||
|
|
||||||
testAccounts map[string]*gtsmodel.Account
|
testAccounts map[string]*gtsmodel.Account
|
||||||
testStatuses map[string]*gtsmodel.Status
|
testStatuses map[string]*gtsmodel.Status
|
||||||
|
highestStatusID string
|
||||||
|
lowestStatusID string
|
||||||
|
|
||||||
timeline timeline.Timeline
|
timeline timeline.Timeline
|
||||||
manager timeline.Manager
|
manager timeline.Manager
|
||||||
|
|
|
@ -17,10 +17,18 @@
|
||||||
|
|
||||||
package timeline
|
package timeline
|
||||||
|
|
||||||
// Timelineable represents any item that can be put in a timeline.
|
// Timelineable represents any item that can be indexed in a timeline.
|
||||||
type Timelineable interface {
|
type Timelineable interface {
|
||||||
GetID() string
|
GetID() string
|
||||||
GetAccountID() string
|
GetAccountID() string
|
||||||
GetBoostOfID() string
|
GetBoostOfID() string
|
||||||
GetBoostOfAccountID() string
|
GetBoostOfAccountID() string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preparable represents any item that can be prepared in a timeline.
|
||||||
|
type Preparable interface {
|
||||||
|
GetID() string
|
||||||
|
GetAccountID() string
|
||||||
|
GetBoostOfID() string
|
||||||
|
GetBoostOfAccountID() string
|
||||||
|
}
|
Loading…
Reference in New Issue