diff options
author | 2023-06-11 11:18:44 +0200 | |
---|---|---|
committer | 2023-06-11 10:18:44 +0100 | |
commit | 5e2897e35cd2bea889fa37a2a857f4dcc076dafc (patch) | |
tree | b1ac6203ffa20f5fff1c460fed942854a6e5c6bd /internal/timeline | |
parent | [docs] Revamp the installation guide (#1877) (diff) | |
download | gotosocial-5e2897e35cd2bea889fa37a2a857f4dcc076dafc.tar.xz |
[bugfix] Invalidate timeline entries for status when stats change (#1879)
Diffstat (limited to 'internal/timeline')
-rw-r--r-- | internal/timeline/manager.go | 35 | ||||
-rw-r--r-- | internal/timeline/timeline.go | 16 | ||||
-rw-r--r-- | internal/timeline/unprepare.go | 50 | ||||
-rw-r--r-- | internal/timeline/unprepare_test.go | 142 |
4 files changed, 239 insertions, 4 deletions
diff --git a/internal/timeline/manager.go b/internal/timeline/manager.go index 95a40aca1..a701756bb 100644 --- a/internal/timeline/manager.go +++ b/internal/timeline/manager.go @@ -75,6 +75,14 @@ type Manager interface { // WipeStatusesFromAccountID removes all items by the given accountID from the given timeline. WipeItemsFromAccountID(ctx context.Context, timelineID string, accountID string) error + // UnprepareItem unprepares/uncaches the prepared version fo the given itemID from the given timelineID. + // Use this for cache invalidation when the prepared representation of an item has changed. + UnprepareItem(ctx context.Context, timelineID string, itemID string) error + + // UnprepareItemFromAllTimelines unprepares/uncaches the prepared version of the given itemID from all timelines. + // Use this for cache invalidation when the prepared representation of an item has changed. + UnprepareItemFromAllTimelines(ctx context.Context, itemID string) error + // Prune manually triggers a prune operation for the given timelineID. Prune(ctx context.Context, timelineID string, desiredPreparedItemsLength int, desiredIndexedItemsLength int) (int, error) @@ -193,7 +201,7 @@ func (m *manager) WipeItemFromAllTimelines(ctx context.Context, itemID string) e }) if len(errors) > 0 { - return gtserror.Newf("one or more errors wiping status %s: %w", itemID, errors.Combine()) + return gtserror.Newf("error(s) wiping status %s: %w", itemID, errors.Combine()) } return nil @@ -204,6 +212,31 @@ func (m *manager) WipeItemsFromAccountID(ctx context.Context, timelineID string, return err } +func (m *manager) UnprepareItemFromAllTimelines(ctx context.Context, itemID string) error { + errors := gtserror.MultiError{} + + // Work through all timelines held by this + // manager, and call Unprepare for each. + m.timelines.Range(func(_ any, v any) bool { + // nolint:forcetypeassert + if err := v.(Timeline).Unprepare(ctx, itemID); err != nil { + errors.Append(err) + } + + return true // always continue range + }) + + if len(errors) > 0 { + return gtserror.Newf("error(s) unpreparing status %s: %w", itemID, errors.Combine()) + } + + return nil +} + +func (m *manager) UnprepareItem(ctx context.Context, timelineID string, itemID string) error { + return m.getOrCreateTimeline(ctx, timelineID).Unprepare(ctx, itemID) +} + func (m *manager) Prune(ctx context.Context, timelineID string, desiredPreparedItemsLength int, desiredIndexedItemsLength int) (int, error) { return m.getOrCreateTimeline(ctx, timelineID).Prune(desiredPreparedItemsLength, desiredIndexedItemsLength), nil } diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go index b973a3905..e7c609638 100644 --- a/internal/timeline/timeline.go +++ b/internal/timeline/timeline.go @@ -78,12 +78,22 @@ type Timeline interface { INDEXING + PREPARATION FUNCTIONS */ - // IndexAndPrepareOne puts a item into the timeline at the appropriate place according to its id, and then immediately prepares it. + // 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. + // 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 a boost of it, already exists recently in the timeline. IndexAndPrepareOne(ctx context.Context, itemID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) + // Unprepare clears the prepared version of the given item (and any boosts + // thereof) from the timeline, but leaves the indexed version in place. + // + // This is useful for cache invalidation when the prepared version of the + // item has changed for some reason (edits, updates, etc), but the item does + // not need to be removed: it will be prepared again next time Get is called. + Unprepare(ctx context.Context, itemID string) error + /* INFO FUNCTIONS */ diff --git a/internal/timeline/unprepare.go b/internal/timeline/unprepare.go new file mode 100644 index 000000000..827b274d8 --- /dev/null +++ b/internal/timeline/unprepare.go @@ -0,0 +1,50 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package timeline + +import ( + "context" +) + +func (t *timeline) Unprepare(ctx context.Context, itemID string) error { + t.Lock() + defer t.Unlock() + + if t.items == nil || t.items.data == nil { + // Nothing to do. + return nil + } + + for e := t.items.data.Front(); e != nil; e = e.Next() { + entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert + + if entry.itemID != itemID && entry.boostOfID != itemID { + // Not relevant. + continue + } + + if entry.prepared == nil { + // It's already unprepared (mood). + continue + } + + entry.prepared = nil // <- eat this up please garbage collector nom nom nom + } + + return nil +} diff --git a/internal/timeline/unprepare_test.go b/internal/timeline/unprepare_test.go new file mode 100644 index 000000000..20bef7537 --- /dev/null +++ b/internal/timeline/unprepare_test.go @@ -0,0 +1,142 @@ +// 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_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +type UnprepareTestSuite struct { + TimelineStandardTestSuite +} + +func (suite *UnprepareTestSuite) TestUnprepareFromFave() { + var ( + ctx = context.Background() + testAccount = suite.testAccounts["local_account_1"] + maxID = "" + sinceID = "" + minID = "" + limit = 1 + local = false + ) + + suite.fillTimeline(testAccount.ID) + + // Get first status from the top (no params). + statuses, err := suite.state.Timelines.Home.GetTimeline( + ctx, + testAccount.ID, + maxID, + sinceID, + minID, + limit, + local, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + if len(statuses) != 1 { + suite.FailNow("couldn't get top status") + } + + targetStatus := statuses[0].(*apimodel.Status) + + // Check fave stats of the top status. + suite.Equal(0, targetStatus.FavouritesCount) + suite.False(targetStatus.Favourited) + + // Fave the top status from testAccount. + if err := suite.state.DB.PutStatusFave(ctx, >smodel.StatusFave{ + ID: id.NewULID(), + AccountID: testAccount.ID, + TargetAccountID: targetStatus.Account.ID, + StatusID: targetStatus.ID, + URI: "https://example.org/some/activity/path", + }); err != nil { + suite.FailNow(err.Error()) + } + + // Repeat call to get first status from the top. + // Get first status from the top (no params). + statuses, err = suite.state.Timelines.Home.GetTimeline( + ctx, + testAccount.ID, + maxID, + sinceID, + minID, + limit, + local, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + if len(statuses) != 1 { + suite.FailNow("couldn't get top status") + } + + targetStatus = statuses[0].(*apimodel.Status) + + // We haven't yet uncached/unprepared the status, + // we've only inserted the fave, so counts should + // stay the same... + suite.Equal(0, targetStatus.FavouritesCount) + suite.False(targetStatus.Favourited) + + // Now call unprepare. + suite.state.Timelines.Home.UnprepareItemFromAllTimelines(ctx, targetStatus.ID) + + // Now a Get should trigger a fresh prepare of the + // target status, and the counts should be updated. + // Repeat call to get first status from the top. + // Get first status from the top (no params). + statuses, err = suite.state.Timelines.Home.GetTimeline( + ctx, + testAccount.ID, + maxID, + sinceID, + minID, + limit, + local, + ) + if err != nil { + suite.FailNow(err.Error()) + } + + if len(statuses) != 1 { + suite.FailNow("couldn't get top status") + } + + targetStatus = statuses[0].(*apimodel.Status) + + suite.Equal(1, targetStatus.FavouritesCount) + suite.True(targetStatus.Favourited) +} + +func TestUnprepareTestSuite(t *testing.T) { + suite.Run(t, new(UnprepareTestSuite)) +} |