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 /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) } }