Skip to content

Commit 0c69f8e

Browse files
committed
feat(retrieval): in-memory LRU replay store + retrieval.replay config
LRUReplayStore is a thin facade over pkg/cache.LRU that maps trace tokens to ReplayEntry values (DocumentID + Query + Model + SelectedIDs + raw ResponseJSON bytes + CreatedAt). The store is safe for concurrent Put/Get, bounds itself by MaxEntries (default 1024) and expires entries past TTL (default 24h). ReplayEntry.ResponseJSON is the literal bytes of the original response — replay returns these verbatim so the byte-exact guarantee holds regardless of how the response is constructed. Go's encoding/json already sorts map keys lexicographically, but storing raw bytes removes any future doubt about determinism. retrieval.replay config block ships with Enabled=true: replay is the moat versus stateless vector RAG and should be on by default. Operators can opt out via retrieval.replay.enabled=false or VLE_RETRIEVAL_REPLAY_ENABLED=false. Capacity / TTL tune via VLE_RETRIEVAL_REPLAY_MAX_ENTRIES and VLE_RETRIEVAL_REPLAY_TTL_SECONDS. Tests cover Put/Get, miss, empty-token safety, LRU eviction at capacity, TTL expiry, in-place update, byte-exactness on tricky payloads (whitespace + unicode), default-TTL non-zero, and a parallel hammer that surfaces races under go test -race. Not durable across process restarts — Phase 3.2 will replace this with persistent storage + per-document versioning. The interface abstraction (ReplayStore) lets that swap happen without touching handlers.
1 parent 1492e6d commit 0c69f8e

4 files changed

Lines changed: 561 additions & 0 deletions

File tree

pkg/config/config.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,31 @@ type RetrievalConfig struct {
205205
Answer AnswerBlock `yaml:"answer"`
206206
Planning PlanningBlock `yaml:"planning"`
207207
ReRank ReRankBlock `yaml:"rerank"`
208+
Replay ReplayBlock `yaml:"replay"`
209+
}
210+
211+
// ReplayBlock configures the Phase 3.1 replay-trace store.
212+
//
213+
// When enabled, every /v1/query and /v1/answer response is stamped
214+
// with a deterministic trace_token and the response body is stored
215+
// in an in-memory LRU. Callers can POST /v1/replay with the token
216+
// (plus the original query + document_id) to retrieve the byte-
217+
// identical response.
218+
//
219+
// The store is opt-out — replay is a moat versus stateless vector
220+
// RAG and should ship on by default. Disable to free the memory
221+
// budget when audit/replay isn't part of the operator's flow.
222+
type ReplayBlock struct {
223+
// Enabled turns the replay store on. Default: true.
224+
Enabled bool `yaml:"enabled"`
225+
226+
// MaxEntries bounds the in-memory LRU. Default: 1024.
227+
MaxEntries int `yaml:"max_entries"`
228+
229+
// TTLSeconds is how long a replay entry remains valid. Default:
230+
// 86400 (24h). Long-running audit flows may want to bump this;
231+
// short-TTL deployments save memory.
232+
TTLSeconds int `yaml:"ttl_seconds"`
208233
}
209234

210235
// ReRankBlock configures the Phase 2.3 content-aware re-rank pass.
@@ -411,6 +436,11 @@ func Default() Config {
411436
MaxContentChars: 2000,
412437
TopK: 0,
413438
},
439+
Replay: ReplayBlock{
440+
Enabled: true,
441+
MaxEntries: 1024,
442+
TTLSeconds: 86400,
443+
},
414444
},
415445
Ingest: IngestConfig{
416446
GlobalLLMConcurrency: 12,
@@ -620,6 +650,24 @@ func applyEnvOverrides(c *Config) {
620650
c.Retrieval.ReRank.TopK = n
621651
}
622652
}
653+
if v := os.Getenv("VLE_RETRIEVAL_REPLAY_ENABLED"); v != "" {
654+
switch strings.ToLower(strings.TrimSpace(v)) {
655+
case "1", "true", "yes", "on":
656+
c.Retrieval.Replay.Enabled = true
657+
case "0", "false", "no", "off":
658+
c.Retrieval.Replay.Enabled = false
659+
}
660+
}
661+
if v := os.Getenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES"); v != "" {
662+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
663+
c.Retrieval.Replay.MaxEntries = n
664+
}
665+
}
666+
if v := os.Getenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS"); v != "" {
667+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
668+
c.Retrieval.Replay.TTLSeconds = n
669+
}
670+
}
623671
}
624672

625673
// Validate checks that required fields for the selected drivers are set.
@@ -713,5 +761,12 @@ func (c Config) Validate() error {
713761
return fmt.Errorf("retrieval.rerank.top_k must be >= 0, got %d", c.Retrieval.ReRank.TopK)
714762
}
715763

764+
if c.Retrieval.Replay.MaxEntries < 0 {
765+
return fmt.Errorf("retrieval.replay.max_entries must be >= 0, got %d", c.Retrieval.Replay.MaxEntries)
766+
}
767+
if c.Retrieval.Replay.TTLSeconds < 0 {
768+
return fmt.Errorf("retrieval.replay.ttl_seconds must be >= 0, got %d", c.Retrieval.Replay.TTLSeconds)
769+
}
770+
716771
return nil
717772
}

pkg/config/config_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,110 @@ func TestDefaultValues(t *testing.T) {
5555
if cfg.Retrieval.ReRank.TopK != 0 {
5656
t.Errorf("retrieval.rerank.top_k = %d, want 0 (keep all)", cfg.Retrieval.ReRank.TopK)
5757
}
58+
if !cfg.Retrieval.Replay.Enabled {
59+
t.Error("retrieval.replay.enabled should default to true (opt-out)")
60+
}
61+
if cfg.Retrieval.Replay.MaxEntries != 1024 {
62+
t.Errorf("retrieval.replay.max_entries = %d, want 1024", cfg.Retrieval.Replay.MaxEntries)
63+
}
64+
if cfg.Retrieval.Replay.TTLSeconds != 86400 {
65+
t.Errorf("retrieval.replay.ttl_seconds = %d, want 86400 (24h)", cfg.Retrieval.Replay.TTLSeconds)
66+
}
5867
if cfg.Log.Level != "info" {
5968
t.Errorf("log.level = %q, want info", cfg.Log.Level)
6069
}
6170
}
6271

72+
func TestReplayEnvOverride(t *testing.T) {
73+
// Not parallel — mutates env. Restore on exit.
74+
prevEnabled := os.Getenv("VLE_RETRIEVAL_REPLAY_ENABLED")
75+
prevMax := os.Getenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES")
76+
prevTTL := os.Getenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS")
77+
defer func() {
78+
os.Setenv("VLE_RETRIEVAL_REPLAY_ENABLED", prevEnabled)
79+
os.Setenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES", prevMax)
80+
os.Setenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS", prevTTL)
81+
}()
82+
83+
os.Setenv("VLE_RETRIEVAL_REPLAY_ENABLED", "false")
84+
os.Setenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES", "256")
85+
os.Setenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS", "3600")
86+
87+
cfg := Default()
88+
applyEnvOverrides(&cfg)
89+
90+
if cfg.Retrieval.Replay.Enabled {
91+
t.Error("VLE_RETRIEVAL_REPLAY_ENABLED=false should disable replay")
92+
}
93+
if cfg.Retrieval.Replay.MaxEntries != 256 {
94+
t.Errorf("replay max_entries = %d, want 256", cfg.Retrieval.Replay.MaxEntries)
95+
}
96+
if cfg.Retrieval.Replay.TTLSeconds != 3600 {
97+
t.Errorf("replay ttl_seconds = %d, want 3600", cfg.Retrieval.Replay.TTLSeconds)
98+
}
99+
}
100+
101+
func TestReplayEnvOverrideEnable(t *testing.T) {
102+
// Toggle on via env from an explicitly-disabled starting state.
103+
prev := os.Getenv("VLE_RETRIEVAL_REPLAY_ENABLED")
104+
defer os.Setenv("VLE_RETRIEVAL_REPLAY_ENABLED", prev)
105+
106+
cfg := Default()
107+
cfg.Retrieval.Replay.Enabled = false
108+
os.Setenv("VLE_RETRIEVAL_REPLAY_ENABLED", "true")
109+
applyEnvOverrides(&cfg)
110+
if !cfg.Retrieval.Replay.Enabled {
111+
t.Error("VLE_RETRIEVAL_REPLAY_ENABLED=true should enable replay even when disabled in YAML")
112+
}
113+
}
114+
115+
func TestReplayEnvOverrideRejectsBad(t *testing.T) {
116+
prevMax := os.Getenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES")
117+
prevTTL := os.Getenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS")
118+
defer func() {
119+
os.Setenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES", prevMax)
120+
os.Setenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS", prevTTL)
121+
}()
122+
123+
os.Setenv("VLE_RETRIEVAL_REPLAY_MAX_ENTRIES", "not-a-number")
124+
os.Setenv("VLE_RETRIEVAL_REPLAY_TTL_SECONDS", "wat")
125+
126+
cfg := Default()
127+
applyEnvOverrides(&cfg)
128+
if cfg.Retrieval.Replay.MaxEntries != 1024 {
129+
t.Errorf("bad max_entries env should preserve default, got %d", cfg.Retrieval.Replay.MaxEntries)
130+
}
131+
if cfg.Retrieval.Replay.TTLSeconds != 86400 {
132+
t.Errorf("bad ttl_seconds env should preserve default, got %d", cfg.Retrieval.Replay.TTLSeconds)
133+
}
134+
}
135+
136+
func TestValidateReplayNegatives(t *testing.T) {
137+
t.Parallel()
138+
139+
cfg := Default()
140+
cfg.Database.URL = "postgres://localhost/test"
141+
cfg.Retrieval.Replay.MaxEntries = -1
142+
if err := cfg.Validate(); err == nil {
143+
t.Error("negative replay max_entries should fail validation")
144+
}
145+
146+
cfg2 := Default()
147+
cfg2.Database.URL = "postgres://localhost/test"
148+
cfg2.Retrieval.Replay.TTLSeconds = -1
149+
if err := cfg2.Validate(); err == nil {
150+
t.Error("negative replay ttl_seconds should fail validation")
151+
}
152+
153+
cfg3 := Default()
154+
cfg3.Database.URL = "postgres://localhost/test"
155+
cfg3.Retrieval.Replay.MaxEntries = 0
156+
cfg3.Retrieval.Replay.TTLSeconds = 0
157+
if err := cfg3.Validate(); err != nil {
158+
t.Errorf("zero replay values should pass validation (use defaults at runtime): %v", err)
159+
}
160+
}
161+
63162
func TestReRankEnvOverride(t *testing.T) {
64163
// Not parallel — mutates env. Restore on exit.
65164
prevEnabled := os.Getenv("VLE_RETRIEVAL_RERANK_ENABLED")

pkg/retrieval/replay.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package retrieval
2+
3+
import (
4+
"sync"
5+
"time"
6+
7+
"github.com/hallelx2/vectorless-engine/pkg/cache"
8+
"github.com/hallelx2/vectorless-engine/pkg/tree"
9+
)
10+
11+
// ReplayStore is the persistence interface for replay-trace entries.
12+
// Implementations MUST be safe for concurrent use; the HTTP layer
13+
// writes from /v1/query and /v1/answer handlers and reads from
14+
// /v1/replay handlers at the same time.
15+
type ReplayStore interface {
16+
// Get retrieves a stored replay entry by trace token. Returns
17+
// (zero, false) when the token is unknown or the entry has been
18+
// evicted (LRU pressure or TTL).
19+
Get(token string) (ReplayEntry, bool)
20+
21+
// Put stores a replay entry under the given trace token. If the
22+
// token is already present, the entry replaces it.
23+
Put(token string, entry ReplayEntry)
24+
}
25+
26+
// ReplayEntry is what /v1/replay needs to reproduce a prior response
27+
// byte-for-byte. ResponseJSON is the literal bytes of the original
28+
// HTTP response — storing the marshalled response (not the underlying
29+
// Go struct) is what guarantees byte-exact replay. Go's
30+
// encoding/json sorts map keys lexicographically, so re-encoding the
31+
// same map would already be deterministic, but persisting raw bytes
32+
// removes any future doubt: it doesn't matter how the response is
33+
// constructed, only what was actually returned.
34+
//
35+
// The remaining fields exist so the replay handler can validate the
36+
// caller's claim — body.query and body.document_id must match
37+
// entry.Query and entry.DocumentID, otherwise the engine returns 409
38+
// with a specific reason.
39+
type ReplayEntry struct {
40+
// DocumentID is the document the original request targeted.
41+
DocumentID tree.DocumentID
42+
43+
// Query is the user query the original request carried.
44+
Query string
45+
46+
// Model is the resolved LLM model name (after defaults). Stored
47+
// so future versions can validate model claims; today the trace
48+
// token already encodes the model, so a mismatch surfaces as
49+
// "trace_token not found" rather than a 409.
50+
Model string
51+
52+
// SelectedIDs is the set of section IDs the strategy picked.
53+
// Stored for debugging and observability; not used by the replay
54+
// handler today (the response bytes already contain the IDs).
55+
SelectedIDs []tree.SectionID
56+
57+
// ResponseJSON is the literal response body sent over the wire.
58+
// The replay handler writes this back verbatim with
59+
// Content-Type: application/json.
60+
ResponseJSON []byte
61+
62+
// CreatedAt is when this entry was stored. Surfaced in logs.
63+
CreatedAt time.Time
64+
}
65+
66+
// LRUReplayStore is an in-memory, TTL-aware, size-bounded replay
67+
// store. It is a thin facade over pkg/cache.LRU: that cache is
68+
// general-purpose (stores any) and concurrency-safe, so wrapping it
69+
// avoids a duplicate LRU implementation. The facade enforces the
70+
// ReplayEntry value type so callers cannot accidentally store the
71+
// wrong shape.
72+
//
73+
// Capacity and TTL are configured at construction. Default capacity
74+
// is 1024 entries, default TTL is 24h — sufficient for any realistic
75+
// replay flow (audit, regression, debugging) while preventing
76+
// unbounded memory growth.
77+
//
78+
// LRUReplayStore is NOT durable across process restarts. This is an
79+
// intentional v1 limitation — the Phase 3.2 plan calls out persistent
80+
// storage (Postgres-backed replay log + document versioning) as the
81+
// next step.
82+
type LRUReplayStore struct {
83+
// mu guards ttl; the underlying cache is already lock-free for
84+
// callers because pkg/cache.LRU has its own internal mutex.
85+
mu sync.RWMutex
86+
ttl time.Duration
87+
88+
c *cache.LRU
89+
}
90+
91+
// Compile-time interface check.
92+
var _ ReplayStore = (*LRUReplayStore)(nil)
93+
94+
// LRUReplayConfig is the configuration for an LRUReplayStore.
95+
type LRUReplayConfig struct {
96+
// MaxEntries bounds the number of stored replay entries. Zero
97+
// defaults to 1024.
98+
MaxEntries int
99+
100+
// TTL is how long an entry remains valid. Zero defaults to 24
101+
// hours.
102+
TTL time.Duration
103+
}
104+
105+
// NewLRUReplayStore constructs an in-memory replay store with the
106+
// given capacity and TTL. Zero values fall back to defaults.
107+
func NewLRUReplayStore(cfg LRUReplayConfig) *LRUReplayStore {
108+
ttl := cfg.TTL
109+
if ttl <= 0 {
110+
ttl = 24 * time.Hour
111+
}
112+
return &LRUReplayStore{
113+
ttl: ttl,
114+
c: cache.NewLRU(cfg.MaxEntries),
115+
}
116+
}
117+
118+
// Get implements ReplayStore.
119+
func (s *LRUReplayStore) Get(token string) (ReplayEntry, bool) {
120+
if token == "" {
121+
return ReplayEntry{}, false
122+
}
123+
v, ok := s.c.Get(token)
124+
if !ok {
125+
return ReplayEntry{}, false
126+
}
127+
entry, ok := v.(ReplayEntry)
128+
if !ok {
129+
// Defensive: should never happen because Put only stores
130+
// ReplayEntry, but a corrupt entry shouldn't panic the
131+
// handler. Treat as a miss.
132+
return ReplayEntry{}, false
133+
}
134+
return entry, true
135+
}
136+
137+
// Put implements ReplayStore.
138+
func (s *LRUReplayStore) Put(token string, entry ReplayEntry) {
139+
if token == "" {
140+
return
141+
}
142+
s.mu.RLock()
143+
ttl := s.ttl
144+
s.mu.RUnlock()
145+
s.c.Set(token, entry, ttl)
146+
}
147+
148+
// Len reports the current number of entries (including expired-but-
149+
// not-yet-evicted entries). Primarily for tests and metrics.
150+
func (s *LRUReplayStore) Len() int {
151+
return s.c.Len()
152+
}

0 commit comments

Comments
 (0)