Cache keyed by sha256(absPath|quality|audioIdx)[:8] with .complete marker; LRU + size-budget eviction; per-key writer-lock; pinned during play; startup orphan reap; integrity verify on HIT; subtitle-completeness gate; hit/miss counters + daily log line. New [downloads.hls_cache] block in config.toml (enabled/size_gb/dir, default 5GB). Smoke test: 2nd play of same source+quality is 23-31× faster (HIT path skips ffmpeg entirely).
361 lines
9.2 KiB
Go
361 lines
9.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestCache(t *testing.T, sizeGB int) *HLSCache {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
c, err := NewHLSCache(root, sizeGB)
|
|
if err != nil {
|
|
t.Fatalf("NewHLSCache: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestKeyForStable(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
|
|
k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
|
|
if k1 != k2 {
|
|
t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
|
|
}
|
|
if c.KeyFor("/a/b/movie.mkv", "720p", 0) == k1 {
|
|
t.Fatal("quality should change key")
|
|
}
|
|
if c.KeyFor("/a/b/movie.mkv", "1080p", 1) == k1 {
|
|
t.Fatal("audio index should change key")
|
|
}
|
|
if c.KeyFor("/x/y/other.mkv", "1080p", 0) == k1 {
|
|
t.Fatal("path should change key")
|
|
}
|
|
}
|
|
|
|
func TestMarkCompleteAndHas(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
key := "abc123"
|
|
if c.HasComplete(key) {
|
|
t.Fatal("fresh cache should not report complete")
|
|
}
|
|
// Production callers create the dir during StartHLSSession; MarkComplete
|
|
// trusts that invariant and fails if the dir was wiped meanwhile.
|
|
if err := os.MkdirAll(c.DirFor(key), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := c.MarkComplete(key); err != nil {
|
|
t.Fatalf("MarkComplete: %v", err)
|
|
}
|
|
if !c.HasComplete(key) {
|
|
t.Fatal("after MarkComplete, HasComplete must be true")
|
|
}
|
|
}
|
|
|
|
func TestMarkCompleteFailsWithoutDir(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
if err := c.MarkComplete("never-created"); err == nil {
|
|
t.Fatal("MarkComplete should error when dir doesn't exist")
|
|
}
|
|
}
|
|
|
|
func TestPinPreventsEviction(t *testing.T) {
|
|
c := newTestCache(t, 1) // 1 GB budget, but min clamp keeps it usable
|
|
c.maxBytes = 1024 // squeeze budget for the test
|
|
|
|
// Write two entries past the budget.
|
|
for i, key := range []string{"old", "new"} {
|
|
dir := c.DirFor(key)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
path := filepath.Join(dir, "seg.bin")
|
|
if err := os.WriteFile(path, make([]byte, 800), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
now := time.Now().Add(time.Duration(i) * time.Hour) // "old" mtime < "new"
|
|
_ = os.Chtimes(dir, now, now)
|
|
}
|
|
|
|
c.Pin("old") // protect the older one
|
|
freed, err := c.Sweep()
|
|
if err != nil {
|
|
t.Fatalf("Sweep: %v", err)
|
|
}
|
|
if freed == 0 {
|
|
t.Fatal("expected some eviction")
|
|
}
|
|
if _, err := os.Stat(c.DirFor("old")); err != nil {
|
|
t.Fatal("pinned 'old' was evicted")
|
|
}
|
|
if _, err := os.Stat(c.DirFor("new")); err == nil {
|
|
t.Fatal("'new' should have been evicted to make room")
|
|
}
|
|
}
|
|
|
|
func TestSweepNoOpUnderBudget(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
dir := c.DirFor("small")
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("tiny"), 0o644)
|
|
freed, err := c.Sweep()
|
|
if err != nil {
|
|
t.Fatalf("Sweep: %v", err)
|
|
}
|
|
if freed != 0 {
|
|
t.Fatalf("expected 0 freed under budget, got %d", freed)
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatal("under-budget entry was wrongly evicted")
|
|
}
|
|
}
|
|
|
|
func TestSweepEmptyRoot(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
freed, err := c.Sweep()
|
|
if err != nil {
|
|
t.Fatalf("Sweep empty: %v", err)
|
|
}
|
|
if freed != 0 {
|
|
t.Fatalf("freed=%d, want 0", freed)
|
|
}
|
|
}
|
|
|
|
func TestInvalidateRemovesDir(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
key := "drop"
|
|
dir := c.DirFor(key)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("y"), 0o644)
|
|
if err := c.Invalidate(key); err != nil {
|
|
t.Fatalf("Invalidate: %v", err)
|
|
}
|
|
if _, err := os.Stat(dir); err == nil {
|
|
t.Fatal("dir still present after Invalidate")
|
|
}
|
|
}
|
|
|
|
func TestTouchUpdatesMtime(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
key := "touch"
|
|
dir := c.DirFor(key)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
old := time.Now().Add(-2 * time.Hour)
|
|
_ = os.Chtimes(dir, old, old)
|
|
|
|
if err := c.Touch(key); err != nil {
|
|
t.Fatalf("Touch: %v", err)
|
|
}
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if !info.ModTime().After(old.Add(time.Minute)) {
|
|
t.Fatalf("mtime not refreshed: %v", info.ModTime())
|
|
}
|
|
}
|
|
|
|
func TestPinUnpinSymmetry(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
c.Pin("k")
|
|
c.Pin("k")
|
|
if !c.isPinned("k") {
|
|
t.Fatal("Pin twice should leave pinned")
|
|
}
|
|
c.Unpin("k")
|
|
if !c.isPinned("k") {
|
|
t.Fatal("Unpin once should keep pinned (refs=1)")
|
|
}
|
|
c.Unpin("k")
|
|
if c.isPinned("k") {
|
|
t.Fatal("Unpin twice should drop pin")
|
|
}
|
|
c.Unpin("k") // safe no-op
|
|
}
|
|
|
|
func TestConcurrentPinUnpin(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
c.Pin("race")
|
|
time.Sleep(time.Microsecond)
|
|
c.Unpin("race")
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if c.isPinned("race") {
|
|
t.Fatal("refs leaked")
|
|
}
|
|
}
|
|
|
|
func TestSweeperLoopExits(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.StartSweeper(ctx, 10*time.Millisecond)
|
|
time.Sleep(30 * time.Millisecond)
|
|
cancel()
|
|
// If StartSweeper doesn't exit on cancel the test would leak a goroutine;
|
|
// the leak detector in the test runner will surface it.
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
|
|
func TestMinBudgetClamp(t *testing.T) {
|
|
root := t.TempDir()
|
|
c, err := NewHLSCache(root, 0) // below floor
|
|
if err != nil {
|
|
t.Fatalf("NewHLSCache: %v", err)
|
|
}
|
|
if c.maxBytes != int64(hlsCacheMinBudgetGB)*1024*1024*1024 {
|
|
t.Fatalf("budget not clamped to min: got %d", c.maxBytes)
|
|
}
|
|
}
|
|
|
|
func TestTryAcquireWriterExclusive(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
if !c.TryAcquireWriter("k") {
|
|
t.Fatal("first acquire should succeed")
|
|
}
|
|
if c.TryAcquireWriter("k") {
|
|
t.Fatal("second acquire for same key must fail")
|
|
}
|
|
if !c.TryAcquireWriter("other") {
|
|
t.Fatal("different key should not conflict")
|
|
}
|
|
c.ReleaseWriter("k")
|
|
if !c.TryAcquireWriter("k") {
|
|
t.Fatal("acquire after release should succeed")
|
|
}
|
|
c.ReleaseWriter("k")
|
|
c.ReleaseWriter("k") // idempotent
|
|
}
|
|
|
|
func TestStartupOrphanCleanup(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Pre-seed: one sealed dir + one orphan old enough + one orphan fresh.
|
|
sealed := filepath.Join(root, "sealed")
|
|
_ = os.MkdirAll(sealed, 0o755)
|
|
_ = os.WriteFile(filepath.Join(sealed, hlsCacheCompleteMarker), nil, 0o644)
|
|
|
|
staleOrphan := filepath.Join(root, "stale_orphan")
|
|
_ = os.MkdirAll(staleOrphan, 0o755)
|
|
old := time.Now().Add(-2 * hlsCacheStartupOrphanAge)
|
|
_ = os.Chtimes(staleOrphan, old, old)
|
|
|
|
freshOrphan := filepath.Join(root, "fresh_orphan")
|
|
_ = os.MkdirAll(freshOrphan, 0o755)
|
|
|
|
if _, err := NewHLSCache(root, 1); err != nil {
|
|
t.Fatalf("NewHLSCache: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(sealed); err != nil {
|
|
t.Fatal("sealed dir was wrongly removed")
|
|
}
|
|
if _, err := os.Stat(staleOrphan); err == nil {
|
|
t.Fatal("stale orphan should have been removed at startup")
|
|
}
|
|
if _, err := os.Stat(freshOrphan); err != nil {
|
|
t.Fatal("fresh orphan should be kept (might be a mid-restart encode)")
|
|
}
|
|
}
|
|
|
|
func TestHitMissCounters(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
if s := c.Stats(); s.Hits != 0 || s.Misses != 0 {
|
|
t.Fatalf("fresh cache stats not zero: %+v", s)
|
|
}
|
|
c.RecordHit()
|
|
c.RecordHit()
|
|
c.RecordMiss()
|
|
s := c.Stats()
|
|
if s.Hits != 2 || s.Misses != 1 {
|
|
t.Fatalf("counters wrong: %+v", s)
|
|
}
|
|
// 2/3 = 67%
|
|
if got := c.hitRatePercent(); got != 67 {
|
|
t.Fatalf("hitRatePercent=%d, want 67", got)
|
|
}
|
|
}
|
|
|
|
func TestStatsEntryCount(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
for _, k := range []string{"a", "b", "c"} {
|
|
dir := c.DirFor(k)
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("hello"), 0o644)
|
|
}
|
|
s := c.Stats()
|
|
if s.EntryCount != 3 {
|
|
t.Fatalf("EntryCount=%d, want 3", s.EntryCount)
|
|
}
|
|
if s.TotalBytes != 15 {
|
|
t.Fatalf("TotalBytes=%d, want 15", s.TotalBytes)
|
|
}
|
|
}
|
|
|
|
func TestVerifyCompleteRejectsMissingFiles(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
key := "v"
|
|
dir := c.DirFor(key)
|
|
_ = os.MkdirAll(filepath.Join(dir, "video"), 0o755)
|
|
|
|
// No .complete yet → reject.
|
|
if c.VerifyComplete(key, 2) {
|
|
t.Fatal("VerifyComplete should reject without .complete")
|
|
}
|
|
|
|
// Mark complete but no files → reject.
|
|
if err := c.MarkComplete(key); err != nil {
|
|
t.Fatalf("MarkComplete: %v", err)
|
|
}
|
|
if c.VerifyComplete(key, 2) {
|
|
t.Fatal("VerifyComplete should reject when init.mp4 missing")
|
|
}
|
|
|
|
// Write init.mp4, last seg missing → reject.
|
|
_ = os.WriteFile(filepath.Join(dir, "video", "init.mp4"), []byte("..."), 0o644)
|
|
if c.VerifyComplete(key, 2) {
|
|
t.Fatal("VerifyComplete should reject when last segment missing")
|
|
}
|
|
|
|
// Write last seg → pass.
|
|
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), []byte("..."), 0o644)
|
|
if !c.VerifyComplete(key, 2) {
|
|
t.Fatal("VerifyComplete should pass with all files present")
|
|
}
|
|
|
|
// Zero-size last seg → reject.
|
|
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), nil, 0o644)
|
|
if c.VerifyComplete(key, 2) {
|
|
t.Fatal("VerifyComplete should reject zero-size last segment")
|
|
}
|
|
}
|
|
|
|
func TestSweepRespectsPinnedExceedsBudget(t *testing.T) {
|
|
c := newTestCache(t, 1)
|
|
c.maxBytes = 256 // squeeze
|
|
|
|
pinned := c.DirFor("pinned")
|
|
_ = os.MkdirAll(pinned, 0o755)
|
|
_ = os.WriteFile(filepath.Join(pinned, "x"), make([]byte, 1024), 0o644)
|
|
c.Pin("pinned")
|
|
|
|
freed, err := c.Sweep()
|
|
if err != nil {
|
|
t.Fatalf("Sweep: %v", err)
|
|
}
|
|
if freed != 0 {
|
|
t.Fatalf("nothing should have been freed: got %d", freed)
|
|
}
|
|
if _, err := os.Stat(pinned); err != nil {
|
|
t.Fatal("pinned dir wrongly removed despite over-budget pin")
|
|
}
|
|
}
|