From 8cb09b6aaf8054daed3354ca0ebd081700923e59 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 7 Aug 2026 21:02:08 +0000 Subject: [PATCH] feat: add cache_max_bytes config key with statfs-derived default Strict int64 parsing via the startup validation framework: a SET but invalid value (negative, float, null, non-numeric) aborts startup naming the key and value. An omitted key resolves after state_dir validation to max(75% of free bytes on the filesystem containing /cache/, 500 MiB), measured via an injectable statfs probe; the floor never applies to explicit values. Zero is valid and means the disk cache is disabled. The effective limit is logged at startup. --- internal/config/cachesize.go | 68 ++++++++++++++++++++++++---- internal/config/config.go | 87 +++++++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 10 deletions(-) diff --git a/internal/config/cachesize.go b/internal/config/cachesize.go index 9ee71a2..2a0f8ad 100644 --- a/internal/config/cachesize.go +++ b/internal/config/cachesize.go @@ -3,6 +3,8 @@ package config import ( "fmt" "log/slog" + "math" + "os" "path/filepath" "syscall" ) @@ -13,6 +15,18 @@ import ( // never to explicitly configured values. const DefaultCacheMaxBytesFloor int64 = 524288000 +// cacheDirPerms is the permission mode for the cache directory created +// before probing free space, matching the state directory permissions. +const cacheDirPerms = 0o750 + +// freeSpaceFractionNumerator and freeSpaceFractionDenominator express +// the 75% share of free space used for the computed default limit as +// integer arithmetic (dividing before multiplying avoids overflow). +const ( + freeSpaceFractionNumerator uint64 = 3 + freeSpaceFractionDenominator uint64 = 4 +) + // FreeSpaceProbeFunc reports the number of free bytes available on the // filesystem containing path. It is a function type so tests can // inject a fake probe instead of depending on the host disk. @@ -38,23 +52,59 @@ func defaultFreeSpaceProbe(path string) (uint64, error) { // ComputeDefaultCacheMaxBytes returns the default cache size limit for // the filesystem containing cacheDir: 75% of the free bytes reported // by probe, with a floor of DefaultCacheMaxBytesFloor. -func ComputeDefaultCacheMaxBytes(_ string, _ FreeSpaceProbeFunc) (int64, error) { - // Red phase: implementation follows the failing tests. - return 0, nil +func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) { + freeBytes, err := probe(cacheDir) + if err != nil { + return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w", + "cache_max_bytes", cacheDir, err) + } + + computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator + if computed > math.MaxInt64 { + computed = math.MaxInt64 + } + + limit := int64(computed) //nolint:gosec // G115: clamped to MaxInt64 above + + if limit < DefaultCacheMaxBytesFloor { + limit = DefaultCacheMaxBytesFloor + } + + return limit, nil } // resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir // validation: an explicitly configured value is kept as-is (no floor // applies), while an omitted key receives the computed default based -// on free space in /cache/. The effective limit is logged. +// on free space in /cache/. The cache directory is created +// first so statfs measures the filesystem that will actually hold the +// cache. The effective limit is logged either way. func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error { - // Red phase: implementation follows the failing tests. - limit, err := ComputeDefaultCacheMaxBytes(filepath.Join(c.StateDir, "cache"), probe) - if err != nil { - return err + if !c.cacheMaxBytesExplicit { + cacheDir := filepath.Join(c.StateDir, "cache") + + if err := os.MkdirAll(cacheDir, cacheDirPerms); err != nil { + return fmt.Errorf("config key %q: cannot create cache directory %q: %w", + "cache_max_bytes", cacheDir, err) + } + + limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe) + if err != nil { + return err + } + + c.CacheMaxBytes = limit + + log.Info("computed default cache size limit from free space", + "cache_max_bytes", limit, + "cache_dir", cacheDir, + ) } - log.Debug("cache size limit resolution not implemented", "computed", limit) + log.Info("effective cache size limit", + "cache_max_bytes", c.CacheMaxBytes, + "cache_disabled", c.CacheMaxBytes == 0, + ) return nil } diff --git a/internal/config/config.go b/internal/config/config.go index adcdcfa..9b34b04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,12 @@ type Config struct { // (75% of free space on the filesystem containing // /cache/, floored at DefaultCacheMaxBytesFloor). CacheMaxBytes int64 + + // cacheMaxBytesExplicit records whether cache_max_bytes was + // explicitly set in the configuration file. Explicit values are + // used exactly as given; only an omitted key gets the computed + // default (and its floor) in resolveCacheMaxBytes. + cacheMaxBytesExplicit bool } // New creates a new Config instance by loading configuration from file. @@ -122,6 +128,16 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { AllowHTTP: loader.boolVal("allow_http", false), UpstreamConnectionsPerHost: loader.intVal( "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost), + CacheMaxBytes: loader.int64Val("cache_max_bytes", 0), + } + + // The computed default for cache_max_bytes needs a validated + // state_dir, so it is resolved later (resolveCacheMaxBytes); here + // we only record whether the operator set the key explicitly. + if sc != nil { + if _, present := sc.Get("cache_max_bytes"); present { + c.cacheMaxBytesExplicit = true + } } // Build DBURL from StateDir if not explicitly set. The derived URL @@ -230,7 +246,7 @@ func isKnownConfigKey(key string) bool { switch key { case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn", "db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http", - "upstream_connections_per_host", "env": + "upstream_connections_per_host", "cache_max_bytes", "env": return true } @@ -300,6 +316,13 @@ func (c *Config) validate() error { return fmt.Errorf("config key %q: value must not be empty", "state_dir") } + // Zero is valid (it disables the disk cache); only negative + // values are rejected. No floor applies to explicit values. + if c.CacheMaxBytes < 0 { + return fmt.Errorf("config key %q: value %d must not be negative", + "cache_max_bytes", c.CacheMaxBytes) + } + for _, host := range c.AllowlistHosts { if err := validateAllowlistHost(host); err != nil { return err @@ -423,6 +446,19 @@ func (l *strictLoader) intVal(key string, defaultVal int) int { return val } +func (l *strictLoader) int64Val(key string, defaultVal int64) int64 { + if l.err != nil { + return 0 + } + + val, err := getInt64(l.sc, key, defaultVal) + if err != nil { + l.err = err + } + + return val +} + func (l *strictLoader) boolVal(key string, defaultVal bool) bool { if l.err != nil { return false @@ -503,6 +539,55 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { } } +// getInt64 returns the 64-bit integer value for key, or defaultVal if +// the key is omitted. A present value that is not a whole number, or +// is explicitly null, is an error; fractional values are never +// truncated and out-of-range values are never clamped. +func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) { + if sc == nil { + return defaultVal, nil + } + + raw, ok := sc.Get(key) + if !ok { + return defaultVal, nil + } + + if raw == nil { + return 0, errNullConfigValue(key) + } + + switch val := raw.(type) { + case int: + return int64(val), nil + case int64: + return val, nil + case uint64: + if val > math.MaxInt64 { + return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer", + key, val) + } + + return int64(val), nil //nolint:gosec // G115: bounds checked above + case float64: + if val != math.Trunc(val) { + return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val) + } + + return int64(val), nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64) + if err != nil { + return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val) + } + + return parsed, nil + default: + return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer", + key, raw, raw) + } +} + // getBool returns the boolean value for key, or defaultVal if the key // is omitted. A present value that is not a boolean (or a ParseBool-able // string), or is explicitly null, is an error; numbers are not accepted