Files
docker-agent/pkg/cache/cache_test.go
T
David Gageot 59bb1afa28 docs(cache): document response cache and harden file write durability
Add a "Response Cache" section to the agent configuration reference and
strengthen the file-backed cache so writes are not just atomic but also
durable.

- docs/configuration/agents/index.md: extend the schema example and
  property table with the new `cache` field, and add a dedicated
  "Response Cache" section describing the matching options
  (case_sensitive, trim_spaces), the storage backends (in-memory vs
  JSON file), and the atomicity guarantees of the file-backed store.
- pkg/cache: fsync the temp file before rename and fsync the parent
  directory after rename so the rename itself is persisted across an
  OS crash. Concurrent readers are still guaranteed to see either the
  previous or the new content in full, never a torn write.
- pkg/cache: add tests asserting that the success path leaves no
  leftover temp files and that a parallel reader never observes a
  partially written cache file under sustained concurrent stores.

Assisted-By: docker-agent
2026-04-28 09:19:47 +02:00

197 lines
4.9 KiB
Go

package cache
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_disabled(t *testing.T) {
c, err := New(Config{Enabled: false})
require.NoError(t, err)
assert.Nil(t, c)
}
func TestMemoryCache_caseSensitiveDefault(t *testing.T) {
c, err := New(Config{Enabled: true, CaseSensitive: true})
require.NoError(t, err)
require.NotNil(t, c)
c.Store("Hello", "world")
got, ok := c.Lookup("Hello")
assert.True(t, ok)
assert.Equal(t, "world", got)
_, ok = c.Lookup("hello")
assert.False(t, ok, "case-sensitive cache should not match different case")
}
func TestMemoryCache_caseInsensitive(t *testing.T) {
c, err := New(Config{Enabled: true, CaseSensitive: false})
require.NoError(t, err)
c.Store("Hello", "world")
got, ok := c.Lookup("HELLO")
assert.True(t, ok)
assert.Equal(t, "world", got)
}
func TestMemoryCache_trimSpaces(t *testing.T) {
c, err := New(Config{Enabled: true, TrimSpaces: true})
require.NoError(t, err)
c.Store(" hello ", "world")
got, ok := c.Lookup("hello")
assert.True(t, ok)
assert.Equal(t, "world", got)
got, ok = c.Lookup("\thello\n")
assert.True(t, ok)
assert.Equal(t, "world", got)
}
func TestMemoryCache_noTrimByDefault(t *testing.T) {
c, err := New(Config{Enabled: true})
require.NoError(t, err)
c.Store(" hello ", "world")
_, ok := c.Lookup("hello")
assert.False(t, ok, "without TrimSpaces, whitespace must be significant")
}
func TestMemoryCache_overwrite(t *testing.T) {
c, err := New(Config{Enabled: true})
require.NoError(t, err)
c.Store("q", "first")
c.Store("q", "second")
got, ok := c.Lookup("q")
assert.True(t, ok)
assert.Equal(t, "second", got)
}
func TestFileCache_persists(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.json")
c1, err := New(Config{Enabled: true, Path: path, CaseSensitive: false, TrimSpaces: true})
require.NoError(t, err)
c1.Store(" Hello ", "world")
// File must exist on disk and contain the normalized key.
data, err := os.ReadFile(path)
require.NoError(t, err)
var entries map[string]string
require.NoError(t, json.Unmarshal(data, &entries))
assert.Equal(t, map[string]string{"hello": "world"}, entries)
// A new cache loaded from the same file recovers the entries.
c2, err := New(Config{Enabled: true, Path: path, CaseSensitive: false, TrimSpaces: true})
require.NoError(t, err)
got, ok := c2.Lookup("HELLO")
assert.True(t, ok)
assert.Equal(t, "world", got)
}
func TestFileCache_missingFileIsFine(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "cache.json")
c, err := New(Config{Enabled: true, Path: path})
require.NoError(t, err)
_, ok := c.Lookup("anything")
assert.False(t, ok)
c.Store("hello", "world")
got, ok := c.Lookup("hello")
assert.True(t, ok)
assert.Equal(t, "world", got)
// And the directory should have been created.
_, err = os.Stat(path)
assert.NoError(t, err)
}
func TestFileCache_corruptFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.json")
require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600))
_, err := New(Config{Enabled: true, Path: path})
assert.Error(t, err)
}
// TestFileCache_atomicWriteLeavesNoTempFiles verifies that the rename-based
// atomic write does not leak temporary files on the happy path.
func TestFileCache_atomicWriteLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.json")
c, err := New(Config{Enabled: true, Path: path})
require.NoError(t, err)
for i := range 5 {
c.Store(fmt.Sprintf("q%d", i), fmt.Sprintf("a%d", i))
}
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, e := range entries {
assert.Equal(t, "cache.json", e.Name(),
"unexpected leftover in cache directory: %q", e.Name())
}
}
// TestFileCache_concurrentStoreNeverYieldsTornFile verifies that concurrent
// Store calls always leave a fully valid JSON file behind — i.e. a parallel
// reader will never observe a half-written cache thanks to the
// rename-over-temp atomicity.
func TestFileCache_concurrentStoreNeverYieldsTornFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cache.json")
c, err := New(Config{Enabled: true, Path: path})
require.NoError(t, err)
done := make(chan struct{})
go func() {
defer close(done)
for i := range 50 {
c.Store(fmt.Sprintf("q%d", i), fmt.Sprintf("a%d", i))
}
}()
// While writes are happening, repeatedly read and parse the file.
// Without atomic rename, this would intermittently see truncated /
// half-written content and json.Unmarshal would error.
for range 100 {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
continue
}
require.NoError(t, err)
if len(data) == 0 {
continue
}
var m map[string]string
require.NoError(t, json.Unmarshal(data, &m),
"reader observed a torn write: %q", string(data))
}
<-done
}