Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[performance] cache recently allowed/denied domains to cut down on db calls #794

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions internal/cache/domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package cache

import (
"time"

"codeberg.org/gruf/go-cache/v2"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)

// DomainCache is a cache wrapper to provide URL and URI lookups for gtsmodel.Status
type DomainBlockCache struct {
cache cache.LookupCache[string, string, *gtsmodel.DomainBlock]
}

// NewStatusCache returns a new instantiated statusCache object
func NewDomainBlockCache() *DomainBlockCache {
c := &DomainBlockCache{}
c.cache = cache.NewLookup(cache.LookupCfg[string, string, *gtsmodel.DomainBlock]{
RegisterLookups: func(lm *cache.LookupMap[string, string]) {
lm.RegisterLookup("id")
},

AddLookups: func(lm *cache.LookupMap[string, string], block *gtsmodel.DomainBlock) {
// Block can be equal to nil when sentinel
if block != nil && block.ID != "" {
lm.Set("id", block.ID, block.Domain)
}
},

DeleteLookups: func(lm *cache.LookupMap[string, string], block *gtsmodel.DomainBlock) {
// Block can be equal to nil when sentinel
if block != nil && block.ID != "" {
lm.Delete("id", block.ID)
}
},
})
c.cache.SetTTL(time.Minute*5, false)
c.cache.Start(time.Second * 10)
return c
}

// GetByID attempts to fetch a status from the cache by its ID, you will receive a copy for thread-safety
func (c *DomainBlockCache) GetByID(id string) (*gtsmodel.DomainBlock, bool) {
return c.cache.GetBy("id", id)
}

// GetByURL attempts to fetch a status from the cache by its URL, you will receive a copy for thread-safety
func (c *DomainBlockCache) GetByDomain(domain string) (*gtsmodel.DomainBlock, bool) {
return c.cache.Get(domain)
}

// Put places a status in the cache, ensuring that the object place is a copy for thread-safety
func (c *DomainBlockCache) Put(domain string, block *gtsmodel.DomainBlock) {
if domain == "" {
panic("invalid domain")
}

if block == nil {
// This is a sentinel value for (no block)
c.cache.Set(domain, nil)
} else {
// This is a valid domain block
c.cache.Set(domain, copyDomainBlock(block))
}
}

// InvalidateByDomain will invalidate a domain block from the cache by domain name.
func (c *DomainBlockCache) InvalidateByDomain(domain string) {
c.cache.Invalidate(domain)
}

// copyStatus performs a surface-level copy of status, only keeping attached IDs intact, not the objects.
// due to all the data being copied being 99% primitive types or strings (which are immutable and passed by ptr)
// this should be a relatively cheap process
func copyDomainBlock(block *gtsmodel.DomainBlock) *gtsmodel.DomainBlock {
return &gtsmodel.DomainBlock{
ID: block.ID,
CreatedAt: block.CreatedAt,
UpdatedAt: block.UpdatedAt,
Domain: block.Domain,
CreatedByAccountID: block.CreatedByAccountID,
CreatedByAccount: nil,
PrivateComment: block.PrivateComment,
PublicComment: block.PublicComment,
Obfuscate: block.Obfuscate,
SubscriptionID: block.SubscriptionID,
}
}
6 changes: 5 additions & 1 deletion internal/db/bundb/bundb.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ func NewBunDBService(ctx context.Context) (db.DB, error) {
notifCache.SetTTL(time.Minute*5, false)
notifCache.Start(time.Second * 10)

// Prepare domain block cache
blockCache := cache.NewDomainBlockCache()

ps := &bunDBService{
Account: accounts,
Admin: &adminDB{
Expand All @@ -181,7 +184,8 @@ func NewBunDBService(ctx context.Context) (db.DB, error) {
conn: conn,
},
Domain: &domainDB{
conn: conn,
conn: conn,
cache: blockCache,
},
Emoji: &emojiDB{
conn: conn,
Expand Down
115 changes: 95 additions & 20 deletions internal/db/bundb/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,59 +20,134 @@ package bundb

import (
"context"
"database/sql"
"net/url"
"strings"

"github.com/superseriousbusiness/gotosocial/internal/cache"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)

type domainDB struct {
conn *DBConn
conn *DBConn
cache *cache.DomainBlockCache
}

func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, db.Error) {
if domain == "" || domain == config.GetHost() {
return false, nil
func (d *domainDB) CreateDomainBlock(ctx context.Context, block gtsmodel.DomainBlock) db.Error {
// Normalize to lowercase
block.Domain = strings.ToLower(block.Domain)

// Attempt to insert new domain block
_, err := d.conn.NewInsert().
Model(&block).
Exec(ctx, &block)
if err != nil {
return d.conn.ProcessError(err)
}

// Cache this domain block
d.cache.Put(block.Domain, &block)

return nil
}

func (d *domainDB) GetDomainBlock(ctx context.Context, domain string) (*gtsmodel.DomainBlock, db.Error) {
// Normalize to lowercase
domain = strings.ToLower(domain)

// Check for easy case, domain referencing *us*
if domain == "" || domain == config.GetAccountDomain() {
return nil, db.ErrNoEntries
}

// Check for already cached rblock
if block, ok := d.cache.GetByDomain(domain); ok {
// A 'nil' return value is a sentinel value for no block
if block == nil {
return nil, db.ErrNoEntries
}

// Else, this block exists
return block, nil
}

block := &gtsmodel.DomainBlock{}

q := d.conn.
NewSelect().
Model(&gtsmodel.DomainBlock{}).
ExcludeColumn("id", "created_at", "updated_at", "created_by_account_id", "private_comment", "public_comment", "obfuscate", "subscription_id").
Model(block).
Where("domain = ?", domain).
Limit(1)

return d.conn.Exists(ctx, q)
// Query database for domain block
switch err := q.Scan(ctx); err {
// No error, block found
case nil:
d.cache.Put(domain, block)
return block, nil

// No error, simply not found
case sql.ErrNoRows:
d.cache.Put(domain, nil)
return nil, db.ErrNoEntries

// Any other db error
default:
return nil, d.conn.ProcessError(err)
}
}

func (d *domainDB) AreDomainsBlocked(ctx context.Context, domains []string) (bool, db.Error) {
// filter out any doubles
uniqueDomains := util.UniqueStrings(domains)
func (d *domainDB) DeleteDomainBlock(ctx context.Context, domain string) db.Error {
// Normalize to lowercase
domain = strings.ToLower(domain)

for _, domain := range uniqueDomains {
if blocked, err := d.IsDomainBlocked(ctx, strings.ToLower(domain)); err != nil {
// Attempt to delete domain block
_, err := d.conn.NewDelete().
Model((*gtsmodel.DomainBlock)(nil)).
Where("domain = ?", domain).
Exec(ctx, nil)
if err != nil {
return d.conn.ProcessError(err)
}

// Clear domain from cache
d.cache.InvalidateByDomain(domain)

return nil
}

func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, db.Error) {
block, err := d.GetDomainBlock(ctx, domain)
if err == nil || err == db.ErrNoEntries {
return (block != nil), nil
}
return false, err
}

func (d *domainDB) AreDomainsBlocked(ctx context.Context, domains []string) (bool, db.Error) {
for _, domain := range domains {
if blocked, err := d.IsDomainBlocked(ctx, domain); err != nil {
return false, err
} else if blocked {
return blocked, nil
}
}

// no blocks found
return false, nil
}

func (d *domainDB) IsURIBlocked(ctx context.Context, uri *url.URL) (bool, db.Error) {
domain := uri.Hostname()
return d.IsDomainBlocked(ctx, domain)
return d.IsDomainBlocked(ctx, uri.Hostname())
}

func (d *domainDB) AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, db.Error) {
domains := []string{}
for _, uri := range uris {
domains = append(domains, uri.Hostname())
if blocked, err := d.IsDomainBlocked(ctx, uri.Hostname()); err != nil {
return false, err
} else if blocked {
return blocked, nil
}
}
return d.AreDomainsBlocked(ctx, domains)
return false, nil
}
9 changes: 8 additions & 1 deletion internal/db/bundb/domain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package bundb_test
import (
"context"
"testing"
"time"

"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
Expand All @@ -33,18 +34,24 @@ type DomainTestSuite struct {
func (suite *DomainTestSuite) TestIsDomainBlocked() {
ctx := context.Background()

now := time.Now()

domainBlock := &gtsmodel.DomainBlock{
ID: "01G204214Y9TNJEBX39C7G88SW",
Domain: "some.bad.apples",
CreatedAt: now,
UpdatedAt: now,
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
CreatedByAccount: suite.testAccounts["admin_account"],
}

// no domain block exists for the given domain yet
blocked, err := suite.db.IsDomainBlocked(ctx, domainBlock.Domain)
suite.NoError(err)
suite.False(blocked)

suite.db.Put(ctx, domainBlock)
err = suite.db.CreateDomainBlock(ctx, *domainBlock)
suite.NoError(err)

// domain block now exists
blocked, err = suite.db.IsDomainBlocked(ctx, domainBlock.Domain)
Expand Down
11 changes: 11 additions & 0 deletions internal/db/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,21 @@ package db
import (
"context"
"net/url"

"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)

// Domain contains DB functions related to domains and domain blocks.
type Domain interface {
// CreateDomainBlock ...
CreateDomainBlock(ctx context.Context, block gtsmodel.DomainBlock) Error

// GetDomainBlock ...
GetDomainBlock(ctx context.Context, domain string) (*gtsmodel.DomainBlock, Error)

// DeleteDomainBlock ...
DeleteDomainBlock(ctx context.Context, domain string) Error

// IsDomainBlocked checks if an instance-level domain block exists for the given domain string (eg., `example.org`).
IsDomainBlocked(ctx context.Context, domain string) (bool, Error)

Expand Down
Loading