Files
pixa/internal/providers/providers.go
T
sneak fa2ec3da6a
check / check (push) Canceled after 0s
Export cache, eviction, upstream and transform metrics (closes #85)
New pixa_ metrics, none with labels:
- source cache hits and misses, counted once per variant made or checked;
  a source whose cached file cannot be used is a miss
- cache usage and limit gauges; usage is set each time eviction reads it
- eviction count and bytes, from the size_bytes the deleted row returns
- upstream fetch duration histogram and fetch error counter
- transform duration histogram and an in-progress gauge, both including
  the wait for a processing slot

Removing a damaged file during reconciliation is not counted as an
eviction. README lists every metric; the metrics config comment now says
the pixa_ metrics are measured whether or not /metrics is served.

Model: opus-5-5
2026-11-11 14:32:38 +00:00

264 lines
7.9 KiB
Go

// Package providers builds from the config the parts pixad serves images
// and logins with, and provides them to the fx graph:
// - the keys derived from the signing key
// - the image cache, with its default limit
// - the fetcher
// - the processor
// - the image service
// - the session manager
// - the encrypted URL generator
package providers
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"syscall"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imageproxy"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/seal"
"sneak.berlin/go/pixa/internal/session"
)
// Constructors returns the constructors of the parts this package builds,
// for fx.Provide. The fx graph must also provide the prometheus.Registerer
// the cache and the image service register their metrics on.
func Constructors() []any {
return []any{
newKeys,
newCache,
newFetcherConfig,
httpfetcher.New,
newProcessor,
newImageService,
newSessionManager,
newEncryptedURLGenerator,
}
}
// newKeys derives the keys of seal.Keys from the signing key. fx calls it
// once, at startup.
func newKeys(cfg *config.Config) (seal.Keys, error) {
return seal.DeriveKeys(cfg.SigningKey)
}
// cacheParams defines dependencies for newCache.
type cacheParams struct {
fx.In
Config *config.Config
Database *database.Database
Logger *logger.Logger
MetricsRegistry prometheus.Registerer
}
// newCache builds the image cache with the disk cache limit cacheMaxBytes
// returns. Its start hook starts the cache's background goroutines. Its
// stop hook stops them, which writes the pending access times. The
// database is open for both: it opens before the cache is built and
// closes after this stop hook.
func newCache(lc fx.Lifecycle, params cacheParams) (*imgcache.Cache, error) {
cfg := params.Config
db := params.Database.DB()
maxBytes, err := cacheMaxBytes(
context.Background(), cfg, db, params.Logger.Get(), defaultFreeSpaceProbe)
if err != nil {
return nil, err
}
cache, err := imgcache.NewCache(db, imgcache.Config{
StateDir: cfg.StateDir,
NegativeTTL: imgcache.DefaultNegativeTTL,
CacheTTL: imgcache.DefaultCacheTTL,
MaxBytes: maxBytes,
Logger: params.Logger.Get(),
}, params.MetricsRegistry)
if err != nil {
return nil, err
}
// The start hook takes no context: the goroutines it starts outlive it,
// and the stop hook stops them.
lc.Append(fx.StartStopHook(
func() {
// Background eviction: startup reconciliation, then periodic
// and write-pressure passes, and the removal of expired
// negative cache rows, which is all it does when the disk
// cache is off.
cache.StartEviction(imgcache.DefaultEvictionInterval)
// Writes the access times that requests hand over, so that no
// request waits for the database to record them
cache.StartPendingAccessTimeWrites()
},
func(ctx context.Context) error {
return errors.Join(
cache.StopEviction(ctx),
cache.StopPendingAccessTimeWrites(ctx),
)
},
))
return cache, nil
}
// freeSpaceProbeFunc reports the number of free bytes available on the
// filesystem containing path. It is a function type so tests can pass a
// fake probe instead of depending on the host disk.
type freeSpaceProbeFunc func(path string) (uint64, error)
var errNegativeBlockSize = errors.New("statfs reported negative block size")
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on the
// given path, as available to unprivileged processes.
func defaultFreeSpaceProbe(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
if stat.Bsize < 0 {
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
}
blockSize := uint64(stat.Bsize)
return stat.Bavail * blockSize, nil
}
// cacheMaxBytes returns cache_max_bytes as set. When the key is omitted,
// it returns config.DefaultCacheMaxBytes of the free bytes probe reports
// for <state_dir>/cache/ and the bytes the cache holds, as db records
// them. It creates that directory first, so the probe measures the
// filesystem that holds the cache.
func cacheMaxBytes(
ctx context.Context,
cfg *config.Config,
db *sql.DB,
log *slog.Logger,
probe freeSpaceProbeFunc,
) (int64, error) {
if cfg.CacheMaxBytesExplicit {
return cfg.CacheMaxBytes, nil
}
cacheDir := filepath.Join(cfg.StateDir, "cache")
err := os.MkdirAll(cacheDir, imgcache.StorageDirPerm)
if err != nil {
return 0, fmt.Errorf("failed to create cache directory: %w", err)
}
freeBytes, err := probe(cacheDir)
if err != nil {
return 0, fmt.Errorf(
"default cache_max_bytes: cannot determine free space for %q: %w",
cacheDir, err)
}
heldBytes, err := imgcache.UsageBytes(ctx, db)
if err != nil {
return 0, err
}
limit := config.DefaultCacheMaxBytes(freeBytes, heldBytes)
log.Info("computed default cache size limit from free space and cache contents",
"cache_max_bytes", limit,
"cache_dir", cacheDir,
)
return limit, nil
}
// newFetcherConfig builds the upstream fetcher's settings from cfg, and
// httpfetcher.New builds the fetcher from them. The settings are a value
// of their own in the fx graph, so that a test can replace the dial
// function alone.
func newFetcherConfig(cfg *config.Config) *httpfetcher.Config {
return &httpfetcher.Config{
Timeout: cfg.UpstreamFetchTimeout,
MaxResponseSize: cfg.UpstreamMaxResponseSize,
AllowHTTP: cfg.AllowHTTP,
MaxConnectionsPerHost: cfg.UpstreamConnectionsPerHost,
MaxConnections: cfg.UpstreamConnections,
BlockedNetworks: cfg.BlockedNetworks,
}
}
// newProcessor builds the image processor from cfg. Its input limit is
// the largest upstream response the fetcher accepts.
func newProcessor(cfg *config.Config) *imageprocessor.ImageProcessor {
return imageprocessor.New(imageprocessor.Params{
MaxInputBytes: cfg.UpstreamMaxResponseSize,
MaxConcurrentProcessing: cfg.MaxConcurrentProcessing,
})
}
// imageServiceParams defines dependencies for newImageService.
type imageServiceParams struct {
fx.In
Config *config.Config
Cache *imgcache.Cache
Fetcher *httpfetcher.HTTPFetcher
Processor *imageprocessor.ImageProcessor
Logger *logger.Logger
MetricsRegistry prometheus.Registerer
}
// newImageService builds the image service from the cache, the fetcher
// and the processor, with the largest upstream response the config allows.
func newImageService(
_ fx.Lifecycle, params imageServiceParams,
) (*imageproxy.Proxy, error) {
return imageproxy.New(&imageproxy.Config{
Cache: params.Cache,
Fetcher: params.Fetcher,
Processor: params.Processor,
MaxResponseSize: params.Config.UpstreamMaxResponseSize,
Logger: params.Logger.Get(),
}, params.MetricsRegistry)
}
// sessionManagerParams defines dependencies for newSessionManager.
type sessionManagerParams struct {
fx.In
Keys seal.Keys
Database *database.Database
}
// newSessionManager builds the session manager with its two keys. Session
// cookies are always Secure, HttpOnly and SameSite=Strict. The random value
// that each logout replaces is saved in the database, so it outlasts a
// restart.
func newSessionManager(
_ fx.Lifecycle, params sessionManagerParams,
) (*session.Manager, error) {
return session.NewManagerWithDatabase(context.Background(),
params.Keys.SessionHashKey, params.Keys.SessionBlockKey,
params.Database.DB())
}
// newEncryptedURLGenerator builds the encrypted URL generator with its key.
func newEncryptedURLGenerator(keys seal.Keys) *encurl.Generator {
return encurl.NewGenerator(keys.EncryptedURLKey)
}