diff --git a/.gitignore b/.gitignore index edc180d..2398902 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ *.test *.out go.work - *.local .env dist/ -/book \ No newline at end of file +/book diff --git a/go.mod b/go.mod index 395990b..9b2d08a 100644 --- a/go.mod +++ b/go.mod @@ -70,13 +70,16 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 // indirect github.com/aws/smithy-go v1.14.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chzyer/readline v1.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/foxcpp/go-mockdns v1.0.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/glog v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect @@ -111,6 +114,7 @@ require ( github.com/miekg/dns v1.1.50 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.2.0 // indirect diff --git a/go.sum b/go.sum index 9f5b813..e22a558 100644 --- a/go.sum +++ b/go.sum @@ -256,9 +256,12 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= @@ -429,8 +432,11 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dhui/dktest v0.3.10 h1:0frpeeoM9pHouHjhLeZDuDTJ0PqjDTrycaHaMmkJAo8= github.com/dhui/dktest v0.3.10/go.mod h1:h5Enh0nG3Qbo9WjNFRrwmKUaePEBhXMOygbz3Ww7Sz0= @@ -612,6 +618,8 @@ github.com/golang-migrate/migrate/v4 v4.15.2/go.mod h1:f2toGLkYqD3JH+Todi4aZ2Zdb github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -1697,6 +1705,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc 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-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= diff --git a/internal/cache/contentcache.go b/internal/cache/contentcache.go new file mode 100644 index 0000000..6d361e7 --- /dev/null +++ b/internal/cache/contentcache.go @@ -0,0 +1,158 @@ +package cache + +import ( + "fmt" + "sync" + + "github.com/dgraph-io/ristretto" +) + +type ContentCache[K any, V any, R any] struct { + m *mm + size int64 + cache *ristretto.Cache + load func(r R) (V, int64, error) +} + +func NewContentCache[K any, V any, R any](contentCacheSize int64, metrics bool, load func(r R) (V, int64, error)) (*ContentCache[K, V, R], error) { + m := New() + size := contentCacheSize + nc := size / 1000 + if (nc < 100) { //mainly for testing + nc = 100 + } + cache, err := ristretto.NewCache(&ristretto.Config{ + //NumCounters is 10 times estimated max number of items in cache, as suggested in https://pkg.go.dev/github.com/dgraph-io/ristretto@v0.1.1#Config + NumCounters: nc, //limit / 10 KB small files * 10 + MaxCost: size, + BufferItems: 64, + Metrics: metrics, + IgnoreInternalCost: true, + }) + if err != nil { + return nil, err + } + + return &ContentCache[K, V, R]{m: m, size: size, cache: cache, load: load}, nil +} + +func (c *ContentCache[K, V, R]) keyToString(key K) string { + return fmt.Sprintf("%v", key) +} + +func (c *ContentCache[K, V, R]) GetContent(key K) (V, bool) { + l := c.m.RLock(key) + defer l.RUnlock() + + v, b := c.cache.Get(c.keyToString(key)) + if v == nil { + var nv V + return nv, b + } + return v.(V), b +} + +func (c *ContentCache[K, V, R]) SetContent(key K, r R) (V, error) { + l := c.m.Lock(key) + defer l.Unlock() + + b, n, err := c.load(r) + if err != nil { + return b, err + } + + c.cache.Set(c.keyToString(key), b, n) + return b, nil +} + +//modified from https://stackoverflow.com/questions/40931373/how-to-gc-a-map-of-mutexes-in-go +type mm struct { + ml sync.Mutex + ma map[interface{}]*mentry +} + +type mentry struct { + m *mm + el sync.RWMutex + cnt int + key interface{} +} + +type Unlocker interface { + Unlock() +} + +type RUnlocker interface { + RUnlock() +} + +func New() *mm { + return &mm{ma: make(map[interface{}]*mentry)} +} + +func (m *mm) Lock(key interface{}) Unlocker { + m.ml.Lock() + e, ok := m.ma[key] + if !ok { + e = &mentry{m: m, key: key} + m.ma[key] = e + } + e.cnt++ + m.ml.Unlock() + + e.el.Lock() + + return e +} + +func (me *mentry) Unlock() { + m := me.m + + m.ml.Lock() + e, ok := m.ma[me.key] + if !ok { + m.ml.Unlock() + panic(fmt.Errorf("Unlock requested for key=%v but no entry found", me.key)) + } + e.cnt-- + if e.cnt < 1 { + delete(m.ma, me.key) + } + m.ml.Unlock() + + e.el.Unlock() +} + + +func (m *mm) RLock(key interface{}) RUnlocker { + m.ml.Lock() + e, ok := m.ma[key] + if !ok { + e = &mentry{m: m, key: key} + m.ma[key] = e + } + e.cnt++ + m.ml.Unlock() + + e.el.RLock() + + return e +} + +func (me *mentry) RUnlock() { + m := me.m + + m.ml.Lock() + e, ok := m.ma[me.key] + if !ok { + m.ml.Unlock() + panic(fmt.Errorf("Unlock requested for key=%v but no entry found", me.key)) + } + e.cnt-- + if e.cnt < 1 { + delete(m.ma, me.key) + } + m.ml.Unlock() + + e.el.RUnlock() +} diff --git a/internal/cache/contentcache_test.go b/internal/cache/contentcache_test.go new file mode 100644 index 0000000..61abe09 --- /dev/null +++ b/internal/cache/contentcache_test.go @@ -0,0 +1,153 @@ +package cache //white box testing + +import ( + "bytes" + "testing" + "sync" + "time" + "fmt" + "io" + + "github.com/stretchr/testify/assert" +) + +type CacheKeyMock string + +func TestGetContent(t *testing.T) { + fmt.Println("testing GetContent...") + load := func(r io.ReadSeeker) (*bytes.Buffer, int64, error) { + b, err := io.ReadAll(r) + nb := bytes.NewBuffer(b) + if err != nil { + return nb, 0, err + } + return nb, int64(nb.Len()), nil + } + cc, err := NewContentCache[CacheKeyMock](8, true, load) + assert.Empty(t, err) + + k1 := CacheKeyMock("id1") + k2 := CacheKeyMock("id2") + k3 := CacheKeyMock("id3") + + ccc, found := cc.GetContent(k1) + assert.Equal(t, false, found) + assert.Equal(t, "hit: 0 miss: 1 keys-added: 0 keys-updated: 0 keys-evicted: 0 cost-added: 0 cost-evicted: 0 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 1 hit-ratio: 0.00", cc.cache.Metrics.String()) + + ccc, err = cc.SetContent(k1, bytes.NewReader([]byte("a"))) + time.Sleep(100 * time.Millisecond) //https://github.com/dgraph-io/ristretto/issues/161 + assert.Empty(t, err) + assert.Equal(t, bytes.NewBuffer([]byte("a")), ccc) + assert.Equal(t, "hit: 0 miss: 1 keys-added: 1 keys-updated: 0 keys-evicted: 0 cost-added: 1 cost-evicted: 0 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 1 hit-ratio: 0.00", cc.cache.Metrics.String()) + + ccc, found = cc.GetContent(k1) + assert.Equal(t, true, found) + assert.Equal(t, bytes.NewBuffer([]byte("a")), ccc) + assert.Equal(t, "hit: 1 miss: 1 keys-added: 1 keys-updated: 0 keys-evicted: 0 cost-added: 1 cost-evicted: 0 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 2 hit-ratio: 0.50", cc.cache.Metrics.String()) + + ccc, err = cc.SetContent(k1, bytes.NewReader([]byte("test"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + assert.Equal(t, bytes.NewBuffer([]byte("test")), ccc) + assert.Equal(t, "hit: 1 miss: 1 keys-added: 1 keys-updated: 1 keys-evicted: 0 cost-added: 4 cost-evicted: 0 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 2 hit-ratio: 0.50", cc.cache.Metrics.String()) + + ccc, found = cc.GetContent(k1) + assert.Equal(t, true, found) + assert.Equal(t, bytes.NewBuffer([]byte("test")), ccc) + assert.Equal(t, "hit: 2 miss: 1 keys-added: 1 keys-updated: 1 keys-evicted: 0 cost-added: 4 cost-evicted: 0 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 3 hit-ratio: 0.67", cc.cache.Metrics.String()) + + ccc, err = cc.SetContent(k2, bytes.NewReader([]byte("overflow"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + assert.Equal(t, bytes.NewBuffer([]byte("overflow")), ccc) + assert.Equal(t, "hit: 2 miss: 1 keys-added: 2 keys-updated: 1 keys-evicted: 1 cost-added: 12 cost-evicted: 4 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 3 hit-ratio: 0.67", cc.cache.Metrics.String()) + + ccc, found = cc.GetContent(k2) + assert.Equal(t, true, found) + assert.Equal(t, bytes.NewBuffer([]byte("overflow")), ccc) + assert.Equal(t, "hit: 3 miss: 1 keys-added: 2 keys-updated: 1 keys-evicted: 1 cost-added: 12 cost-evicted: 4 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 4 hit-ratio: 0.75", cc.cache.Metrics.String()) + + ccc, err = cc.SetContent(k3, bytes.NewReader([]byte("content too big"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + assert.Equal(t, bytes.NewBuffer([]byte("content too big")), ccc) + assert.Equal(t, "hit: 3 miss: 1 keys-added: 2 keys-updated: 1 keys-evicted: 1 cost-added: 12 cost-evicted: 4 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 4 hit-ratio: 0.75", cc.cache.Metrics.String()) + + ccc, found = cc.GetContent(k3) + assert.Equal(t, false, found) + assert.Equal(t, "hit: 3 miss: 2 keys-added: 2 keys-updated: 1 keys-evicted: 1 cost-added: 12 cost-evicted: 4 sets-dropped: 0 sets-rejected: 0 gets-dropped: 0 gets-kept: 0 gets-total: 5 hit-ratio: 0.60", cc.cache.Metrics.String()) +} + +func TestDataRace(t *testing.T) { + fmt.Println("testing data race...") + var wg sync.WaitGroup + load := func(r io.ReadSeeker) (*bytes.Buffer, int64, error) { + b, err := io.ReadAll(r) + nb := bytes.NewBuffer(b) + if err != nil { + return nb, 0, err + } + return nb, int64(nb.Len()), nil + } + cc, err := NewContentCache[CacheKeyMock](16, true, load) + assert.Empty(t, err) + k1 := CacheKeyMock("id1") + k2 := CacheKeyMock("id2") + wg.Add(1) + go func() { + defer wg.Done() + _, err := cc.SetContent(k1, bytes.NewReader([]byte("data"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, err = cc.SetContent(k2, bytes.NewReader([]byte("race"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, found := cc.GetContent(k1) + assert.Equal(t, true, found) + _, found = cc.GetContent(k2) + assert.Equal(t, true, found) + }() + wg.Add(1) + go func() { + defer wg.Done() + _, err := cc.SetContent(k1, bytes.NewReader([]byte("race"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, err = cc.SetContent(k2, bytes.NewReader([]byte("data"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, found := cc.GetContent(k1) + assert.Equal(t, true, found) + _, found = cc.GetContent(k2) + assert.Equal(t, true, found) + }() + wg.Add(1) + go func() { + defer wg.Done() + _, err := cc.SetContent(k2, bytes.NewReader([]byte("data"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, err = cc.SetContent(k1, bytes.NewReader([]byte("race"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, found := cc.GetContent(k1) + assert.Equal(t, true, found) + _, found = cc.GetContent(k2) + assert.Equal(t, true, found) + }() + wg.Add(1) + go func() { + defer wg.Done() + _, err := cc.SetContent(k2, bytes.NewReader([]byte("race"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, err = cc.SetContent(k1, bytes.NewReader([]byte("data"))) + time.Sleep(100 * time.Millisecond) + assert.Empty(t, err) + _, found := cc.GetContent(k1) + assert.Equal(t, true, found) + _, found = cc.GetContent(k2) + assert.Equal(t, true, found) + }() + wg.Wait() +} diff --git a/internal/handler/site/site_handler.go b/internal/handler/site/site_handler.go index 3a67dd5..eb59ca5 100644 --- a/internal/handler/site/site_handler.go +++ b/internal/handler/site/site_handler.go @@ -1,6 +1,7 @@ package site import ( + "bytes" "context" "fmt" "io" @@ -10,6 +11,7 @@ import ( "strings" "time" + "github.com/oursky/pageship/internal/cache" "github.com/oursky/pageship/internal/httputil" "github.com/oursky/pageship/internal/site" ) @@ -18,12 +20,19 @@ type SiteHandler struct { desc *site.Descriptor publicFS site.FS next http.Handler + cc *cache.ContentCache } func NewSiteHandler(desc *site.Descriptor, middlewares []Middleware) *SiteHandler { + cc, err := cache.NewContentCache(1 << 24, false) //16 MiB + if err != nil { + cc = nil + } + h := &SiteHandler{ desc: desc, publicFS: site.SubFS(desc.FS, path.Clean("/"+desc.Config.Public)), + cc: cc, } publicDesc := *desc @@ -73,15 +82,26 @@ func (h *SiteHandler) serveFile(w http.ResponseWriter, r *http.Request) { } if info.Hash != "" { w.Header().Set("ETag", fmt.Sprintf(`"%s"`, info.Hash)) + w.Header().Set("Cache-Control", "public, max-age=31536000, no-cache") } - reader := &lazyReader{ + lReader := &lazyReader{ fs: h.publicFS, path: r.URL.Path, ctx: r.Context(), } - defer reader.Close() + defer lReader.Close() + var reader = io.ReadSeeker(lReader) + if info.Hash != "" { + cell, err := h.cc.GetContent(info.Hash, reader) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } else { + reader = bytes.NewReader(cell.Data.Bytes()) + } + } writer := httputil.NewTimeoutResponseWriter(w, 10*time.Second) http.ServeContent(writer, r, path.Base(r.URL.Path), info.ModTime, reader) }