Skip to content

Commit

Permalink
feat(chore): storage handle fresh/stale at once with multi layer stor…
Browse files Browse the repository at this point in the history
…age (#455)

* feat(chore): storage handle fresh/stale at once with multi layer storage

* fix(vary): handle properly vary

* wip

* fix(plugins): unit tests on the souin API, reset the storage for each test

* feat(storage): redis retrieve the stored response

* fix(ci): e2e tests

* fix(review): wording + escape from for loop

* fix(storage): etcd provider

* wip

* fix(chore): ETag regression

* feat(chore): support invalidation on cache-tests.fyi

* fix(review): vejipe: use fresh and stale

* feat(chore): use gob encoding instead of json

* fix(e2e): goa_url

* fix(review): rename key to variedKey
  • Loading branch information
darkweak committed Feb 27, 2024
1 parent 6b127c4 commit ecd55d3
Show file tree
Hide file tree
Showing 24 changed files with 620 additions and 86 deletions.
28 changes: 15 additions & 13 deletions docs/e2e/Souin E2E.postman_collection.json
Expand Up @@ -3582,28 +3582,30 @@
" pm.test(\"Ensure stored keys array is empty\", function () {",
" pm.response.to.have.status(200);",
" let jsonData = pm.response.json();",
" pm.expect(jsonData).to.eql([]);",
" pm.expect(jsonData.length).to.eql(0);",
" pm.expect(jsonData).to.eql([\"IDX_GET-http-localhost:4443-/default\"]);",
" pm.expect(jsonData.length).to.eql(1);",
" });",
" pm.sendRequest(utils.request(baseUrl + additionalPath, cacheControl), function(_, response) {",
" pm.expect(response).to.have.status(200);",
" pm.sendRequest(utils.request(`${baseUrl}${utils.getVar(pm, 'souin_base_api')}${utils.getVar(pm, 'souin_api')}`, cacheControl), function (_, res) {",
" pm.test(`Check Souin API has ${isCached ? 'two' : 'none'} registered key after the first cache set`, function () {",
" pm.test(`Check Souin API has ${isCached ? 'three' : 'none'} registered key after the first cache set`, function () {",
" pm.expect(res).to.have.status(200);",
" let jsonData = res.json();",
" pm.expect(jsonData.length).to.eql(isCached ? 2 : 0);",
" pm.expect(jsonData.length).to.eql(isCached ? 3 : 0);",
" pm.expect(jsonData[0]).to.eql(isCached ? baseKey : undefined);",
" pm.expect(jsonData[1]).to.eql(isCached ? 'IDX_GET-http-localhost:4443-/default' : undefined);",
" pm.expect(jsonData[2]).to.eql(isCached ? 'IDX_GET-http-localhost:4443-/test1' : undefined);",
" }",
" );",
"",
" pm.sendRequest(utils.request(baseUrl + additionalPath + 'testing', cacheControl), function() {",
" pm.sendRequest(utils.request(`${baseUrl}${utils.getVar(pm, 'souin_base_api')}${utils.getVar(pm, 'souin_api')}`, cacheControl), function (_, r) {",
" pm.test(`Check Souin API has ${isCached ? 'four' : 'none'} registered key${isCached ? 's' : ''} after the second cache set`, function () {",
" pm.test(`Check Souin API has ${isCached ? 'five' : 'none'} registered key${isCached ? 's' : ''} after the second cache set`, function () {",
" pm.expect(r).to.have.status(200);",
" pm.expect(r).to.not.have.header(\"Cache-Status\");",
" pm.expect(r).to.not.have.header(\"Age\");",
" let jsonData = r.json();",
" pm.expect(jsonData.length).to.eql(isCached ? 4 : 4);",
" pm.expect(jsonData.length).to.eql(isCached ? 5 : 0);",
" });",
" });",
" });",
Expand All @@ -3621,8 +3623,8 @@
" pm.test(\"Ensure stored keys array is empty\", function () {",
" pm.response.to.have.status(200);",
" let jsonData = pm.response.json();",
" pm.expect(jsonData).to.eql([]);",
" pm.expect(jsonData.length).to.eql(0);",
" // pm.expect(jsonData).to.eql([]);",
" pm.expect(jsonData.length).to.eql(3);",
" });",
" pm.sendRequest(rq, function(_, response) {",
" pm.expect(response).to.have.status(200);",
Expand All @@ -3631,8 +3633,8 @@
" pm.test(`Check Souin API has ${isCached ? 'two' : 'none'} registered key after the first cache set`, function () {",
" pm.expect(res).to.have.status(200);",
" let jsonData = res.json();",
" pm.expect(jsonData.length).to.eql(isCached ? 2 : 0);",
" pm.expect(jsonData[0]).to.include(isCached ? baseKey : undefined);",
" pm.expect(jsonData.length).to.eql(isCached ? 5 : 0);",
" // pm.expect(jsonData[4]).to.include(isCached ? baseKey : undefined);",
" }",
" );",
"",
Expand All @@ -3644,7 +3646,7 @@
" pm.expect(r).to.not.have.header(\"Cache-Status\");",
" pm.expect(r).to.not.have.header(\"Age\");",
" let jsonData = r.json();",
" pm.expect(jsonData.length).to.eql(isCached ? 4 : 0);",
" pm.expect(jsonData.length).to.eql(isCached ? 7 : 0);",
" });",
" });",
" });",
Expand Down Expand Up @@ -3716,11 +3718,11 @@
"value": "http://domain.com"
},
{
"key": "goa_url",
"key": "goyave_url",
"value": "http://domain.com"
},
{
"key": "goyave_url",
"key": "goa_url",
"value": "http://domain.com"
},
{
Expand Down
38 changes: 29 additions & 9 deletions pkg/api/souin.go
@@ -1,13 +1,17 @@
package api

import (
"bytes"
"encoding/gob"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"

"github.com/darkweak/souin/configurationtypes"
"github.com/darkweak/souin/pkg/storage"
"github.com/darkweak/souin/pkg/storage/types"
"github.com/darkweak/souin/pkg/surrogate/providers"
)
Expand Down Expand Up @@ -62,10 +66,30 @@ func initializeSouin(
}

// BulkDelete allow user to delete multiple items with regexp
func (s *SouinAPI) BulkDelete(key string) {
func (s *SouinAPI) BulkDelete(key string, purge bool) {
key, _ = strings.CutPrefix(key, storage.MappingKeyPrefix)
for _, current := range s.storers {
current.DeleteMany(key)
if b := current.Get(storage.MappingKeyPrefix + key); len(b) > 0 {
var mapping types.StorageMapper
if e := gob.NewDecoder(bytes.NewBuffer(b)).Decode(&mapping); e == nil {
for k := range mapping.Mapping {
current.Delete(k)
}
}

if purge {
current.Delete(storage.MappingKeyPrefix + key)
} else {
newFreshTime := time.Now()
for k, v := range mapping.Mapping {
v.FreshTime = newFreshTime
mapping.Mapping[k] = v
}
}
}
}

s.Delete(key)
}

// Delete will delete a record into the provider cache system and will update the Souin API if enabled
Expand Down Expand Up @@ -196,9 +220,7 @@ func (s *SouinAPI) HandleRequest(w http.ResponseWriter, r *http.Request) {
}

for _, k := range keysToInvalidate {
for _, current := range s.storers {
current.Delete(k)
}
s.BulkDelete(k, invalidator.Purge)
}
w.WriteHeader(http.StatusOK)
case "PURGE":
Expand All @@ -217,14 +239,12 @@ func (s *SouinAPI) HandleRequest(w http.ResponseWriter, r *http.Request) {
fmt.Println("Successfully clear the cache and the surrogate keys storage.")
} else {
submatch := keysRg.FindAllStringSubmatch(r.RequestURI, -1)[0][1]
s.BulkDelete(submatch)
s.BulkDelete(submatch, true)
}
} else {
ck, _ := s.surrogateStorage.Purge(r.Header)
for _, k := range ck {
for _, current := range s.storers {
current.Delete(k)
}
s.BulkDelete(k, true)
}
}
w.WriteHeader(http.StatusNoContent)
Expand Down
61 changes: 43 additions & 18 deletions pkg/middleware/middleware.go
Expand Up @@ -257,7 +257,7 @@ func (s *SouinBaseHandler) Store(
// "Implies that the response is uncacheable"
status += "; detail=UPSTREAM-VARY-STAR"
} else {
cachedKey += rfc.GetVariedCacheKey(rq, variedHeaders)
variedKey := cachedKey + rfc.GetVariedCacheKey(rq, variedHeaders)
s.Configuration.GetLogger().Sugar().Debugf("Store the response %+v with duration %v", res, ma)

var wg sync.WaitGroup
Expand All @@ -267,12 +267,17 @@ func (s *SouinBaseHandler) Store(
case <-rq.Context().Done():
status += "; detail=REQUEST-CANCELED-OR-UPSTREAM-BROKEN-PIPE"
default:
vhs := http.Header{}
for _, hname := range variedHeaders {
hn := strings.Split(hname, ":")
vhs.Set(hn[0], rq.Header.Get(hn[0]))
}
for _, storer := range s.Storers {
wg.Add(1)
go func(currentStorer types.Storer) {
defer wg.Done()
if currentStorer.Set(cachedKey, response, currentMatchedURL, ma) == nil {
s.Configuration.GetLogger().Sugar().Debugf("Stored the key %s in the %s provider", cachedKey, currentStorer.Name())
if currentStorer.SetMultiLevel(cachedKey, variedKey, response, vhs, res.Header.Get("Etag"), ma) == nil {
s.Configuration.GetLogger().Sugar().Debugf("Stored the key %s in the %s provider", variedKey, currentStorer.Name())
} else {
mu.Lock()
fails = append(fails, fmt.Sprintf("; detail=%s-INSERTION-ERROR", currentStorer.Name()))
Expand All @@ -285,7 +290,7 @@ func (s *SouinBaseHandler) Store(
if len(fails) < s.storersLen {
go func(rs http.Response, key string) {
_ = s.SurrogateKeyStorer.Store(&rs, key)
}(res, cachedKey)
}(res, variedKey)
status += "; stored"
}

Expand Down Expand Up @@ -382,7 +387,7 @@ func (s *SouinBaseHandler) Upstream(
if !isVaryStar {
for _, vh := range variedHeaders {
if rq.Header.Get(vh) != sfWriter.requestHeaders.Get(vh) {
cachedKey += rfc.GetVariedCacheKey(rq, variedHeaders)
// cachedKey += rfc.GetVariedCacheKey(rq, variedHeaders)
return s.Upstream(customWriter, rq, next, requestCc, cachedKey)
}
}
Expand Down Expand Up @@ -464,6 +469,15 @@ func (s *SouinBaseHandler) HandleInternally(r *http.Request) (bool, http.Handler
}

type handlerFunc = func(http.ResponseWriter, *http.Request) error
type statusCodeLogger struct {
http.ResponseWriter
statusCode int
}

func (s *statusCodeLogger) WriteHeader(code int) {
s.statusCode = code
s.ResponseWriter.WriteHeader(code)
}

func (s *SouinBaseHandler) ServeHTTP(rw http.ResponseWriter, rq *http.Request, next handlerFunc) error {
start := time.Now()
Expand All @@ -485,10 +499,23 @@ func (s *SouinBaseHandler) ServeHTTP(rw http.ResponseWriter, rq *http.Request, n

if !req.Context().Value(context.SupportedMethod).(bool) {
rw.Header().Set("Cache-Status", cacheName+"; fwd=bypass; detail=UNSUPPORTED-METHOD")
nrw := &statusCodeLogger{
ResponseWriter: rw,
statusCode: 0,
}

err := next(rw, req)
err := next(nrw, req)
s.SurrogateKeyStorer.Invalidate(req.Method, rw.Header())

if err == nil && req.Method != http.MethodGet && nrw.statusCode < http.StatusBadRequest {
// Invalidate related GET keys when the method is not allowed and the response is valid
req.Method = http.MethodGet
keyname := s.context.SetContext(req, rq).Context().Value(context.Key).(string)
for _, storer := range s.Storers {
storer.DeleteMany(fmt.Sprintf("(%s)?%s((%s|/).*|$)", storage.MappingKeyPrefix, keyname, rfc.VarySeparator))
}
}

return err
}

Expand Down Expand Up @@ -528,17 +555,19 @@ func (s *SouinBaseHandler) ServeHTTP(rw http.ResponseWriter, rq *http.Request, n
s.Configuration.GetLogger().Sugar().Debugf("Request cache-control %+v", requestCc)
if modeContext.Bypass_request || !requestCc.NoCache {
validator := rfc.ParseRequest(req)
var response *http.Response
var fresh, stale *http.Response
for _, currentStorer := range s.Storers {
response = currentStorer.Prefix(cachedKey, req, validator)
if response != nil {
s.Configuration.GetLogger().Sugar().Debugf("Found response in the %s storage", currentStorer.Name())
fresh, stale = currentStorer.GetMultiLevel(cachedKey, req, validator)

if fresh != nil || stale != nil {
s.Configuration.GetLogger().Sugar().Debugf("Found at least one valid response in the %s storage", currentStorer.Name())
break
}
}

headerName, _ := s.SurrogateKeyStorer.GetSurrogateControl(customWriter.Header())
if response != nil && (!modeContext.Strict || rfc.ValidateCacheControl(response, requestCc)) {
if fresh != nil && (!modeContext.Strict || rfc.ValidateCacheControl(fresh, requestCc)) {
response := fresh
if validator.ResponseETag != "" && validator.Matched {
rfc.SetCacheStatusHeader(response)
for h, v := range response.Header {
Expand Down Expand Up @@ -585,13 +614,9 @@ func (s *SouinBaseHandler) ServeHTTP(rw http.ResponseWriter, rq *http.Request, n

return err
}
} else if response == nil && !requestCc.OnlyIfCached && (requestCc.MaxStaleSet || requestCc.MaxStale > -1) {
for _, currentStorer := range s.Storers {
response = currentStorer.Prefix(storage.StalePrefix+cachedKey, req, validator)
if response != nil {
break
}
}
} else if !requestCc.OnlyIfCached && (requestCc.MaxStaleSet || requestCc.MaxStale > -1) {
response := stale

if nil != response && (!modeContext.Strict || rfc.ValidateCacheControl(response, requestCc)) {
addTime, _ := time.ParseDuration(response.Header.Get(rfc.StoredTTLHeader))
rfc.SetCacheStatusHeader(response)
Expand Down
7 changes: 6 additions & 1 deletion pkg/rfc/revalidation.go
Expand Up @@ -59,7 +59,12 @@ func ParseRequest(req *http.Request) *Revalidator {
}

func ValidateETag(res *http.Response, validator *Revalidator) {
validator.ResponseETag = res.Header.Get("ETag")
ValidateETagFromHeader(res.Header.Get("ETag"), validator)

}

func ValidateETagFromHeader(etag string, validator *Revalidator) {
validator.ResponseETag = etag
validator.NeedRevalidation = validator.NeedRevalidation || validator.ResponseETag != ""
validator.Matched = validator.ResponseETag == "" || (validator.ResponseETag != "" && len(validator.RequestETags) == 0)

Expand Down

0 comments on commit ecd55d3

Please sign in to comment.