check / check (push) Waiting to run
A hit opens the variant file and nothing else: its content type comes from the file's first bytes, and a file in no output format is a miss. Its access time and count, and the access time of a cached source a miss uses, go without waiting to one background goroutine that writes them once a second; each kind of access time waits in a channel of 10,000 that drops the rest. Expired negative cache rows are left to CleanExpired. The .meta files, metaCache, Lookup and golang-lru go, with their tests; the two reconciliation walks, identical without the .meta skip, share one. make bench runs a parallel cache hit benchmark on the host, outside make check. Model: opus-5-5
266 lines
7.0 KiB
Go
266 lines
7.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"image"
|
|
"image/jpeg"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/database"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// countingHost is the allowlisted host whose photoPath the server of
|
|
// newCountingDatabaseServer serves.
|
|
const countingHost = "counting.example.com"
|
|
|
|
// countingConnector opens connections to the SQLite database file name with
|
|
// sqliteDriver, each one counting in statements the statements run on it.
|
|
type countingConnector struct {
|
|
sqliteDriver driver.Driver
|
|
name string
|
|
statements *atomic.Int64
|
|
}
|
|
|
|
func (c countingConnector) Connect(context.Context) (driver.Conn, error) {
|
|
conn, err := c.sqliteDriver.Open(c.name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return countingConn{Conn: conn, statements: c.statements}, nil
|
|
}
|
|
|
|
func (c countingConnector) Driver() driver.Driver {
|
|
return c.sqliteDriver
|
|
}
|
|
|
|
// countingConn counts the statements run on its connection. It has only the
|
|
// methods of driver.Conn, so database/sql prepares every query and every
|
|
// other statement with Prepare, which counts it.
|
|
type countingConn struct {
|
|
driver.Conn
|
|
|
|
statements *atomic.Int64
|
|
}
|
|
|
|
func (c countingConn) Prepare(query string) (driver.Stmt, error) {
|
|
c.statements.Add(1)
|
|
|
|
return c.Conn.Prepare(query)
|
|
}
|
|
|
|
// newCountingDatabase returns a database with pixa's schema, in a file of
|
|
// its own and with one connection, and the count of the statements run on
|
|
// it once the schema is in place.
|
|
func newCountingDatabase(tb testing.TB) (*sql.DB, *atomic.Int64) {
|
|
tb.Helper()
|
|
|
|
path := filepath.Join(tb.TempDir(), "state.sqlite3")
|
|
|
|
schemaDB, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
tb.Fatalf("opening the database: %v", err)
|
|
}
|
|
|
|
defer func() { _ = schemaDB.Close() }()
|
|
|
|
err = database.ApplyMigrations(tb.Context(), schemaDB, nil)
|
|
if err != nil {
|
|
tb.Fatalf("applying the migrations: %v", err)
|
|
}
|
|
|
|
statements := new(atomic.Int64)
|
|
|
|
db := sql.OpenDB(countingConnector{
|
|
sqliteDriver: schemaDB.Driver(),
|
|
name: path,
|
|
statements: statements,
|
|
})
|
|
db.SetMaxOpenConns(1)
|
|
|
|
tb.Cleanup(func() { _ = db.Close() })
|
|
|
|
return db, statements
|
|
}
|
|
|
|
// newCountingDatabaseServer returns a router for both image routes whose
|
|
// image cache uses newCountingDatabase, the count of the statements run on
|
|
// that database, and a URL on each route for a different image of
|
|
// photoPath on countingHost. Nothing writes the cache's pending counts and
|
|
// access times in the background, so every statement counted comes from a
|
|
// request.
|
|
func newCountingDatabaseServer(
|
|
tb testing.TB,
|
|
) (http.Handler, *atomic.Int64, []string) {
|
|
tb.Helper()
|
|
|
|
db, statements := newCountingDatabase(tb)
|
|
|
|
var photo bytes.Buffer
|
|
|
|
err := jpeg.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 100, 100)), nil)
|
|
if err != nil {
|
|
tb.Fatalf("encoding the photo: %v", err)
|
|
}
|
|
|
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
|
StateDir: tb.TempDir(),
|
|
NegativeTTL: 5 * time.Minute,
|
|
})
|
|
if err != nil {
|
|
tb.Fatalf("imgcache.NewCache() error = %v", err)
|
|
}
|
|
|
|
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
|
|
Cache: cache,
|
|
Fetcher: newMockFetcher(fstest.MapFS{
|
|
countingHost + photoPath: &fstest.MapFile{Data: photo.Bytes()},
|
|
}),
|
|
SigningKey: testSigningKey,
|
|
Allowlist: []string{countingHost},
|
|
Logger: slog.New(slog.DiscardHandler),
|
|
})
|
|
if err != nil {
|
|
tb.Fatalf("imgcache.NewService() error = %v", err)
|
|
}
|
|
|
|
encGen, err := encurl.NewGenerator(testSigningKey)
|
|
if err != nil {
|
|
tb.Fatalf("encurl.NewGenerator() error = %v", err)
|
|
}
|
|
|
|
token, err := encGen.Generate(&imagerequest.Request{
|
|
Source: imagerequest.Source{Host: countingHost, Path: photoPath},
|
|
Transform: imagerequest.Transform{
|
|
Size: imagerequest.Size{Width: 40, Height: 40},
|
|
Format: imagerequest.FormatJPEG,
|
|
Quality: 85,
|
|
FitMode: imagerequest.FitCover,
|
|
},
|
|
})
|
|
if err != nil {
|
|
tb.Fatalf("encGen.Generate() error = %v", err)
|
|
}
|
|
|
|
h := &Handlers{
|
|
log: slog.New(slog.DiscardHandler),
|
|
imgSvc: svc,
|
|
encGen: encGen,
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/v1/image/*", h.HandleImage())
|
|
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
|
|
|
return r, statements, []string{
|
|
"/v1/image/" + countingHost + photoPath + "/50x50.jpeg",
|
|
"/v1/e/" + token + "/img.jpg",
|
|
}
|
|
}
|
|
|
|
// serveImage sends srv a GET for target and returns the response, failing
|
|
// the test unless it is a 200 with an image.
|
|
func serveImage(
|
|
tb testing.TB, srv http.Handler, target string,
|
|
) *httptest.ResponseRecorder {
|
|
tb.Helper()
|
|
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec,
|
|
httptest.NewRequestWithContext(tb.Context(), http.MethodGet, target, nil))
|
|
|
|
if rec.Code != http.StatusOK || rec.Body.Len() == 0 {
|
|
tb.Errorf("GET %s: %d with %d bytes, want 200 with an image",
|
|
target, rec.Code, rec.Body.Len())
|
|
}
|
|
|
|
return rec
|
|
}
|
|
|
|
// TestImageRoutes_CacheHitsMakeNoDatabaseCall serves an image on each image
|
|
// route once, which makes and caches it, then serves it again from the cache.
|
|
// Serving those cache hits must run no statement on the database.
|
|
func TestImageRoutes_CacheHitsMakeNoDatabaseCall(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv, statements, targets := newCountingDatabaseServer(t)
|
|
|
|
for _, target := range targets {
|
|
before := statements.Load()
|
|
|
|
serveImage(t, srv, target)
|
|
|
|
afterMiss := statements.Load()
|
|
t.Logf("GET %s, a miss: %d statements", target, afterMiss-before)
|
|
|
|
if afterMiss == before {
|
|
t.Fatalf("GET %s, a miss: no statement counted, want some", target)
|
|
}
|
|
|
|
for range 3 {
|
|
rec := serveImage(t, srv, target)
|
|
|
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
|
if cacheStatus != string(imgcache.CacheHit) {
|
|
t.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
|
target, cacheStatus, imgcache.CacheHit)
|
|
}
|
|
}
|
|
|
|
if hitStatements := statements.Load() - afterMiss; hitStatements != 0 {
|
|
t.Errorf("GET %s: 3 cache hits ran %d statements, want 0",
|
|
target, hitStatements)
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkHandleImage_CacheHits serves one cached image through the
|
|
// /v1/image/ route from b.RunParallel's goroutines, and reports the cache
|
|
// hits served per second. It fails if serving them ran any statement on the
|
|
// database. script/bench runs it.
|
|
func BenchmarkHandleImage_CacheHits(b *testing.B) {
|
|
srv, statements, targets := newCountingDatabaseServer(b)
|
|
target := targets[0]
|
|
|
|
serveImage(b, srv, target)
|
|
|
|
before := statements.Load()
|
|
|
|
b.ResetTimer()
|
|
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
for pb.Next() {
|
|
rec := serveImage(b, srv, target)
|
|
|
|
cacheStatus := rec.Header().Get("X-Pixa-Cache")
|
|
if cacheStatus != string(imgcache.CacheHit) {
|
|
b.Errorf("GET %s: X-Pixa-Cache = %q, want %s",
|
|
target, cacheStatus, imgcache.CacheHit)
|
|
|
|
return
|
|
}
|
|
}
|
|
})
|
|
|
|
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "hits/s")
|
|
|
|
if hitStatements := statements.Load() - before; hitStatements != 0 {
|
|
b.Fatalf("%d cache hits ran %d statements, want 0", b.N, hitStatements)
|
|
}
|
|
}
|