2023-12-02 14:32:48 -09:00
|
|
|
package cache_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/djherbis/fscache"
|
|
|
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("FileHaunter", func() {
|
|
|
|
|
var fs fscache.FileSystem
|
|
|
|
|
var fsCache *fscache.FSCache
|
|
|
|
|
var cacheDir string
|
|
|
|
|
var err error
|
|
|
|
|
var maxItems int
|
2024-08-22 15:28:22 -08:00
|
|
|
var maxSize uint64
|
2023-12-02 14:32:48 -09:00
|
|
|
|
|
|
|
|
JustBeforeEach(func() {
|
|
|
|
|
tempDir, _ := os.MkdirTemp("", "spread_fs")
|
|
|
|
|
cacheDir = filepath.Join(tempDir, "cache1")
|
|
|
|
|
fs, err = fscache.NewFs(cacheDir, 0700)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
DeferCleanup(func() { _ = os.RemoveAll(tempDir) })
|
|
|
|
|
|
2026-06-05 14:06:52 -08:00
|
|
|
// Use a short haunter period so cleanup runs promptly; the assertions
|
|
|
|
|
// below poll with Eventually instead of racing a fixed sleep.
|
2024-08-22 15:28:22 -08:00
|
|
|
fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy(
|
2026-06-05 14:06:52 -08:00
|
|
|
cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond),
|
2024-08-22 15:28:22 -08:00
|
|
|
))
|
2023-12-02 14:32:48 -09:00
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
DeferCleanup(fsCache.Clean)
|
|
|
|
|
|
|
|
|
|
Expect(createTestFiles(fsCache)).To(Succeed())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("When maxSize is defined", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
maxSize = 20
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("removes files", func() {
|
2026-06-05 14:06:52 -08:00
|
|
|
// stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty.
|
|
|
|
|
// With maxSize=20, the haunter scrubs the empty file plus enough of
|
|
|
|
|
// the oldest files to bring the total size down to <= 20 bytes.
|
|
|
|
|
// Which files survive (and therefore the exact count) depends on
|
|
|
|
|
// access-time ordering, so we only assert the haunter's guarantees:
|
|
|
|
|
// the empty file is always scrubbed and the total size stays within
|
|
|
|
|
// the configured limit.
|
|
|
|
|
Eventually(func(g Gomega) {
|
|
|
|
|
g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
|
|
|
|
size, err := dirSize(cacheDir)
|
|
|
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
g.Expect(size).To(BeNumerically("<=", maxSize))
|
|
|
|
|
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed())
|
2023-12-02 14:32:48 -09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-05 14:06:52 -08:00
|
|
|
Context("When maxItems is defined", func() {
|
2023-12-02 14:32:48 -09:00
|
|
|
BeforeEach(func() {
|
|
|
|
|
maxItems = 3
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("removes files", func() {
|
2026-06-05 14:06:52 -08:00
|
|
|
// With maxItems=3, the haunter scrubs the empty file plus enough of
|
|
|
|
|
// the oldest files to bring the count within the limit. As above, the
|
|
|
|
|
// exact survivors depend on access-time ordering, so we assert the
|
|
|
|
|
// guaranteed invariants: the empty file is gone and the item count
|
|
|
|
|
// stays within the configured limit.
|
|
|
|
|
Eventually(func(g Gomega) {
|
|
|
|
|
g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed")
|
|
|
|
|
entries, readErr := os.ReadDir(cacheDir)
|
|
|
|
|
g.Expect(readErr).ToNot(HaveOccurred())
|
|
|
|
|
g.Expect(len(entries)).To(BeNumerically("<=", maxItems))
|
|
|
|
|
}).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed())
|
2023-12-02 14:32:48 -09:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
func createTestFiles(c *fscache.FSCache) error {
|
|
|
|
|
// Create 5 normal files and 1 empty
|
2026-02-08 05:57:30 -09:00
|
|
|
for i := range 6 {
|
2023-12-02 14:32:48 -09:00
|
|
|
name := fmt.Sprintf("stream-%v", i)
|
|
|
|
|
var r fscache.ReadAtCloser
|
|
|
|
|
if i < 5 {
|
|
|
|
|
r = createCachedStream(c, name, "hello")
|
|
|
|
|
} else { // Last one is empty
|
|
|
|
|
r = createCachedStream(c, name, "")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.Exists(name) {
|
|
|
|
|
return errors.New(name + " should exist")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<-time.After(10 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
err := r.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 14:06:52 -08:00
|
|
|
// dirSize returns the total size in bytes of all regular files in dir.
|
|
|
|
|
func dirSize(dir string) (uint64, error) {
|
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
var total uint64
|
|
|
|
|
for _, e := range entries {
|
|
|
|
|
info, err := e.Info()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
if !info.Mode().IsRegular() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
total += uint64(info.Size())
|
|
|
|
|
}
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-02 14:32:48 -09:00
|
|
|
func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser {
|
|
|
|
|
r, w, _ := c.Get(name)
|
|
|
|
|
_, _ = w.Write([]byte(contents))
|
|
|
|
|
_ = w.Close()
|
|
|
|
|
_, _ = io.Copy(io.Discard, r)
|
|
|
|
|
return r
|
|
|
|
|
}
|