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 <state_dir>/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.
This commit is contained in:
@@ -3,6 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -13,6 +15,18 @@ import (
|
|||||||
// never to explicitly configured values.
|
// never to explicitly configured values.
|
||||||
const DefaultCacheMaxBytesFloor int64 = 524288000
|
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
|
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
||||||
// filesystem containing path. It is a function type so tests can
|
// filesystem containing path. It is a function type so tests can
|
||||||
// inject a fake probe instead of depending on the host disk.
|
// 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
|
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
|
||||||
// the filesystem containing cacheDir: 75% of the free bytes reported
|
// the filesystem containing cacheDir: 75% of the free bytes reported
|
||||||
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
||||||
func ComputeDefaultCacheMaxBytes(_ string, _ FreeSpaceProbeFunc) (int64, error) {
|
func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) {
|
||||||
// Red phase: implementation follows the failing tests.
|
freeBytes, err := probe(cacheDir)
|
||||||
return 0, nil
|
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
|
// resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir
|
||||||
// validation: an explicitly configured value is kept as-is (no floor
|
// validation: an explicitly configured value is kept as-is (no floor
|
||||||
// applies), while an omitted key receives the computed default based
|
// applies), while an omitted key receives the computed default based
|
||||||
// on free space in <state_dir>/cache/. The effective limit is logged.
|
// on free space in <state_dir>/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 {
|
func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error {
|
||||||
// Red phase: implementation follows the failing tests.
|
if !c.cacheMaxBytesExplicit {
|
||||||
limit, err := ComputeDefaultCacheMaxBytes(filepath.Join(c.StateDir, "cache"), probe)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("cache size limit resolution not implemented", "computed", limit)
|
c.CacheMaxBytes = limit
|
||||||
|
|
||||||
|
log.Info("computed default cache size limit from free space",
|
||||||
|
"cache_max_bytes", limit,
|
||||||
|
"cache_dir", cacheDir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("effective cache size limit",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
|
"cache_disabled", c.CacheMaxBytes == 0,
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ type Config struct {
|
|||||||
// (75% of free space on the filesystem containing
|
// (75% of free space on the filesystem containing
|
||||||
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
||||||
CacheMaxBytes int64
|
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.
|
// 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),
|
AllowHTTP: loader.boolVal("allow_http", false),
|
||||||
UpstreamConnectionsPerHost: loader.intVal(
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
"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
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
@@ -230,7 +246,7 @@ func isKnownConfigKey(key string) bool {
|
|||||||
switch key {
|
switch key {
|
||||||
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
|
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
|
||||||
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
|
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
|
||||||
"upstream_connections_per_host", "env":
|
"upstream_connections_per_host", "cache_max_bytes", "env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +316,13 @@ func (c *Config) validate() error {
|
|||||||
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
|
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 {
|
for _, host := range c.AllowlistHosts {
|
||||||
if err := validateAllowlistHost(host); err != nil {
|
if err := validateAllowlistHost(host); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -423,6 +446,19 @@ func (l *strictLoader) intVal(key string, defaultVal int) int {
|
|||||||
return val
|
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 {
|
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||||
if l.err != nil {
|
if l.err != nil {
|
||||||
return false
|
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
|
// 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
|
// 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
|
// string), or is explicitly null, is an error; numbers are not accepted
|
||||||
|
|||||||
Reference in New Issue
Block a user