pkg/server/responseCache.go (124 lines of code) (raw):
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/JetBrains/ij-perf-report-aggregator/pkg/http-error"
"github.com/VictoriaMetrics/fastcache"
"github.com/valyala/bytebufferpool"
"github.com/zeebo/xxh3"
)
var byteBufferPool bytebufferpool.Pool
type CachingHandler struct {
handler func(request *http.Request) (*bytebufferpool.ByteBuffer, bool, error)
manager *ResponseCacheManager
}
func (ch *CachingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ch.manager.handle(w, r, ch.handler)
}
type ResponseCacheManager struct {
cache *fastcache.Cache
}
func NewResponseCacheManager() (*ResponseCacheManager, error) {
cacheSize := 1000 * 1000 * 1000
cache := fastcache.New(cacheSize)
return &ResponseCacheManager{
cache: cache,
}, nil
}
func (rcm *ResponseCacheManager) CreateHandler(handler func(request *http.Request) (*bytebufferpool.ByteBuffer, bool, error)) http.Handler {
return &CachingHandler{
handler: handler,
manager: rcm,
}
}
func generateCacheKey(request *http.Request) []byte {
buffer := bytebufferpool.Get()
defer bytebufferpool.Put(buffer)
// if json requested, it means that handler can return data in several formats
if request.Header.Get("Accept") == "application/json" {
_, _ = buffer.WriteString("j:")
}
u := request.URL
_, _ = buffer.WriteString(u.Path)
// do not complicate, use RawQuery as is without sorting
if u.RawQuery != "" {
_, _ = buffer.WriteString(u.RawQuery)
}
return CopyBuffer(buffer)
}
// we don't add here salt like "when server was started" to reflect changes in a new server logic,
// because if no data in cache (server restarted), in any case data will be recomputed
func computeEtag(result []byte) string {
hash := xxh3.Hash128(result)
return strconv.FormatUint(hash.Hi, 36) + "-" + strconv.FormatUint(hash.Lo, 36)
}
func (rcm *ResponseCacheManager) Clear() {
rcm.cache.Reset()
}
func (rcm *ResponseCacheManager) handle(w http.ResponseWriter, request *http.Request, handler func(request *http.Request) (*bytebufferpool.ByteBuffer, bool, error)) {
w.Header().Set("Vary", "Accept-Encoding")
cacheKey := generateCacheKey(request)
value := rcm.cache.Get(nil, cacheKey)
var result []byte
if value != nil && request.Header.Get("Cache-Control") != "no-cache" {
var err error
result, err = decompressData(value)
if err != nil {
slog.Error("cannot decompress cached result", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prevEtag := request.Header.Get("If-None-Match")
eTag := computeEtag(result)
if prevEtag == eTag {
w.Header().Set("ETag", eTag)
w.WriteHeader(http.StatusNotModified)
return
}
} else {
buffer, releaseBuffer, err := handler(request)
if err != nil {
rcm.handleError(err, w)
return
}
result, err = rcm.compressData(buffer.B)
if err != nil {
slog.Error("cannot compress result", "error", err)
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
rcm.cache.Set(cacheKey, result)
result = buffer.B
if releaseBuffer {
bytebufferpool.Put(buffer)
}
}
w.Header().Set("ETag", computeEtag(result))
w.Header().Set("Content-Length", strconv.Itoa(len(result)))
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, err := w.Write(result)
if err != nil {
slog.Error("cannot write cached result", "error", err)
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
}
func (rcm *ResponseCacheManager) handleError(err error, w http.ResponseWriter) {
var httpError *http_error.HttpError
if errors.As(err, &httpError) {
w.WriteHeader(httpError.Code)
writehttpError(w, httpError)
} else {
if errors.Is(err, context.Canceled) {
http.Error(w, err.Error(), 499)
} else {
slog.Error("cannot handle http request", "error", err)
http.Error(w, err.Error(), http.StatusServiceUnavailable)
}
}
}
func CopyBuffer(buffer *bytebufferpool.ByteBuffer) []byte {
result := make([]byte, len(buffer.B))
copy(result, buffer.B)
return result
}