mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-20 15:07:40 +00:00
Serve static sites from memory, checked against the store once a second
Every request to an object-store root cost a HEAD and a GET to S3, 80 ms at the median, before a byte left the ingress. One process-wide cache, bounded at 256 MiB and shared by every site, now holds objects up to 2 MiB with their absence: an entry checked within the last second is served from memory, an older one costs one HEAD, and the bytes are read again only when the ETag moved. Sites publish in place, so a publish is served within a second. Concurrent misses ask the store once; a store that stops answering leaves held objects serving. Text is held brotli-encoded beside its bytes and sent that way to a client that accepts br, as its own representation (ETag "<tag>-br", Vary). Cloudflare was pulling 557 KB of docs HTML uncompressed across the country per miss. Everything under /_next/static/ is immutable whatever its extension. Every middleware shares one S3 client and its connection pool. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -33,4 +33,4 @@ test:
|
||||
corepack enable && corepack prepare [email protected] --activate
|
||||
( cd webui && pnpm install --frozen-lockfile && pnpm build )
|
||||
go mod download
|
||||
go test . ./pkg/api/... ./pkg/kms/... ./pkg/provider/acme/... ./pkg/tls/...
|
||||
go test . ./pkg/api/... ./pkg/kms/... ./pkg/provider/acme/... ./pkg/tls/... ./pkg/middlewares/staticfiles/...
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package staticfiles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
// cacheBytes bounds what the process holds, across every site.
|
||||
cacheBytes = 256 << 20
|
||||
// cacheObjectMax is the largest object held. A larger one streams from the
|
||||
// store on every request, as it did before the cache.
|
||||
cacheObjectMax = 2 << 20
|
||||
// cacheFresh is how long an entry is served without asking the store. Sites
|
||||
// publish in place (cloud apps/projects writes <org>/<slug>/ and keeps the
|
||||
// keys), so it is also the longest a publish waits to be served.
|
||||
cacheFresh = time.Second
|
||||
// entryOverhead is charged per entry on top of its bytes, so a stream of
|
||||
// distinct misses is bounded like everything else.
|
||||
entryOverhead = 256
|
||||
)
|
||||
|
||||
// objects is the cache every object-store root in the process shares: one bound
|
||||
// for the process, and a configuration reload, which rebuilds every middleware,
|
||||
// keeps what it holds.
|
||||
var objects = newObjectCache(cacheBytes, cacheObjectMax, cacheFresh)
|
||||
|
||||
// objectCache holds objects as last read from the store, least recently used
|
||||
// first out. An entry checked within fresh is served from memory; an older one
|
||||
// costs one HEAD, and its bytes are read again only when the ETag moved. Absence
|
||||
// is held the same way, so the page ladder's probes ("pricing" before
|
||||
// "pricing.html") stop costing a round trip each.
|
||||
type objectCache struct {
|
||||
limit, max int64
|
||||
fresh time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
lru *list.List // of *cacheEntry, most recent first
|
||||
index map[string]*list.Element
|
||||
bytes int64
|
||||
|
||||
flight singleflight.Group
|
||||
}
|
||||
|
||||
// cacheEntry is one object as last read, or the fact that it was absent. It is
|
||||
// never changed once stored; a refresh stores a new one.
|
||||
type cacheEntry struct {
|
||||
id string
|
||||
info objectInfo
|
||||
missing bool
|
||||
body []byte // nil when the object is larger than max, or its read failed
|
||||
br []byte // body brotli-encoded, when its type compresses
|
||||
checked time.Time
|
||||
}
|
||||
|
||||
func (e *cacheEntry) cost() int64 { return int64(len(e.id)+len(e.body)+len(e.br)) + entryOverhead }
|
||||
|
||||
func newObjectCache(limit, max int64, fresh time.Duration) *objectCache {
|
||||
return &objectCache{limit: limit, max: max, fresh: fresh, now: time.Now, lru: list.New(), index: map[string]*list.Element{}}
|
||||
}
|
||||
|
||||
func (c *objectCache) get(id string) *cacheEntry {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
el, ok := c.index[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c.lru.MoveToFront(el)
|
||||
return el.Value.(*cacheEntry)
|
||||
}
|
||||
|
||||
func (c *objectCache) put(e *cacheEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if el, ok := c.index[e.id]; ok {
|
||||
c.bytes -= el.Value.(*cacheEntry).cost()
|
||||
el.Value = e
|
||||
c.lru.MoveToFront(el)
|
||||
} else {
|
||||
c.index[e.id] = c.lru.PushFront(e)
|
||||
}
|
||||
c.bytes += e.cost()
|
||||
for c.bytes > c.limit {
|
||||
el := c.lru.Back()
|
||||
old := el.Value.(*cacheEntry)
|
||||
c.lru.Remove(el)
|
||||
delete(c.index, old.id)
|
||||
c.bytes -= old.cost()
|
||||
}
|
||||
}
|
||||
|
||||
// cachedStore is an objectStore answered from an objectCache where it can. s3FS
|
||||
// and the handler do not change for it.
|
||||
type cachedStore struct {
|
||||
objectStore
|
||||
bucket string
|
||||
cache *objectCache
|
||||
}
|
||||
|
||||
func (s *cachedStore) stat(ctx context.Context, key string) (objectInfo, error) {
|
||||
e, err := s.entry(ctx, key)
|
||||
if err != nil {
|
||||
return objectInfo{}, err
|
||||
}
|
||||
return e.info, nil
|
||||
}
|
||||
|
||||
func (s *cachedStore) open(ctx context.Context, key string) (readSeekCloser, error) {
|
||||
e, err := s.entry(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.body == nil {
|
||||
return s.objectStore.open(ctx, key)
|
||||
}
|
||||
return &cachedBody{Reader: bytes.NewReader(e.body), br: e.br}, nil
|
||||
}
|
||||
|
||||
// entry is key's current entry: from memory when it was checked within fresh,
|
||||
// otherwise from the store, asked once per key however many requests wait.
|
||||
func (s *cachedStore) entry(ctx context.Context, key string) (*cacheEntry, error) {
|
||||
id := s.bucket + "/" + key
|
||||
e := s.cache.get(id)
|
||||
if e == nil || s.cache.now().Sub(e.checked) >= s.cache.fresh {
|
||||
old := e
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("%w: %w", errObjectStoreUnavailable, ctx.Err())
|
||||
case r := <-s.cache.flight.DoChan(id, func() (any, error) { return s.refresh(id, key, old) }):
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
e = r.Val.(*cacheEntry)
|
||||
case old == nil || !errors.Is(r.Err, errObjectStoreUnavailable):
|
||||
return nil, r.Err
|
||||
default:
|
||||
// The store could not answer and the last answer is held: the
|
||||
// site keeps serving through the outage, and the store is asked
|
||||
// again once per fresh rather than once per request.
|
||||
held := *old
|
||||
held.checked = s.cache.now()
|
||||
s.cache.put(&held)
|
||||
e = &held
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.missing {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// refresh asks the store about key. Absence is held; a store failure is not, so
|
||||
// an outage is asked about again on the next request. The context is the
|
||||
// refresh's own, because every request waiting on it shares the answer.
|
||||
func (s *cachedStore) refresh(id, key string, old *cacheEntry) (*cacheEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
|
||||
defer cancel()
|
||||
|
||||
now := s.cache.now()
|
||||
info, err := s.objectStore.stat(ctx, key)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
e := &cacheEntry{id: id, missing: true, checked: now}
|
||||
s.cache.put(e)
|
||||
return e, nil
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if old != nil && !old.missing && old.info.etag == info.etag && old.info.size == info.size && old.info.modTime.Equal(info.modTime) {
|
||||
e := *old
|
||||
e.info, e.checked = info, now
|
||||
s.cache.put(&e)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
e := &cacheEntry{id: id, info: info, checked: now}
|
||||
if info.size <= s.cache.max {
|
||||
if e.body = s.read(ctx, key, info.size); e.body == nil {
|
||||
// Overwritten or gone between the HEAD and the read. Hold nothing
|
||||
// fresh: this request streams from the store, the next asks again.
|
||||
e.checked = time.Time{}
|
||||
}
|
||||
e.br = encode(key, e.body)
|
||||
}
|
||||
s.cache.put(e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// read returns the object's bytes, or nil when they are not the size the HEAD
|
||||
// reported or cannot be read.
|
||||
func (s *cachedStore) read(ctx context.Context, key string, size int64) []byte {
|
||||
rc, err := s.objectStore.open(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(rc, size+1))
|
||||
if err != nil || int64(len(body)) != size {
|
||||
return nil
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// encode returns body brotli-encoded when its type is text and the encoding
|
||||
// saves a tenth or more, else nil. It runs once per object version, not per
|
||||
// request.
|
||||
func encode(key string, body []byte) []byte {
|
||||
if len(body) < 1024 || !compressible(key) {
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := brotli.NewWriterLevel(&buf, brotli.DefaultCompression)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := w.Close(); err != nil || buf.Len() > len(body)*9/10 {
|
||||
return nil
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func compressible(key string) bool {
|
||||
t, _, _ := strings.Cut(mime.TypeByExtension(path.Ext(key)), ";")
|
||||
switch t = strings.TrimSpace(t); {
|
||||
case strings.HasPrefix(t, "text/"), strings.HasSuffix(t, "+json"), strings.HasSuffix(t, "+xml"):
|
||||
return true
|
||||
}
|
||||
switch t {
|
||||
case "application/javascript", "application/json", "application/xml", "application/wasm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cachedBody is a held object's bytes as a stream, with its encoded twin.
|
||||
type cachedBody struct {
|
||||
*bytes.Reader
|
||||
br []byte
|
||||
}
|
||||
|
||||
func (*cachedBody) Close() error { return nil }
|
||||
func (b *cachedBody) brotli() []byte { return b.br }
|
||||
@@ -0,0 +1,500 @@
|
||||
package staticfiles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/hanzoai/ingress/pkg/config/dynamic"
|
||||
)
|
||||
|
||||
// liveStore is an objectStore whose objects a test can overwrite while it serves,
|
||||
// counting what the cache asks of it.
|
||||
type liveStore struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
mods map[string]time.Time
|
||||
down bool
|
||||
gate chan struct{} // when set, stat waits on it
|
||||
|
||||
stats, opens atomic.Int64
|
||||
}
|
||||
|
||||
func newLiveStore(objs map[string][]byte) *liveStore {
|
||||
s := &liveStore{objects: map[string][]byte{}, mods: map[string]time.Time{}}
|
||||
for k, v := range objs {
|
||||
s.publish(k, v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// publish writes key in place, the way a Sites deploy does.
|
||||
func (s *liveStore) publish(key string, body []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.objects[key] = body
|
||||
s.mods[key] = time.Date(2026, 1, 2, 3, 4, 5, len(s.mods)*1000, time.UTC)
|
||||
}
|
||||
|
||||
func (s *liveStore) stat(_ context.Context, key string) (objectInfo, error) {
|
||||
s.stats.Add(1)
|
||||
if s.gate != nil {
|
||||
<-s.gate
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.down {
|
||||
return objectInfo{}, fmt.Errorf("%w: refused", errObjectStoreUnavailable)
|
||||
}
|
||||
b, ok := s.objects[key]
|
||||
if !ok {
|
||||
return objectInfo{}, fs.ErrNotExist
|
||||
}
|
||||
return objectInfo{key: key, size: int64(len(b)), modTime: s.mods[key], etag: etagOf(b)}, nil
|
||||
}
|
||||
|
||||
func (s *liveStore) open(_ context.Context, key string) (readSeekCloser, error) {
|
||||
s.opens.Add(1)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, ok := s.objects[key]
|
||||
if !ok {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return nopSeekCloser{bytes.NewReader(b)}, nil
|
||||
}
|
||||
|
||||
func (s *liveStore) list(context.Context, string) ([]objectInfo, error) { return nil, nil }
|
||||
|
||||
// clock is a settable now for the cache.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
|
||||
func (c *clock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func cachedServer(t *testing.T, store objectStore, c *objectCache, cfg dynamic.StaticFiles) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(s3Handler(&cachedStore{objectStore: store, bucket: "sites", cache: c}, "org/site", cfg))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func testCache(limit, max int64) (*objectCache, *clock) {
|
||||
clk := &clock{t: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)}
|
||||
c := newObjectCache(limit, max, time.Second)
|
||||
c.now = clk.now
|
||||
return c, clk
|
||||
}
|
||||
|
||||
func body(t *testing.T, srv *httptest.Server, path string, header ...string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i+1 < len(header); i += 2 {
|
||||
req.Header.Set(header[i], header[i+1])
|
||||
}
|
||||
// The transport would otherwise ask for gzip and hide Content-Encoding.
|
||||
tr := &http.Transport{DisableCompression: true}
|
||||
defer tr.CloseIdleConnections()
|
||||
resp, err := (&http.Client{Transport: tr}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
// TestCacheServesFreshFromMemory: within fresh a repeat request asks the store
|
||||
// nothing, and the response is the same as the first.
|
||||
func TestCacheServesFreshFromMemory(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/index.html": []byte("<title>v1</title>")})
|
||||
c, _ := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
for range 3 {
|
||||
if resp, got := body(t, srv, "/"); resp.StatusCode != 200 || got != "<title>v1</title>" {
|
||||
t.Fatalf("GET / = %d %q", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
if s, o := store.stats.Load(), store.opens.Load(); s != 1 || o != 1 {
|
||||
t.Fatalf("store asked %d stats, %d opens for three requests within fresh, want 1 and 1", s, o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheRevalidatesWithOneHead: after fresh, an unchanged object costs one
|
||||
// stat and no read.
|
||||
func TestCacheRevalidatesWithOneHead(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/app.js": []byte("main()")})
|
||||
c, clk := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
body(t, srv, "/app.js")
|
||||
clk.advance(2 * time.Second)
|
||||
if _, got := body(t, srv, "/app.js"); got != "main()" {
|
||||
t.Fatalf("revalidated body = %q", got)
|
||||
}
|
||||
if s, o := store.stats.Load(), store.opens.Load(); s != 2 || o != 1 {
|
||||
t.Fatalf("store asked %d stats, %d opens, want 2 and 1: an unchanged ETag keeps the bytes", s, o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachePublishShowsAfterFresh: a deploy overwrites keys in place. The old
|
||||
// bytes serve for at most fresh, then the new ones, with the new validator.
|
||||
func TestCachePublishShowsAfterFresh(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/index.html": []byte("<title>v1</title>")})
|
||||
c, clk := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
first, _ := body(t, srv, "/")
|
||||
store.publish("org/site/index.html", []byte("<title>v2</title>"))
|
||||
|
||||
if _, got := body(t, srv, "/"); got != "<title>v1</title>" {
|
||||
t.Fatalf("within fresh = %q, want the held v1", got)
|
||||
}
|
||||
clk.advance(time.Second)
|
||||
resp, got := body(t, srv, "/")
|
||||
if got != "<title>v2</title>" {
|
||||
t.Fatalf("after fresh = %q, want the published v2", got)
|
||||
}
|
||||
if resp.Header.Get("ETag") == first.Header.Get("ETag") {
|
||||
t.Fatalf("ETag %q did not move with the publish", resp.Header.Get("ETag"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheHoldsAbsence: the page ladder's probe for the bare key is held like
|
||||
// an object, and a page published under a held miss appears after fresh.
|
||||
func TestCacheHoldsAbsence(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/pricing.html": []byte("<title>pricing</title>")})
|
||||
c, clk := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
body(t, srv, "/pricing")
|
||||
stats := store.stats.Load()
|
||||
if resp, got := body(t, srv, "/pricing"); resp.StatusCode != 200 || got != "<title>pricing</title>" {
|
||||
t.Fatalf("GET /pricing = %d %q", resp.StatusCode, got)
|
||||
}
|
||||
if n := store.stats.Load() - stats; n != 0 {
|
||||
t.Fatalf("repeat /pricing asked the store %d times, want 0", n)
|
||||
}
|
||||
|
||||
if resp, _ := body(t, srv, "/new"); resp.StatusCode != 404 {
|
||||
t.Fatalf("GET /new = %d before it is published", resp.StatusCode)
|
||||
}
|
||||
store.publish("org/site/new.html", []byte("<title>new</title>"))
|
||||
clk.advance(time.Second)
|
||||
if resp, got := body(t, srv, "/new"); resp.StatusCode != 200 || got != "<title>new</title>" {
|
||||
t.Fatalf("GET /new after publish = %d %q", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheOutageIsNotHeld: a failed stat is a 502 now and asked again on the
|
||||
// very next request, not after fresh.
|
||||
func TestCacheOutageIsNotHeld(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/a.css": []byte("a{}")})
|
||||
c, _ := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
store.mu.Lock()
|
||||
store.down = true
|
||||
store.mu.Unlock()
|
||||
if resp, _ := body(t, srv, "/a.css"); resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("outage = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
store.mu.Lock()
|
||||
store.down = false
|
||||
store.mu.Unlock()
|
||||
if resp, got := body(t, srv, "/a.css"); resp.StatusCode != 200 || got != "a{}" {
|
||||
t.Fatalf("after recovery = %d %q, want 200 at once", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheServesHeldThroughOutage: a store that stops answering after an object
|
||||
// was read leaves that object served, and the store is asked again once per fresh.
|
||||
func TestCacheServesHeldThroughOutage(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/index.html": []byte("<title>held</title>")})
|
||||
c, clk := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
body(t, srv, "/")
|
||||
store.mu.Lock()
|
||||
store.down = true
|
||||
store.mu.Unlock()
|
||||
for range 2 {
|
||||
clk.advance(2 * time.Second)
|
||||
if resp, got := body(t, srv, "/"); resp.StatusCode != 200 || got != "<title>held</title>" {
|
||||
t.Fatalf("during outage = %d %q, want the held page", resp.StatusCode, got)
|
||||
}
|
||||
}
|
||||
if s := store.stats.Load(); s != 3 {
|
||||
t.Fatalf("store asked %d stats, want 3: once per request past fresh", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheLargeObjectStreams: an object over max is never held; every request
|
||||
// streams it, and its stat is still held.
|
||||
func TestCacheLargeObjectStreams(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("x"), 4096)
|
||||
store := newLiveStore(map[string][]byte{"org/site/film.mp4": big})
|
||||
c, _ := testCache(1<<20, 1024)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
for range 2 {
|
||||
if _, got := body(t, srv, "/film.mp4"); got != string(big) {
|
||||
t.Fatalf("large object body is %d bytes, want %d", len(got), len(big))
|
||||
}
|
||||
}
|
||||
if s, o := store.stats.Load(), store.opens.Load(); s != 1 || o != 2 {
|
||||
t.Fatalf("store asked %d stats, %d opens, want 1 and 2", s, o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheBound: the cache never holds more than its limit, drops the least
|
||||
// recently used first, and keeps what was just read.
|
||||
func TestCacheBound(t *testing.T) {
|
||||
// Every id below is 20 bytes, so the limit holds exactly four entries.
|
||||
c, _ := testCache(4*(20+100+entryOverhead), 1<<20)
|
||||
for i := range 10 {
|
||||
c.put(&cacheEntry{id: fmt.Sprintf("sites/k%02d-padding-xx", i), body: make([]byte, 100)})
|
||||
if i == 5 {
|
||||
c.get("sites/k00-padding-xx") // gone already: a get of an evicted id is a miss
|
||||
}
|
||||
c.get("sites/k02-padding-xx")
|
||||
}
|
||||
if c.bytes > c.limit {
|
||||
t.Fatalf("held %d bytes over a limit of %d", c.bytes, c.limit)
|
||||
}
|
||||
if len(c.index) != c.lru.Len() {
|
||||
t.Fatalf("index holds %d, list %d", len(c.index), c.lru.Len())
|
||||
}
|
||||
if c.get("sites/k02-padding-xx") == nil {
|
||||
t.Fatal("the most recently read entry was evicted")
|
||||
}
|
||||
if c.get("sites/k09-padding-xx") == nil {
|
||||
t.Fatal("the newest entry was evicted")
|
||||
}
|
||||
for _, gone := range []string{"sites/k00-padding-xx", "sites/k01-padding-xx", "sites/k03-padding-xx"} {
|
||||
if c.get(gone) != nil {
|
||||
t.Fatalf("%s survived; least recently used goes first", gone)
|
||||
}
|
||||
}
|
||||
|
||||
// Replacing an entry charges the difference, not both.
|
||||
c.put(&cacheEntry{id: "sites/k09-padding-xx", body: make([]byte, 10)})
|
||||
var sum int64
|
||||
for el := c.lru.Front(); el != nil; el = el.Next() {
|
||||
sum += el.Value.(*cacheEntry).cost()
|
||||
}
|
||||
if sum != c.bytes {
|
||||
t.Fatalf("accounted %d bytes, entries cost %d", c.bytes, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheCoalesces: many requests for one cold object ask the store once.
|
||||
func TestCacheCoalesces(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/index.html": []byte("<title>one</title>")})
|
||||
store.gate = make(chan struct{})
|
||||
c, _ := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resp, err := http.Get(srv.URL + "/index.html")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 || string(b) != "<title>one</title>" {
|
||||
errs <- fmt.Errorf("%d %q", resp.StatusCode, b)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// Let every request reach the cache before the one stat answers.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for store.stats.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(store.gate)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
if s, o := store.stats.Load(), store.opens.Load(); s != 1 || o != 1 {
|
||||
t.Fatalf("%d concurrent requests asked %d stats, %d opens, want 1 and 1", n, s, o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheWaiterCancels: a request that gives up while the store is slow is
|
||||
// released, and the refresh it joined still lands for the next one.
|
||||
func TestCacheWaiterCancels(t *testing.T) {
|
||||
store := newLiveStore(map[string][]byte{"org/site/a.js": []byte("a")})
|
||||
store.gate = make(chan struct{})
|
||||
c, _ := testCache(1<<20, 1<<20)
|
||||
cs := &cachedStore{objectStore: store, bucket: "sites", cache: c}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := cs.stat(ctx, "org/site/a.js"); !errors.Is(err, errObjectStoreUnavailable) {
|
||||
t.Fatalf("cancelled wait = %v, want an availability error", err)
|
||||
}
|
||||
close(store.gate)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for c.get("sites/org/site/a.js") == nil && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if info, err := cs.stat(context.Background(), "org/site/a.js"); err != nil || info.size != 1 {
|
||||
t.Fatalf("stat after the refresh = %+v %v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheBrotli: text is sent brotli-encoded to a client that accepts it, as
|
||||
// its own representation, and as identity to one that does not or that asks for
|
||||
// a range.
|
||||
func TestCacheBrotli(t *testing.T) {
|
||||
page := []byte("<!doctype html><title>br</title>" + strings.Repeat("<p>hanzo</p>", 400))
|
||||
store := newLiveStore(map[string][]byte{
|
||||
"org/site/index.html": page,
|
||||
"org/site/logo.png": bytes.Repeat([]byte{0x89}, 4096),
|
||||
})
|
||||
c, _ := testCache(1<<20, 1<<20)
|
||||
srv := cachedServer(t, store, c, dynamic.StaticFiles{})
|
||||
|
||||
resp, enc := body(t, srv, "/", "Accept-Encoding", "gzip, deflate, br")
|
||||
if resp.Header.Get("Content-Encoding") != "br" {
|
||||
t.Fatalf("Content-Encoding = %q, want br", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary = %q", resp.Header.Get("Vary"))
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != fmt.Sprint(len(enc)) {
|
||||
t.Fatalf("Content-Length = %s for %d encoded bytes", got, len(enc))
|
||||
}
|
||||
dec, err := io.ReadAll(brotli.NewReader(strings.NewReader(enc)))
|
||||
if err != nil || !bytes.Equal(dec, page) {
|
||||
t.Fatalf("br body does not decode to the page (%v)", err)
|
||||
}
|
||||
brTag := resp.Header.Get("ETag")
|
||||
if !strings.HasSuffix(brTag, `-br"`) {
|
||||
t.Fatalf("br ETag = %q, want its own validator", brTag)
|
||||
}
|
||||
|
||||
if resp, _ := body(t, srv, "/", "Accept-Encoding", "br", "If-None-Match", brTag); resp.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("If-None-Match on the br validator = %d, want 304", resp.StatusCode)
|
||||
}
|
||||
|
||||
for _, h := range [][]string{
|
||||
{"Accept-Encoding", "gzip"},
|
||||
{"Accept-Encoding", "br;q=0"},
|
||||
{"Accept-Encoding", "br", "Range", "bytes=0-9"},
|
||||
} {
|
||||
resp, got := body(t, srv, "/", h...)
|
||||
if resp.Header.Get("Content-Encoding") != "" {
|
||||
t.Fatalf("%v: Content-Encoding = %q, want identity", h, resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
if !strings.HasPrefix(string(page), got) || got == "" {
|
||||
t.Fatalf("%v: body is not the identity page", h)
|
||||
}
|
||||
if strings.HasSuffix(resp.Header.Get("ETag"), `-br"`) {
|
||||
t.Fatalf("%v: identity carries the br validator", h)
|
||||
}
|
||||
}
|
||||
|
||||
if resp, _ := body(t, srv, "/logo.png", "Accept-Encoding", "br"); resp.Header.Get("Content-Encoding") != "" || resp.Header.Get("Vary") != "" {
|
||||
t.Fatalf("an image was encoded: %q / Vary %q", resp.Header.Get("Content-Encoding"), resp.Header.Get("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptsBrotli(t *testing.T) {
|
||||
for header, want := range map[string]bool{
|
||||
"": false,
|
||||
"gzip": false,
|
||||
"br": true,
|
||||
"gzip, br": true,
|
||||
"gzip;q=1.0, BR;q=0.5": true,
|
||||
"br;q=0": false,
|
||||
"br; q=0.0, gzip": false,
|
||||
"brotli": false,
|
||||
} {
|
||||
if got := acceptsBrotli(header); got != want {
|
||||
t.Errorf("acceptsBrotli(%q) = %v, want %v", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextStaticIsImmutable: everything Next writes under _next/static is named
|
||||
// by its content, so it is cached for a year whatever its extension; the same
|
||||
// file elsewhere keeps the default.
|
||||
func TestNextStaticIsImmutable(t *testing.T) {
|
||||
store := newMapStore(map[string][]byte{
|
||||
"cd/_next/static/media/hero.4f3a9c21.png": []byte("png"),
|
||||
"cd/logo.png": []byte("png"),
|
||||
"cd/pay/_next/static/chunks/main.js": []byte("js"),
|
||||
})
|
||||
srv := httptest.NewServer(s3Handler(store, "cd", dynamic.StaticFiles{}))
|
||||
defer srv.Close()
|
||||
|
||||
for p, want := range map[string]string{
|
||||
"/_next/static/media/hero.4f3a9c21.png": "public, max-age=31536000, immutable",
|
||||
"/pay/_next/static/chunks/main.js": "public, max-age=31536000, immutable",
|
||||
"/logo.png": "max-age=86400",
|
||||
} {
|
||||
resp, err := srv.Client().Get(srv.URL + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if got := resp.Header.Get("Cache-Control"); got != want {
|
||||
t.Errorf("%s Cache-Control = %q, want %q", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectFSSharesClient: every root under one store configuration rides one
|
||||
// client, and so one connection pool.
|
||||
func TestObjectFSSharesClient(t *testing.T) {
|
||||
t.Setenv("S3_ENDPOINT", "http://s3.test:9000")
|
||||
t.Setenv("AWS_ACCESS_KEY_ID", "k")
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
|
||||
a, err := newObjectFS("s3://sites/org/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := newObjectFS("s3://other/org/b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca := a.store.(*cachedStore).objectStore.(*minioStore).client
|
||||
cb := b.store.(*cachedStore).objectStore.(*minioStore).client
|
||||
if ca != cb {
|
||||
t.Fatal("two roots on one store built two clients")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
s3 "github.com/hanzos3/go"
|
||||
@@ -181,6 +183,14 @@ func (f *s3File) Close() error {
|
||||
return f.rc.Close()
|
||||
}
|
||||
|
||||
// brotli is the object's encoded twin when the cache holds one.
|
||||
func (f *s3File) brotli() []byte {
|
||||
if e, ok := f.rc.(encoded); ok {
|
||||
return e.brotli()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// s3Dir is a directory (prefix) exposed as an http.File. Only Stat and Readdir
|
||||
// are meaningful; Read/Seek exist to satisfy http.File and are never reached
|
||||
// because the handler branches on IsDir first.
|
||||
@@ -310,15 +320,44 @@ func newObjectFS(root string) (*s3FS, error) {
|
||||
}
|
||||
|
||||
endpoint = strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
||||
client, err := s3.New(endpoint, &s3.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: os.Getenv("S3_USE_SSL") == "true",
|
||||
Region: region,
|
||||
BucketLookup: s3.BucketLookupPath,
|
||||
})
|
||||
client, err := clientFor(endpoint, region, accessKey, secretKey, os.Getenv("S3_USE_SSL") == "true")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 client: %w", err)
|
||||
}
|
||||
|
||||
return &s3FS{store: &minioStore{client: client, bucket: bucket}, prefix: prefix}, nil
|
||||
store := &cachedStore{objectStore: &minioStore{client: client, bucket: bucket}, bucket: bucket, cache: objects}
|
||||
return &s3FS{store: store, prefix: prefix}, nil
|
||||
}
|
||||
|
||||
// clients holds one client per store configuration, so every site rides one
|
||||
// pool of kept-alive connections instead of each middleware dialing its own.
|
||||
var (
|
||||
clientsMu sync.Mutex
|
||||
clients = map[string]*s3.Client{}
|
||||
)
|
||||
|
||||
func clientFor(endpoint, region, accessKey, secretKey string, secure bool) (*s3.Client, error) {
|
||||
id := strings.Join([]string{endpoint, region, accessKey, secretKey, strconv.FormatBool(secure)}, "\x00")
|
||||
clientsMu.Lock()
|
||||
defer clientsMu.Unlock()
|
||||
if c, ok := clients[id]; ok {
|
||||
return c, nil
|
||||
}
|
||||
tr, err := s3.DefaultTransport(secure)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr.MaxIdleConnsPerHost = 64
|
||||
c, err := s3.New(endpoint, &s3.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: secure,
|
||||
Region: region,
|
||||
BucketLookup: s3.BucketLookupPath,
|
||||
Transport: tr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients[id] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package staticfiles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -211,15 +212,56 @@ func (h *staticFiles) openRegular(ctx context.Context, name string) (http.File,
|
||||
// the extension, then the body through ServeContent so Range and conditional
|
||||
// requests keep working.
|
||||
func (h *staticFiles) serveOpen(w http.ResponseWriter, r *http.Request, f http.File, d fs.FileInfo) {
|
||||
h.setCacheHeaders(w, d)
|
||||
h.setCacheHeaders(w, r.URL.Path, d)
|
||||
|
||||
if contentType := mime.TypeByExtension(filepath.Ext(d.Name())); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if e, ok := f.(encoded); ok {
|
||||
if br := e.brotli(); br != nil {
|
||||
// Both representations name what they vary on, so no cache between
|
||||
// here and the reader hands one to a client that asked for the other.
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
// A range names bytes of the identity representation; it is answered
|
||||
// from that one.
|
||||
if r.Header.Get("Range") == "" && acceptsBrotli(r.Header.Get("Accept-Encoding")) {
|
||||
w.Header().Set("Content-Encoding", "br")
|
||||
// A strong validator names one representation (RFC 9110 8.8.3).
|
||||
if tag := w.Header().Get("ETag"); tag != "" {
|
||||
w.Header().Set("ETag", strings.TrimSuffix(tag, `"`)+`-br"`)
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(br)))
|
||||
http.ServeContent(w, r, d.Name(), d.ModTime(), bytes.NewReader(br))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, d.Name(), d.ModTime(), f.(io.ReadSeeker))
|
||||
}
|
||||
|
||||
// encoded is a file that also holds its bytes brotli-encoded, as the object
|
||||
// cache's files do.
|
||||
type encoded interface{ brotli() []byte }
|
||||
|
||||
// acceptsBrotli reports whether an Accept-Encoding value names br with a weight
|
||||
// above zero.
|
||||
func acceptsBrotli(header string) bool {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
coding, params, _ := strings.Cut(part, ";")
|
||||
if !strings.EqualFold(strings.TrimSpace(coding), "br") {
|
||||
continue
|
||||
}
|
||||
if q, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok {
|
||||
w, err := strconv.ParseFloat(strings.TrimSpace(q), 64)
|
||||
return err == nil && w > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *staticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
upath := r.URL.Path
|
||||
if !strings.HasPrefix(upath, "/") {
|
||||
@@ -356,12 +398,16 @@ func (h *staticFiles) serveDirectoryListing(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
func (h *staticFiles) setCacheHeaders(w http.ResponseWriter, d fs.FileInfo) {
|
||||
func (h *staticFiles) setCacheHeaders(w http.ResponseWriter, name string, d fs.FileInfo) {
|
||||
ext := filepath.Ext(d.Name())
|
||||
|
||||
switch {
|
||||
case cacheControlHas(h.cacheControl, ext):
|
||||
w.Header().Set("Cache-Control", h.cacheControl[ext])
|
||||
case strings.Contains(name, "/_next/static/") && kindOf(name) != page:
|
||||
// Next names every file it writes here by its content, so a new build is
|
||||
// a new URL and this one never changes, whatever its extension.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
case cacheControlHas(h.cacheControl, "*"):
|
||||
w.Header().Set("Cache-Control", h.cacheControl["*"])
|
||||
case ext == ".html" || ext == ".htm":
|
||||
@@ -455,15 +501,7 @@ func (h *staticFiles) serveFile(w http.ResponseWriter, r *http.Request, name str
|
||||
return
|
||||
}
|
||||
|
||||
h.setCacheHeaders(w, d)
|
||||
|
||||
ext := filepath.Ext(d.Name())
|
||||
contentType := mime.TypeByExtension(ext)
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, d.Name(), d.ModTime(), f)
|
||||
h.serveOpen(w, r, f, d)
|
||||
}
|
||||
|
||||
func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
|
||||
|
||||
Reference in New Issue
Block a user