mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-26 13:57:46 +00:00
feat: add size-based cache eviction (#1170)
The cache server retired entries 30 days after creation regardless of use, so a job that ran often enough to keep its cache warm still lost it on a fixed schedule. Nothing bounded the disk either. Retention now counts from last access alone, and a repository over its limit sheds least recently accessed entries until it fits, enforced on commit as well as on the periodic sweep. ```yaml cache: retention: 168h # remove entries not accessed for seven days repo_size_limit: 10GB # cap each repository size_limit: 0 # cap the whole cache, off by default sweep_interval: 1h # minimum time between sweeps ``` Sizes accept `10GB`, `512mb`, `1TiB` or a plain byte count, binary either way. Leave a key out for its default; `0` turns a limit off, and `0s` does the same for `retention`. Whatever these allow, the cache also sheds entries to keep free space above `health_check.min_free_disk_space_mb` when health checks are enabled, so it cannot grow past the point where the runner stops accepting work. Supporting fixes: serving an entry stamps its access time, so a find cannot hand a job a download URL for an entry the next eviction is about to remove; an entry larger than the limit is dropped on its own account rather than emptying its repository to make room; and a blob that cannot be unlinked keeps its row, so the next sweep retries instead of orphaning bytes no limit can account for. Closes https://gitea.com/gitea/runner/issues/1168 --------- Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1170 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"os/signal"
|
||||
|
||||
"gitea.com/gitea/runner/act/artifactcache"
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -52,13 +53,14 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co
|
||||
if secret == "" {
|
||||
return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server")
|
||||
}
|
||||
cacheHandler, err := artifactcache.StartHandler(
|
||||
dir,
|
||||
host,
|
||||
port,
|
||||
secret,
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
cacheHandler, err := artifactcache.StartHandler(artifactcache.Options{
|
||||
Dir: dir,
|
||||
OutboundIP: host,
|
||||
Port: port,
|
||||
InternalSecret: secret,
|
||||
Policy: run.CachePolicy(cfg),
|
||||
Logger: log.StandardLogger().WithField("module", "cache_request"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -374,7 +375,10 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
|
||||
}
|
||||
|
||||
// init a cache server
|
||||
handler, err := artifactcache.StartHandler("", "", 0, "", log.StandardLogger().WithField("module", "cache_request"))
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{
|
||||
Policy: run.CachePolicy(&config.Config{Cache: config.DefaultCache()}),
|
||||
Logger: log.StandardLogger().WithField("module", "cache_request"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.com/gitea/runner/act/runner"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/disk"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
"gitea.com/gitea/runner/internal/pkg/metrics"
|
||||
"gitea.com/gitea/runner/internal/pkg/report"
|
||||
@@ -89,17 +90,18 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
warnIgnoredCachePolicy(cfg)
|
||||
// The v1 client appends its path to this without a separator, so the slash is required.
|
||||
envs["ACTIONS_CACHE_URL"] = strings.TrimRight(cfg.Cache.ExternalServer, "/") + "/"
|
||||
} else {
|
||||
warnIgnoredCacheSecret(cfg)
|
||||
handler, err := artifactcache.StartHandler(
|
||||
cfg.Cache.Dir,
|
||||
cfg.Cache.Host,
|
||||
cfg.Cache.Port,
|
||||
"",
|
||||
log.StandardLogger().WithField("module", "cache_request"),
|
||||
)
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{
|
||||
Dir: cfg.Cache.Dir,
|
||||
OutboundIP: cfg.Cache.Host,
|
||||
Port: cfg.Cache.Port,
|
||||
Policy: CachePolicy(cfg),
|
||||
Logger: log.StandardLogger().WithField("module", "cache_request"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
// go on
|
||||
@@ -693,7 +695,7 @@ func checkFreeDisk(cfg *config.Config) (bool, string) {
|
||||
root = filepath.FromSlash("/" + strings.TrimLeft(cfg.Container.WorkdirParent, "/"))
|
||||
}
|
||||
root = nearestExistingPath(root)
|
||||
available, err := freeDiskBytes(root)
|
||||
available, err := disk.FreeBytes(root)
|
||||
if err != nil {
|
||||
return false, fmt.Sprintf("cannot determine free disk space for %s: %v", root, err)
|
||||
}
|
||||
@@ -726,6 +728,35 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons
|
||||
}))
|
||||
}
|
||||
|
||||
// minFreeDisk keeps the cache from growing past the point where the runner stops taking
|
||||
// work, but only when health checks are on, since the key is documented as opt-in.
|
||||
func minFreeDisk(cfg *config.Config) int64 {
|
||||
if !cfg.HealthCheck.Enabled {
|
||||
return 0
|
||||
}
|
||||
return cfg.HealthCheck.MinFreeDiskSpaceMB * 1024 * 1024
|
||||
}
|
||||
|
||||
// CachePolicy maps the cache config onto the cache server's own type, in bytes not MiB.
|
||||
func CachePolicy(cfg *config.Config) artifactcache.Policy {
|
||||
return artifactcache.Policy{
|
||||
Retention: cfg.Cache.Retention,
|
||||
RepoSizeLimit: int64(cfg.Cache.RepoSizeLimit),
|
||||
SizeLimit: int64(cfg.Cache.SizeLimit),
|
||||
SweepInterval: cfg.Cache.SweepInterval,
|
||||
MinFreeDisk: minFreeDisk(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// warnIgnoredCachePolicy flags eviction settings configured on a runner that points at an external cache server.
|
||||
func warnIgnoredCachePolicy(cfg *config.Config) {
|
||||
defaults := config.DefaultCache()
|
||||
if cfg.Cache.Retention != defaults.Retention || cfg.Cache.RepoSizeLimit != defaults.RepoSizeLimit ||
|
||||
cfg.Cache.SizeLimit != defaults.SizeLimit || cfg.Cache.SweepInterval != defaults.SweepInterval {
|
||||
log.Warn("cache eviction settings are ignored when cache.external_server is set; configure them on that server instead")
|
||||
}
|
||||
}
|
||||
|
||||
// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server.
|
||||
func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
if cfg.Cache.ExternalServer != "" {
|
||||
|
||||
@@ -25,7 +25,7 @@ func emptyCfg() *config.Config { return &config.Config{} }
|
||||
|
||||
func TestRunner_registerCacheForTask(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||
|
||||
t.Run("empty token", func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
|
||||
// /find, no auth on the signed archiveLocation download.
|
||||
func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||
handler, err := artifactcache.StartHandler(dir, "127.0.0.1", 0, "", nil)
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
defer handler.Close()
|
||||
|
||||
@@ -162,7 +162,7 @@ func decodeJSON(resp *http.Response, v any) error {
|
||||
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "remote-cache")
|
||||
const secret = "shared-secret-for-tests"
|
||||
remote, err := artifactcache.StartHandler(dir, "127.0.0.2", 0, secret, nil) // advertised, never dialled
|
||||
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: dir, OutboundIP: "127.0.0.2", InternalSecret: secret}) // advertised, never dialled
|
||||
require.NoError(t, err)
|
||||
defer remote.Close()
|
||||
external := strings.Replace(remote.ExternalURL(), "127.0.0.2", "127.0.0.1", 1)
|
||||
|
||||
@@ -159,6 +159,20 @@ cache:
|
||||
# the action's own bundle, keeping the untouched copy beside it. The same edit lets the stock
|
||||
# upload-artifact and download-artifact work here. A bundle that does not match is left alone.
|
||||
#v2: true
|
||||
# How the cache server discards entries, ignored when external_server is set since that
|
||||
# server applies its own. Leave a setting out for its default; 0s or 0 turns the three
|
||||
# limits off. Sizes accept 10GB, 512mb, 1TiB or a plain byte count, binary either way.
|
||||
# Whatever these allow, the cache still sheds entries to keep free space on its volume
|
||||
# above health_check.min_free_disk_space_mb when health checks are enabled.
|
||||
# Remove entries nothing has read or written within this window. Only last access counts.
|
||||
#retention: 168h
|
||||
# Cap one repository, removing its least recently accessed entries until it fits. An entry
|
||||
# larger than the limit is dropped rather than emptying the repository to make room.
|
||||
#repo_size_limit: 10GB
|
||||
# Cap the whole cache the same way. Off by default, since the free space floor bounds it.
|
||||
#size_limit: 0
|
||||
# Minimum time between two eviction sweeps. This one has no "off".
|
||||
#sweep_interval: 1h
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -81,6 +82,47 @@ type Cache struct {
|
||||
ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error.
|
||||
OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed.
|
||||
V2 *bool `yaml:"v2"` // V2 serves the actions cache service v2 API to jobs, used by actions/cache@v4.2 and later, and edits the action bundles that would otherwise refuse it. Unset means enabled.
|
||||
|
||||
// Eviction settings, ignored when ExternalServer is set since that server applies its own.
|
||||
Retention time.Duration `yaml:"retention"` // Retention removes entries nothing has read or written within this window. Default 168h, 0 keeps them regardless of age.
|
||||
RepoSizeLimit Size `yaml:"repo_size_limit"` // RepoSizeLimit caps one repository, evicting least recently accessed first. Default 10GB, 0 is no limit.
|
||||
SizeLimit Size `yaml:"size_limit"` // SizeLimit caps the whole cache the same way. No limit by default.
|
||||
SweepInterval time.Duration `yaml:"sweep_interval"` // SweepInterval is the minimum time between two eviction sweeps. Default 1h; a cadence has no "off".
|
||||
}
|
||||
|
||||
// DefaultCache returns the cache eviction defaults, seeded before the file is read so a
|
||||
// written 0 can mean off. SizeLimit stays zero: the free space floor bounds the whole cache.
|
||||
func DefaultCache() Cache {
|
||||
return Cache{
|
||||
Retention: 7 * 24 * time.Hour,
|
||||
RepoSizeLimit: 10 * 1024 * 1024 * 1024,
|
||||
SweepInterval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Size is a byte count written the way people say it: 10GB, 512mb, 1TiB, or a plain number
|
||||
// of bytes. Units are binary and case-insensitive, so GB and GiB both mean 1024³.
|
||||
type Size int64
|
||||
|
||||
func (s *Size) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("line %d: size must be a scalar such as 10GB", value.Line)
|
||||
}
|
||||
size, err := parseSize(value.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("line %d: %w", value.Line, err)
|
||||
}
|
||||
*s = size
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSize reads a Size such as 10GB, 512mb, 1TiB or a plain byte count.
|
||||
func parseSize(value string) (Size, error) {
|
||||
bytes, err := units.RAMInBytes(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%q is not a size such as 10GB, 512MB or a plain byte count", value)
|
||||
}
|
||||
return Size(bytes), nil
|
||||
}
|
||||
|
||||
// Container represents the configuration for the container.
|
||||
@@ -142,7 +184,7 @@ type Config struct {
|
||||
// LoadDefault returns the default configuration.
|
||||
// If file is not empty, it will be used to load the configuration.
|
||||
func LoadDefault(file string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
cfg := &Config{Cache: DefaultCache()}
|
||||
definedRunnerKeys := map[string]bool{}
|
||||
if file != "" {
|
||||
content, err := os.ReadFile(file)
|
||||
|
||||
@@ -168,6 +168,37 @@ runner:
|
||||
assert.Equal(t, 5*time.Minute, cfg.Runner.PostTaskScriptTimeout)
|
||||
}
|
||||
|
||||
func TestLoadDefault_LoadsCacheEviction(t *testing.T) {
|
||||
write := func(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("sizes accept any spelling of the unit", func(t *testing.T) {
|
||||
cfg, err := LoadDefault(write(t, "cache:\n retention: 336h\n repo_size_limit: 50gb\n size_limit: 1TiB\n sweep_interval: 15m\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 336*time.Hour, cfg.Cache.Retention)
|
||||
assert.Equal(t, Size(50*1024*1024*1024), cfg.Cache.RepoSizeLimit)
|
||||
assert.Equal(t, Size(1024*1024*1024*1024), cfg.Cache.SizeLimit)
|
||||
assert.Equal(t, 15*time.Minute, cfg.Cache.SweepInterval)
|
||||
})
|
||||
|
||||
t.Run("zero turns a limit off where an absent key keeps its default", func(t *testing.T) {
|
||||
cfg, err := LoadDefault(write(t, "cache:\n repo_size_limit: 0\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, cfg.Cache.RepoSizeLimit)
|
||||
assert.Equal(t, DefaultCache().Retention, cfg.Cache.Retention, "an absent key still defaults")
|
||||
})
|
||||
|
||||
t.Run("a bad size names the offending value", func(t *testing.T) {
|
||||
_, err := LoadDefault(write(t, "cache:\n repo_size_limit: banana\n"))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "banana")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadDefault_LoadsJobHooks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
@@ -26,7 +26,10 @@ const (
|
||||
kindSection
|
||||
)
|
||||
|
||||
var durationType = reflect.TypeFor[time.Duration]()
|
||||
var (
|
||||
durationType = reflect.TypeFor[time.Duration]()
|
||||
sizeType = reflect.TypeFor[Size]()
|
||||
)
|
||||
|
||||
// GetValue renders a flat list or mapping one entry per line, and anything nested as YAML.
|
||||
func GetValue(file, path string) (string, error) {
|
||||
@@ -537,6 +540,13 @@ func scalarNode(typ reflect.Type, value string) (*yaml.Node, error) {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: duration.String()}, nil
|
||||
}
|
||||
|
||||
if typ == sizeType {
|
||||
if _, err := parseSize(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, nil
|
||||
}
|
||||
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package disk reports free space on the volume holding a path. Platforms without an
|
||||
// implementation return an error, so callers treat the check as unavailable rather than
|
||||
// as a full disk.
|
||||
package disk
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
|
||||
|
||||
package run
|
||||
package disk
|
||||
|
||||
import "fmt"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
// FreeBytes reports the space available to an unprivileged user on the volume holding path.
|
||||
func FreeBytes(path string) (uint64, error) {
|
||||
return 0, fmt.Errorf("free disk space checks are not supported for %s", path)
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
|
||||
|
||||
package run
|
||||
package disk
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
// FreeBytes reports the space available to an unprivileged user on the volume holding path.
|
||||
func FreeBytes(path string) (uint64, error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return 0, err
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
//go:build windows
|
||||
|
||||
package run
|
||||
package disk
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func freeDiskBytes(path string) (uint64, error) {
|
||||
// FreeBytes reports the space available to an unprivileged user on the volume holding path.
|
||||
func FreeBytes(path string) (uint64, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
Reference in New Issue
Block a user