mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 21:03:59 +00:00
8d37dc45ea
Ship Group A (bug fixes) + Group B (pipeline enhancements) from plans/260410-1009-openclaw-ts-feature-port — five changes that share cmd/ wiring and pipeline plumbing so they commit as a unit. cache: InMemoryCache now supports periodic sweep + max-size cap via variadic options. PermissionCache wires 60s sweep + 10k entry cap + Close() hook in gateway shutdown so long-running gateways don't leak per-user permission entries. Backward compatible for zero-arg callers. store: ContactCollector.seen cache key now includes tenant_id + channel_ instance so the same sender in different tenants (or different bot instances in the same tenant) no longer silently skip upserts against each other. Zero-tenant (Desktop) behaviour preserved. pipeline: EffectiveContextWindow is resolved once per run in ContextStage via a ResolveContextWindow callback (backed by providers.ModelRegistry) so PruneStage bills history against the actual model window instead of a stale static config. Nil resolver / unknown model fall back to Config.ContextWindow for backward compatibility. Locked to the model observed at context build time to prevent mid-run budget drift. pipeline: PipelineConfig.ReserveTokens carves out an optional safety buffer subtracted from the history budget so compaction fires slightly before the hard limit — protects against provider over-delivery and token counter drift on streaming responses. Zero (default) preserves legacy budget math. agent: ModelRegistry flows gateway → ResolverDeps → LoopConfig → Loop → pipeline adapter so resolver can look up per-model capabilities at run time without re-touching gateway internals. 20 regression tests across cache, contact collector, and pipeline cover the critical paths: cross-tenant isolation, cache sweep + eviction + Close idempotency, per-model window override + fallback, and reserve token buffer behaviour. All passing with -race on both go build ./... and go build -tags sqliteonly ./....
197 lines
4.7 KiB
Go
197 lines
4.7 KiB
Go
package cache
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInMemoryCache_GetSet(t *testing.T) {
|
|
c := NewInMemoryCache[string]()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "hello", "world", 0)
|
|
val, ok := c.Get(ctx, "hello")
|
|
if !ok {
|
|
t.Fatal("expected key to exist")
|
|
}
|
|
if val != "world" {
|
|
t.Fatalf("expected 'world', got %q", val)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryCache_TTLExpiry(t *testing.T) {
|
|
c := NewInMemoryCache[int]()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "key", 42, 20*time.Millisecond)
|
|
|
|
// Should be present immediately
|
|
val, ok := c.Get(ctx, "key")
|
|
if !ok || val != 42 {
|
|
t.Fatal("expected key to be present before TTL expiry")
|
|
}
|
|
|
|
time.Sleep(40 * time.Millisecond)
|
|
|
|
_, ok = c.Get(ctx, "key")
|
|
if ok {
|
|
t.Fatal("expected key to be expired after TTL")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryCache_Delete(t *testing.T) {
|
|
c := NewInMemoryCache[string]()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "k", "v", 0)
|
|
c.Delete(ctx, "k")
|
|
|
|
_, ok := c.Get(ctx, "k")
|
|
if ok {
|
|
t.Fatal("expected key to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryCache_DeleteByPrefix(t *testing.T) {
|
|
c := NewInMemoryCache[string]()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "agent:1:files", "a", 0)
|
|
c.Set(ctx, "agent:1:meta", "b", 0)
|
|
c.Set(ctx, "agent:2:files", "c", 0)
|
|
|
|
c.DeleteByPrefix(ctx, "agent:1:")
|
|
|
|
if _, ok := c.Get(ctx, "agent:1:files"); ok {
|
|
t.Error("agent:1:files should be deleted")
|
|
}
|
|
if _, ok := c.Get(ctx, "agent:1:meta"); ok {
|
|
t.Error("agent:1:meta should be deleted")
|
|
}
|
|
if _, ok := c.Get(ctx, "agent:2:files"); !ok {
|
|
t.Error("agent:2:files should still exist")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryCache_Clear(t *testing.T) {
|
|
c := NewInMemoryCache[int]()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "a", 1, 0)
|
|
c.Set(ctx, "b", 2, 0)
|
|
c.Set(ctx, "c", 3, 0)
|
|
|
|
c.Clear(ctx)
|
|
|
|
for _, k := range []string{"a", "b", "c"} {
|
|
if _, ok := c.Get(ctx, k); ok {
|
|
t.Errorf("key %q should be cleared", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInMemoryCache_PeriodicSweep verifies expired entries are removed by
|
|
// the background sweep goroutine (not just lazy on Get).
|
|
func TestInMemoryCache_PeriodicSweep(t *testing.T) {
|
|
c := NewInMemoryCache[string](
|
|
WithSweepInterval[string](20*time.Millisecond),
|
|
)
|
|
defer c.Close()
|
|
ctx := context.Background()
|
|
|
|
c.Set(ctx, "short", "v1", 10*time.Millisecond)
|
|
c.Set(ctx, "long", "v2", 0) // no expiry
|
|
|
|
// Wait for sweep to run at least once after short entry expires
|
|
time.Sleep(60 * time.Millisecond)
|
|
|
|
// Short entry should be removed by sweep even without Get
|
|
if count := c.sizeLocked(); count != 1 {
|
|
t.Errorf("expected 1 entry after sweep, got %d", count)
|
|
}
|
|
if _, ok := c.Get(ctx, "long"); !ok {
|
|
t.Error("long-lived entry should still exist")
|
|
}
|
|
}
|
|
|
|
// TestInMemoryCache_MaxSizeEviction verifies oldest entries are evicted when
|
|
// max size cap is reached.
|
|
func TestInMemoryCache_MaxSizeEviction(t *testing.T) {
|
|
c := NewInMemoryCache[int](
|
|
WithMaxSize[int](5),
|
|
WithSweepInterval[int](10*time.Millisecond),
|
|
)
|
|
defer c.Close()
|
|
ctx := context.Background()
|
|
|
|
// Insert 10 entries with distinct creation times to ensure oldest-first ordering
|
|
for i := 0; i < 10; i++ {
|
|
c.Set(ctx, string(rune('a'+i)), i, 0)
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
|
|
// Trigger sweep by waiting for interval
|
|
time.Sleep(30 * time.Millisecond)
|
|
|
|
if count := c.sizeLocked(); count > 5 {
|
|
t.Errorf("expected size ≤ 5 after max-size eviction, got %d", count)
|
|
}
|
|
}
|
|
|
|
// TestInMemoryCache_Close verifies Close stops the sweep goroutine (no leak).
|
|
func TestInMemoryCache_Close(t *testing.T) {
|
|
c := NewInMemoryCache[string](
|
|
WithSweepInterval[string](10*time.Millisecond),
|
|
)
|
|
ctx := context.Background()
|
|
c.Set(ctx, "k", "v", 0)
|
|
|
|
c.Close()
|
|
// Close should be idempotent
|
|
c.Close()
|
|
|
|
// After Close, cache is still readable for lazy access but sweep is stopped
|
|
if _, ok := c.Get(ctx, "k"); !ok {
|
|
t.Error("Get should still work after Close")
|
|
}
|
|
}
|
|
|
|
// TestInMemoryCache_ConcurrentSweepAndSet verifies no race between sweep and Set.
|
|
func TestInMemoryCache_ConcurrentSweepAndSet(t *testing.T) {
|
|
c := NewInMemoryCache[int](
|
|
WithSweepInterval[int](1*time.Millisecond),
|
|
WithMaxSize[int](100),
|
|
)
|
|
defer c.Close()
|
|
ctx := context.Background()
|
|
|
|
done := make(chan bool)
|
|
go func() {
|
|
for i := 0; i < 500; i++ {
|
|
c.Set(ctx, string(rune('a'+(i%26))), i, 5*time.Millisecond)
|
|
}
|
|
done <- true
|
|
}()
|
|
go func() {
|
|
for i := 0; i < 500; i++ {
|
|
_, _ = c.Get(ctx, string(rune('a'+(i%26))))
|
|
}
|
|
done <- true
|
|
}()
|
|
<-done
|
|
<-done
|
|
}
|
|
|
|
// TestInMemoryCache_BackwardCompatZeroArg verifies existing call sites with
|
|
// zero-arg constructor still work (variadic options).
|
|
func TestInMemoryCache_BackwardCompatZeroArg(t *testing.T) {
|
|
c := NewInMemoryCache[string]()
|
|
defer c.Close()
|
|
ctx := context.Background()
|
|
c.Set(ctx, "k", "v", 0)
|
|
if v, ok := c.Get(ctx, "k"); !ok || v != "v" {
|
|
t.Errorf("backward compat broken: got %v, %v", v, ok)
|
|
}
|
|
}
|