1 Commits
Author SHA1 Message Date
sneak 018c12a8d2 Count hits, misses, fetches and transforms as Prometheus counters (closes #311)
check / check (push) Waiting to run
The five counts the cache_stats table held are now Prometheus counters in
memory, as fields of the cache. NewCache makes them and registers them on
the registry it is given. pixad gives it Prometheus' default registry,
which /metrics serves; each test gives it a new one. The Increment methods
add to their cache's counters, so counting makes no database call.

cache_stats, Cache.Stats, Service.Stats, CacheStats and the pending counts
are gone, with their tests. The background goroutine now writes only access
times, and its names say so: StartPendingAccessTimeWrites,
StopPendingAccessTimeWrites and pendingaccesstimes.go. The two stop tests
whose write under way was a count now use access times.

Model: opus-5-5
2026-10-26 09:12:33 +00:00
28 changed files with 314 additions and 152 deletions
+20 -18
View File
@@ -90,9 +90,8 @@ which answers 200 whenever pixa is running, in maintenance mode too (see
On SIGTERM or SIGINT pixa stops accepting connections. The requests in progress
and the images being processed get 5 seconds to finish. A request not finished
by then is cut off. pixa then writes to the database the times cached images
were last used that are not written yet (see Storage), and exits. It exits with
1 when:
by then is cut off. pixa then writes the waiting last-used times of cached
images to the database (see Storage) and exits. It exits with 1 when:
- images were still being processed after those 5 seconds,
- some of those times could not be written, or
@@ -213,14 +212,14 @@ Test). Toward it, a cache hit opens the transformed image's file and nothing
else, and makes no database call. The content type comes from the file's first
bytes, which are those of the format pixa wrote it in; a file whose first bytes
are not those of a format pixa writes is not served, and the image is converted
again. The time the image was last served, which eviction orders by, is handed
to a background writer without waiting for it. The hit is counted in memory. A
miss also hands that writer the time it last used a cached source image. Once a
second that writer writes the waiting times in one transaction. While 10,000
times of transformed images, or 10,000 of source images, wait to be written, a
further one is dropped, and that image only looks less recently used to
eviction. `script/bench` measures how many hits pixa serves per second (see Load
Test).
again. A hit hands the time it was served to a background writer and does not
wait for the writer. Eviction orders by that time. The hit is counted in memory.
A miss that uses a cached source image also hands that writer the time it used
the image. Once a second that writer writes the waiting times in one
transaction. While 10,000 times of transformed images, or 10,000 of source
images, wait to be written, a further one is dropped, and that image only looks
less recently used to eviction. `script/bench` measures how many hits pixa
serves per second (see Load Test).
### Routes
@@ -819,13 +818,8 @@ See `configs/config.example.yml` for all options with defaults.
- **Database**: SQLite via modernc.org/sqlite
- **Static assets**: embedded via `//go:embed`
- **Metrics**: Prometheus, at `/metrics`. pixa serves `/metrics` and measures
HTTP requests only when `metrics.username` and `metrics.password` are set. A
request's `handler` label is the pattern of the route it matches, such as
`/v1/image/*`, never its path. Requests that match no route share the label
`unmatched`: those the router answers 404 or 405, and CORS preflights. A
request's `method` label is its method when that is `GET`, `HEAD`, `POST`,
`PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT` or `TRACE`, and `other` for any
other method. `/metrics` holds:
HTTP requests only when `metrics.username` and `metrics.password` are set.
`/metrics` holds:
- the duration of HTTP requests
- the size of HTTP responses
- the HTTP requests in flight
@@ -836,6 +830,14 @@ See `configs/config.example.yml` for all options with defaults.
bytes
- `pixa_upstream_fetch_bytes_total`: the bytes those fetches read
- `pixa_transforms_total`: images converted
A request's `handler` label is the pattern of the route it matches, such as
`/v1/image/*`, never its path. Requests that match no route share the label
`unmatched`: those the router answers 404 or 405, and CORS preflights. A
request's `method` label is its method when that is `GET`, `HEAD`, `POST`,
`PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT` or `TRACE`, and `other` for
any other method.
- **Logging**: stdlib slog
## Entrypoints
+8 -7
View File
@@ -34,13 +34,14 @@ P2: security: per-origin rate limiting
and transforms are counted as Prometheus counters in memory (closes #311).
They are `pixa_cache_hits_total`, `pixa_cache_misses_total`,
`pixa_upstream_fetches_total`, `pixa_upstream_fetch_bytes_total` and
`pixa_transforms_total`, in `internal/imgcache/counters.go`, on the default
registry that `/metrics` serves. The `Increment` methods add to them.
`cache_stats`, `Cache.Stats`, `Service.Stats`, `CacheStats` and the pending
counts are gone. The background goroutine writes only access times now:
`StartPendingAccessTimeWrites` starts it and `StopPendingAccessTimeWrites`
stops it, in `pendingaccesstimes.go`. The counters count for the whole
process, so the tests that check them do not call `t.Parallel`.
`pixa_transforms_total`, fields of the cache. `NewCache` makes them and
registers them on the registry it is given (`internal/imgcache/counters.go`).
pixad gives it Prometheus' default registry, which `/metrics` serves; each
test gives it a new one. The `Increment` methods add to their cache's
counters. `cache_stats`, `Cache.Stats`, `Service.Stats`, `CacheStats` and the
pending counts are gone. The background goroutine writes only access times
now: `StartPendingAccessTimeWrites` starts it and
`StopPendingAccessTimeWrites` stops it, in `pendingaccesstimes.go`.
- 2026-10-21 the schema file declares only the tables and columns pixa writes
and reads (closes #310): `output_content` and `request_cache` are gone, and so
are the columns nothing read: the host, path, query, `content_type` and
+3
View File
@@ -7,6 +7,7 @@ import (
"os/signal"
"syscall"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/cobra"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
@@ -61,6 +62,8 @@ func run(_ *cobra.Command, _ []string) {
server.New,
middleware.New,
healthcheck.New,
// The registry /metrics serves
func() prometheus.Registerer { return prometheus.DefaultRegisterer },
),
fx.Invoke(
func(log *logger.Logger) { log.Identify() },
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
@@ -120,7 +121,7 @@ func newCountingDatabaseServer(
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
StateDir: tb.TempDir(),
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
tb.Fatalf("imgcache.NewCache() error = %v", err)
}
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
@@ -127,7 +128,10 @@ func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
t.Fatalf("database.New() error = %v", err)
}
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
_, err = New(lc, Params{
Logger: log, Database: db, Config: cfg,
MetricsRegistry: prometheus.NewRegistry(),
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
+3 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -47,7 +48,8 @@ func TestHandlersBuildTheirOwnFetcherWhenNoneIsProvided(t *testing.T) {
app := fxtest.New(t,
fx.Supply(cfg),
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New),
fx.Provide(globals.New, logger.New, database.New, healthcheck.New, New,
func() prometheus.Registerer { return prometheus.NewRegistry() }),
fx.Populate(&h),
)
app.RequireStart()
@@ -12,6 +12,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
@@ -35,7 +36,7 @@ func newRoutesFetchingThrough(
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
+13 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/config"
@@ -30,6 +31,11 @@ type Params struct {
Database *database.Database
Config *config.Config
// MetricsRegistry is the registry the image cache registers its
// counters on. pixad provides Prometheus' default registry, the one
// /metrics serves.
MetricsRegistry prometheus.Registerer
// Fetcher, when provided, fetches upstream images in place of the
// fetcher the handlers build from the config. Only tests provide one;
// pixad does not.
@@ -52,6 +58,10 @@ type Handlers struct {
// refererBlocklist matches the hosts of referer_blocklist; its IsAllowed
// reports whether a URL's host is on that list.
refererBlocklist *allowlist.HostAllowList
// metricsRegistry is the registry the image cache registers its
// counters on.
metricsRegistry prometheus.Registerer
}
// New creates a new Handlers instance.
@@ -69,6 +79,7 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
fetcher: params.Fetcher,
csrfProtect: csrfProtect,
refererBlocklist: allowlist.New(params.Config.RefererBlocklist),
metricsRegistry: params.MetricsRegistry,
}
lc.Append(fx.Hook{
@@ -116,7 +127,8 @@ func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
// initImageService initializes the image cache and service.
func (s *Handlers) initImageService() error {
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
cache, err := imgcache.NewCache(
s.db.DB(), newCacheConfig(s.config, s.log), s.metricsRegistry)
if err != nil {
return err
}
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
@@ -48,7 +49,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
StateDir: tmpDir,
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -12,6 +12,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imagerequest"
@@ -33,7 +34,7 @@ func newSignedHostServer(
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
@@ -13,6 +13,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
@@ -31,7 +32,7 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -10,6 +10,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
@@ -50,7 +51,7 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
+31 -10
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/imagerequest"
)
@@ -93,6 +94,14 @@ type Cache struct {
pendingAccessTimesDone chan struct{}
pendingAccessTimesCancel context.CancelFunc
// The counts of what the cache does, made and registered by
// registerCounters (see counters.go)
cacheHits prometheus.Counter
cacheMisses prometheus.Counter
upstreamFetches prometheus.Counter
upstreamFetchBytes prometheus.Counter
transforms prometheus.Counter
// reconciliationPageSize is the most rows one read of a content table
// returns in the reconciliation pass. newCache sets it to
// defaultReconciliationPageSize; tests set it smaller.
@@ -105,15 +114,22 @@ type Cache struct {
reconciliationReadTestHook func(rows int)
}
// NewCache creates a new cache instance.
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
return newCache(db, config, defaultFreeSpaceProbe)
// NewCache creates a new cache instance and registers its counters on
// metricsRegistry. pixad passes Prometheus' default registry, the one
// /metrics serves.
func NewCache(
db *sql.DB, config CacheConfig, metricsRegistry prometheus.Registerer,
) (*Cache, error) {
return newCache(db, config, metricsRegistry, defaultFreeSpaceProbe)
}
// newCache is NewCache with the free-space probe passed in, so tests
// can fake the free space the default limit is worked out from.
func newCache(
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
db *sql.DB,
config CacheConfig,
metricsRegistry prometheus.Registerer,
probe FreeSpaceProbeFunc,
) (*Cache, error) {
log := config.Logger
if log == nil {
@@ -135,6 +151,11 @@ func newCache(
reconciliationPageSize: defaultReconciliationPageSize,
}
err := c.registerCounters(metricsRegistry)
if err != nil {
return nil, err
}
if c.disabled {
return c, nil
}
@@ -362,13 +383,13 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
// methods, it only adds to the Prometheus counters in memory (see
// methods, it only adds to the cache's Prometheus counters in memory (see
// counters.go), so it never waits for the database or fails.
func (c *Cache) IncrementStats(hit bool, fetchBytes int64) {
if hit {
cacheHits.Inc()
c.cacheHits.Inc()
} else {
cacheMisses.Inc()
c.cacheMisses.Inc()
}
c.IncrementUpstreamFetch(fetchBytes)
@@ -381,13 +402,13 @@ func (c *Cache) IncrementUpstreamFetch(fetchBytes int64) {
return
}
upstreamFetches.Inc()
upstreamFetchBytes.Add(float64(fetchBytes))
c.upstreamFetches.Inc()
c.upstreamFetchBytes.Add(float64(fetchBytes))
}
// IncrementTransformCount counts one image transcoded by the image processor.
func (c *Cache) IncrementTransformCount() {
transforms.Inc()
c.transforms.Inc()
}
// touchVariant hands the access time of a variant served from the cache to
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
_ "modernc.org/sqlite"
)
@@ -52,7 +53,7 @@ func TestCache_CleanExpired(t *testing.T) {
cache, _ := NewCache(db, CacheConfig{
StateDir: tmpDir,
NegativeTTL: 1 * time.Millisecond,
})
}, prometheus.NewRegistry())
ctx := context.Background()
@@ -103,7 +104,7 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
_, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("NewCache() error = %v", err)
}
+4 -2
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
)
// Static errors returned by the stub free-space probes below.
@@ -144,7 +146,7 @@ func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: stateDir,
UseDefaultMaxBytes: true,
}, probe)
}, prometheus.NewRegistry(), probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
@@ -175,7 +177,7 @@ func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
cache, err := newCache(evictionTestDB(t), CacheConfig{
StateDir: t.TempDir(),
MaxBytes: 1024,
}, probe)
}, prometheus.NewRegistry(), probe)
if err != nil {
t.Fatalf("newCache returned error: %v", err)
}
@@ -143,15 +143,13 @@ func jpegSize(data []byte) (int, int) {
// one uncached variant while the first one's fetch is held. Between them they
// must fetch the source once and transcode it once, every one must be answered
// with the same 50x25 JPEG, and each must count one miss.
//
//nolint:paralleltest // it reads counters that other tests add to
func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
t.Parallel()
svc, fixtures, fetcher := setupHeldFetchService(t)
const requests = 8
before := readCounters()
pending := make([]<-chan getResult, 0, requests)
for range requests {
pending = append(pending,
@@ -195,8 +193,8 @@ func TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
want := counterValues{0, requests, 1, int64(len(photo)), 1}
if got := readCounters().minus(before); got != want {
t.Errorf("the requests added %+v to the counters, want %+v", got, want)
if got := readCounters(svc.cache); got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}
+24 -14
View File
@@ -1,35 +1,45 @@
package imgcache
import (
"fmt"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// The counts of what the image cache does, kept in memory as Prometheus
// counters. They are registered on Prometheus' default registry, which
// /metrics serves. Adding to one waits for nothing and makes no database
// call. Each counts for the whole process since it started.
//
//nolint:gochecknoglobals // Prometheus registers each counter once per process
var (
cacheHits = promauto.NewCounter(prometheus.CounterOpts{
// registerCounters makes the cache's counters and registers them on
// metricsRegistry. The counters are kept in memory, so adding to one waits
// for nothing and makes no database call. It fails when metricsRegistry
// already holds counters of the same names.
func (c *Cache) registerCounters(metricsRegistry prometheus.Registerer) error {
c.cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_cache_hits_total",
Help: "Image requests served from the cache.",
})
cacheMisses = promauto.NewCounter(prometheus.CounterOpts{
c.cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_cache_misses_total",
Help: "Image requests not served from the cache.",
})
upstreamFetches = promauto.NewCounter(prometheus.CounterOpts{
c.upstreamFetches = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_upstream_fetches_total",
Help: "Fetches of a source image from upstream that read any bytes.",
})
upstreamFetchBytes = promauto.NewCounter(prometheus.CounterOpts{
c.upstreamFetchBytes = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_upstream_fetch_bytes_total",
Help: "Bytes read from upstream by fetches of source images.",
})
transforms = promauto.NewCounter(prometheus.CounterOpts{
c.transforms = prometheus.NewCounter(prometheus.CounterOpts{
Name: "pixa_transforms_total",
Help: "Images the image processor transformed.",
})
)
for _, counter := range []prometheus.Counter{
c.cacheHits, c.cacheMisses, c.upstreamFetches, c.upstreamFetchBytes, c.transforms,
} {
err := metricsRegistry.Register(counter)
if err != nil {
return fmt.Errorf("failed to register the cache's counters: %w", err)
}
}
return nil
}
+45 -28
View File
@@ -1,13 +1,14 @@
package imgcache
import (
"errors"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
// counterValues holds the values of the counters in counters.go.
// counterValues holds the values of a cache's counters.
type counterValues struct {
hitCount int64
missCount int64
@@ -16,37 +17,30 @@ type counterValues struct {
transformCount int64
}
// readCounters reads the counters in counters.go. They count for the whole
// process, and every test that serves an image adds to them, so a test that
// checks how much they rose does not call t.Parallel. Go runs the tests that
// do not call it one at a time, and the tests that do only after them.
func readCounters() counterValues {
// readCounters reads cache's counters.
func readCounters(cache *Cache) counterValues {
return counterValues{
hitCount: int64(testutil.ToFloat64(cacheHits)),
missCount: int64(testutil.ToFloat64(cacheMisses)),
upstreamFetchCount: int64(testutil.ToFloat64(upstreamFetches)),
upstreamFetchBytes: int64(testutil.ToFloat64(upstreamFetchBytes)),
transformCount: int64(testutil.ToFloat64(transforms)),
hitCount: int64(testutil.ToFloat64(cache.cacheHits)),
missCount: int64(testutil.ToFloat64(cache.cacheMisses)),
upstreamFetchCount: int64(testutil.ToFloat64(cache.upstreamFetches)),
upstreamFetchBytes: int64(testutil.ToFloat64(cache.upstreamFetchBytes)),
transformCount: int64(testutil.ToFloat64(cache.transforms)),
}
}
// minus returns how much each counter rose from before to v.
func (v counterValues) minus(before counterValues) counterValues {
return counterValues{
hitCount: v.hitCount - before.hitCount,
missCount: v.missCount - before.missCount,
upstreamFetchCount: v.upstreamFetchCount - before.upstreamFetchCount,
upstreamFetchBytes: v.upstreamFetchBytes - before.upstreamFetchBytes,
transformCount: v.transformCount - before.transformCount,
}
}
// TestCounters_AreOnTheRegistryMetricsServes checks that the counters in
// counters.go are on Prometheus' default registry, which /metrics serves,
// under the names README.md gives them.
func TestCounters_AreOnTheRegistryMetricsServes(t *testing.T) {
// TestNewCache_RegistersItsCountersOnItsRegistry checks that NewCache
// registers the cache's counters on the registry it is given, under the
// names README.md gives them.
func TestNewCache_RegistersItsCountersOnItsRegistry(t *testing.T) {
t.Parallel()
registry := prometheus.NewRegistry()
_, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}, registry)
if err != nil {
t.Fatalf("NewCache() error = %v", err)
}
names := []string{
"pixa_cache_hits_total",
"pixa_cache_misses_total",
@@ -55,13 +49,36 @@ func TestCounters_AreOnTheRegistryMetricsServes(t *testing.T) {
"pixa_transforms_total",
}
count, err := testutil.GatherAndCount(prometheus.DefaultGatherer, names...)
count, err := testutil.GatherAndCount(registry, names...)
if err != nil {
t.Fatalf("testutil.GatherAndCount() error = %v", err)
}
if count != len(names) {
t.Errorf("%d of the counters %v are on the default registry, want %d",
t.Errorf("%d of the counters %v are on the registry, want %d",
count, names, len(names))
}
}
// TestNewCache_FailsWhenItsCountersCannotBeRegistered makes a second cache
// on the registry of a first. Its counters have the same names, so the
// registry refuses them, and NewCache must return that error.
func TestNewCache_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
t.Parallel()
registry := prometheus.NewRegistry()
_, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}, registry)
if err != nil {
t.Fatalf("NewCache() of the first cache error = %v", err)
}
_, err = NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}, registry)
t.Logf("NewCache() of the second cache error = %v", err)
var alreadyRegistered prometheus.AlreadyRegisteredError
if !errors.As(err, &alreadyRegistered) {
t.Errorf("NewCache() of the second cache error = %v, want %T",
err, alreadyRegistered)
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/imagerequest"
@@ -74,7 +75,7 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
NegativeTTL: 5 * time.Minute,
MaxBytes: maxBytes,
DisableDiskCache: maxBytes == 0,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/imagerequest"
)
@@ -19,7 +20,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatal(err)
}
@@ -68,7 +69,7 @@ func TestNegativeCache_Expired(t *testing.T) {
cache, err := NewCache(db, CacheConfig{
StateDir: dir,
NegativeTTL: 1 * time.Millisecond, // very short TTL
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatal(err)
}
@@ -191,7 +192,7 @@ func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) {
StateDir: t.TempDir(),
NegativeTTL: time.Second,
DisableDiskCache: tt.disableDiskCache,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -265,7 +266,8 @@ func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int {
func TestCleanExpiredDeletesInBatches(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()},
prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -319,7 +321,8 @@ func TestCleanExpiredDeletesInBatches(t *testing.T) {
func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()},
prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -349,7 +352,8 @@ func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) {
func TestCleanExpiredStopsOnceContextEnds(t *testing.T) {
t.Parallel()
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()})
cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()},
prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -112,12 +112,10 @@ func assertWarningNames(t *testing.T, logs, path string) {
// pass, which must remove it with a warning naming it. It then asks for two
// new sizes of the source: the first must fetch it again and cache it whole,
// so that the second is made from the cache without a third fetch.
//
//nolint:paralleltest // it reads counters that other tests add to
func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
svc, fixtures := SetupTestService(t)
t.Parallel()
before := readCounters()
svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
@@ -130,7 +128,7 @@ func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCounters().minus(before).upstreamFetchCount
fetches := readCounters(svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
@@ -150,12 +148,10 @@ func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
// It then asks for two new sizes of the source: the first must fetch it
// again rather than fail to decode the partial file, and the second is
// made from the cache.
//
//nolint:paralleltest // it reads counters that other tests add to
func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
svc, fixtures := SetupTestService(t)
t.Parallel()
before := readCounters()
svc, fixtures := SetupTestService(t)
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
@@ -168,7 +164,7 @@ func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
fetches := readCounters().minus(before).upstreamFetchCount
fetches := readCounters(svc.cache).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
@@ -19,6 +19,10 @@ import (
// for StopPendingAccessTimeWrites to return.
const pendingAccessTimesWait = 5 * time.Second
// otherWritesWait is how long a test gives other writes, once one write waits
// for the database's connection, to start waiting for it too.
const otherWritesWait = 100 * time.Millisecond
// holdDatabase takes the one connection of cache's database, so that every
// other query waits for it, and returns the func that frees it.
func holdDatabase(t *testing.T, cache *Cache) func() {
@@ -98,6 +102,84 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
}
}
// TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish holds the
// database's one connection while the goroutine that writes the pending
// access times waits for it, hands over another access time, then stops that
// goroutine. The stop must leave the write under way to finish rather than
// cut it off, and once the connection is free it must write the access time
// that write left.
func TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
anHourAgo := time.Now().Add(-time.Hour)
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
setVariantLastAccessed(t, cache, key, anHourAgo)
}
cache.StartPendingAccessTimeWrites()
defer func() { _ = cache.StopPendingAccessTimeWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
cache.touchVariant(testVariantKeyOne)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
// Handed over after that write took the pending access times
cache.touchVariant(testVariantKeyTwo)
// The goroutine is told to finish here as well as by the stop, so that it
// is told before its write under way ends, however late the stop runs.
// The access time above then reaches the database only through the
// stop's own write.
cache.pendingAccessTimesCancel()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingAccessTimeWrites(t.Context())
}()
// Had the stop cut off the write under way, its own write would wait for
// the connection too.
time.Sleep(otherWritesWait)
waits := cache.db.Stats().WaitCount - waitsBefore
t.Logf("%d writes waited for the database", waits)
if waits > 1 {
t.Errorf("%d writes waited for the database, want 1: "+
"the stop cut off the write under way", waits)
}
releaseDatabase()
select {
case err := <-stopped:
if err != nil {
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
}
case <-time.After(pendingAccessTimesWait):
t.Fatal("StopPendingAccessTimeWrites() did not return once the database was free")
}
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
if countRows(t, cache, `
SELECT COUNT(*) FROM variant_content
WHERE cache_key = ? AND last_accessed_at > ?
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) != 1 {
t.Errorf("the access time of variant %s was not written", key)
}
}
}
// TestStopPendingAccessTimeWrites_ReturnsWhenItsContextEnds holds the
// database's one connection while the goroutine that writes the pending
// access times waits for it, then stops that goroutine with a context that
+3 -5
View File
@@ -558,14 +558,12 @@ func TestService_Get_Caching(t *testing.T) {
// Left at the present: the files' status change and birth times, which a
// program cannot set, and the times of the directories holding the files,
// which other entries share.
//
//nolint:paralleltest // it reads counters that other tests add to
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
before := readCounters()
resp, err := svc.Get(t.Context(), variant)
if err != nil {
t.Fatalf("Get() first request error = %v", err)
@@ -597,7 +595,7 @@ func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
_ = resp.Content.Close()
fetches := readCounters().minus(before).upstreamFetchCount
fetches := readCounters(svc.cache).upstreamFetchCount
if fetches != 1 {
t.Errorf("upstream fetches = %d, want 1", fetches)
}
+27 -33
View File
@@ -14,11 +14,10 @@ import (
// TestService_Get_CountsStats walks Get through a miss that fetches the
// source, a hit, a miss that reuses the cached source, and two misses whose
// source cannot be used, checking how much each request added to every
// counter in counters.go.
//
//nolint:paralleltest // it reads counters that other tests add to
// source cannot be used, checking every counter of the cache after each.
func TestService_Get_CountsStats(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
// NewTestFS builds the same files the test service's fetcher serves.
@@ -36,8 +35,7 @@ func TestService_Get_CountsStats(t *testing.T) {
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
// want is what the request adds to hits, misses, upstream fetches,
// upstream bytes and transforms.
// want is hits, misses, upstream fetches, upstream bytes, transforms.
steps := []struct {
name string
path string
@@ -48,18 +46,16 @@ func TestService_Get_CountsStats(t *testing.T) {
{"miss that fetches the source", testPathPhoto, 50, false,
counterValues{0, 1, 1, photoBytes, 1}},
{"hit", testPathPhoto, 50, false,
counterValues{1, 0, 0, 0, 0}},
counterValues{1, 1, 1, photoBytes, 1}},
{"miss that reuses the cached source", testPathPhoto, 25, false,
counterValues{0, 1, 0, 0, 1}},
counterValues{1, 2, 1, photoBytes, 2}},
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
counterValues{0, 1, 1, fakeBytes, 0}},
counterValues{1, 3, 2, photoBytes + fakeBytes, 2}},
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
counterValues{0, 1, 0, 0, 0}},
counterValues{1, 4, 2, photoBytes + fakeBytes, 2}},
}
for _, step := range steps {
before := readCounters()
resp, err := svc.Get(t.Context(), &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
@@ -80,10 +76,9 @@ func TestService_Get_CountsStats(t *testing.T) {
_ = resp.Content.Close()
}
got := readCounters().minus(before)
got := readCounters(svc.cache)
if got != step.want {
t.Fatalf("the %s added %+v to the counters, want %+v",
step.name, got, step.want)
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
}
}
}
@@ -119,17 +114,16 @@ func (u *fakeUpstream) Read(p []byte) (int, error) {
return 0, u.err
}
// TestService_Get_CountsInterruptedMisses checks how much a miss whose
// request context ends during or after the upstream fetch, and a miss whose
// upstream body is over the size limit, add to every counter in counters.go.
//
//nolint:paralleltest // it reads counters that other tests add to
// TestService_Get_CountsInterruptedMisses checks every counter of the cache
// after a miss whose request context ends during or after the upstream
// fetch, and after a miss whose upstream body is over the size limit.
func TestService_Get_CountsInterruptedMisses(t *testing.T) {
t.Parallel()
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
half := len(photo) / 2
// want is what the request adds to hits, misses, upstream fetches,
// upstream bytes and transforms.
// want is hits, misses, upstream fetches, upstream bytes, transforms.
tests := []struct {
name string
served int // bytes of the photo the upstream body serves
@@ -149,6 +143,8 @@ func TestService_Get_CountsInterruptedMisses(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(t.Context())
@@ -164,8 +160,6 @@ func TestService_Get_CountsInterruptedMisses(t *testing.T) {
svc.fetcher = upstream
before := readCounters()
resp, err := svc.Get(ctx, &imagerequest.Request{
Source: imagerequest.Source{
Host: fixtures.GoodHost,
@@ -188,20 +182,20 @@ func TestService_Get_CountsInterruptedMisses(t *testing.T) {
_ = resp.Content.Close()
}
got := readCounters().minus(before)
got := readCounters(svc.cache)
if got != tc.want {
t.Errorf("the miss added %+v to the counters, want %+v", got, tc.want)
t.Errorf("counters = %+v, want %+v", got, tc.want)
}
})
}
}
// TestService_Get_CountsHitAfterRequestEnds checks every counter in
// counters.go after a hit served with a request context that has already
// ended: only the hit count moves.
//
//nolint:paralleltest // it reads counters that other tests add to
// TestService_Get_CountsHitAfterRequestEnds checks every counter of the
// cache after a hit served with a request context that has already ended:
// only the hit count moves.
func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
req := &imagerequest.Request{
@@ -225,7 +219,7 @@ func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
_ = resp.Content.Close()
want := readCounters()
want := readCounters(svc.cache)
want.hitCount++
ctx, cancel := context.WithCancel(t.Context())
@@ -242,7 +236,7 @@ func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
}
got := readCounters()
got := readCounters(svc.cache)
if got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
+2 -1
View File
@@ -14,6 +14,7 @@ import (
"testing/fstest"
"time"
"github.com/prometheus/client_golang/prometheus"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
@@ -184,7 +185,7 @@ func SetupTestService(
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
NegativeTTL: 5 * time.Minute,
})
}, prometheus.NewRegistry())
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
@@ -19,6 +19,7 @@ import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -170,6 +171,7 @@ func startImageProxy(
handlers.New,
middleware.New,
func() httpfetcher.Fetcher { return fetcher },
func() prometheus.Registerer { return prometheus.NewRegistry() },
),
fx.Populate(&h, &mw, &db),
)
@@ -223,6 +225,7 @@ func startImageProxyWithBlockedNetworks(
healthcheck.New,
handlers.New,
middleware.New,
func() prometheus.Registerer { return prometheus.NewRegistry() },
),
fx.Populate(&h, &mw),
)
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
@@ -81,6 +82,7 @@ func newTestServer(t *testing.T) *Server {
h, err := handlers.New(lc, handlers.Params{
Logger: log, Healthcheck: hc, Database: db, Config: cfg,
MetricsRegistry: prometheus.NewRegistry(),
})
if err != nil {
t.Fatalf("handlers.New() error = %v", err)
+2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
@@ -46,6 +47,7 @@ func startPixa(t *testing.T, cfg *config.Config) *Server {
healthcheck.New,
handlers.New,
middleware.New,
func() prometheus.Registerer { return prometheus.NewRegistry() },
),
fx.Populate(&h, &mw),
)