diff --git a/app.go b/app.go index 3353c58..1cd65e8 100644 --- a/app.go +++ b/app.go @@ -8,17 +8,18 @@ import ( shutdowner "git.jlel.se/jlelse/go-shutdowner" ts "git.jlel.se/jlelse/template-strings" - "github.com/dgraph-io/ristretto" ct "github.com/elnormous/contenttype" apc "github.com/go-ap/client" "github.com/go-fed/httpsig" + "github.com/kaorimatz/go-opml" rotatelogs "github.com/lestrrat-go/file-rotatelogs" + "github.com/samber/go-singleflightx" "github.com/yuin/goldmark" + c "go.goblog.app/app/pkgs/cache" "go.goblog.app/app/pkgs/minify" "go.goblog.app/app/pkgs/plugins" "go.hacdias.com/indielib/indieauth" "golang.org/x/crypto/acme/autocert" - "golang.org/x/sync/singleflight" ) type goBlog struct { @@ -39,9 +40,9 @@ type goBlog struct { autocertManager *autocert.Manager autocertInit sync.Once // Blogroll - blogrollCacheGroup singleflight.Group + blogrollCacheGroup singleflightx.Group[string, []*opml.Outline] // Blogstats - blogStatsCacheGroup singleflight.Group + blogStatsCacheGroup singleflightx.Group[string, *blogStatsData] // Cache cache *cache // Config @@ -81,7 +82,7 @@ type goBlog struct { mediaStorage mediaStorage // Microformats mfInit sync.Once - mfCache *ristretto.Cache + mfCache *c.Cache[string, []byte] // Micropub mpImpl *micropubImplementation // Minify @@ -90,11 +91,11 @@ type goBlog struct { pluginHost *plugins.PluginHost // Profile image profileImageHashString string - profileImageHashGroup singleflight.Group + profileImageHashGroup *sync.Once // Reactions reactionsInit sync.Once - reactionsCache *ristretto.Cache - reactionsSfg singleflight.Group + reactionsCache *c.Cache[string, string] + reactionsSfg singleflightx.Group[string, string] // Regex Redirects regexRedirects []*regexRedirect // Sessions diff --git a/blogroll.go b/blogroll.go index f0a32e2..6b0c46b 100644 --- a/blogroll.go +++ b/blogroll.go @@ -33,7 +33,7 @@ func (bc *configBlog) getBlogrollPath() (bool, string) { func (a *goBlog) serveBlogroll(w http.ResponseWriter, r *http.Request) { blog, bc := a.getBlog(r) - outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() (any, error) { + outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() ([]*opml.Outline, error) { return a.getBlogrollOutlines(blog) }) if err != nil { @@ -48,7 +48,7 @@ func (a *goBlog) serveBlogroll(w http.ResponseWriter, r *http.Request) { Data: &blogrollRenderData{ title: c.Title, description: c.Description, - outlines: outlines.([]*opml.Outline), + outlines: outlines, download: can + blogrollDownloadFile, refresh: can + blogrollRefreshSubpath, }, @@ -57,7 +57,7 @@ func (a *goBlog) serveBlogroll(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveBlogrollExport(w http.ResponseWriter, r *http.Request) { blog, _ := a.getBlog(r) - outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() (any, error) { + outlines, err, _ := a.blogrollCacheGroup.Do(blog, func() ([]*opml.Outline, error) { return a.getBlogrollOutlines(blog) }) if err != nil { @@ -70,7 +70,7 @@ func (a *goBlog) serveBlogrollExport(w http.ResponseWriter, r *http.Request) { _ = pw.CloseWithError(opml.Render(pw, &opml.OPML{ Version: "2.0", DateCreated: time.Now().UTC(), - Outlines: outlines.([]*opml.Outline), + Outlines: outlines, })) }() w.Header().Set(contentType, contenttype.XMLUTF8) diff --git a/blogstats.go b/blogstats.go index 28a864d..dafb0db 100644 --- a/blogstats.go +++ b/blogstats.go @@ -38,7 +38,7 @@ func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) { func (a *goBlog) serveBlogStatsTable(w http.ResponseWriter, r *http.Request) { blog, _ := a.getBlog(r) - data, err, _ := a.blogStatsCacheGroup.Do(blog, func() (any, error) { + data, err, _ := a.blogStatsCacheGroup.Do(blog, func() (*blogStatsData, error) { return a.db.getBlogStats(blog) }) if err != nil { @@ -127,8 +127,8 @@ func (db *database) getBlogStats(blog string) (data *blogStatsData, err error) { return stats, nil } // Prevent creating posts while getting stats - db.pcm.Lock() - defer db.pcm.Unlock() + db.pcm.RLock() + defer db.pcm.RUnlock() // Scan objects currentStats := blogStatsRow{} var currentMonth, currentYear string diff --git a/cache.go b/cache.go index 6a84d80..30533b9 100644 --- a/cache.go +++ b/cache.go @@ -7,10 +7,10 @@ import ( "sort" "time" - "github.com/dgraph-io/ristretto" + "github.com/samber/go-singleflightx" "go.goblog.app/app/pkgs/bodylimit" "go.goblog.app/app/pkgs/bufferpool" - "golang.org/x/sync/singleflight" + c "go.goblog.app/app/pkgs/cache" ) const ( @@ -20,8 +20,8 @@ const ( ) type cache struct { - g singleflight.Group - c *ristretto.Cache + g singleflightx.Group[string, *cacheItem] + c *c.Cache[string, *cacheItem] } func (a *goBlog) initCache() error { @@ -29,32 +29,10 @@ func (a *goBlog) initCache() error { if a.cfg.Cache != nil && !a.cfg.Cache.Enable { return nil // Cache disabled } - - c, err := ristretto.NewCache(&ristretto.Config{ - NumCounters: 40000, - MaxCost: 20 * bodylimit.MB, - BufferItems: 64, - Metrics: true, - }) - if err != nil { - return err - } - - a.cache.c = c - go a.logCacheMetrics() + a.cache.c = c.New[string, *cacheItem](time.Minute, 20*bodylimit.MB) return nil } -func (a *goBlog) logCacheMetrics() { - ticker := time.NewTicker(15 * time.Minute) - defer ticker.Stop() - - for range ticker.C { - met := a.cache.c.Metrics - a.info("Cache metrics", "metrics", met.String()) - } -} - func cacheLoggedIn(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), cacheLoggedInKey, true) @@ -70,11 +48,10 @@ func (a *goBlog) cacheMiddleware(next http.Handler) http.Handler { } key := generateCacheKey(r) - cacheInterface, _, _ := a.cache.g.Do(key, func() (interface{}, error) { + ci, _, _ := a.cache.g.Do(key, func() (*cacheItem, error) { return a.cache.getOrCreateCache(key, next, r), nil }) - ci := cacheInterface.(*cacheItem) a.serveCachedResponse(w, r, ci) }) } @@ -153,7 +130,7 @@ func (a *goBlog) setCacheHeaders(w http.ResponseWriter, cache *cacheItem) { func (c *cache) getOrCreateCache(key string, next http.Handler, r *http.Request) *cacheItem { if rItem, ok := c.c.Get(key); ok { - return rItem.(*cacheItem) + return rItem } // Remove original timeout, add new one @@ -201,8 +178,7 @@ func (c *cache) saveCache(key string, item *cacheItem) { if item.expiration > 0 { ttl = time.Duration(item.expiration) * time.Second } - c.c.SetWithTTL(key, item, item.cost(), ttl) - c.c.Wait() + c.c.Set(key, item, ttl, item.cost()) } func (c *cache) purge() { diff --git a/cache_test.go b/cache_test.go index 8445b7e..2bf8463 100644 --- a/cache_test.go +++ b/cache_test.go @@ -6,9 +6,11 @@ import ( "net/http/httptest" "strconv" "testing" + "time" - "github.com/dgraph-io/ristretto" "github.com/stretchr/testify/assert" + "go.goblog.app/app/pkgs/bodylimit" + cpkg "go.goblog.app/app/pkgs/cache" ) func Benchmark_cacheItem_cost(b *testing.B) { @@ -53,11 +55,7 @@ func Benchmark_cacheKey(b *testing.B) { func Benchmark_cache_getCache(b *testing.B) { c := &cache{} - c.c, _ = ristretto.NewCache(&ristretto.Config{ - NumCounters: 40 * 1000, - MaxCost: 20 * 1000 * 1000, - BufferItems: 64, - }) + c.c = cpkg.New[string, *cacheItem](time.Minute, 10*bodylimit.MB) req := httptest.NewRequest(http.MethodGet, "/abc?abc=def&hij=klm", nil) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "abcdefghijklmnopqrstuvwxyz") diff --git a/check.go b/check.go index ce4e2d0..c42a599 100644 --- a/check.go +++ b/check.go @@ -10,11 +10,11 @@ import ( "time" "github.com/carlmjohnson/requests" - "github.com/dgraph-io/ristretto" "github.com/klauspost/compress/gzhttp" "github.com/samber/lo" "github.com/sourcegraph/conc/pool" "go.goblog.app/app/pkgs/bodylimit" + cpkg "go.goblog.app/app/pkgs/cache" "go.goblog.app/app/pkgs/httpcachetransport" ) @@ -46,14 +46,8 @@ func (a *goBlog) checkLinks(posts ...*post) error { cancelFunc() fmt.Println("Cancelled link check") }) - // Create HTTP cache - cache, err := ristretto.NewCache(&ristretto.Config{ - NumCounters: 50000, MaxCost: 5000, BufferItems: 64, IgnoreInternalCost: true, - }) - if err != nil { - return err - } // Create HTTP client + cache := cpkg.New[string, []byte](time.Minute, 5000) client := &http.Client{ Timeout: 30 * time.Second, Transport: httpcachetransport.NewHttpCacheTransportNoBody(gzhttp.Transport(&http.Transport{ diff --git a/database.go b/database.go index 25f705a..866221c 100644 --- a/database.go +++ b/database.go @@ -10,17 +10,17 @@ import ( "github.com/google/uuid" sqlite "github.com/mattn/go-sqlite3" + "github.com/samber/go-singleflightx" "github.com/schollz/sqlite3dump" - "golang.org/x/sync/singleflight" ) type database struct { a *goBlog db *sql.DB // Other things - pc singleflight.Group // persistant cache - pcm sync.Mutex // post creation - sp sync.Mutex // short path creation + pc singleflightx.Group[string, []byte] // persistant cache + pcm sync.RWMutex // post creation + sp sync.Mutex // short path creation debug bool } diff --git a/go.mod b/go.mod index 7d388fd..44ec44f 100644 --- a/go.mod +++ b/go.mod @@ -13,14 +13,13 @@ require ( github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/carlmjohnson/requests v0.24.1 github.com/dchest/captcha v1.0.0 - github.com/dgraph-io/ristretto v0.1.1 github.com/disintegration/imaging v1.6.2 github.com/dmulholl/mp3lib v1.0.0 github.com/elnormous/contenttype v1.0.4 github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 github.com/emersion/go-smtp v0.21.3 github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594 - github.com/go-ap/client v0.0.0-20240710145250-eec2de3441ed + github.com/go-ap/client v0.0.0-20240801112518-4c25c5a0156a github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 github.com/go-chi/chi/v5 v5.1.0 github.com/go-fed/httpsig v1.1.0 @@ -42,6 +41,7 @@ require ( github.com/paulmach/go.geojson v1.5.0 github.com/posener/wstest v1.2.0 github.com/pquerna/otp v1.4.0 + github.com/samber/go-singleflightx v0.3.1 github.com/samber/lo v1.46.0 github.com/schollz/sqlite3dump v1.3.1 github.com/snabb/sitemap v1.0.4 @@ -76,14 +76,11 @@ require ( github.com/andybalholm/cascadia v1.3.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/boombuler/barcode v1.0.2 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.2 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // indirect - github.com/golang/glog v1.2.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 708336b..a02762c 100644 --- a/go.sum +++ b/go.sum @@ -36,9 +36,6 @@ github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXye github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= github.com/carlmjohnson/requests v0.24.1 h1:M8hmzyJr3A9D3u96MjCNuUVLd7Z3hQb7UjP5DsBp3lE= github.com/carlmjohnson/requests v0.24.1/go.mod h1:duYA/jDnyZ6f3xbcF5PpZ9N8clgopubP2nK5i6MVMhU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -47,19 +44,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/captcha v1.0.0 h1:vw+bm/qMFvTgcjQlYVTuQBJkarm5R0YSsDKhm1HZI2o= github.com/dchest/captcha v1.0.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= -github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= -github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.2 h1:/u628IuisSTwri5/UKloiIsH8+qF2Pu7xEQX+yIKg68= github.com/dlclark/regexp2 v1.11.2/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dmulholl/mp3lib v1.0.0 h1:PZq24kJBIk5zIxi/t6Qp8/EOAbAqThyrUCpkUKLBeWQ= github.com/dmulholl/mp3lib v1.0.0/go.mod h1:4RoA+iht/khfwxmH1ieoxZTzYVbb0am/zdvFkyGRr6I= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8= github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= @@ -75,8 +65,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594 h1:er3GvGCm7bJwHostjZlsRy7uiUuCquUVF9Fe0TrwiPI= github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594/go.mod h1:yRUfFCoZY6C1CWalauqEQ5xYgSckzEBEO/2MBC6BOME= -github.com/go-ap/client v0.0.0-20240710145250-eec2de3441ed h1:3KG6nQMhTqxvnCT+Cav4cQi03isE66N2mxvIocSO+20= -github.com/go-ap/client v0.0.0-20240710145250-eec2de3441ed/go.mod h1:xk8Lh1zbW/ZpLuPd5bIYhFu27Fo5Y2SsPgZfw75aDSg= +github.com/go-ap/client v0.0.0-20240801112518-4c25c5a0156a h1:UrZzmoqFbrR/pOWRFcbxdhE7QNM4R0a2Rru8RJFHlvI= +github.com/go-ap/client v0.0.0-20240801112518-4c25c5a0156a/go.mod h1:Mzm41jnI+F2Fp3cKWg3A2GzwnHNv6alHHhPl0y8w5rI= github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 h1:H9MGShwybHLSln6K8RxHPMHiLcD86Lru+5TVW2TcXHY= github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0/go.mod h1:5x8a6P/dhmMGFxWLcyYlyOuJ2lRNaHGhRv+yu8BaTSI= github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= @@ -90,9 +80,6 @@ github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqw github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY= -github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -218,6 +205,8 @@ github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3 github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/samber/go-singleflightx v0.3.1 h1:D9NAWs1t2TKf4wpkxRw6MIXqpDt2bXb5ZZyT2QuvybY= +github.com/samber/go-singleflightx v0.3.1/go.mod h1:X2BR+oheHIYc73PvxRMlcASg6KYYTQyUYpdVU7t/ux4= github.com/samber/lo v1.46.0 h1:w8G+oaCPgz1PoCJztqymCFaKwXt+5cCXn51uPxExFfQ= github.com/samber/lo v1.46.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= github.com/schollz/sqlite3dump v1.3.1 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA= @@ -340,7 +329,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/microformats.go b/microformats.go index 011346b..67d1ad2 100644 --- a/microformats.go +++ b/microformats.go @@ -10,9 +10,9 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/carlmjohnson/requests" - "github.com/dgraph-io/ristretto" "go.goblog.app/app/pkgs/bodylimit" "go.goblog.app/app/pkgs/bufferpool" + cpkg "go.goblog.app/app/pkgs/cache" "go.goblog.app/app/pkgs/contenttype" "go.goblog.app/app/pkgs/httpcachetransport" "willnorris.com/go/microformats" @@ -20,12 +20,7 @@ import ( func (a *goBlog) initMicroformatsCache() { a.mfInit.Do(func() { - a.mfCache, _ = ristretto.NewCache(&ristretto.Config{ - NumCounters: 100, - MaxCost: 10, // Cache http responses for 10 requests - BufferItems: 64, - IgnoreInternalCost: true, - }) + a.mfCache = cpkg.New[string, []byte](time.Minute, 10) }) } diff --git a/persistentCache.go b/persistentCache.go index 5b7e75a..bc61b32 100644 --- a/persistentCache.go +++ b/persistentCache.go @@ -26,7 +26,7 @@ func (db *database) retrievePersistentCacheContext(c context.Context, key string if db == nil { return nil, errors.New("database is nil") } - d, err, _ := db.pc.Do(key, func() (any, error) { + d, err, _ := db.pc.Do(key, func() ([]byte, error) { if row, err := db.QueryRowContext(c, "select data from persistent_cache where key = @key", sql.Named("key", key)); err != nil { return nil, err } else { @@ -40,10 +40,7 @@ func (db *database) retrievePersistentCacheContext(c context.Context, key string if err != nil { return nil, err } - if d == nil { - return nil, nil - } - return d.([]byte), nil + return d, nil } func (db *database) clearPersistentCache(pattern string) error { diff --git a/pkgs/cache/cache.go b/pkgs/cache/cache.go new file mode 100644 index 0000000..009dd39 --- /dev/null +++ b/pkgs/cache/cache.go @@ -0,0 +1,161 @@ +package cache + +import ( + "sync" + "time" +) + +// Cache stores arbitrary data with expiration time and cost. +type Cache[K comparable, V any] struct { + items map[K]*element[K, *item[K, V]] + evictionList *list[K, *item[K, V]] + mutex sync.RWMutex + close chan struct{} + maxCost int64 + currentCost int64 +} + +// An item represents arbitrary data with expiration time and cost. +type item[K comparable, V any] struct { + key K + data V + expires int64 + cost int64 +} + +// New creates a new cache that asynchronously cleans expired entries after the given time passes. +func New[K comparable, V any](cleaningInterval time.Duration, maxCost int64) *Cache[K, V] { + cache := &Cache[K, V]{ + items: make(map[K]*element[K, *item[K, V]]), + evictionList: newList[K, *item[K, V]](), + close: make(chan struct{}), + maxCost: maxCost, + } + + go func() { + ticker := time.NewTicker(cleaningInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + cache.evictExpiredItems() + case <-cache.close: + return + } + } + }() + + return cache +} + +// Get gets the value for the given key. +func (cache *Cache[K, V]) Get(key K) (V, bool) { + cache.mutex.RLock() + defer cache.mutex.RUnlock() + + element, exists := cache.items[key] + if !exists { + var zeroValue V + return zeroValue, false + } + + item := element.value + if item.expires > 0 && time.Now().UnixNano() > item.expires { + cache.mutex.RUnlock() + cache.mutex.Lock() + cache.removeElement(element) + cache.mutex.Unlock() + cache.mutex.RLock() + var zeroValue V + return zeroValue, false + } + + cache.evictionList.moveToFront(element) + + return item.data, true +} + +// Set sets a value for the given key with an expiration duration and a cost. +// If the duration is 0 or less, it will be stored forever. +func (cache *Cache[K, V]) Set(key K, value V, duration time.Duration, cost int64) { + var expires int64 + if duration > 0 { + expires = time.Now().Add(duration).UnixNano() + } + + cache.mutex.Lock() + defer cache.mutex.Unlock() + + if element, exists := cache.items[key]; exists { + cache.evictionList.moveToFront(element) + item := element.value + cache.currentCost -= item.cost + item.data = value + item.expires = expires + item.cost = cost + cache.currentCost += cost + } else { + newItem := &item[K, V]{key: key, data: value, expires: expires, cost: cost} + element := cache.evictionList.pushFront(newItem) + cache.items[key] = element + cache.currentCost += cost + } + + for cache.maxCost > 0 && cache.currentCost > cache.maxCost { + element := cache.evictionList.back() + if element != nil { + cache.removeElement(element) + } + } +} + +// Delete deletes the key and its value from the cache. +func (cache *Cache[K, V]) Delete(key K) { + cache.mutex.Lock() + defer cache.mutex.Unlock() + + if element, exists := cache.items[key]; exists { + cache.removeElement(element) + } +} + +// Clear removes all items from the cache. +func (cache *Cache[K, V]) Clear() { + cache.mutex.Lock() + defer cache.mutex.Unlock() + + cache.items = make(map[K]*element[K, *item[K, V]]) + cache.evictionList = newList[K, *item[K, V]]() + cache.currentCost = 0 +} + +// Close closes the cache and frees up resources. +func (cache *Cache[K, V]) Close() { + close(cache.close) + cache.Clear() +} + +// evictExpiredItems removes all expired items from the cache. +func (cache *Cache[K, V]) evictExpiredItems() { + cache.mutex.Lock() + defer cache.mutex.Unlock() + + now := time.Now().UnixNano() + for element := cache.evictionList.back(); element != nil; { + prev := element.prev + item := element.value + if item.expires > 0 && now > item.expires { + cache.removeElement(element) + } + element = prev + } +} + +// removeElement removes the specified list element from the cache. +func (cache *Cache[K, V]) removeElement(element *element[K, *item[K, V]]) { + cache.evictionList.remove(element) + item := element.value + delete(cache.items, item.key) + cache.currentCost -= item.cost +} diff --git a/pkgs/cache/cache_test.go b/pkgs/cache/cache_test.go new file mode 100644 index 0000000..e882744 --- /dev/null +++ b/pkgs/cache/cache_test.go @@ -0,0 +1,337 @@ +package cache + +import ( + "sync" + "testing" + "time" +) + +func TestGetSet(t *testing.T) { + cycle := 100 * time.Millisecond + c := New[string, string](cycle, 100) + defer c.Close() + + c.Set("sticky", "forever", 0, 1) + c.Set("hello", "Hello", cycle/2, 1) + + hello, found := c.Get("hello") + if !found { + t.FailNow() + } + if hello != "Hello" { + t.FailNow() + } + + time.Sleep(cycle / 2) + + _, found = c.Get("hello") + if found { + t.FailNow() + } + + time.Sleep(cycle) + + _, found = c.Get("404") + if found { + t.FailNow() + } + + _, found = c.Get("sticky") + if !found { + t.FailNow() + } +} + +func TestDelete(t *testing.T) { + c := New[string, string](time.Minute, 100) + c.Set("hello", "Hello", time.Hour, 1) + + _, found := c.Get("hello") + if !found { + t.FailNow() + } + + c.Delete("hello") + + _, found = c.Get("hello") + if found { + t.FailNow() + } +} + +func TestLRUEviction(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + for i := 0; i < 10; i++ { + c.Set(string(rune('a'+i)), string(rune('A'+i)), 0, 1) + } + + // Access first item to make it recently used + c.Get("a") + + // Add another item to trigger eviction + c.Set("k", "K", 0, 1) + + // Ensure 'a' is still there + if _, found := c.Get("a"); !found { + t.Error("expected 'a' to be found") + } + + // Ensure 'b' is evicted + if _, found := c.Get("b"); found { + t.Error("expected 'b' to be evicted") + } +} + +func TestCostEviction(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + // Add items with varying costs + c.Set("a", "A", 0, 3) + c.Set("b", "B", 0, 3) + c.Set("c", "C", 0, 3) + + // This should evict 'a' + c.Set("d", "D", 0, 4) + + if _, found := c.Get("a"); found { + t.Error("expected 'a' to be evicted") + } + + if _, found := c.Get("b"); !found { + t.Error("expected 'b' to be found") + } + + if _, found := c.Get("c"); !found { + t.Error("expected 'c' to be found") + } + + if _, found := c.Get("d"); !found { + t.Error("expected 'd' to be found") + } +} + +func TestTimeEviction(t *testing.T) { + c := New[string, string](100*time.Millisecond, 10) + defer c.Close() + + c.Set("a", "A", 10*time.Millisecond, 1) + + time.Sleep(200 * time.Millisecond) + + if _, found := c.Get("a"); found { + t.Error("expected 'a' to be evicted") + } +} + +func BenchmarkNew(b *testing.B) { + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + New[string, string](5*time.Second, 100).Close() + } + }) +} + +func BenchmarkGet(b *testing.B) { + c := New[string, string](5*time.Second, 100) + defer c.Close() + + c.Set("Hello", "World", 0, 1) + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Get("Hello") + } + }) +} + +func BenchmarkSet(b *testing.B) { + c := New[string, string](5*time.Second, 100) + defer c.Close() + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Set("Hello", "World", 0, 1) + } + }) +} + +func BenchmarkDelete(b *testing.B) { + c := New[string, string](5*time.Second, 100) + defer c.Close() + + b.ResetTimer() + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Delete("Hello") + } + }) +} + +func TestConcurrentSetGet(t *testing.T) { + c := New[string, string](time.Minute, 100) + defer c.Close() + + var wg sync.WaitGroup + + set := func(k, v string) { + defer wg.Done() + c.Set(k, v, time.Hour, 1) + } + + get := func(k string) { + defer wg.Done() + c.Get(k) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go set(string(rune('a'+i)), string(rune('A'+i))) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go get(string(rune('a' + i))) + } + + wg.Wait() +} + +func TestConcurrentSetDelete(t *testing.T) { + c := New[string, string](time.Minute, 100) + defer c.Close() + + var wg sync.WaitGroup + + set := func(k, v string) { + defer wg.Done() + c.Set(k, v, time.Hour, 1) + } + + delete := func(k string) { + defer wg.Done() + c.Delete(k) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go set(string(rune('a'+i)), string(rune('A'+i))) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go delete(string(rune('a' + i))) + } + + wg.Wait() +} + +func TestConcurrentSetGetDelete(t *testing.T) { + c := New[string, string](time.Minute, 100) + defer c.Close() + + var wg sync.WaitGroup + + set := func(k, v string) { + defer wg.Done() + c.Set(k, v, time.Hour, 1) + } + + get := func(k string) { + defer wg.Done() + c.Get(k) + } + + delete := func(k string) { + defer wg.Done() + c.Delete(k) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go set(string(rune('a'+i)), string(rune('A'+i))) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go get(string(rune('a' + i))) + } + + for i := 0; i < 100; i++ { + wg.Add(1) + go delete(string(rune('a' + i))) + } + + wg.Wait() +} + +func TestNegativeCost(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + c.Set("neg", "Negative", 0, -5) + + _, found := c.Get("neg") + if !found { + t.Error("expected 'neg' to be found") + } +} + +func TestZeroDuration(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + c.Set("zero", "Zero", 0, 1) + + _, found := c.Get("zero") + if !found { + t.Error("expected 'zero' to be found") + } +} + +func TestClear(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + c.Set("a", "A", 0, 1) + c.Set("b", "B", 0, 1) + + c.Clear() + + if _, found := c.Get("a"); found { + t.Error("expected 'a' to be cleared") + } + + if _, found := c.Get("b"); found { + t.Error("expected 'b' to be cleared") + } +} + +func TestSameKey(t *testing.T) { + c := New[string, string](time.Minute, 10) + defer c.Close() + + c.Set("a", "A", 0, 1) + c.Set("a", "B", 0, 1) + + val, found := c.Get("a") + if !found { + t.Error("expected 'a' to be found") + } + + if val != "B" { + t.Errorf("expected 'a' to be 'B', got %v", val) + } +} diff --git a/pkgs/cache/list.go b/pkgs/cache/list.go new file mode 100644 index 0000000..9316828 --- /dev/null +++ b/pkgs/cache/list.go @@ -0,0 +1,76 @@ +package cache + +import ( + "sync" +) + +type element[K comparable, V any] struct { + prev, next *element[K, V] + value V +} + +type list[K comparable, V any] struct { + head, tail *element[K, V] + mu sync.RWMutex +} + +func newList[K comparable, V any]() *list[K, V] { + return &list[K, V]{} +} + +func (l *list[K, V]) back() *element[K, V] { + l.mu.RLock() + defer l.mu.RUnlock() + return l.tail +} + +func (l *list[K, V]) pushFront(value V) *element[K, V] { + l.mu.Lock() + defer l.mu.Unlock() + node := &element[K, V]{value: value} + if l.head == nil { + l.head = node + l.tail = node + } else { + node.next = l.head + l.head.prev = node + l.head = node + } + return node +} + +func (l *list[K, V]) moveToFront(node *element[K, V]) { + l.mu.Lock() + defer l.mu.Unlock() + if l.head == node { + return + } + l.removeWithoutLock(node) + node.next = l.head + node.prev = nil + l.head.prev = node + l.head = node +} + +func (l *list[K, V]) remove(node *element[K, V]) { + l.mu.Lock() + defer l.mu.Unlock() + l.removeWithoutLock(node) +} + +func (l *list[K, V]) removeWithoutLock(node *element[K, V]) { + if node == l.head { + l.head = node.next + } + if node == l.tail { + l.tail = node.prev + } + if node.prev != nil { + node.prev.next = node.next + } + if node.next != nil { + node.next.prev = node.prev + } + node.prev = nil + node.next = nil +} diff --git a/pkgs/httpcachetransport/httpCacheTransport.go b/pkgs/httpcachetransport/httpCacheTransport.go index ec0dcdb..533f0f6 100644 --- a/pkgs/httpcachetransport/httpCacheTransport.go +++ b/pkgs/httpcachetransport/httpCacheTransport.go @@ -8,29 +8,27 @@ import ( "net/http/httputil" "time" - "github.com/dgraph-io/ristretto" + cpkg "go.goblog.app/app/pkgs/cache" ) type httpCacheTransport struct { - parent http.RoundTripper - ristrettoCache *ristretto.Cache - ttl time.Duration - body bool - maxSize int64 + parent http.RoundTripper + cache *cpkg.Cache[string, []byte] + ttl time.Duration + body bool + maxSize int64 } func (t *httpCacheTransport) RoundTrip(r *http.Request) (*http.Response, error) { requestUrl := r.URL.String() - if t.ristrettoCache != nil { - if cached, hasCached := t.ristrettoCache.Get(requestUrl); hasCached { - if cachedResp, ok := cached.([]byte); ok { - return http.ReadResponse(bufio.NewReader(bytes.NewReader(cachedResp)), r) - } + if t.cache != nil { + if cached, hasCached := t.cache.Get(requestUrl); hasCached { + return http.ReadResponse(bufio.NewReader(bytes.NewReader(cached)), r) } } resp, err := t.parent.RoundTrip(r) - if err == nil && t.ristrettoCache != nil { + if err == nil && t.cache != nil { // Limit the response size limitedResp := &http.Response{ Status: resp.Status, @@ -47,8 +45,7 @@ func (t *httpCacheTransport) RoundTrip(r *http.Request) (*http.Response, error) if err != nil { return resp, err } - t.ristrettoCache.SetWithTTL(requestUrl, respBytes, 1, t.ttl) - t.ristrettoCache.Wait() + t.cache.Set(requestUrl, respBytes, t.ttl, 1) return http.ReadResponse(bufio.NewReader(bytes.NewReader(respBytes)), r) } return resp, err @@ -56,11 +53,11 @@ func (t *httpCacheTransport) RoundTrip(r *http.Request) (*http.Response, error) // Creates a new http.RoundTripper that caches all // request responses (by the request URL) in ristretto. -func NewHttpCacheTransport(parent http.RoundTripper, ristrettoCache *ristretto.Cache, ttl time.Duration, maxSize int64) http.RoundTripper { - return &httpCacheTransport{parent, ristrettoCache, ttl, true, maxSize} +func NewHttpCacheTransport(parent http.RoundTripper, c *cpkg.Cache[string, []byte], ttl time.Duration, maxSize int64) http.RoundTripper { + return &httpCacheTransport{parent, c, ttl, true, maxSize} } // Like NewHttpCacheTransport but doesn't cache body -func NewHttpCacheTransportNoBody(parent http.RoundTripper, ristrettoCache *ristretto.Cache, ttl time.Duration, maxSize int64) http.RoundTripper { - return &httpCacheTransport{parent, ristrettoCache, ttl, false, maxSize} +func NewHttpCacheTransportNoBody(parent http.RoundTripper, c *cpkg.Cache[string, []byte], ttl time.Duration, maxSize int64) http.RoundTripper { + return &httpCacheTransport{parent, c, ttl, false, maxSize} } diff --git a/pkgs/httpcachetransport/httpCacheTransport_test.go b/pkgs/httpcachetransport/httpCacheTransport_test.go index 5c3df0c..28d672e 100644 --- a/pkgs/httpcachetransport/httpCacheTransport_test.go +++ b/pkgs/httpcachetransport/httpCacheTransport_test.go @@ -9,9 +9,9 @@ import ( "time" "github.com/carlmjohnson/requests" - "github.com/dgraph-io/ristretto" "github.com/stretchr/testify/assert" "go.goblog.app/app/pkgs/bodylimit" + cpkg "go.goblog.app/app/pkgs/cache" ) const fakeResponse = `HTTP/1.1 200 OK @@ -23,12 +23,7 @@ Date: Wed, 14 Dec 2022 10:34:03 GMT ` func TestHttpCacheTransport(t *testing.T) { - cache, _ := ristretto.NewCache(&ristretto.Config{ - NumCounters: 100, - MaxCost: 10, - BufferItems: 64, - IgnoreInternalCost: true, - }) + cache := cpkg.New[string, []byte](time.Minute, 10) counter := 0 diff --git a/profileImage.go b/profileImage.go index 2280afd..15d8287 100644 --- a/profileImage.go +++ b/profileImage.go @@ -15,6 +15,7 @@ import ( "regexp" "strconv" "strings" + "sync" _ "embed" @@ -141,24 +142,23 @@ func (a *goBlog) hasProfileImage() bool { } func (a *goBlog) profileImageHash() string { - _, _, _ = a.profileImageHashGroup.Do("", func() (interface{}, error) { - if a.profileImageHashString != "" { - return nil, nil - } + if a.profileImageHashGroup == nil { + a.profileImageHashGroup = new(sync.Once) + } + a.profileImageHashGroup.Do(func() { if _, err := os.Stat(a.cfg.User.ProfileImageFile); err != nil { a.profileImageHashString = profileImageNoImageHash - return nil, nil + return } hash := sha256.New() file, err := os.Open(a.cfg.User.ProfileImageFile) if err != nil { a.profileImageHashString = profileImageNoImageHash - return nil, nil + return } _, _ = io.Copy(hash, file) _ = file.Close() a.profileImageHashString = fmt.Sprintf("%x", hash.Sum(nil)) - return nil, nil }) return a.profileImageHashString } @@ -200,7 +200,7 @@ func (a *goBlog) serveUpdateProfileImage(w http.ResponseWriter, r *http.Request) return } // Reset hash - a.profileImageHashString = "" + a.profileImageHashGroup = nil // Clear http cache a.cache.purge() // Redirect @@ -208,7 +208,7 @@ func (a *goBlog) serveUpdateProfileImage(w http.ResponseWriter, r *http.Request) } func (a *goBlog) serveDeleteProfileImage(w http.ResponseWriter, r *http.Request) { - a.profileImageHashString = "" + a.profileImageHashGroup = nil if err := os.Remove(a.cfg.User.ProfileImageFile); err != nil && !errors.Is(err, os.ErrNotExist) { a.serveError(w, r, "Failed to delete profile image", http.StatusInternalServerError) return diff --git a/reactions.go b/reactions.go index f6948df..494862b 100644 --- a/reactions.go +++ b/reactions.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/dgraph-io/ristretto" "github.com/samber/lo" + c "go.goblog.app/app/pkgs/cache" "go.goblog.app/app/pkgs/contenttype" ) @@ -35,19 +35,14 @@ func (a *goBlog) initReactions() { if !a.reactionsEnabled() { return } - a.reactionsCache, _ = ristretto.NewCache(&ristretto.Config{ - NumCounters: 1000, - MaxCost: 100, // Cache reactions for 100 posts - BufferItems: 64, - IgnoreInternalCost: true, - }) + a.reactionsCache = c.New[string, string](time.Minute, 100) }) } func (a *goBlog) deleteReactionsCache(path string) { a.initReactions() if a.reactionsCache != nil { - a.reactionsCache.Del(path) + a.reactionsCache.Delete(path) } } @@ -77,7 +72,7 @@ func (a *goBlog) saveReaction(reaction, path string) error { a.initReactions() // Delete from cache defer a.reactionsSfg.Forget(path) - defer a.reactionsCache.Del(path) + defer a.reactionsCache.Delete(path) // Insert reaction _, err := a.db.Exec("insert into reactions (path, reaction, count) values (?, ?, 1) on conflict (path, reaction) do update set count=count+1", path, reaction) return err @@ -105,26 +100,22 @@ func (a *goBlog) getReactionsFromDatabase(path string) (string, error) { // Check cache if val, cached := a.reactionsCache.Get(path); cached { // Return from cache - return val.(string), nil + return val, nil } // Get reactions - res, err, _ := a.reactionsSfg.Do(path, func() (any, error) { + res, err, _ := a.reactionsSfg.Do(path, func() (string, error) { row, err := a.db.QueryRow(reactionsQuery, path, allowedReactionsStr, reactionsPostParam, "false") if err != nil { - return nil, err + return "", err } var jsonResult string err = row.Scan(&jsonResult) if err != nil { - return nil, err + return "", err } // Cache result - a.reactionsCache.SetWithTTL(path, jsonResult, 1, reactionsCacheTTL) - a.reactionsCache.Wait() + a.reactionsCache.Set(path, jsonResult, reactionsCacheTTL, 1) return jsonResult, nil }) - if err != nil || res == nil { - return "", err - } - return res.(string), nil + return res, err }