Build the image pipeline in pixad's fx graph (closes #313)
check / check (push) Waiting to run

The new package internal/providers builds the cache, the fetcher, the
processor, the image service, the session manager and the encrypted URL
generator from the config. pixad's fx graph provides them with
providers.Constructors(), and handlers.New receives them built. The
server tests build the same graph, with only the fetcher's dial function
replaced. database.New opens the database, so these parts can read it
while fx builds them; the cache's stop hook still runs before the
database's.

Each setting's default is declared in internal/config, and
internal/providers works out the default cache_max_bytes. The cache's
MaxBytes is the one value for the disk cache limit, and 0 turns the disk
cache off. The fetcher and the processor take explicit values.

Model: opus-5-5
This commit is contained in:
2026-10-26 11:16:09 +00:00
parent ac2bae0489
commit 6d9631a8fc
54 changed files with 1181 additions and 1102 deletions
@@ -0,0 +1,315 @@
package providers
import (
"context"
"database/sql"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/globals"
"sneak.berlin/go/pixa/internal/logger"
)
// testSigningKey is a throwaway signing key.
const testSigningKey = "test-signing-key-0123456789abcdef"
// Static errors returned by the stub free-space probes below.
var (
errTestStatfsFailed = errors.New("statfs failed")
errTestProbeNotExpected = errors.New("probe must not be called")
)
// newTestDB opens an in-memory database with pixa's schema, on one
// connection, as pixad's database has.
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
db.SetMaxOpenConns(1)
err = database.ApplyMigrations(context.Background(), db, nil)
if err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// TestCacheMaxBytesFromConfig checks the disk cache limit worked out from
// cache_max_bytes: omitted, it is the default from the free space; 0 turns
// the disk cache off; a positive value is the limit, unchanged.
func TestCacheMaxBytesFromConfig(t *testing.T) {
t.Parallel()
const oneGiB = 1 << 30
cases := []struct {
name string
cacheMaxBytes int64
cacheMaxBytesExplicit bool
wantMaxBytes int64
}{
{
name: "cache_max_bytes omitted",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: false,
// 75% of the 4 GiB the probe reports free
wantMaxBytes: 3221225472,
},
{
name: "cache_max_bytes: 0",
cacheMaxBytes: 0,
cacheMaxBytesExplicit: true,
wantMaxBytes: 0,
},
{
name: "cache_max_bytes: 1 GiB",
cacheMaxBytes: oneGiB,
cacheMaxBytesExplicit: true,
wantMaxBytes: oneGiB,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDir: t.TempDir(),
CacheMaxBytes: tc.cacheMaxBytes,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
got, err := cacheMaxBytes(t.Context(), cfg, newTestDB(t),
slog.New(slog.DiscardHandler),
func(string) (uint64, error) { return 4294967296, nil })
if err != nil {
t.Fatalf("cacheMaxBytes() error = %v", err)
}
t.Logf("MaxBytes = %d", got)
if got != tc.wantMaxBytes {
t.Errorf("MaxBytes = %d, want %d", got, tc.wantMaxBytes)
}
})
}
}
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes builds and starts the
// cache once with cache_max_bytes omitted and once with cache_max_bytes:
// 0, and checks by whether the cache directories were created that the
// disk cache is on in the first case and off in the second.
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
cacheMaxBytesExplicit bool
wantDiskCache bool
}{
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
cfg := &config.Config{
SigningKey: testSigningKey,
StateDir: stateDir,
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
CacheMaxBytes: 0,
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
if err != nil {
t.Fatalf("logger.New() error = %v", err)
}
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
if err != nil {
t.Fatalf("database.New() error = %v", err)
}
_, err = newCache(lc, cacheParams{
Config: cfg,
Database: db,
Logger: log,
MetricsRegistry: prometheus.NewRegistry(),
})
if err != nil {
t.Fatalf("newCache() error = %v", err)
}
lc.RequireStart()
t.Cleanup(lc.RequireStop)
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
gotDiskCache := err == nil
if gotDiskCache != tc.wantDiskCache {
t.Errorf("cache directories created = %v, want %v",
gotDiskCache, tc.wantDiskCache)
}
})
}
}
// TestDefaultCacheMaxBytesCountsWhatTheCacheHolds verifies that the
// default limit is 75% of the free space plus what the cache already
// holds, so a cache filled to its limit keeps that limit across a
// restart instead of shrinking to 75% of the space left free.
func TestDefaultCacheMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()}
db := newTestDB(t)
log := slog.New(slog.DiscardHandler)
// Empty cache, 4 GiB free -> 3 GiB default.
got, err := cacheMaxBytes(t.Context(), cfg, db, log,
func(string) (uint64, error) { return 4294967296, nil })
if err != nil {
t.Fatalf("cacheMaxBytes returned error: %v", err)
}
t.Logf("default for an empty cache with 4 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
got)
}
// The cache now holds those 3 GiB, which leaves 1 GiB free.
_, err = db.ExecContext(t.Context(),
`INSERT INTO variant_content (cache_key, size_bytes) VALUES (?, ?)`,
"aabbccdd0001", 3221225472,
)
if err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
}
got, err = cacheMaxBytes(t.Context(), cfg, db, log,
func(string) (uint64, error) { return 1073741824, nil })
if err != nil {
t.Fatalf("cacheMaxBytes returned error: %v", err)
}
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
if got != 3221225472 {
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
}
}
// TestDefaultCacheMaxBytesPropagatesProbeError verifies that a failing
// free-space probe produces an error naming cache_max_bytes, instead of
// a silently wrong default.
func TestDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
t.Parallel()
_, err := cacheMaxBytes(t.Context(), &config.Config{StateDir: t.TempDir()},
newTestDB(t), slog.New(slog.DiscardHandler),
func(string) (uint64, error) { return 0, errTestStatfsFailed })
if err == nil {
t.Fatal("probe failure must produce an error, got nil")
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "cache_max_bytes") {
t.Errorf("error %q does not name cache_max_bytes", err.Error())
}
}
// TestDefaultCacheMaxBytesProbesTheCacheDirectory verifies that with
// cache_max_bytes omitted the limit is the computed default, and that the
// probe is pointed at <state_dir>/cache/, which must be created first so
// statfs measures the right filesystem.
func TestDefaultCacheMaxBytesProbesTheCacheDirectory(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
wantCacheDir := filepath.Join(stateDir, "cache")
var probedPath string
// 4 GiB free -> 3 GiB default.
probe := func(path string) (uint64, error) {
probedPath = path
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
path, info, err)
}
return 4294967296, nil
}
got, err := cacheMaxBytes(t.Context(), &config.Config{StateDir: stateDir},
newTestDB(t), slog.New(slog.DiscardHandler), probe)
if err != nil {
t.Fatalf("cacheMaxBytes returned error: %v", err)
}
if got != 3221225472 {
t.Errorf("MaxBytes = %d, want computed default 3221225472", got)
}
if probedPath != wantCacheDir {
t.Errorf("free space probed at %q, want cache directory %q",
probedPath, wantCacheDir)
}
}
// TestExplicitCacheMaxBytesIsKept verifies that an explicit
// cache_max_bytes is kept exactly as given and the free-space probe is
// never consulted.
func TestExplicitCacheMaxBytesIsKept(t *testing.T) {
t.Parallel()
probe := func(string) (uint64, error) {
t.Error("free-space probe must not be consulted for explicit values")
return 0, errTestProbeNotExpected
}
cfg := &config.Config{
StateDir: t.TempDir(),
CacheMaxBytes: 1024,
CacheMaxBytesExplicit: true,
}
got, err := cacheMaxBytes(t.Context(), cfg, newTestDB(t),
slog.New(slog.DiscardHandler), probe)
if err != nil {
t.Fatalf("cacheMaxBytes returned error: %v", err)
}
if got != 1024 {
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)", got)
}
}