Build the image pipeline in pixad's fx graph (closes #313)
check / check (push) Canceled after 0s

internal/app now provides the cache, the fetcher, the processor, the
image service, the session manager and the encrypted URL generator, and
handlers.New receives them built. 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, the default
cache_max_bytes included, which internal/app works out before it builds
the cache. 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. The server tests build the pipeline themselves,
as they cannot import internal/app.

Model: opus-5-5
This commit is contained in:
2026-10-26 10:31:44 +00:00
parent ac2bae0489
commit 27493568fd
54 changed files with 1219 additions and 1064 deletions
+206
View File
@@ -0,0 +1,206 @@
package app
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/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/session"
)
// 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,
cfg *config.Config,
db *database.Database,
log *logger.Logger,
metricsRegistry prometheus.Registerer,
) (*imgcache.Cache, error) {
maxBytes, err := cacheMaxBytes(
context.Background(), cfg, db.DB(), log.Get(), defaultFreeSpaceProbe)
if err != nil {
return nil, err
}
cache, err := imgcache.NewCache(db.DB(), imgcache.CacheConfig{
StateDir: cfg.StateDir,
NegativeTTL: imgcache.DefaultNegativeTTL,
MaxBytes: maxBytes,
Logger: log.Get(),
}, 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
}
// newFetcher builds the upstream fetcher from cfg.
func newFetcher(cfg *config.Config) *httpfetcher.HTTPFetcher {
return httpfetcher.New(&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,
})
}
// newImageService builds the image service from the cache, the fetcher
// and the processor, with the signing key, the allowlist and the upstream
// settings of cfg.
func newImageService(
cfg *config.Config,
cache *imgcache.Cache,
fetcher *httpfetcher.HTTPFetcher,
processor *imageprocessor.ImageProcessor,
log *logger.Logger,
) (*imgcache.Service, error) {
return imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: fetcher,
Processor: processor,
SigningKey: cfg.SigningKey,
Allowlist: cfg.AllowlistHosts,
AllowHTTP: cfg.AllowHTTP,
MaxResponseSize: cfg.UpstreamMaxResponseSize,
Logger: log.Get(),
})
}
// newSessionManager builds the session manager. 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(
cfg *config.Config, db *database.Database,
) (*session.Manager, error) {
return session.NewManagerWithDatabase(
context.Background(), cfg.SigningKey, db.DB())
}
// newEncryptedURLGenerator builds the encrypted URL generator, with a key
// derived from the signing key.
func newEncryptedURLGenerator(cfg *config.Config) (*encurl.Generator, error) {
return encurl.NewGenerator(cfg.SigningKey)
}