diff --git a/CHANGELOG.md b/CHANGELOG.md index b49aa89..4686942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +## What's Changed + +* Add `hardcache local status` command with human-readable and `--json` output. +* Move trim-only flags (`--unused-for`, `--max-size`) under `trim` and `trimd` commands. + ## [v0.2.0](https://github.com/AlekSi/hardcache/releases/tag/v0.2.0) (2025-12-07) ## What's Changed diff --git a/Makefile b/Makefile index efd99a1..a79787c 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,5 @@ test: run: go build -race -o bin/ bin/hardcache --help + bin/hardcache local status mkdir -p tmp/cache diff --git a/README.md b/README.md index 66c1267..7a8b142 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Hardcache is a tool for managing the Go build cache. -The initial public version supports only a more flexible trimming policy of a standard local cache. -More functionality will be published soon, including support for `GOCACHEPROG`. +It currently supports local cache status reporting and a more flexible trimming policy +of a standard local cache. More functionality will be published soon, including support +for `GOCACHEPROG`. ## Installation @@ -31,6 +32,20 @@ It can be overridden with `--dir` flag: hardcache local --dir=/tmp/cache ... ``` +#### Status + +Display current cache and disk usage stats: + +``` +hardcache local status +``` + +Use compact JSON output for scripting: + +``` +hardcache local status --json +``` + #### Manual trimming Go standard build cache does not support [disabling trimming](https://github.com/golang/go/issues/69565), diff --git a/go.mod b/go.mod index d754607..d70d608 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ godebug ( require ( github.com/AlekSi/lazyerrors v0.6.0 - github.com/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8 + github.com/AlekSi/shoulda v0.0.1 github.com/alecthomas/kong v1.16.1 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/xhit/go-str2duration/v2 v2.1.0 diff --git a/go.sum b/go.sum index 29ea51f..09ff62c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/AlekSi/lazyerrors v0.6.0 h1:TX4iCP7N+YOuOmUl6Gv5OQ7brhPzPPrSsEAm+at+avk= github.com/AlekSi/lazyerrors v0.6.0/go.mod h1:TresBdOmCoC169IDo09YbrYUDXiqRwlLpInBeS1qD2g= -github.com/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8 h1:PtLNw2ECAPhUCCJvy+ASrZgDaFV5XKh184qJ/vx0CXs= -github.com/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8/go.mod h1:2BlJHePkF/aGLNg+T2Zqoa6AzrXFB11MQpFxq4TBK2g= +github.com/AlekSi/shoulda v0.0.1 h1:7wdUHXfzY0OLTVx0DJAk5uxb+jcg/a5Nrfo9lg2sT5I= +github.com/AlekSi/shoulda v0.0.1/go.mod h1:2BlJHePkF/aGLNg+T2Zqoa6AzrXFB11MQpFxq4TBK2g= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= diff --git a/internal/caches/local/local.go b/internal/caches/local/local.go index 300cfad..d0fbb3d 100644 --- a/internal/caches/local/local.go +++ b/internal/caches/local/local.go @@ -64,7 +64,8 @@ func (c *Cache) FuzzDir() string { // TrimForce removes cache entries (starting from least recently used), // enforcing both cutoff date and max cache size, if set. // It ignores the last trim time, but updates it. -func (c *Cache) TrimForce() (before, freed int64) { +// It returns statistics derived from the same cache scan used for trimming. +func (c *Cache) TrimForce() (before, freed int64, stats *cache.Stats) { return c.dc.TrimForce(c.cutoff, c.maxSize, c.l) } diff --git a/internal/caches/local/local_test.go b/internal/caches/local/local_test.go index 3d932bd..6dfe4aa 100644 --- a/internal/caches/local/local_test.go +++ b/internal/caches/local/local_test.go @@ -4,6 +4,8 @@ import ( "encoding/hex" "log/slog" "math" + "os" + "path/filepath" "testing" "time" @@ -98,12 +100,27 @@ func TestCache(t *testing.T) { func TestTrimNoop(t *testing.T) { t.Parallel() - c, err := New(localtest.Setup(t), nil, nil, logger(t)) + dir := localtest.Setup(t) + trimPath := filepath.Join(dir, "trim.txt") + trimBefore, err := os.ReadFile(trimPath) + musta.NoError(t, err) + + c, err := New(dir, nil, nil, logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, -1) shoulda.BeEqual(t, freed, 0) + trimAfter, err := os.ReadFile(trimPath) + musta.NoError(t, err) + shoulda.BeEqual(t, string(trimAfter), string(trimBefore)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 1219, + Bytes: 109_518_524, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) } func TestTrimCutoffNone(t *testing.T) { @@ -112,9 +129,16 @@ func TestTrimCutoffNone(t *testing.T) { c, err := New(localtest.Setup(t), new(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)), nil, logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(0)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 1219, + Bytes: 109_518_524, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) } func TestTrimCutoffAll(t *testing.T) { @@ -123,9 +147,11 @@ func TestTrimCutoffAll(t *testing.T) { c, err := New(localtest.Setup(t), new(time.Date(2999, time.January, 1, 0, 0, 0, 0, time.UTC)), nil, logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(109_518_524)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{}) } func TestTrimCutoffPart(t *testing.T) { @@ -134,9 +160,16 @@ func TestTrimCutoffPart(t *testing.T) { c, err := New(localtest.Setup(t), new(time.Date(2025, time.November, 17, 17, 13, 0, 0, time.UTC)), nil, logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(3_975_344)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 794, + Bytes: 105_543_180, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 0, 198000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) } func TestTrimSizeNone(t *testing.T) { @@ -145,9 +178,16 @@ func TestTrimSizeNone(t *testing.T) { c, err := New(localtest.Setup(t), nil, new(int64(math.MaxInt64)), logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(0)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 1219, + Bytes: 109_518_524, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) } func TestTrimSizeAll(t *testing.T) { @@ -156,9 +196,11 @@ func TestTrimSizeAll(t *testing.T) { c, err := New(localtest.Setup(t), nil, new(int64(0)), logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(109_518_524)) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{}) } func TestTrimSizePart(t *testing.T) { @@ -168,8 +210,48 @@ func TestTrimSizePart(t *testing.T) { c, err := New(localtest.Setup(t), nil, new(maxSize), logger(t)) musta.NoError(t, err) - before, freed := c.TrimForce() + before, freed, stats := c.TrimForce() shoulda.BeEqual(t, before, int64(109_518_524)) shoulda.BeEqual(t, freed, int64(60_023_595)) - shoulda.BeLess(t, before-freed, maxSize) + + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 413, + Bytes: 49_494_929, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 2, 195332000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) +} + +func TestTrimCutoffAndSize(t *testing.T) { + t.Parallel() + + cutoff := new(time.Date(2025, time.November, 17, 17, 13, 0, 0, time.UTC)) + + t.Run("cutoff is sufficient", func(t *testing.T) { + maxSize := int64(106_000_000) + c, err := New(localtest.Setup(t), cutoff, &maxSize, logger(t)) + musta.NoError(t, err) + + before, freed, stats := c.TrimForce() + shoulda.BeEqual(t, before, int64(109_518_524)) + shoulda.BeEqual(t, freed, int64(3_975_344)) + shoulda.BeEqual(t, stats.Bytes, int64(105_543_180)) + shoulda.BeEqual(t, stats.Entries, 794) + }) + + t.Run("size trimming is also needed", func(t *testing.T) { + maxSize := int64(50_000_000) + c, err := New(localtest.Setup(t), cutoff, &maxSize, logger(t)) + musta.NoError(t, err) + + before, freed, stats := c.TrimForce() + shoulda.BeEqual(t, before, int64(109_518_524)) + shoulda.BeEqual(t, freed, int64(60_023_595)) + shoulda.BeDeepEqual(t, stats, &cache.Stats{ + Entries: 413, + Bytes: 49_494_929, + LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 2, 195332000, time.UTC).Local()), + MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()), + }) + }) } diff --git a/internal/commands/local_status.go b/internal/commands/local_status.go new file mode 100644 index 0000000..bbc069f --- /dev/null +++ b/internal/commands/local_status.go @@ -0,0 +1,134 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "math" + "time" + + "github.com/AlekSi/hardcache/internal/caches/local" + "github.com/AlekSi/hardcache/internal/go/cache" + "github.com/AlekSi/hardcache/internal/unit" +) + +type localStatusOutput struct { + Directory string `json:"directory"` + Cache struct { + Entries int `json:"entries"` + Bytes int64 `json:"bytes"` + Human string `json:"human"` + LeastRecentlyUsed *string `json:"least_recently_used"` + MostRecentlyUsed *string `json:"most_recently_used"` + } `json:"cache"` + Disk struct { + TotalBytes int64 `json:"total_bytes"` + TotalHuman string `json:"total_human"` + UsedBytes int64 `json:"used_bytes"` + UsedHuman string `json:"used_human"` + UsedPercent float64 `json:"used_percent"` + FreeBytes int64 `json:"free_bytes"` + FreeHuman string `json:"free_human"` + FreePercent float64 `json:"free_percent"` + } `json:"disk"` + CacheOfTotalPercent float64 `json:"cache_of_total_percent"` +} + +// LocalStatusOpts contains flag values for [LocalStatus]. +type LocalStatusOpts struct { + Dir string + JSON bool +} + +// LocalStatus writes local cache and disk usage statistics to out. +func LocalStatus(opts *LocalStatusOpts, out io.Writer, l *slog.Logger) error { + c, err := local.New(opts.Dir, nil, nil, l) + if err != nil { + return err + } + + _, _, stats := c.TrimForce() + total, free, err := local.DiskInfo(opts.Dir) + if err != nil { + return err + } + + output := newLocalStatusOutput(opts.Dir, stats, total, free) + if opts.JSON { + return json.NewEncoder(out).Encode(output) + } + + _, err = fmt.Fprint(out, output) + return err +} + +func newLocalStatusOutput(dir string, stats *cache.Stats, total, free int64) localStatusOutput { + used := max(total-free, 0) + percent := func(value int64) float64 { + if total <= 0 { + return 0 + } + + return math.Round(float64(value)/float64(total)*10_000) / 100 + } + + res := localStatusOutput{ + Directory: dir, + CacheOfTotalPercent: percent(stats.Bytes), + } + res.Cache.Entries = stats.Entries + res.Cache.Bytes = stats.Bytes + res.Cache.Human = unit.Bytes(stats.Bytes).String() + formatTime := func(t *time.Time) *string { + if t == nil { + return nil + } + + return new(t.Local().Format(time.RFC3339)) + } + res.Cache.LeastRecentlyUsed = formatTime(stats.LeastRecentlyUsed) + res.Cache.MostRecentlyUsed = formatTime(stats.MostRecentlyUsed) + res.Disk.TotalBytes = total + res.Disk.TotalHuman = unit.Bytes(total).String() + res.Disk.UsedBytes = used + res.Disk.UsedHuman = unit.Bytes(used).String() + res.Disk.UsedPercent = percent(used) + res.Disk.FreeBytes = free + res.Disk.FreeHuman = unit.Bytes(free).String() + res.Disk.FreePercent = percent(free) + + return res +} + +func (s localStatusOutput) String() string { + formatTime := func(t *string) string { + if t == nil { + return "n/a" + } + + return *t + } + + return fmt.Sprintf( + `Directory: %s +Cache entries: %d +Cache size: %s (%d bytes) +Least recently used: %s +Most recently used: %s +Disk total: %s (%d bytes) +Disk used: %s (%d bytes) (%.2f%%) +Disk free: %s (%d bytes) (%.2f%%) +Cache of total disk: %.2f%% +`, + s.Directory, + s.Cache.Entries, + s.Cache.Human, s.Cache.Bytes, + formatTime(s.Cache.LeastRecentlyUsed), + formatTime(s.Cache.MostRecentlyUsed), + s.Disk.TotalHuman, s.Disk.TotalBytes, + s.Disk.UsedHuman, s.Disk.UsedBytes, s.Disk.UsedPercent, + s.Disk.FreeHuman, s.Disk.FreeBytes, s.Disk.FreePercent, + s.CacheOfTotalPercent, + ) +} diff --git a/internal/commands/local_status_test.go b/internal/commands/local_status_test.go new file mode 100644 index 0000000..d9f1fb3 --- /dev/null +++ b/internal/commands/local_status_test.go @@ -0,0 +1,80 @@ +package commands + +import ( + "encoding/json" + "log/slog" + "strings" + "testing" + "time" + + "github.com/AlekSi/shoulda" + "github.com/AlekSi/shoulda/musta" + + "github.com/AlekSi/hardcache/internal/caches/local" + "github.com/AlekSi/hardcache/internal/caches/local/localtest" +) + +func TestLocalStatus(t *testing.T) { + t.Parallel() + + dir := localtest.Setup(t) + lru := time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local().Format(time.RFC3339) + mru := time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local().Format(time.RFC3339) + + t.Run("text", func(t *testing.T) { + var output strings.Builder + musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir}, &output, slog.Default())) + + actual := output.String() + shoulda.SatisfyWith(t, actual, "Directory: "+dir, strings.Contains) + shoulda.SatisfyWith(t, actual, "Cache entries: 1219", strings.Contains) + shoulda.SatisfyWith(t, actual, "Cache size: 109MB (109518524 bytes)", strings.Contains) + shoulda.SatisfyWith(t, actual, "Least recently used: "+lru, strings.Contains) + shoulda.SatisfyWith(t, actual, "Most recently used: "+mru, strings.Contains) + shoulda.SatisfyWith(t, actual, "Disk total: ", strings.Contains) + shoulda.SatisfyWith(t, actual, "Disk free: ", strings.Contains) + }) + + t.Run("JSON", func(t *testing.T) { + var output strings.Builder + musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir, JSON: true}, &output, slog.Default())) + + actual := output.String() + shoulda.SatisfyWith(t, actual, "\n", strings.HasSuffix) + shoulda.BeEqual(t, strings.Count(actual, "\n"), 1) + + var got localStatusOutput + musta.NoError(t, json.Unmarshal([]byte(actual), &got)) + shoulda.BeEqual(t, got.Directory, dir) + shoulda.BeEqual(t, got.Cache.Entries, 1219) + shoulda.BeEqual(t, got.Cache.Bytes, int64(109_518_524)) + shoulda.BeEqual(t, got.Cache.Human, "109MB") + musta.NotBeZero(t, got.Cache.LeastRecentlyUsed) + musta.NotBeZero(t, got.Cache.MostRecentlyUsed) + shoulda.BeEqual(t, *got.Cache.LeastRecentlyUsed, lru) + shoulda.BeEqual(t, *got.Cache.MostRecentlyUsed, mru) + shoulda.BeGreater(t, got.Disk.TotalBytes, int64(0)) + shoulda.BeEqual(t, got.Disk.UsedBytes+got.Disk.FreeBytes, got.Disk.TotalBytes) + }) +} + +func TestLocalStatusEmpty(t *testing.T) { + t.Parallel() + + dir := localtest.Setup(t) + c, err := local.New(dir, nil, new(int64(0)), slog.Default()) + musta.NoError(t, err) + + before, freed, _ := c.TrimForce() + shoulda.BeEqual(t, before, int64(109_518_524)) + shoulda.BeEqual(t, freed, before) + + var output strings.Builder + musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir}, &output, slog.Default())) + + actual := output.String() + shoulda.SatisfyWith(t, actual, "Cache entries: 0", strings.Contains) + shoulda.SatisfyWith(t, actual, "Cache size: 0B (0 bytes)", strings.Contains) + shoulda.SatisfyWith(t, actual, "Least recently used: n/a", strings.Contains) + shoulda.SatisfyWith(t, actual, "Most recently used: n/a", strings.Contains) +} diff --git a/internal/commands/local_trim.go b/internal/commands/local_trim.go index 299863f..9fc6cb9 100644 --- a/internal/commands/local_trim.go +++ b/internal/commands/local_trim.go @@ -1,6 +1,7 @@ package commands import ( + "context" "fmt" "log/slog" "strings" @@ -80,17 +81,60 @@ func localTrim(opts *LocalTrimOpts, now func() time.Time, l *slog.Logger) error return err } - before, freed := c.TrimForce() - l.Debug( - "Local cache trimmed", - slog.Int64("before_bytes", before), - slog.Int64("freed_bytes", freed), - ) + ctx := context.Background() + debugEnabled := l.Enabled(ctx, slog.LevelDebug) + infoEnabled := l.Enabled(ctx, slog.LevelInfo) + before, freed, stats := c.TrimForce() + if before < 0 { + before = stats.Bytes + freed + } + + if debugEnabled { + l.Debug( + "Local cache trimmed", + slog.Int64("before_bytes", before), + slog.Int64("after_bytes", stats.Bytes), + slog.Int64("freed_bytes", freed), + ) + } + if !infoEnabled { + return nil + } + + total, free, err := local.DiskInfo(opts.Dir) + if err != nil { + return err + } + + status := newLocalStatusOutput(opts.Dir, stats, total, free) + + formatTime := func(t *string) string { + if t == nil { + return "n/a" + } + + return *t + } + l.Info( "Local cache trimmed", - slog.String("directory", opts.Dir), + slog.String("directory", status.Directory), slog.String("before", fmt.Sprintf("%s (%d bytes)", unit.Bytes(before), before)), slog.String("freed", fmt.Sprintf("%s (%d bytes)", unit.Bytes(freed), freed)), + slog.Group("cache", + slog.Int("entries", status.Cache.Entries), + slog.String("size", fmt.Sprintf("%s (%d bytes)", status.Cache.Human, status.Cache.Bytes)), + slog.String("least_recently_used", formatTime(status.Cache.LeastRecentlyUsed)), + slog.String("most_recently_used", formatTime(status.Cache.MostRecentlyUsed)), + ), + slog.Group("disk", + slog.String("total", fmt.Sprintf("%s (%d bytes)", status.Disk.TotalHuman, status.Disk.TotalBytes)), + slog.String("used", fmt.Sprintf("%s (%d bytes)", status.Disk.UsedHuman, status.Disk.UsedBytes)), + slog.String("used_percent", fmt.Sprintf("%.2f%%", status.Disk.UsedPercent)), + slog.String("free", fmt.Sprintf("%s (%d bytes)", status.Disk.FreeHuman, status.Disk.FreeBytes)), + slog.String("free_percent", fmt.Sprintf("%.2f%%", status.Disk.FreePercent)), + ), + slog.String("cache_of_total_disk", fmt.Sprintf("%.2f%%", status.CacheOfTotalPercent)), ) return nil diff --git a/internal/commands/local_trim_test.go b/internal/commands/local_trim_test.go index e5bf1e8..120e74a 100644 --- a/internal/commands/local_trim_test.go +++ b/internal/commands/local_trim_test.go @@ -10,6 +10,7 @@ import ( "github.com/AlekSi/shoulda" "github.com/AlekSi/shoulda/musta" + "github.com/AlekSi/hardcache/internal/caches/local" "github.com/AlekSi/hardcache/internal/caches/local/localtest" ) @@ -21,18 +22,32 @@ func TestLocalTrim(t *testing.T) { var buf strings.Builder l := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - started := time.Now().Add(-time.Second).Truncate(time.Second) + started := time.Now() musta.NoError(t, LocalTrim(&LocalTrimOpts{ Dir: dir, MaxSize: "50MB", }, l)) - finished := time.Now().Add(time.Second).Truncate(time.Second) + finished := time.Now() + + checkLocalTrimOutput(t, dir, &buf, l, started, finished) +} + +func checkLocalTrimOutput(t *testing.T, dir string, buf *strings.Builder, l *slog.Logger, started time.Time, finished time.Time) { + t.Helper() + + c := musta.NotFail(local.New(dir, nil, nil, l))(t) + _, _, stats := c.TrimForce() + shoulda.BeEqual(t, stats.Bytes, int64(49_494_929)) + shoulda.BeGreater(t, stats.Entries, 0) + + status := newLocalStatusOutput(dir, stats, 0, 0) + musta.NotBeZero(t, status.Cache.LeastRecentlyUsed) + musta.NotBeZero(t, status.Cache.MostRecentlyUsed) lines := strings.Split(strings.TrimSpace(buf.String()), "\n") shoulda.BeEqual(t, len(lines), 810) - lines = lines[len(lines)-3:] var actual []map[string]any @@ -45,13 +60,25 @@ func TestLocalTrim(t *testing.T) { timestamp, err := time.Parse(time.RFC3339Nano, m["time"].(string)) musta.NoError(t, err) - shoulda.Satisfy(t, timestamp, started.Before) - shoulda.Satisfy(t, timestamp, finished.After) + shoulda.NotSatisfy(t, timestamp, started.After) + shoulda.NotSatisfy(t, timestamp, finished.Before) delete(m, "time") actual = append(actual, m) } + disk := actual[2]["disk"].(map[string]any) + shoulda.BeEqual(t, len(disk), 5) + for _, name := range []string{"total", "used", "free"} { + shoulda.SatisfyWith(t, disk[name].(string), " bytes)", strings.HasSuffix) + } + for _, name := range []string{"used_percent", "free_percent"} { + shoulda.SatisfyWith(t, disk[name].(string), "%", strings.HasSuffix) + } + shoulda.SatisfyWith(t, actual[2]["cache_of_total_disk"].(string), "%", strings.HasSuffix) + delete(actual[2], "disk") + delete(actual[2], "cache_of_total_disk") + shoulda.BeDeepEqual(t, actual, []map[string]any{ { "level": "DEBUG", @@ -64,6 +91,7 @@ func TestLocalTrim(t *testing.T) { "level": "DEBUG", "msg": "Local cache trimmed", "before_bytes": 109518524.0, + "after_bytes": 49494929.0, "freed_bytes": 60023595.0, }, { @@ -72,6 +100,12 @@ func TestLocalTrim(t *testing.T) { "directory": dir, "before": "109MB (109518524 bytes)", "freed": "60MB (60023595 bytes)", + "cache": map[string]any{ + "entries": float64(status.Cache.Entries), + "size": "49MB (49494929 bytes)", + "least_recently_used": *status.Cache.LeastRecentlyUsed, + "most_recently_used": *status.Cache.MostRecentlyUsed, + }, }, }) } diff --git a/internal/commands/local_trimd_test.go b/internal/commands/local_trimd_test.go index fb2ab37..09212f8 100644 --- a/internal/commands/local_trimd_test.go +++ b/internal/commands/local_trimd_test.go @@ -2,13 +2,11 @@ package commands import ( "context" - "encoding/json" "log/slog" "strings" "testing" "time" - "github.com/AlekSi/shoulda" "github.com/AlekSi/shoulda/musta" "github.com/AlekSi/hardcache/internal/caches/local/localtest" @@ -26,7 +24,7 @@ func TestLocalTrimd(t *testing.T) { var buf strings.Builder l := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) - started := time.Now().Add(-time.Second).Truncate(time.Second) + started := time.Now() musta.NoError(t, LocalTrimd(ctx, &LocalTrimdOpts{ Dir: dir, @@ -34,50 +32,7 @@ func TestLocalTrimd(t *testing.T) { Interval: unit.Duration(time.Hour), }, l)) - finished := time.Now().Add(time.Second).Truncate(time.Second) + finished := time.Now() - lines := strings.Split(strings.TrimSpace(buf.String()), "\n") - shoulda.BeEqual(t, len(lines), 810) - - lines = lines[len(lines)-3:] - - var actual []map[string]any - for _, line := range lines { - t.Log(line) - - var m map[string]any - musta.NoError(t, json.Unmarshal([]byte(line), &m)) - - timestamp, err := time.Parse(time.RFC3339Nano, m["time"].(string)) - musta.NoError(t, err) - - shoulda.Satisfy(t, timestamp, started.Before) - shoulda.Satisfy(t, timestamp, finished.After) - delete(m, "time") - - actual = append(actual, m) - } - - shoulda.BeDeepEqual(t, actual, []map[string]any{ - { - "level": "DEBUG", - "msg": "trim.txt updated", - "before_bytes": 109518524.0, - "after_bytes": 49494929.0, - "freed_bytes": 60023595.0, - }, - { - "level": "DEBUG", - "msg": "Local cache trimmed", - "before_bytes": 109518524.0, - "freed_bytes": 60023595.0, - }, - { - "level": "INFO", - "msg": "Local cache trimmed", - "directory": dir, - "before": "109MB (109518524 bytes)", - "freed": "60MB (60023595 bytes)", - }, - }) + checkLocalTrimOutput(t, dir, &buf, l, started, finished) } diff --git a/internal/go/cache/cache_extra.go b/internal/go/cache/cache_extra.go index fd43c91..0bfbf10 100644 --- a/internal/go/cache/cache_extra.go +++ b/internal/go/cache/cache_extra.go @@ -20,6 +20,15 @@ import ( // EntryNotFoundError is exported for use in other packages. type EntryNotFoundError = entryNotFoundError +// Stats describes cache state derived from a full directory scan. +// LeastRecentlyUsed and MostRecentlyUsed are approximate last-use times from filesystem mtimes. +type Stats struct { + Entries int + Bytes int64 + LeastRecentlyUsed *time.Time + MostRecentlyUsed *time.Time +} + // fileInfo represents information about a file or directory with executable in the cache. // The order of fields is weird to make struct smaller. type fileInfo struct { @@ -32,6 +41,10 @@ type fileInfo struct { // enforcing both cutoff date and max cache size, if set. // Like [Trim], it honors the last trim time, doing nothing if the last trim was recent. func (c *DiskCache) TrimExtra(cutoff *time.Time, maxSize *int64, l *slog.Logger) (before, freed int64) { + if cutoff == nil && maxSize == nil { + return -1, 0 + } + // see DiskCache.Trim if data, err := lockedfile.Read(filepath.Join(c.dir, "trim.txt")); err == nil { if t, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); err == nil { @@ -43,53 +56,47 @@ func (c *DiskCache) TrimExtra(cutoff *time.Time, maxSize *int64, l *slog.Logger) } } - before, freed = c.TrimForce(cutoff, maxSize, l) + before, freed, _ = c.TrimForce(cutoff, maxSize, l) return } // TrimForce removes cache entries (starting from least recently used), // enforcing both cutoff date and max cache size, if set. -// It ignores the last trim time, but updates it. +// If both are nil, it only scans the cache. Otherwise, it ignores the last +// trim time, but updates it. // +// It returns statistics derived from the same cache scan used for trimming. // Passed logger is used for debug messages only. -func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) (before, freed int64) { +func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) (before, freed int64, stats *Stats) { before = -1 - now := c.now() - - defer func() { - var b bytes.Buffer - fmt.Fprintf(&b, "%d", now.Unix()) - if err := lockedfile.Write(filepath.Join(c.dir, "trim.txt"), &b, 0o666); err != nil { - l.Debug("Failed to write trim.txt", slog.String("error", err.Error())) - return - } - - l.Debug( - "trim.txt updated", - slog.Int64("before_bytes", before), - slog.Int64("after_bytes", before-freed), - slog.Int64("freed_bytes", freed), - ) - }() + if cutoff != nil || maxSize != nil { + now := c.now() + defer func() { + var b bytes.Buffer + fmt.Fprintf(&b, "%d", now.Unix()) + if err := lockedfile.Write(filepath.Join(c.dir, "trim.txt"), &b, 0o666); err != nil { + l.Debug("Failed to write trim.txt", slog.String("error", err.Error())) + return + } - if cutoff == nil && maxSize == nil { - return + l.Debug( + "trim.txt updated", + slog.Int64("before_bytes", before), + slog.Int64("after_bytes", before-freed), + slog.Int64("freed_bytes", freed), + ) + }() } - files, before := c.read(l) - - if cutoff == nil && before <= *maxSize { - return + files, bytes := c.read(l) + if cutoff != nil || maxSize != nil { + before = bytes } - slices.SortFunc(files, func(f1, f2 fileInfo) int { - return f1.ModTime.Compare(f2.ModTime) - }) - if cutoff != nil { for i, fi := range files { - if !fi.ModTime.Before(*cutoff) { - break + if fi.Name == "" || !fi.ModTime.Before(*cutoff) { + continue } p := filepath.Join(c.dir, fi.Name[:2], fi.Name) @@ -104,10 +111,14 @@ func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) } } - if maxSize != nil { - for _, fi := range files { + if maxSize != nil && before-freed > *maxSize { + slices.SortFunc(files, func(f1, f2 fileInfo) int { + return f1.ModTime.Compare(f2.ModTime) + }) + + for i, fi := range files { if before-freed <= *maxSize { - return + break } if fi.Name == "" { @@ -122,6 +133,24 @@ func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) l.Debug("Removed entry by max size", slog.String("name", p)) freed += fi.Size + files[i] = fileInfo{} + } + } + + stats = new(Stats) + for _, fi := range files { + if fi.Name == "" { + continue + } + + stats.Entries++ + stats.Bytes += fi.Size + + if stats.LeastRecentlyUsed == nil || fi.ModTime.Before(*stats.LeastRecentlyUsed) { + stats.LeastRecentlyUsed = &fi.ModTime + } + if stats.MostRecentlyUsed == nil || fi.ModTime.After(*stats.MostRecentlyUsed) { + stats.MostRecentlyUsed = &fi.ModTime } } @@ -131,7 +160,6 @@ func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) // read reads the entire cache directory. func (c *DiskCache) read(l *slog.Logger) (files []fileInfo, before int64) { files = make([]fileInfo, 0, 256) - for i := range 256 { subdir := filepath.Join(c.dir, fmt.Sprintf("%02x", i)) diff --git a/main.go b/main.go index 3a4640b..10682b6 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,10 @@ var cli struct { Local struct { Dir string `default:"${local_dir_default}" type:"path" help:"Directory to use."` + Status struct { + JSON bool `help:"Output as compact JSON."` + } `cmd:"" help:"Show local cache status."` + Trim struct { UnusedFor unit.Duration `default:"5d" help:"${local_unused_for_help}"` MaxSize string `default:"0GB" help:"${local_max_size_help}"` @@ -87,6 +91,12 @@ func main() { var err error switch kongCtx.Command() { + case "local status": + err = commands.LocalStatus(&commands.LocalStatusOpts{ + Dir: cli.Local.Dir, + JSON: cli.Local.Status.JSON, + }, os.Stdout, l) + case "local trim": err = commands.LocalTrim(&commands.LocalTrimOpts{ Dir: cli.Local.Dir,