From 702d534136908ab21a51e1c569517c0f11b680e4 Mon Sep 17 00:00:00 2001 From: tsmethurst Date: Wed, 18 Aug 2021 14:21:19 +0200 Subject: [PATCH] and yet more --- internal/cache/cache.go | 23 ++++++++ internal/cache/error.go | 27 +++++++++ internal/cache/fetch.go | 33 +++++++++++ internal/cache/store.go | 31 +++++++++++ internal/cache/sweep.go | 42 ++++++++++++++ internal/db/db.go | 1 + internal/db/media.go | 26 +++++++++ internal/db/pg/account_test.go | 70 ++++++++++++++++++++++++ internal/db/pg/media.go | 53 ++++++++++++++++++ internal/db/pg/mention.go | 29 ++++------ internal/db/pg/pg.go | 7 +++ internal/db/pg/status_test.go | 14 ++--- internal/gtsmodel/status.go | 8 +-- internal/typeutils/converter.go | 7 ++- internal/typeutils/internaltofrontend.go | 39 +++++++++++-- 15 files changed, 375 insertions(+), 35 deletions(-) create mode 100644 internal/cache/error.go create mode 100644 internal/cache/fetch.go create mode 100644 internal/cache/store.go create mode 100644 internal/cache/sweep.go create mode 100644 internal/db/media.go create mode 100644 internal/db/pg/account_test.go create mode 100644 internal/db/pg/media.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 1d2d0533b..3f797beb6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -18,8 +18,31 @@ package cache +import ( + "sync" + "time" +) + // Cache defines an in-memory cache that is safe to be wiped when the application is restarted type Cache interface { Store(k string, v interface{}) error Fetch(k string) (interface{}, error) } + +type cache struct { + stored *sync.Map +} + +// New returns a new in-memory cache. +func New() Cache { + cache := &cache{ + stored: &sync.Map{}, + } + go cache.sweep() + return cache +} + +type cacheEntry struct { + updated time.Time + value interface{} +} diff --git a/internal/cache/error.go b/internal/cache/error.go new file mode 100644 index 000000000..df7cd8710 --- /dev/null +++ b/internal/cache/error.go @@ -0,0 +1,27 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package cache + +import "errors" + +// CacheError models an error returned by the in-memory cache. +type CacheError error + +// ErrNotFound means that a value for the requested key was not found in the cache. +var ErrNotFound = errors.New("value not found in cache") diff --git a/internal/cache/fetch.go b/internal/cache/fetch.go new file mode 100644 index 000000000..540e9707e --- /dev/null +++ b/internal/cache/fetch.go @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package cache + +func (c *cache) Fetch(k string) (interface{}, error) { + ceI, stored := c.stored.Load(k) + if !stored { + return nil, ErrNotFound + } + + ce, ok := ceI.(*cacheEntry) + if !ok { + panic("cache entry was not a *cacheEntry -- this should never happen") + } + + return ce.value, nil +} diff --git a/internal/cache/store.go b/internal/cache/store.go new file mode 100644 index 000000000..50aa08cfc --- /dev/null +++ b/internal/cache/store.go @@ -0,0 +1,31 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package cache + +import "time" + +func (c *cache) Store(k string, v interface{}) error { + ce := &cacheEntry{ + updated: time.Now(), + value: v, + } + + c.stored.Store(k, ce) + return nil +} diff --git a/internal/cache/sweep.go b/internal/cache/sweep.go new file mode 100644 index 000000000..e3b19f6a4 --- /dev/null +++ b/internal/cache/sweep.go @@ -0,0 +1,42 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package cache + +import "time" + +// sweep removes all entries more than 5 minutes old, on a loop. +func (c *cache) sweep() { + t := time.NewTicker(5 * time.Minute) + for range t.C { + toRemove := []interface{}{} + c.stored.Range(func(key interface{}, value interface{}) bool { + ce, ok := value.(*cacheEntry) + if !ok { + panic("cache entry was not a *cacheEntry -- this should never happen") + } + if ce.updated.Add(5 * time.Minute).After(time.Now()) { + toRemove = append(toRemove, key) + } + return true + }) + for _, r := range toRemove { + c.stored.Delete(r) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go index b3d04939b..d74eb27ed 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -35,6 +35,7 @@ type DB interface { Admin Basic Instance + Media Mention Notification Relationship diff --git a/internal/db/media.go b/internal/db/media.go new file mode 100644 index 000000000..a677ad019 --- /dev/null +++ b/internal/db/media.go @@ -0,0 +1,26 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package db + +import "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + +type Media interface { + // GetAttachmentByID gets a single attachment by its ID + GetAttachmentByID(id string) (*gtsmodel.MediaAttachment, DBError) +} diff --git a/internal/db/pg/account_test.go b/internal/db/pg/account_test.go new file mode 100644 index 000000000..7ea5ff39a --- /dev/null +++ b/internal/db/pg/account_test.go @@ -0,0 +1,70 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package pg_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type AccountTestSuite struct { + PGStandardTestSuite +} + +func (suite *AccountTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() + suite.testTags = testrig.NewTestTags() + suite.testMentions = testrig.NewTestMentions() +} + +func (suite *AccountTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.log = testrig.NewTestLog() + + testrig.StandardDBSetup(suite.db, suite.testAccounts) +} + +func (suite *AccountTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) +} + +func (suite *AccountTestSuite) TestGetAccountByIDWithExtras() { + account, err := suite.db.GetAccountByID(suite.testAccounts["local_account_1"].ID) + if err != nil { + suite.FailNow(err.Error()) + } + suite.NotNil(account) + suite.NotNil(account.AvatarMediaAttachment) + suite.NotEmpty(account.AvatarMediaAttachment.URL) + suite.NotNil(account.HeaderMediaAttachment) + suite.NotEmpty(account.HeaderMediaAttachment.URL) +} + +func TestAccountTestSuite(t *testing.T) { + suite.Run(t, new(AccountTestSuite)) +} diff --git a/internal/db/pg/media.go b/internal/db/pg/media.go new file mode 100644 index 000000000..dff301fa5 --- /dev/null +++ b/internal/db/pg/media.go @@ -0,0 +1,53 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package pg + +import ( + "context" + + "github.com/go-pg/pg/v10" + "github.com/go-pg/pg/v10/orm" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type mediaDB struct { + config *config.Config + conn *pg.DB + log *logrus.Logger + cancel context.CancelFunc +} + +func (m *mediaDB) newMediaQ(i interface{}) *orm.Query { + return m.conn.Model(i). + Relation("Account") +} + +func (m *mediaDB) GetAttachmentByID(id string) (*gtsmodel.MediaAttachment, db.DBError) { + attachment := >smodel.MediaAttachment{} + + q := m.newMediaQ(attachment). + Where("media_attachment.id = ?", id) + + err := processErrorResponse(q.Select()) + + return attachment, err +} diff --git a/internal/db/pg/mention.go b/internal/db/pg/mention.go index e9a27c867..7ab395756 100644 --- a/internal/db/pg/mention.go +++ b/internal/db/pg/mention.go @@ -43,35 +43,28 @@ func (m *mentionDB) newMentionQ(i interface{}) *orm.Query { Relation("TargetAccount") } -func (m *mentionDB) processResponse(mention *gtsmodel.Mention, err error) (*gtsmodel.Mention, db.DBError) { - switch err { - case pg.ErrNoRows: - return nil, db.ErrNoEntries - case nil: - return mention, nil - default: - return nil, err - } -} - func (m *mentionDB) GetMention(id string) (*gtsmodel.Mention, db.DBError) { mention := >smodel.Mention{} q := m.newMentionQ(mention). Where("mention.id = ?", id) - return m.processResponse(mention, q.Select()) + err := processErrorResponse(q.Select()) + + return mention, err } func (m *mentionDB) GetMentions(ids []string) ([]*gtsmodel.Mention, db.DBError) { mentions := []*gtsmodel.Mention{} - q := m.newMentionQ(mentions). - Where("mention.id in (?)", pg.In(ids)) - - if err := q.Select(); err != nil { - return nil, err + if len(ids) == 0 { + return mentions, nil } - return mentions, nil + q := m.newMentionQ(&mentions). + Where("mention.id in (?)", pg.In(ids)) + + err := processErrorResponse(q.Select()) + + return mentions, err } diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 098513b96..f63c62cf4 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -50,6 +50,7 @@ type postgresService struct { db.Admin db.Basic db.Instance + db.Media db.Mention db.Notification db.Relationship @@ -128,6 +129,12 @@ func NewPostgresService(ctx context.Context, c *config.Config, log *logrus.Logge log: log, cancel: cancel, }, + Media: &mediaDB{ + config: c, + conn: conn, + log: log, + cancel: cancel, + }, Mention: &mentionDB{ config: c, conn: conn, diff --git a/internal/db/pg/status_test.go b/internal/db/pg/status_test.go index da7299dfa..9d699f0b9 100644 --- a/internal/db/pg/status_test.go +++ b/internal/db/pg/status_test.go @@ -29,7 +29,7 @@ type StatusTestSuite struct { PGStandardTestSuite } -func (suite *PGStandardTestSuite) SetupSuite() { +func (suite *StatusTestSuite) SetupSuite() { suite.testTokens = testrig.NewTestTokens() suite.testClients = testrig.NewTestClients() suite.testApplications = testrig.NewTestApplications() @@ -41,7 +41,7 @@ func (suite *PGStandardTestSuite) SetupSuite() { suite.testMentions = testrig.NewTestMentions() } -func (suite *PGStandardTestSuite) SetupTest() { +func (suite *StatusTestSuite) SetupTest() { suite.config = testrig.NewTestConfig() suite.db = testrig.NewTestDB() suite.log = testrig.NewTestLog() @@ -49,11 +49,11 @@ func (suite *PGStandardTestSuite) SetupTest() { testrig.StandardDBSetup(suite.db, suite.testAccounts) } -func (suite *PGStandardTestSuite) TearDownTest() { +func (suite *StatusTestSuite) TearDownTest() { testrig.StandardDBTeardown(suite.db) } -func (suite *PGStandardTestSuite) TestGetStatusByID() { +func (suite *StatusTestSuite) TestGetStatusByID() { status, err := suite.db.GetStatusByID(suite.testStatuses["local_account_1_status_1"].ID) if err != nil { suite.FailNow(err.Error()) @@ -67,7 +67,7 @@ func (suite *PGStandardTestSuite) TestGetStatusByID() { suite.Nil(status.InReplyToAccount) } -func (suite *PGStandardTestSuite) TestGetStatusByURI() { +func (suite *StatusTestSuite) TestGetStatusByURI() { status, err := suite.db.GetStatusByURI(suite.testStatuses["local_account_1_status_1"].URI) if err != nil { suite.FailNow(err.Error()) @@ -81,7 +81,7 @@ func (suite *PGStandardTestSuite) TestGetStatusByURI() { suite.Nil(status.InReplyToAccount) } -func (suite *PGStandardTestSuite) TestGetStatusWithExtras() { +func (suite *StatusTestSuite) TestGetStatusWithExtras() { status, err := suite.db.GetStatusByID(suite.testStatuses["admin_account_status_1"].ID) if err != nil { suite.FailNow(err.Error()) @@ -95,5 +95,5 @@ func (suite *PGStandardTestSuite) TestGetStatusWithExtras() { } func TestStatusTestSuite(t *testing.T) { - suite.Run(t, new(PGStandardTestSuite)) + suite.Run(t, new(StatusTestSuite)) } diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index c7af2bb76..354f37e04 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -34,16 +34,16 @@ type Status struct { Content string // Database IDs of any media attachments associated with this status AttachmentIDs []string `pg:"attachments,array"` - Attachments []*MediaAttachment `pg:"rel:has-many"` + Attachments []*MediaAttachment `pg:"attached_media,rel:has-many"` // Database IDs of any tags used in this status TagIDs []string `pg:"tags,array"` - Tags []*Tag `pg:"many2many:status_to_tags"` // https://pg.uptrace.dev/orm/many-to-many-relation/ + Tags []*Tag `pg:"attached_tags,many2many:status_to_tags"` // https://pg.uptrace.dev/orm/many-to-many-relation/ // Database IDs of any mentions in this status MentionIDs []string `pg:"mentions,array"` - Mentions []*Mention `pg:"rel:has-many"` + Mentions []*Mention `pg:"attached_mentions,rel:has-many"` // Database IDs of any emojis used in this status EmojiIDs []string `pg:"emojis,array"` - Emojis []*Emoji `pg:"many2many:status_to_emojis"` // https://pg.uptrace.dev/orm/many-to-many-relation/ + Emojis []*Emoji `pg:"attached_emojis,many2many:status_to_emojis"` // https://pg.uptrace.dev/orm/many-to-many-relation/ // when was this status created? CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // when was this status updated? diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 10d9a0f18..3a90cfa53 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -24,6 +24,7 @@ import ( "github.com/go-fed/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/cache" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -175,8 +176,9 @@ type TypeConverter interface { } type converter struct { - config *config.Config - db db.DB + config *config.Config + db db.DB + frontendCache cache.Cache } // NewConverter returns a new Converter @@ -184,5 +186,6 @@ func NewConverter(config *config.Config, db db.DB) TypeConverter { return &converter{ config: config, db: db, + frontendCache: cache.New(), } } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 552157d0c..cdf68730b 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -62,6 +62,14 @@ func (c *converter) AccountToMastoSensitive(a *gtsmodel.Account) (*model.Account } func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, error) { + // first check if we have this account in our frontEnd cache + if accountI, err := c.frontendCache.Fetch(a.ID); err == nil { + if account, ok := accountI.(*model.Account); ok { + // we have it, so just return it as-is + return account, nil + } + } + // count followers followersCount, err := c.db.CountAccountFollowers(a.ID, false) if err != nil { @@ -90,14 +98,30 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e // build the avatar and header URLs var aviURL string var aviURLStatic string - if a.AvatarMediaAttachment != nil { + if a.AvatarMediaAttachmentID != "" { + // make sure avi is pinned to this account + if a.AvatarMediaAttachment == nil { + avi, err := c.db.GetAttachmentByID(a.AvatarMediaAttachmentID) + if err != nil { + return nil, fmt.Errorf("error retrieving avatar: %s", err) + } + a.AvatarMediaAttachment = avi + } aviURL = a.AvatarMediaAttachment.URL aviURLStatic = a.AvatarMediaAttachment.Thumbnail.URL } var headerURL string var headerURLStatic string - if a.HeaderMediaAttachment != nil { + if a.HeaderMediaAttachmentID != "" { + // make sure header is pinned to this account + if a.HeaderMediaAttachment == nil { + avi, err := c.db.GetAttachmentByID(a.HeaderMediaAttachmentID) + if err != nil { + return nil, fmt.Errorf("error retrieving avatar: %s", err) + } + a.HeaderMediaAttachment = avi + } headerURL = a.HeaderMediaAttachment.URL headerURLStatic = a.HeaderMediaAttachment.Thumbnail.URL } @@ -132,7 +156,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e suspended = true } - return &model.Account{ + accountFrontend := &model.Account{ ID: a.ID, Username: a.Username, Acct: acct, @@ -153,7 +177,14 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e Emojis: emojis, // TODO: implement this Fields: fields, Suspended: suspended, - }, nil + } + + // put the account in our cache in case we need it again soon + if err := c.frontendCache.Store(a.ID, accountFrontend); err != nil { + return nil, err + } + + return accountFrontend, nil } func (c *converter) AccountToMastoBlocked(a *gtsmodel.Account) (*model.Account, error) {