spaxel/mothership/internal/recording/buffer_test.go
jedarden 11ab29836e feat(recording): disk-backed circular buffer with 48h time-based retention
New package mothership/internal/recording implements a disk-backed circular
buffer for continuous CSI frame recording. Frames are persisted in the same
binary format used over WebSocket (raw frame bytes + 8-byte recv timestamp).

Key features:
- Time-based retention (default 48h) prunes expired records automatically
  on each Append and via an explicit Prune() method
- Configurable retention via SPAXEL_RECORDING_RETENTION env var (e.g. "24h")
- ScanRange(from, to time.Time, fn) for time-windowed read-back
- Space-bounded: fixed-size file with circular eviction prevents disk exhaustion
- Crash-safe: 32-byte header (magic + write/oldest/wrap positions) survives restarts
- 18 tests covering write, read-back, time-based pruning, wrap-around,
  crash recovery, ScanRange, env var configuration, and storage bounds

Foundation for Phase 8 time-travel replay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:01:58 -04:00

462 lines
11 KiB
Go

package recording
import (
"os"
"path/filepath"
"testing"
"time"
)
func tempBuffer(t *testing.T, maxMB int, retention time.Duration) (*Buffer, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "test_recording.bin")
b, err := NewBuffer(path, maxMB, retention)
if err != nil {
t.Fatalf("NewBuffer: %v", err)
}
return b, path
}
func makeFrame(size int) []byte {
b := make([]byte, size)
for i := range b {
b[i] = byte(i % 251)
}
return b
}
func TestNewBuffer(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
if b.writePos != headerSize {
t.Errorf("writePos = %d, want %d", b.writePos, headerSize)
}
if b.hasData() {
t.Error("new buffer should be empty")
}
if b.Retention() != time.Hour {
t.Errorf("retention = %v, want %v", b.Retention(), time.Hour)
}
}
func TestAppendAndScan(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
now := time.Now().UnixNano()
frame := makeFrame(152)
for i := 0; i < 5; i++ {
ts := now + int64(i)*int64(time.Millisecond)
if err := b.Append(ts, frame); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
var count int
if err := b.Scan(func(_ int64, f []byte) bool {
count++
if len(f) != len(frame) {
t.Errorf("frame %d: got %d bytes, want %d", count, len(f), len(frame))
}
return true
}); err != nil {
t.Fatalf("Scan: %v", err)
}
if count != 5 {
t.Errorf("scan count = %d, want 5", count)
}
}
func TestScanPreservesOrder(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
base := time.Now().UnixNano()
frame := makeFrame(50)
const n = 10
for i := 0; i < n; i++ {
if err := b.Append(base+int64(i), frame); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
var prev int64 = -1
i := 0
b.Scan(func(recvTimeNS int64, _ []byte) bool {
if recvTimeNS <= prev {
t.Errorf("out-of-order at position %d: %d <= %d", i, recvTimeNS, prev)
}
prev = recvTimeNS
i++
return true
})
if i != n {
t.Errorf("scanned %d records, want %d", i, n)
}
}
func TestTimeBasedPruningOnAppend(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
// Write three frames with timestamps 2 hours ago.
old := time.Now().Add(-2 * time.Hour).UnixNano()
frame := makeFrame(100)
for i := 0; i < 3; i++ {
if err := b.Append(old+int64(i), frame); err != nil {
t.Fatalf("old Append %d: %v", i, err)
}
}
// Appending a fresh frame should prune the 2-hour-old frames
// (cutoff = now - 1h, so frames at now-2h are evicted).
nowNS := time.Now().UnixNano()
if err := b.Append(nowNS, frame); err != nil {
t.Fatalf("new Append: %v", err)
}
var count int
if err := b.Scan(func(_ int64, _ []byte) bool {
count++
return true
}); err != nil {
t.Fatalf("Scan: %v", err)
}
if count != 1 {
t.Errorf("after time-based pruning, scan count = %d, want 1", count)
}
}
func TestExplicitPrune(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
// Write frames with timestamps 2 hours ago.
old := time.Now().Add(-2 * time.Hour).UnixNano()
frame := makeFrame(100)
for i := 0; i < 3; i++ {
if err := b.Append(old+int64(i), frame); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
if err := b.Prune(); err != nil {
t.Fatalf("Prune: %v", err)
}
var count int
b.Scan(func(_ int64, _ []byte) bool {
count++
return true
})
if count != 0 {
t.Errorf("after explicit Prune, count = %d, want 0", count)
}
if b.hasData() {
t.Error("hasData should be false after pruning all records")
}
}
func TestScanRange(t *testing.T) {
// Use 24h retention so no frames get pruned during the test.
b, _ := tempBuffer(t, 1, 24*time.Hour)
defer b.Close()
// Use a fixed base time so the test is deterministic.
base := time.Unix(1_000_000, 0)
frame := makeFrame(100)
const n = 10
for i := 0; i < n; i++ {
ts := base.Add(time.Duration(i) * time.Hour)
if err := b.Append(ts.UnixNano(), frame); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
// Query hours [2, 5] inclusive — expect 4 records.
from := base.Add(2 * time.Hour)
to := base.Add(5 * time.Hour)
var got []int64
if err := b.ScanRange(from, to, func(recvTimeNS int64, _ []byte) bool {
got = append(got, recvTimeNS)
return true
}); err != nil {
t.Fatalf("ScanRange: %v", err)
}
if len(got) != 4 {
t.Fatalf("ScanRange count = %d, want 4", len(got))
}
for i, ts := range got {
want := base.Add(time.Duration(i+2) * time.Hour).UnixNano()
if ts != want {
t.Errorf("got[%d] = %d, want %d", i, ts, want)
}
}
}
func TestScanRangeBeforeData(t *testing.T) {
b, _ := tempBuffer(t, 1, 24*time.Hour)
defer b.Close()
base := time.Unix(1_000_000, 0)
frame := makeFrame(50)
for i := 0; i < 3; i++ {
b.Append(base.Add(time.Duration(i)*time.Minute).UnixNano(), frame)
}
// Query entirely before the data.
var count int
b.ScanRange(base.Add(-10*time.Minute), base.Add(-1*time.Minute), func(_ int64, _ []byte) bool {
count++
return true
})
if count != 0 {
t.Errorf("count for query before data = %d, want 0", count)
}
}
func TestScanRangeAfterData(t *testing.T) {
b, _ := tempBuffer(t, 1, 24*time.Hour)
defer b.Close()
base := time.Unix(1_000_000, 0)
frame := makeFrame(50)
for i := 0; i < 3; i++ {
b.Append(base.Add(time.Duration(i)*time.Minute).UnixNano(), frame)
}
// Query entirely after the data.
var count int
b.ScanRange(base.Add(10*time.Minute), base.Add(20*time.Minute), func(_ int64, _ []byte) bool {
count++
return true
})
if count != 0 {
t.Errorf("count for query after data = %d, want 0", count)
}
}
func TestScanRangeInvalidRange(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
now := time.Now()
err := b.ScanRange(now.Add(time.Hour), now, func(_ int64, _ []byte) bool { return true })
if err == nil {
t.Error("expected error for from > to")
}
}
func TestScanRangeEarlyStop(t *testing.T) {
b, _ := tempBuffer(t, 1, 24*time.Hour)
defer b.Close()
base := time.Unix(1_000_000, 0)
frame := makeFrame(50)
for i := 0; i < 5; i++ {
b.Append(base.Add(time.Duration(i)*time.Minute).UnixNano(), frame)
}
// Stop after first record.
var count int
b.ScanRange(base, base.Add(10*time.Minute), func(_ int64, _ []byte) bool {
count++
return count < 2 // stop after second record
})
if count != 2 {
t.Errorf("early-stop scan returned %d records, want 2", count)
}
}
func TestCrashRecovery(t *testing.T) {
frame := makeFrame(152)
dir := t.TempDir()
path := filepath.Join(dir, "recording.bin")
b1, err := NewBuffer(path, 1, time.Hour)
if err != nil {
t.Fatal(err)
}
now := time.Now().UnixNano()
for i := 0; i < 3; i++ {
if err := b1.Append(now+int64(i), frame); err != nil {
t.Fatalf("b1.Append %d: %v", i, err)
}
}
savedWrite := b1.writePos
savedOldest := b1.oldestPos
b1.Close()
// Reopen should restore state from the header.
b2, err := NewBuffer(path, 1, time.Hour)
if err != nil {
t.Fatal(err)
}
defer b2.Close()
if b2.writePos != savedWrite {
t.Errorf("writePos after reopen = %d, want %d", b2.writePos, savedWrite)
}
if b2.oldestPos != savedOldest {
t.Errorf("oldestPos after reopen = %d, want %d", b2.oldestPos, savedOldest)
}
// Data should be readable after reopen.
var count int
b2.Scan(func(_ int64, _ []byte) bool {
count++
return true
})
if count != 3 {
t.Errorf("after reopen, scan count = %d, want 3", count)
}
}
func TestWrapAround(t *testing.T) {
b, _ := tempBuffer(t, 1, 48*time.Hour)
defer b.Close()
frame := makeFrame(152)
recordSize := recordOverhead + int64(len(frame))
dataArea := b.fileSize - headerSize
recsBeforeWrap := int(dataArea / recordSize)
now := time.Now().UnixNano()
for i := 0; i < recsBeforeWrap; i++ {
if err := b.Append(now+int64(i), frame); err != nil {
t.Fatalf("Append %d before wrap: %v", i, err)
}
}
beforeWrapPos := b.writePos
if err := b.Append(now+int64(recsBeforeWrap), frame); err != nil {
t.Fatalf("Append triggering wrap: %v", err)
}
if b.writePos >= beforeWrapPos {
t.Errorf("writePos %d should have wrapped (was %d before wrap)", b.writePos, beforeWrapPos)
}
}
func TestStorageBounded(t *testing.T) {
b, _ := tempBuffer(t, 1, 48*time.Hour)
defer b.Close()
frame := makeFrame(100)
recordSize := recordOverhead + int64(len(frame))
dataArea := b.fileSize - headerSize
count := int(dataArea/recordSize) + 100 // force many wraps + evictions
now := time.Now().UnixNano()
for i := 0; i < count; i++ {
if err := b.Append(now+int64(i), frame); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
if b.writePos < headerSize || b.writePos > b.fileSize {
t.Errorf("writePos %d out of range [%d, %d]", b.writePos, headerSize, b.fileSize)
}
}
func TestRetentionEnvVar(t *testing.T) {
t.Setenv(RetentionEnvVar, "24h")
// Pass 0 so that the env var is the only non-default source.
b, _ := tempBuffer(t, 1, 0)
defer b.Close()
if b.Retention() != 24*time.Hour {
t.Errorf("retention = %v, want 24h (from env var)", b.Retention())
}
}
func TestRetentionEnvVarInvalidFallsBack(t *testing.T) {
t.Setenv(RetentionEnvVar, "not-a-duration")
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
// Invalid env var should fall back to the parameter value.
if b.Retention() != time.Hour {
t.Errorf("retention = %v, want 1h (fallback to parameter)", b.Retention())
}
}
func TestInvalidMagicStartsFresh(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.bin")
if err := os.WriteFile(path, []byte("GARBAGE_DATA_123456789012345678901234567890"), 0644); err != nil {
t.Fatal(err)
}
b, err := NewBuffer(path, 1, time.Hour)
if err != nil {
t.Fatalf("should recover from bad magic: %v", err)
}
defer b.Close()
if b.writePos != headerSize {
t.Errorf("writePos = %d after bad magic, want %d", b.writePos, headerSize)
}
if b.hasData() {
t.Error("buffer from bad-magic file should start empty")
}
}
func TestFrameTooLarge(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
oversized := make([]byte, maxFrameBytes+1)
if err := b.Append(0, oversized); err == nil {
t.Error("expected error for oversized frame")
}
}
func TestScanReadBackData(t *testing.T) {
b, _ := tempBuffer(t, 1, time.Hour)
defer b.Close()
base := time.Now().UnixNano()
frames := [][]byte{
makeFrame(24), // header-only (0 subcarriers)
makeFrame(152), // 64 subcarriers
makeFrame(280), // 128 subcarriers (max)
}
for i, f := range frames {
if err := b.Append(base+int64(i), f); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
var idx int
if err := b.Scan(func(recvTimeNS int64, f []byte) bool {
wantTS := base + int64(idx)
if recvTimeNS != wantTS {
t.Errorf("record %d: recvTimeNS = %d, want %d", idx, recvTimeNS, wantTS)
}
if len(f) != len(frames[idx]) {
t.Errorf("record %d: len = %d, want %d", idx, len(f), len(frames[idx]))
}
for j := range f {
if f[j] != frames[idx][j] {
t.Errorf("record %d byte %d: got %d, want %d", idx, j, f[j], frames[idx][j])
break
}
}
idx++
return true
}); err != nil {
t.Fatalf("Scan: %v", err)
}
if idx != len(frames) {
t.Errorf("scanned %d records, want %d", idx, len(frames))
}
}