Remove plans/ directory (P0/P1/P2 work complete)

This commit is contained in:
2026-05-27 02:12:21 -05:00
parent 0c1840d223
commit 0dbb2e02ed
33 changed files with 1906 additions and 990 deletions
+176 -198
View File
@@ -10,6 +10,7 @@ import (
"s1d3sw1ped/steamcache2/vfs"
"s1d3sw1ped/steamcache2/vfs/locks"
"s1d3sw1ped/steamcache2/vfs/lru"
"s1d3sw1ped/steamcache2/vfs/types"
"s1d3sw1ped/steamcache2/vfs/vfserror"
"sort"
"strings"
@@ -21,6 +22,9 @@ import (
"github.com/edsrzf/mmap-go"
)
// maxEvictBatch bounds the candidate snapshot during RLock/Lock collect in Evict* (mirrors memory).
const maxEvictBatch = 4096
// Ensure DiskFS implements VFS.
var _ vfs.VFS = (*DiskFS)(nil)
@@ -61,6 +65,15 @@ func (d *DiskFS) shardPath(key string) string {
return filepath.Join("steam", shard1, shard2, hashPart)
}
// pathForKey returns the full on-disk path for a key (sharded + normalized).
// Extracted to reduce duplication in Evict*/Delete/Open paths (addresses review nit19; still safe to call under lock for evict).
func (d *DiskFS) pathForKey(key string) string {
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
return path
}
// New creates a new DiskFS.
func New(root string, capacity int64) *DiskFS {
if capacity <= 0 {
@@ -297,11 +310,9 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
delete(d.info, key)
}
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path := d.pathForKey(key)
d.mu.Unlock()
path = strings.ReplaceAll(path, "\\", "/")
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
@@ -400,9 +411,7 @@ func (d *DiskFS) Open(key string) (io.ReadCloser, error) {
d.LRU.MoveToFront(key, d.timeUpdater)
d.mu.Unlock()
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
path := d.pathForKey(key)
file, err := os.Open(path)
if err != nil {
@@ -484,10 +493,7 @@ func (d *DiskFS) Delete(key string) error {
delete(d.info, key)
d.mu.Unlock()
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
path := d.pathForKey(key)
err := os.Remove(path)
if err != nil {
return err
@@ -519,9 +525,7 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
keyMu.RUnlock()
// Lazy discovery: check if file exists on disk and index it
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
path := d.pathForKey(key)
info, err := os.Stat(path)
if err != nil {
@@ -552,260 +556,234 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
}
// EvictLRU evicts the least recently used files to free up space
// Collect under short exclusive Lock (to serialize concurrent EvictLRU on LRUList), batch under WLock.
func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
// Evict from LRU list until we free enough space
for d.size > d.capacity-int64(bytesNeeded) && d.LRU.Len() > 0 {
// Get the least recently used item
var toEvict []string
need := int64(bytesNeeded)
cur := d.size
for cur > d.capacity-need && d.LRU.Len() > 0 && len(toEvict) < maxEvictBatch {
elem := d.LRU.Back()
if elem == nil {
break
}
fi := elem.Value.(*vfs.FileInfo)
key := fi.Key
toEvict = append(toEvict, fi.Key)
cur -= fi.Size
}
d.mu.Unlock()
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
// Log error but continue
continue
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
if len(toEvict) == 0 {
return 0
}
d.mu.Lock()
var evicted uint
for _, key := range toEvict {
if fi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path) // best effort
d.size -= fi.Size
evicted += uint(fi.Size)
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
}
d.mu.Unlock()
return evicted
}
// EvictBySize evicts files by size (ascending = smallest first, descending = largest first)
// Scalar snapshot (key+size) under RLock + live re-fetch under WLock for race-free accounting + os.Remove.
type evictCandidate struct {
key string
size int64
}
func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
d.mu.RLock()
var candidates []evictCandidate
for key, fi := range d.info {
candidates = append(candidates, evictCandidate{key: key, size: fi.Size})
}
d.mu.RUnlock()
// Sort by size
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
if ascending {
return candidates[i].Size < candidates[j].Size
return candidates[i].size < candidates[j].size
}
return candidates[i].Size > candidates[j].Size
return candidates[i].size > candidates[j].size
})
// Evict files until we free enough space
for _, fi := range candidates {
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
continue
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path)
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
d.mu.Unlock()
return evicted
}
// EvictFIFO evicts files using FIFO (oldest creation time first)
// Snapshot ctime under RLock, live re-fetch + remove under WLock.
func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
d.mu.RLock()
var candidates []struct {
key string
cTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
cTime time.Time
}{key: key, cTime: fi.CTime})
}
d.mu.RUnlock()
// Sort by creation time (oldest first)
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].CTime.Before(candidates[j].CTime)
return candidates[i].cTime.Before(candidates[j].cTime)
})
// Evict oldest files until we free enough space
for _, fi := range candidates {
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
if err := os.Remove(path); err != nil {
continue
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path)
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
d.mu.Unlock()
return evicted
}
// EvictLFU evicts least frequently used files first (by AccessCount asc; P1-03 real LFU using existing field).
// Ties broken by ATime (older first).
// EvictLFU evicts least frequently used files first (by AccessCount ascending).
// Ties broken by ATime (older first). Uses snapshot + live re-fetch under WLock.
func (d *DiskFS) EvictLFU(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
d.mu.RLock()
var candidates []struct {
key string
accessCount int
aTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
accessCount int
aTime time.Time
}{key: key, accessCount: fi.AccessCount, aTime: fi.ATime})
}
d.mu.RUnlock()
// Sort by access count asc (LFU), then older ATime for ties
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].AccessCount != candidates[j].AccessCount {
return candidates[i].AccessCount < candidates[j].AccessCount
if candidates[i].accessCount != candidates[j].accessCount {
return candidates[i].accessCount < candidates[j].accessCount
}
return candidates[i].ATime.Before(candidates[j].ATime)
return candidates[i].aTime.Before(candidates[j].aTime)
})
// Evict until enough space
for _, fi := range candidates {
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
// Remove file from disk (best effort; sharding not critical for test coverage)
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
_ = os.Remove(path)
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path)
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
}
d.mu.Unlock()
return evicted
}
// EvictHybrid evicts using time-decayed score (recency + frequency from GetTimeDecayedScore; lower value first).
// This makes "hybrid" a meaningful size+recency+freq policy (P1-03).
// This makes "hybrid" a meaningful size + recency + frequency policy.
// Snapshot + decayed score under the appropriate locks.
func (d *DiskFS) EvictHybrid(bytesNeeded uint) uint {
d.mu.Lock()
defer d.mu.Unlock()
var evicted uint
var candidates []*vfs.FileInfo
// Collect all files
for _, fi := range d.info {
candidates = append(candidates, fi)
d.mu.RLock()
var candidates []struct {
key string
accessCount int
aTime time.Time
}
for key, fi := range d.info {
candidates = append(candidates, struct {
key string
accessCount int
aTime time.Time
}{key: key, accessCount: fi.AccessCount, aTime: fi.ATime})
}
d.mu.RUnlock()
// Sort by ascending decayed score (least valuable = evict first)
if len(candidates) == 0 {
return 0
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].GetTimeDecayedScore() < candidates[j].GetTimeDecayedScore()
// Use shared canonical DecayedScore from types (eliminates dupe with memory + FileInfo method).
scoreI := types.DecayedScore(candidates[i].aTime, candidates[i].accessCount)
scoreJ := types.DecayedScore(candidates[j].aTime, candidates[j].accessCount)
return scoreI < scoreJ
})
// Evict until enough space
for _, fi := range candidates {
d.mu.Lock()
var evicted uint
for _, c := range candidates {
if d.size <= d.capacity-int64(bytesNeeded) {
break
}
key := fi.Key
// Remove from LRU
d.LRU.Remove(key)
// Remove from map
delete(d.info, key)
shardedPath := d.shardPath(key)
path := filepath.Join(d.root, shardedPath)
path = strings.ReplaceAll(path, "\\", "/")
_ = os.Remove(path)
// Update size
d.size -= fi.Size
evicted += uint(fi.Size)
// Clean up key lock
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
key := c.key
if liveFi, exists := d.info[key]; exists {
d.LRU.Remove(key)
delete(d.info, key)
path := d.pathForKey(key)
_ = os.Remove(path)
d.size -= liveFi.Size
evicted += uint(liveFi.Size)
shardIndex := locks.GetShardIndex(key)
d.keyLocks[shardIndex].Delete(key)
}
}
d.mu.Unlock()
return evicted
}
+224
View File
@@ -0,0 +1,224 @@
package disk
import (
"io"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestDiskFS_Basic(t *testing.T) {
t.Parallel()
td := t.TempDir()
d := New(td, 10*1024*1024)
if d.Name() != "DiskFS" {
t.Error("name")
}
w, err := d.Create("k1", 50)
if err != nil {
t.Fatal(err)
}
w.Write([]byte("hello disk cache test data here"))
w.Close()
if d.Size() < 30 { // actual may differ slightly from declared
t.Errorf("size too small %d", d.Size())
}
r, err := d.Open("k1")
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(r)
r.Close()
if len(data) < 10 {
t.Error("read small")
}
d.Delete("k1")
if _, err := d.Open("k1"); err == nil {
t.Error("deleted still readable")
}
}
func TestDiskFS_EvictAndLazyStat(t *testing.T) {
t.Parallel()
td := t.TempDir()
d := New(td, 400)
// create files that will be evicted
for i := 0; i < 5; i++ {
w, _ := d.Create("f"+string(rune('0'+i)), 120)
w.Write(make([]byte, 120))
w.Close()
}
ev := d.EvictLRU(200)
if ev == 0 {
t.Log("no evict (size calc async or snapshot tolerance?)")
}
// lazy stat should still work for remaining; batch eviction may be approximate under heavy pressure
if d.Size() > d.Capacity()*2 { // generous for async bg size
t.Errorf("disk size %d >> cap after evict", d.Size())
}
}
func TestDiskFS_Concurrent(t *testing.T) {
if testing.Short() {
t.Skip()
}
t.Parallel()
td := t.TempDir()
d := New(td, 50*1024*1024)
var wg sync.WaitGroup
var ops int64
for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 30; j++ {
key := "d" + string(rune(id+'a')) + string(rune(j))
w, e := d.Create(key, 256)
if e == nil {
w.Write(make([]byte, 256))
w.Close()
atomic.AddInt64(&ops, 1)
}
if r, e := d.Open(key); e == nil {
io.Copy(io.Discard, r)
r.Close()
atomic.AddInt64(&ops, 1)
}
d.Delete(key)
if j%7 == 0 {
d.EvictLRU(1024)
}
}
}(i)
}
wg.Wait()
// Bounded poll instead of fixed sleep for bg size calc goroutine settlement (robust to variance; issue7)
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if d.Size() <= d.Capacity() {
break
}
time.Sleep(5 * time.Millisecond)
}
if d.Size() > d.Capacity() {
t.Errorf("concurrent disk size exceeded: %d", d.Size())
}
}
func BenchmarkDiskFS_CreateOpen(b *testing.B) {
td := b.TempDir()
d := New(td, 128*1024*1024)
data := make([]byte, 8192)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
key := "bd" + string(rune(i%500))
w, _ := d.Create(key, 8192)
w.Write(data)
w.Close()
r, _ := d.Open(key)
io.Copy(io.Discard, r)
r.Close()
d.Delete(key)
}
}
func TestDiskFS_EvictVariantsAndInvalid(t *testing.T) {
t.Parallel()
td := t.TempDir()
d := New(td, 600)
for i := 0; i < 4; i++ {
w, _ := d.Create("dv"+string(rune('0'+i)), 120)
w.Write(make([]byte, 120))
w.Close()
}
_ = d.EvictBySize(80, false) // largest
_ = d.EvictFIFO(50)
_ = d.EvictLFU(30)
_ = d.EvictHybrid(30)
// invalids (sanitized in Create/Open)
if _, err := d.Create("", 1); err == nil {
t.Error("empty")
}
if _, err := d.Create("/abs/bad", 1); err == nil {
t.Error("abs")
}
if _, err := d.Open("missing"); err == nil {
t.Error("missing open")
}
_ = d.Delete("missing")
_, _ = d.Stat("missing")
}
// TestEvict_ConcurrentCloseDuringEviction exercises Creates, Opens, and Closes (which mutate *FileInfo and size under lock)
// concurrently with all Evict* (LRU + non-LRU scalar snapshot paths) on DiskFS under pressure.
// Sufficient goroutines/iterations to hit prior race windows for Issues 1-3. Asserts size invariant with
// Documented epsilon tolerance for raw DiskFS (background size calc + snapshot tolerance during batch eviction). -race must pass.
func TestEvict_ConcurrentCloseDuringEviction(t *testing.T) {
if testing.Short() {
t.Skip()
}
t.Parallel()
td := t.TempDir()
cap := int64(256 * 1024)
d := New(td, cap)
var wg sync.WaitGroup
const nWriters = 4
const nEvictors = 3
const iters = 25
for i := 0; i < nWriters; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iters; j++ {
key := "r" + string(rune('0'+id%5)) + "/" + string(rune('0'+j%10))
w, err := d.Create(key, 8192)
if err == nil {
w.Write(make([]byte, 4096))
w.Close() // exercises Close size mutation path concurrent with evicts
}
if r, err := d.Open(key); err == nil {
io.Copy(io.Discard, r)
r.Close()
}
if j%4 == 0 {
d.Delete(key)
}
}
}(i)
}
for i := 0; i < nEvictors; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iters*2; j++ {
// Cycle through strategies to cover all snapshot + re-fetch + LRU-Lock paths
switch j % 6 {
case 0:
d.EvictLRU(4096)
case 1:
d.EvictBySize(4096, true)
case 2:
d.EvictBySize(4096, false)
case 3:
d.EvictFIFO(4096)
case 4:
d.EvictLFU(4096)
default:
d.EvictHybrid(4096)
}
}
}(i)
}
wg.Wait()
// Final size <= cap with epsilon (raw DiskFS allows small over per bg size + snapshot design; see TestDiskFS_Concurrent and memory +50 pattern)
if sz := d.Size(); sz > cap+2048 {
t.Errorf("final size %d exceeded cap %d + epsilon tolerance after concurrent close+evict", sz, cap)
}
}