Description
simplefs never restores its key index on startup, so a cache directory populated by a previous process is unreachable after a restart — while the files stay on disk forever. Two consequences: the cache is always cold after a restart, and the directory grows without bound across restarts.
Init() walks the directory, but only to sum actualSize:
files, _ := os.ReadDir(provider.path)
provider.logger.Debugf("Regenerating simplefs cache from files in the given directory.")
for _, f := range files {
if !f.IsDir() {
info, _ := f.Info()
provider.actualSize += info.Size()
}
}
No cache.Set is issued for any of those files, so the ttlcache index starts empty. Since Get resolves a key to a path through that index, every pre-existing file is orphaned: unreadable, undeletable (eviction is driven by the same index), and permanently counted in actualSize. The debug line "Regenerating simplefs cache from files in the given directory" describes an intent the code does not implement.
This interacts badly with #57: on restart actualSize is initialised from orphaned files that can never be evicted, so a bounded (directory_size) instance starts closer to — or already past — its ceiling, and reaches the deadlock sooner.
Reproduction
simplefs/restart_test.go, against main (v0.0.19):
package simplefs_test
import (
"crypto/rand"
"net/http"
"os"
"testing"
"time"
"github.com/darkweak/storages/core"
"github.com/darkweak/storages/simplefs"
"go.uber.org/zap"
)
func newProvider(t *testing.T, dir string) core.Storer {
t.Helper()
logger, _ := zap.NewDevelopment()
p, err := simplefs.Factory(core.CacheProvider{Path: dir}, logger.Sugar(), 0)
if err != nil {
t.Fatal(err)
}
if err := p.Init(); err != nil {
t.Fatal(err)
}
return p
}
func TestSurvivesRestart(t *testing.T) {
dir := t.TempDir()
body := make([]byte, 2048)
_, _ = rand.Read(body)
p1 := newProvider(t, dir)
if err := p1.SetMultiLevel("base", "varied-1", body, http.Header{}, "etag", time.Hour, "real"); err != nil {
t.Fatal(err)
}
if len(p1.Get("varied-1")) == 0 {
t.Fatal("pre-restart Get returned nothing")
}
// Same directory, fresh provider == process restart.
p2 := newProvider(t, dir)
files, _ := os.ReadDir(dir)
t.Logf("after restart: %d files on disk, ListKeys()=%v, Get len=%d", len(files), p2.ListKeys(), len(p2.Get("varied-1")))
if len(p2.Get("varied-1")) == 0 {
t.Errorf("cold after restart: %d files remain on disk but are unreachable", len(files))
}
}
Output:
before restart: 1 files on disk, key readable
WARN simplefs/simplefs.go:166 Impossible to get the key varied-1 in Simplefs
after restart: 1 files still on disk, ListKeys()=[], Get len=0
--- FAIL: TestSurvivesRestart
Impact
For a filesystem-backed store, surviving a restart is close to the whole point of choosing it over an in-memory one — otherwise the disk write buys nothing over otter, at the cost of unbounded growth. On a long-lived cache (CDN edge, media delivery) every process restart currently leaks the entire working set.
Possible directions
The mapping entries already carry everything needed (variedKey, varied headers, etag, freshness/staleness deadlines — see core.MappingUpdater), so a rebuild is mostly a matter of persisting them alongside the bodies:
- Write a sidecar (or a single index file) holding key → path plus the mapping metadata, and repopulate the ttlcache in
Init(), dropping entries whose deadline has already passed and deleting their files.
- Failing that,
Init() should at least delete the files it cannot index, so the directory does not grow without bound and actualSize reflects reality.
Happy to put a PR together for either shape if you have a preference.
Description
simplefsnever restores its key index on startup, so a cache directory populated by a previous process is unreachable after a restart — while the files stay on disk forever. Two consequences: the cache is always cold after a restart, and the directory grows without bound across restarts.Init()walks the directory, but only to sumactualSize:No
cache.Setis issued for any of those files, so the ttlcache index starts empty. SinceGetresolves a key to a path through that index, every pre-existing file is orphaned: unreadable, undeletable (eviction is driven by the same index), and permanently counted inactualSize. The debug line "Regenerating simplefs cache from files in the given directory" describes an intent the code does not implement.This interacts badly with #57: on restart
actualSizeis initialised from orphaned files that can never be evicted, so a bounded (directory_size) instance starts closer to — or already past — its ceiling, and reaches the deadlock sooner.Reproduction
simplefs/restart_test.go, againstmain(v0.0.19):Output:
Impact
For a filesystem-backed store, surviving a restart is close to the whole point of choosing it over an in-memory one — otherwise the disk write buys nothing over
otter, at the cost of unbounded growth. On a long-lived cache (CDN edge, media delivery) every process restart currently leaks the entire working set.Possible directions
The mapping entries already carry everything needed (
variedKey, varied headers, etag, freshness/staleness deadlines — seecore.MappingUpdater), so a rebuild is mostly a matter of persisting them alongside the bodies:Init(), dropping entries whose deadline has already passed and deleting their files.Init()should at least delete the files it cannot index, so the directory does not grow without bound andactualSizereflects reality.Happy to put a PR together for either shape if you have a preference.