ops: Signal disk-full and eviction capacity pressure
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Failing after 39s

When the disk (or memory) tier is at cap or the volume returns ENOSPC,
ops currently look like random misses with no clear "we are dropping
data." Count those events as capacity_pressure_events on /metrics and
log tier plus reason so operators can tell capacity pressure from a
cold cache, without changing the existing evictions counter.

Link: #36
This commit is contained in:
ash
2026-09-07 19:23:55 +00:00
parent 8cebc1f96c
commit d7af699e84
12 changed files with 381 additions and 77 deletions
+20 -16
View File
@@ -403,11 +403,13 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
dir := filepath.Dir(path)
// 0700 (not 0755): per-shard cache dirs hold untrusted CDN content; restrict to owner only (G301 addressed).
if err := os.MkdirAll(dir, 0700); err != nil {
d.recordIfNoSpace(err)
return nil, err
}
file, err := os.Create(path) // #nosec G304 -- path built by pathForKey from sanitized (Clean, no ..) hash-derived key under trusted disk.root; no untrusted file inclusion
if err != nil {
d.recordIfNoSpace(err)
return nil, err
}
@@ -438,7 +440,19 @@ type diskWriteCloser struct {
}
func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
return dwc.file.Write(p)
n, err = dwc.file.Write(p)
if err != nil {
dwc.disk.recordIfNoSpace(err)
}
return n, err
}
// recordIfNoSpace increments capacity_pressure_events and logs when err is ENOSPC (or Windows disk-full).
func (d *DiskFS) recordIfNoSpace(err error) {
if !isNoSpaceError(err) {
return
}
metrics.NoteNoSpace(d.metrics, err)
}
func (dwc *diskWriteCloser) Close() error {
@@ -722,9 +736,7 @@ func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}
@@ -775,9 +787,7 @@ func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}
@@ -826,9 +836,7 @@ func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}
@@ -882,9 +890,7 @@ func (d *DiskFS) EvictLFU(bytesNeeded uint) uint {
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}
@@ -939,8 +945,6 @@ func (d *DiskFS) EvictHybrid(bytesNeeded uint) uint {
}
d.mu.Unlock()
if d.metrics != nil && evicted > 0 {
d.metrics.IncrementEvictions()
}
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
return evicted
}
+48
View File
@@ -11,6 +11,7 @@ import (
"testing"
"time"
"s1d3sw1ped/steamcache2/steamcache/metrics"
"s1d3sw1ped/steamcache2/vfs"
)
@@ -122,6 +123,42 @@ func TestDiskFS_InitPopulatesIndexOnRestart(t *testing.T) {
}
}
func TestDiskFS_CapacityPressureOnEvict(t *testing.T) {
t.Parallel()
td := t.TempDir()
d, err := New(td, 500, nil)
if err != nil {
t.Fatal(err)
}
_ = d.Size()
met := metrics.NewMetrics()
d.SetMetrics(met)
for i := 0; i < 3; i++ {
k := "f" + string(rune('0'+i))
w, cerr := d.Create(k, 200)
if cerr != nil {
t.Fatal(cerr)
}
if _, werr := w.Write(make([]byte, 200)); werr != nil {
t.Fatal(werr)
}
if cerr := w.Close(); cerr != nil {
t.Fatal(cerr)
}
}
evicted := d.EvictLRU(100)
if evicted == 0 {
t.Fatalf("expected eviction under cap, size=%d cap=%d", d.Size(), d.Capacity())
}
st := met.GetStats()
if st.Evictions == 0 {
t.Error("evictions counter not incremented under disk cap pressure")
}
if st.CapacityPressureEvents == 0 {
t.Error("capacity_pressure_events not incremented under disk cap pressure")
}
}
func TestDiskFS_EvictAndLazyStat(t *testing.T) {
t.Parallel()
td := t.TempDir()
@@ -138,10 +175,21 @@ func TestDiskFS_EvictAndLazyStat(t *testing.T) {
w.Write(make([]byte, 120))
w.Close()
}
met := metrics.NewMetrics()
d.SetMetrics(met)
ev := d.EvictLRU(200)
if ev == 0 {
t.Log("no evict (size calc async or snapshot tolerance?)")
}
if ev > 0 {
st := met.GetStats()
if st.Evictions == 0 {
t.Error("evictions counter not incremented after disk EvictLRU freed bytes")
}
if st.CapacityPressureEvents == 0 {
t.Error("capacity_pressure_events not incremented after disk EvictLRU freed bytes")
}
}
// Explicit post-evict consistency checks: for any key no longer visible via Stat, its on-disk
// file must be absent (verifies coordinated unlink + no resurrection via lazy discovery).
// Keys still present after this small evict are allowed (accounting tolerance in raw DiskFS).
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package disk
import (
"errors"
"golang.org/x/sys/unix"
)
// isNoSpaceError reports whether err is ENOSPC (or wraps it).
func isNoSpaceError(err error) bool {
return err != nil && errors.Is(err, unix.ENOSPC)
}
+58
View File
@@ -0,0 +1,58 @@
//go:build !windows
package disk
import (
"io"
"os"
"testing"
"golang.org/x/sys/unix"
"s1d3sw1ped/steamcache2/steamcache/metrics"
)
func TestIsNoSpaceError(t *testing.T) {
t.Parallel()
if isNoSpaceError(nil) {
t.Error("nil must not be ENOSPC")
}
if isNoSpaceError(io.EOF) {
t.Error("EOF must not be ENOSPC")
}
if !isNoSpaceError(unix.ENOSPC) {
t.Error("unix.ENOSPC should match")
}
wrapped := &os.PathError{Op: "write", Path: "x", Err: unix.ENOSPC}
if !isNoSpaceError(wrapped) {
t.Error("PathError wrapping ENOSPC should match")
}
}
func TestDiskFS_ENOSPCCapacityPressure(t *testing.T) {
t.Parallel()
d, err := New(t.TempDir(), 1024, nil)
if err != nil {
t.Fatal(err)
}
met := metrics.NewMetrics()
d.SetMetrics(met)
d.recordIfNoSpace(io.EOF)
if got := met.GetStats().CapacityPressureEvents; got != 0 {
t.Fatalf("non-ENOSPC counted: %d", got)
}
d.recordIfNoSpace(unix.ENOSPC)
if got := met.GetStats().CapacityPressureEvents; got != 1 {
t.Fatalf("unix.ENOSPC: CapacityPressureEvents=%d, want 1", got)
}
if got := met.GetStats().Evictions; got != 0 {
t.Fatalf("ENOSPC must not increment evictions, got %d", got)
}
d.recordIfNoSpace(&os.PathError{Op: "write", Path: "p", Err: unix.ENOSPC})
if got := met.GetStats().CapacityPressureEvents; got != 2 {
t.Fatalf("wrapped ENOSPC: CapacityPressureEvents=%d, want 2", got)
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package disk
import (
"errors"
"golang.org/x/sys/windows"
)
// isNoSpaceError reports whether err is a Windows disk-full equivalent of ENOSPC.
func isNoSpaceError(err error) bool {
if err == nil {
return false
}
return errors.Is(err, windows.ERROR_DISK_FULL) || errors.Is(err, windows.ERROR_HANDLE_DISK_FULL)
}
+56
View File
@@ -0,0 +1,56 @@
//go:build windows
package disk
import (
"io"
"os"
"testing"
"golang.org/x/sys/windows"
"s1d3sw1ped/steamcache2/steamcache/metrics"
)
func TestIsNoSpaceError(t *testing.T) {
t.Parallel()
if isNoSpaceError(nil) {
t.Error("nil must not be disk-full")
}
if isNoSpaceError(io.EOF) {
t.Error("EOF must not be disk-full")
}
if !isNoSpaceError(windows.ERROR_DISK_FULL) {
t.Error("ERROR_DISK_FULL should match")
}
if !isNoSpaceError(windows.ERROR_HANDLE_DISK_FULL) {
t.Error("ERROR_HANDLE_DISK_FULL should match")
}
wrapped := &os.PathError{Op: "write", Path: "x", Err: windows.ERROR_DISK_FULL}
if !isNoSpaceError(wrapped) {
t.Error("PathError wrapping ERROR_DISK_FULL should match")
}
}
func TestDiskFS_ENOSPCCapacityPressure(t *testing.T) {
t.Parallel()
d, err := New(t.TempDir(), 1024, nil)
if err != nil {
t.Fatal(err)
}
met := metrics.NewMetrics()
d.SetMetrics(met)
d.recordIfNoSpace(io.EOF)
if got := met.GetStats().CapacityPressureEvents; got != 0 {
t.Fatalf("non-ENOSPC counted: %d", got)
}
d.recordIfNoSpace(windows.ERROR_DISK_FULL)
if got := met.GetStats().CapacityPressureEvents; got != 1 {
t.Fatalf("ERROR_DISK_FULL: CapacityPressureEvents=%d, want 1", got)
}
if got := met.GetStats().Evictions; got != 0 {
t.Fatalf("disk-full must not increment evictions, got %d", got)
}
}