diff --git a/pkg/cache/lru.go b/pkg/cache/lru.go new file mode 100644 index 000000000..0f04075ec --- /dev/null +++ b/pkg/cache/lru.go @@ -0,0 +1,347 @@ +// Package cache provides a generic, thread-safe LRU cache with TTL support, +// stale-while-revalidate, and tag-based invalidation. +package cache + +import ( + "container/list" + "sync" + "time" +) + +// entry is a cache entry stored in the doubly-linked list. +type entry[K comparable, V any] struct { + key K + value V + tags []string + expiresAt time.Time + staleAt time.Time // After this time, entry is "stale" but still serveable during SWR +} + +// OnEvictFunc is called when an entry is evicted from the cache. +type OnEvictFunc[K comparable, V any] func(key K, value V) + +// FetchFunc is used for stale-while-revalidate: called in background to refresh a stale entry. +type FetchFunc[K comparable, V any] func(key K) (V, error) + +// Options configures the LRU cache. +type Options[K comparable, V any] struct { + // MaxSize is the maximum number of entries. Zero means unlimited. + MaxSize int + + // TTL is the default time-to-live for entries. Zero means no expiration. + TTL time.Duration + + // StaleTTL is the additional duration after TTL during which stale entries + // are still returned while a background refresh runs. Zero disables SWR. + StaleTTL time.Duration + + // OnEvict is called when an entry is evicted (optional). + OnEvict OnEvictFunc[K, V] + + // FetchFunc is called in the background to refresh stale entries (optional, enables SWR). + FetchFunc FetchFunc[K, V] + + // Now returns the current time. Defaults to time.Now if nil (useful for testing). + Now func() time.Time +} + +// LRU is a generic, thread-safe least-recently-used cache with TTL support. +type LRU[K comparable, V any] struct { + mu sync.Mutex + items map[K]*list.Element + evictList *list.List + tags map[string]map[K]struct{} // tag -> set of keys + opts Options[K, V] + now func() time.Time +} + +// New creates a new LRU cache with the given options. +func New[K comparable, V any](opts Options[K, V]) *LRU[K, V] { + nowFn := opts.Now + if nowFn == nil { + nowFn = time.Now + } + return &LRU[K, V]{ + items: make(map[K]*list.Element), + evictList: list.New(), + tags: make(map[string]map[K]struct{}), + opts: opts, + now: nowFn, + } +} + +// Set adds or updates an entry with the default TTL. +func (c *LRU[K, V]) Set(key K, value V) { + c.SetWithTTL(key, value, c.opts.TTL) +} + +// SetWithTags adds or updates an entry with the default TTL and associated tags. +func (c *LRU[K, V]) SetWithTags(key K, value V, tags []string) { + c.SetWithOptions(key, value, c.opts.TTL, tags) +} + +// SetWithTTL adds or updates an entry with a specific TTL. +func (c *LRU[K, V]) SetWithTTL(key K, value V, ttl time.Duration) { + c.SetWithOptions(key, value, ttl, nil) +} + +// SetWithOptions adds or updates an entry with a specific TTL and tags. +func (c *LRU[K, V]) SetWithOptions(key K, value V, ttl time.Duration, tags []string) { + c.mu.Lock() + defer c.mu.Unlock() + + now := c.now() + var expiresAt, staleAt time.Time + if ttl > 0 { + expiresAt = now.Add(ttl) + if c.opts.StaleTTL > 0 { + staleAt = expiresAt.Add(c.opts.StaleTTL) + } + } + + if elem, ok := c.items[key]; ok { + c.evictList.MoveToFront(elem) + ent := elem.Value.(*entry[K, V]) + // Remove old tags + c.removeTagsLocked(key, ent.tags) + ent.value = value + ent.expiresAt = expiresAt + ent.staleAt = staleAt + ent.tags = tags + c.addTagsLocked(key, tags) + return + } + + ent := &entry[K, V]{ + key: key, + value: value, + tags: tags, + expiresAt: expiresAt, + staleAt: staleAt, + } + elem := c.evictList.PushFront(ent) + c.items[key] = elem + c.addTagsLocked(key, tags) + + if c.opts.MaxSize > 0 && c.evictList.Len() > c.opts.MaxSize { + c.evictOldestLocked() + } +} + +// Get retrieves an entry. Returns the value and true if found and not expired. +// If SWR is enabled and the entry is stale, returns the stale value (true) and +// triggers a background refresh. +func (c *LRU[K, V]) Get(key K) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + elem, ok := c.items[key] + if !ok { + var zero V + return zero, false + } + + ent := elem.Value.(*entry[K, V]) + now := c.now() + + // Check hard expiration (past stale window or no SWR) + if !ent.expiresAt.IsZero() { + hardDeadline := ent.expiresAt + if !ent.staleAt.IsZero() { + hardDeadline = ent.staleAt + } + if now.After(hardDeadline) { + c.removeLocked(key) + var zero V + return zero, false + } + } + + // Check if stale (past TTL but within SWR window) + if !ent.expiresAt.IsZero() && now.After(ent.expiresAt) && !ent.staleAt.IsZero() { + // Entry is stale — return it but trigger background refresh + c.evictList.MoveToFront(elem) + if c.opts.FetchFunc != nil { + go c.refreshEntry(key) + } + return ent.value, true + } + + c.evictList.MoveToFront(elem) + return ent.value, true +} + +// Peek retrieves an entry without updating its position in the LRU list. +func (c *LRU[K, V]) Peek(key K) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + elem, ok := c.items[key] + if !ok { + var zero V + return zero, false + } + + ent := elem.Value.(*entry[K, V]) + now := c.now() + + if !ent.expiresAt.IsZero() { + hardDeadline := ent.expiresAt + if !ent.staleAt.IsZero() { + hardDeadline = ent.staleAt + } + if now.After(hardDeadline) { + c.removeLocked(key) + var zero V + return zero, false + } + } + + return ent.value, true +} + +// Delete removes an entry from the cache. +func (c *LRU[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + c.removeLocked(key) +} + +// InvalidateByTag removes all entries associated with the given tag. +func (c *LRU[K, V]) InvalidateByTag(tag string) { + c.mu.Lock() + defer c.mu.Unlock() + + keys, ok := c.tags[tag] + if !ok { + return + } + // Collect keys first to avoid modifying the map during iteration + toRemove := make([]K, 0, len(keys)) + for k := range keys { + toRemove = append(toRemove, k) + } + for _, k := range toRemove { + c.removeLocked(k) + } +} + +// Len returns the number of entries in the cache. +func (c *LRU[K, V]) Len() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.evictList.Len() +} + +// Clear removes all entries from the cache. +func (c *LRU[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.opts.OnEvict != nil { + for _, elem := range c.items { + ent := elem.Value.(*entry[K, V]) + c.opts.OnEvict(ent.key, ent.value) + } + } + + c.items = make(map[K]*list.Element) + c.evictList.Init() + c.tags = make(map[string]map[K]struct{}) +} + +// Keys returns all keys in the cache, ordered from most to least recently used. +func (c *LRU[K, V]) Keys() []K { + c.mu.Lock() + defer c.mu.Unlock() + + keys := make([]K, 0, c.evictList.Len()) + for elem := c.evictList.Front(); elem != nil; elem = elem.Next() { + ent := elem.Value.(*entry[K, V]) + keys = append(keys, ent.key) + } + return keys +} + +// Purge removes all expired entries from the cache. +func (c *LRU[K, V]) Purge() int { + c.mu.Lock() + defer c.mu.Unlock() + + now := c.now() + purged := 0 + for elem := c.evictList.Back(); elem != nil; { + prev := elem.Prev() + ent := elem.Value.(*entry[K, V]) + if !ent.expiresAt.IsZero() { + deadline := ent.expiresAt + if !ent.staleAt.IsZero() { + deadline = ent.staleAt + } + if now.After(deadline) { + c.removeLocked(ent.key) + purged++ + } + } + elem = prev + } + return purged +} + +// --- internal helpers --- + +func (c *LRU[K, V]) removeLocked(key K) { + elem, ok := c.items[key] + if !ok { + return + } + ent := elem.Value.(*entry[K, V]) + c.removeTagsLocked(key, ent.tags) + c.evictList.Remove(elem) + delete(c.items, key) + + if c.opts.OnEvict != nil { + c.opts.OnEvict(ent.key, ent.value) + } +} + +func (c *LRU[K, V]) evictOldestLocked() { + elem := c.evictList.Back() + if elem == nil { + return + } + ent := elem.Value.(*entry[K, V]) + c.removeLocked(ent.key) +} + +func (c *LRU[K, V]) addTagsLocked(key K, tags []string) { + for _, tag := range tags { + if c.tags[tag] == nil { + c.tags[tag] = make(map[K]struct{}) + } + c.tags[tag][key] = struct{}{} + } +} + +func (c *LRU[K, V]) removeTagsLocked(key K, tags []string) { + for _, tag := range tags { + if tagSet, ok := c.tags[tag]; ok { + delete(tagSet, key) + if len(tagSet) == 0 { + delete(c.tags, tag) + } + } + } +} + +func (c *LRU[K, V]) refreshEntry(key K) { + if c.opts.FetchFunc == nil { + return + } + value, err := c.opts.FetchFunc(key) + if err != nil { + return // Keep stale entry on refresh failure + } + // Re-insert with fresh TTL + c.Set(key, value) +} diff --git a/pkg/cache/lru_test.go b/pkg/cache/lru_test.go new file mode 100644 index 000000000..7541245f5 --- /dev/null +++ b/pkg/cache/lru_test.go @@ -0,0 +1,574 @@ +package cache + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// mockClock provides a controllable clock for testing. +type mockClock struct { + mu sync.Mutex + now time.Time +} + +func newMockClock(t time.Time) *mockClock { + return &mockClock{now: t} +} + +func (c *mockClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *mockClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +// --- Basic LRU Tests --- + +func TestLRU_SetAndGet(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 10}) + + c.Set("a", 1) + c.Set("b", 2) + + v, ok := c.Get("a") + if !ok || v != 1 { + t.Fatalf("expected (1, true), got (%d, %v)", v, ok) + } + + v, ok = c.Get("b") + if !ok || v != 2 { + t.Fatalf("expected (2, true), got (%d, %v)", v, ok) + } + + _, ok = c.Get("missing") + if ok { + t.Fatal("expected false for missing key") + } +} + +func TestLRU_Update(t *testing.T) { + c := New[string, string](Options[string, string]{MaxSize: 10}) + + c.Set("k", "v1") + c.Set("k", "v2") + + v, ok := c.Get("k") + if !ok || v != "v2" { + t.Fatalf("expected (v2, true), got (%s, %v)", v, ok) + } + + if c.Len() != 1 { + t.Fatalf("expected len 1, got %d", c.Len()) + } +} + +func TestLRU_EvictionOrder(t *testing.T) { + var evicted []string + c := New[string, int](Options[string, int]{ + MaxSize: 3, + OnEvict: func(key string, _ int) { + evicted = append(evicted, key) + }, + }) + + c.Set("a", 1) + c.Set("b", 2) + c.Set("c", 3) + // Cache is full: [c, b, a] (front to back) + + // Access "a" to move it to front: [a, c, b] + c.Get("a") + + // Add "d" — should evict "b" (LRU) + c.Set("d", 4) + + if len(evicted) != 1 || evicted[0] != "b" { + t.Fatalf("expected eviction of 'b', got %v", evicted) + } + + _, ok := c.Get("b") + if ok { + t.Fatal("expected 'b' to be evicted") + } + + // Remaining: [d, a, c] + keys := c.Keys() + if len(keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(keys)) + } + if keys[0] != "d" || keys[1] != "a" || keys[2] != "c" { + t.Fatalf("expected [d, a, c], got %v", keys) + } +} + +func TestLRU_MaxSizeZero_Unlimited(t *testing.T) { + c := New[int, int](Options[int, int]{}) + + for i := 0; i < 1000; i++ { + c.Set(i, i) + } + + if c.Len() != 1000 { + t.Fatalf("expected 1000, got %d", c.Len()) + } +} + +func TestLRU_Delete(t *testing.T) { + var evictCalled bool + c := New[string, int](Options[string, int]{ + MaxSize: 10, + OnEvict: func(key string, _ int) { + evictCalled = true + }, + }) + + c.Set("a", 1) + c.Delete("a") + + if !evictCalled { + t.Fatal("expected OnEvict to be called on Delete") + } + + _, ok := c.Get("a") + if ok { + t.Fatal("expected 'a' to be deleted") + } + + if c.Len() != 0 { + t.Fatalf("expected len 0, got %d", c.Len()) + } +} + +func TestLRU_Clear(t *testing.T) { + var evictCount int + c := New[string, int](Options[string, int]{ + MaxSize: 10, + OnEvict: func(_ string, _ int) { + evictCount++ + }, + }) + + c.Set("a", 1) + c.Set("b", 2) + c.Set("c", 3) + c.Clear() + + if evictCount != 3 { + t.Fatalf("expected 3 evictions, got %d", evictCount) + } + + if c.Len() != 0 { + t.Fatalf("expected len 0, got %d", c.Len()) + } +} + +func TestLRU_Peek_DoesNotPromote(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 3}) + + c.Set("a", 1) + c.Set("b", 2) + c.Set("c", 3) + // Order: [c, b, a] + + // Peek "a" — should NOT move it to front + v, ok := c.Peek("a") + if !ok || v != 1 { + t.Fatalf("expected (1, true), got (%d, %v)", v, ok) + } + + // Add "d" — should evict "a" (still LRU since Peek didn't promote) + c.Set("d", 4) + + _, ok = c.Get("a") + if ok { + t.Fatal("expected 'a' to be evicted (Peek should not promote)") + } +} + +// --- TTL Tests --- + +func TestLRU_TTL_Expiry(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 5 * time.Minute, + Now: clock.Now, + }) + + c.Set("a", 1) + + // Before expiry + v, ok := c.Get("a") + if !ok || v != 1 { + t.Fatalf("expected (1, true) before TTL, got (%d, %v)", v, ok) + } + + // Advance past TTL + clock.Advance(6 * time.Minute) + + _, ok = c.Get("a") + if ok { + t.Fatal("expected 'a' to be expired after TTL") + } + + if c.Len() != 0 { + t.Fatalf("expected len 0 after expiry, got %d", c.Len()) + } +} + +func TestLRU_TTL_PerEntry(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 10 * time.Minute, + Now: clock.Now, + }) + + c.Set("long", 1) + c.SetWithTTL("short", 2, 2*time.Minute) + + clock.Advance(3 * time.Minute) + + // "short" should be expired + _, ok := c.Get("short") + if ok { + t.Fatal("expected 'short' to be expired") + } + + // "long" should still be alive + v, ok := c.Get("long") + if !ok || v != 1 { + t.Fatalf("expected (1, true), got (%d, %v)", v, ok) + } +} + +func TestLRU_Peek_ExpiresEntries(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 1 * time.Minute, + Now: clock.Now, + }) + + c.Set("a", 1) + clock.Advance(2 * time.Minute) + + _, ok := c.Peek("a") + if ok { + t.Fatal("expected Peek to detect expired entry") + } +} + +func TestLRU_Purge(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 5 * time.Minute, + Now: clock.Now, + }) + + c.Set("a", 1) + c.Set("b", 2) + c.SetWithTTL("c", 3, 1*time.Minute) + + clock.Advance(2 * time.Minute) + + purged := c.Purge() + if purged != 1 { + t.Fatalf("expected 1 purged, got %d", purged) + } + + if c.Len() != 2 { + t.Fatalf("expected 2 remaining, got %d", c.Len()) + } + + // "c" should be gone, "a" and "b" alive + _, ok := c.Peek("c") + if ok { + t.Fatal("expected 'c' to be purged") + } +} + +// --- Stale-While-Revalidate Tests --- + +func TestLRU_SWR_ReturnsStaleAndRefreshes(t *testing.T) { + clock := newMockClock(time.Now()) + var fetchCalls atomic.Int32 + refreshDone := make(chan struct{}, 1) + + c := New[string, string](Options[string, string]{ + MaxSize: 10, + TTL: 5 * time.Minute, + StaleTTL: 10 * time.Minute, + Now: clock.Now, + FetchFunc: func(key string) (string, error) { + fetchCalls.Add(1) + refreshDone <- struct{}{} + return "refreshed-" + key, nil + }, + }) + + c.Set("k", "original") + + // Advance past TTL but within SWR window + clock.Advance(7 * time.Minute) + + // Should return stale value + v, ok := c.Get("k") + if !ok || v != "original" { + t.Fatalf("expected stale (original, true), got (%s, %v)", v, ok) + } + + // Wait for background refresh + <-refreshDone + + // Small sleep to let the goroutine finish Set() + time.Sleep(50 * time.Millisecond) + + if fetchCalls.Load() != 1 { + t.Fatalf("expected 1 fetch call, got %d", fetchCalls.Load()) + } + + // Now entry should be refreshed + // Reset clock to make the refreshed entry fresh + clock.Advance(0) // keep same time + v, ok = c.Get("k") + if !ok || v != "refreshed-k" { + t.Fatalf("expected (refreshed-k, true), got (%s, %v)", v, ok) + } +} + +func TestLRU_SWR_HardExpiry(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 5 * time.Minute, + StaleTTL: 10 * time.Minute, + Now: clock.Now, + }) + + c.Set("k", 42) + + // Advance past both TTL and StaleTTL (5+10 = 15 min window) + clock.Advance(16 * time.Minute) + + _, ok := c.Get("k") + if ok { + t.Fatal("expected hard expiry past SWR window") + } +} + +func TestLRU_SWR_NoFetchFunc_StaleStillReturned(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + TTL: 5 * time.Minute, + StaleTTL: 10 * time.Minute, + Now: clock.Now, + // No FetchFunc — SWR still returns stale, just no background refresh + }) + + c.Set("k", 99) + clock.Advance(7 * time.Minute) // past TTL, within SWR + + v, ok := c.Get("k") + if !ok || v != 99 { + t.Fatalf("expected stale (99, true) without FetchFunc, got (%d, %v)", v, ok) + } +} + +// --- Tag-Based Invalidation Tests --- + +func TestLRU_Tags_InvalidateByTag(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 10}) + + c.SetWithTags("user:1", 1, []string{"users"}) + c.SetWithTags("user:2", 2, []string{"users"}) + c.SetWithTags("post:1", 10, []string{"posts"}) + c.SetWithTags("user:1:posts", 5, []string{"users", "posts"}) + + if c.Len() != 4 { + t.Fatalf("expected 4 entries, got %d", c.Len()) + } + + c.InvalidateByTag("users") + + if c.Len() != 1 { + t.Fatalf("expected 1 entry after invalidating 'users', got %d", c.Len()) + } + + // "post:1" should survive + v, ok := c.Get("post:1") + if !ok || v != 10 { + t.Fatalf("expected (10, true), got (%d, %v)", v, ok) + } + + // "user:*" should be gone + _, ok = c.Get("user:1") + if ok { + t.Fatal("expected 'user:1' invalidated") + } + _, ok = c.Get("user:1:posts") + if ok { + t.Fatal("expected 'user:1:posts' invalidated") + } +} + +func TestLRU_Tags_InvalidateNonexistentTag(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 10}) + c.Set("a", 1) + + // Should not panic + c.InvalidateByTag("nonexistent") + + if c.Len() != 1 { + t.Fatalf("expected 1 entry, got %d", c.Len()) + } +} + +func TestLRU_Tags_UpdateRemovesOldTags(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 10}) + + c.SetWithTags("k", 1, []string{"tag-a"}) + c.SetWithTags("k", 2, []string{"tag-b"}) + + // Invalidating old tag should not remove the entry + c.InvalidateByTag("tag-a") + v, ok := c.Get("k") + if !ok || v != 2 { + t.Fatalf("expected (2, true) after old tag invalidation, got (%d, %v)", v, ok) + } + + // Invalidating new tag should remove it + c.InvalidateByTag("tag-b") + _, ok = c.Get("k") + if ok { + t.Fatal("expected 'k' to be invalidated by tag-b") + } +} + +// --- Concurrent Access Tests --- + +func TestLRU_ConcurrentAccess(t *testing.T) { + c := New[int, int](Options[int, int]{MaxSize: 100}) + + var wg sync.WaitGroup + const goroutines = 50 + const opsPerRoutine = 200 + + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for i := 0; i < opsPerRoutine; i++ { + key := (id*opsPerRoutine + i) % 150 + switch i % 4 { + case 0: + c.Set(key, id) + case 1: + c.Get(key) + case 2: + c.Delete(key) + case 3: + c.Peek(key) + } + } + }(g) + } + + wg.Wait() + + // No panics, no races (run with -race) + if c.Len() < 0 { + t.Fatal("impossible") + } +} + +func TestLRU_ConcurrentTags(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 100}) + + var wg sync.WaitGroup + const goroutines = 20 + + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for i := 0; i < 100; i++ { + key := fmt.Sprintf("key-%d-%d", id, i) + tag := fmt.Sprintf("tag-%d", id%5) + c.SetWithTags(key, i, []string{tag}) + } + }(g) + } + + wg.Wait() + + // Invalidate one tag + c.InvalidateByTag("tag-0") + + // Should still have entries from other tags + if c.Len() == 0 { + t.Fatal("expected some entries to remain after partial tag invalidation") + } +} + +// --- Edge Cases --- + +func TestLRU_ZeroTTL_NoExpiry(t *testing.T) { + clock := newMockClock(time.Now()) + c := New[string, int](Options[string, int]{ + MaxSize: 10, + Now: clock.Now, + }) + + c.Set("k", 1) + clock.Advance(24 * time.Hour * 365) // 1 year later + + v, ok := c.Get("k") + if !ok || v != 1 { + t.Fatalf("expected no expiry with zero TTL, got (%d, %v)", v, ok) + } +} + +func TestLRU_MaxSizeOne(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 1}) + + c.Set("a", 1) + c.Set("b", 2) + + _, ok := c.Get("a") + if ok { + t.Fatal("expected 'a' evicted with MaxSize=1") + } + + v, ok := c.Get("b") + if !ok || v != 2 { + t.Fatalf("expected (2, true), got (%d, %v)", v, ok) + } +} + +func TestLRU_Keys_Order(t *testing.T) { + c := New[string, int](Options[string, int]{MaxSize: 10}) + + c.Set("a", 1) + c.Set("b", 2) + c.Set("c", 3) + + // Access "a" to promote it + c.Get("a") + + keys := c.Keys() + // Expected: [a, c, b] — "a" most recent, "b" least recent + if len(keys) != 3 || keys[0] != "a" || keys[1] != "c" || keys[2] != "b" { + t.Fatalf("expected [a, c, b], got %v", keys) + } +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 000000000..e01b063fa --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,70 @@ +package errors + +import ( + "fmt" + "time" +) + +// ErrConfig represents a configuration validation error. +type ErrConfig struct { + Field string // config field path (e.g. "agents.defaults.model") + Message string // human-readable description +} + +func (e *ErrConfig) Error() string { + return fmt.Sprintf("config error [%s]: %s", e.Field, e.Message) +} + +// ErrProvider represents an error from an LLM provider. +type ErrProvider struct { + Provider string // provider name (e.g. "openai", "anthropic") + StatusCode int // HTTP status code (0 if not applicable) + Message string + Cause error +} + +func (e *ErrProvider) Error() string { + if e.Cause != nil { + return fmt.Sprintf("provider error [%s] (HTTP %d): %s: %v", e.Provider, e.StatusCode, e.Message, e.Cause) + } + if e.StatusCode > 0 { + return fmt.Sprintf("provider error [%s] (HTTP %d): %s", e.Provider, e.StatusCode, e.Message) + } + return fmt.Sprintf("provider error [%s]: %s", e.Provider, e.Message) +} + +func (e *ErrProvider) Unwrap() error { + return e.Cause +} + +// ErrToolExecution represents an error during tool execution. +type ErrToolExecution struct { + ToolName string + Duration time.Duration + Cause error +} + +func (e *ErrToolExecution) Error() string { + return fmt.Sprintf("tool execution error [%s] (took %s): %v", e.ToolName, e.Duration, e.Cause) +} + +func (e *ErrToolExecution) Unwrap() error { + return e.Cause +} + +// ErrRetryable wraps a transient error that may succeed on retry. +type ErrRetryable struct { + Cause error + RetryAfter time.Duration // suggested delay before retry (0 = use default) +} + +func (e *ErrRetryable) Error() string { + if e.RetryAfter > 0 { + return fmt.Sprintf("retryable error (retry after %s): %v", e.RetryAfter, e.Cause) + } + return fmt.Sprintf("retryable error: %v", e.Cause) +} + +func (e *ErrRetryable) Unwrap() error { + return e.Cause +} diff --git a/pkg/ids/codec.go b/pkg/ids/codec.go new file mode 100644 index 000000000..281454fad --- /dev/null +++ b/pkg/ids/codec.go @@ -0,0 +1,44 @@ +package ids + +import ( + "encoding/hex" + "errors" + "strings" +) + +// ToBytes converts a canonical UUID string (8-4-4-4-12) into its 16-byte form. +func ToBytes(u string) ([16]byte, error) { + var out [16]byte + // Strip hyphens + s := strings.ReplaceAll(u, "-", "") + if len(s) != 32 { + return out, errors.New("invalid uuid length") + } + b, err := hex.DecodeString(s) + if err != nil { + return out, err + } + copy(out[:], b) + return out, nil +} + +// MustToBytes converts string to bytes and panics on error. Safe for ids generated by New(). +func MustToBytes(u string) []byte { + b, err := ToBytes(u) + if err != nil { + panic(err) + } + return b[:] +} + +// FromBytes converts 16 raw bytes into UUID type. +func FromBytes(b []byte) UUID { + var out UUID + copy(out[:], b) + return out +} + +// FromString is an alias for MustParse. Panics on invalid input. +func FromString(s string) UUID { + return MustParse(s) +} diff --git a/pkg/ids/type.go b/pkg/ids/type.go new file mode 100644 index 000000000..1c3fca822 --- /dev/null +++ b/pkg/ids/type.go @@ -0,0 +1,103 @@ +package ids + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// UUID represents a 16-byte RFC-9562 UUIDv7 value. +type UUID [16]byte + +// Parse converts a canonical UUID string into UUID. +func Parse(s string) (UUID, error) { + var out UUID + b, err := ToBytes(s) + if err != nil { + return out, err + } + copy(out[:], b[:]) + return out, nil +} + +// MustParse parses and panics on error. Safe when input is known-good. +func MustParse(s string) UUID { + u, err := Parse(s) + if err != nil { + panic(err) + } + return u +} + +// Bytes returns raw 16 bytes. +func (u UUID) Bytes() []byte { return u[:][:] } + +// IsZero returns true if UUID is all zero bytes. +func (u UUID) IsZero() bool { + for _, b := range u { + if b != 0 { + return false + } + } + return true +} + +// String returns canonical UUID string representation. +func (u UUID) String() string { return encodeCanonical([16]byte(u)) } + +// Value implements driver.Valuer, returning raw 16-byte BLOB for storage. +// SQLite stores UUIDs as BLOB PRIMARY KEY — 16 bytes vs 36 bytes for TEXT. +// Zero UUID maps to SQL NULL (prevents accidental zero-value primary keys). +func (u UUID) Value() (driver.Value, error) { + if u.IsZero() { + return nil, nil + } + b := make([]byte, 16) + copy(b, u[:]) + return b, nil +} + +// Scan implements sql.Scanner to read UUID from database values (BLOB or TEXT). +func (u *UUID) Scan(src interface{}) error { + if src == nil { + // leave zero value + return nil + } + switch v := src.(type) { + case []byte: + if len(v) != 16 { + return fmt.Errorf("invalid uuid blob length: %d", len(v)) + } + copy(u[:], v) + return nil + case string: + parsed, err := Parse(v) + if err != nil { + return err + } + *u = parsed + return nil + default: + return errors.New("unsupported uuid scan source type") + } +} + +// MarshalJSON encodes UUID as JSON string. +func (u UUID) MarshalJSON() ([]byte, error) { + return json.Marshal(u.String()) +} + +// UnmarshalJSON decodes UUID from JSON string. +func (u *UUID) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + parsed, err := Parse(s) + if err != nil { + return err + } + *u = parsed + return nil +} diff --git a/pkg/ids/uuidv7.go b/pkg/ids/uuidv7.go new file mode 100644 index 000000000..069e49b9a --- /dev/null +++ b/pkg/ids/uuidv7.go @@ -0,0 +1,82 @@ +package ids + +import ( + "crypto/rand" + "time" +) + +// New returns a RFC 9562 UUID version 7 as UUID type. +// Layout: +// - 48-bit big-endian Unix milliseconds timestamp +// - 4-bit version (0b0111) +// - 12-bit randomness (rand_a) +// - 2-bit variant (0b10) +// - 62-bit randomness (rand_b) +func New() UUID { + var u UUID + + // 48-bit timestamp (ms since Unix epoch), big-endian + ms := uint64(time.Now().UnixMilli()) + u[0] = byte(ms >> 40) + u[1] = byte(ms >> 32) + u[2] = byte(ms >> 24) + u[3] = byte(ms >> 16) + u[4] = byte(ms >> 8) + u[5] = byte(ms) + + // Fill remaining bytes with randomness + _, _ = rand.Read(u[6:]) + + // Set version (0b0111 in high nibble of byte 6) + u[6] = (u[6] & 0x0f) | 0x70 + // Set variant (0b10 in high bits of byte 8) + u[8] = (u[8] & 0x3f) | 0x80 + + return u +} + +// encodeCanonical renders the UUID bytes as 8-4-4-4-12 hexadecimal groups. +func encodeCanonical(u [16]byte) string { + var dst [36]byte + hex := func(b byte) (byte, byte) { + const hexdigits = "0123456789abcdef" + return hexdigits[b>>4], hexdigits[b&0x0f] + } + writeByte := func(off int, b byte) int { + h, l := hex(b) + dst[off] = h + dst[off+1] = l + return off + 2 + } + + o := 0 + // 4 bytes -> 8 chars + for i := 0; i < 4; i++ { + o = writeByte(o, u[i]) + } + dst[o] = '-' + o++ + // 2 bytes -> 4 chars + for i := 4; i < 6; i++ { + o = writeByte(o, u[i]) + } + dst[o] = '-' + o++ + // 2 bytes -> 4 chars + for i := 6; i < 8; i++ { + o = writeByte(o, u[i]) + } + dst[o] = '-' + o++ + // 2 bytes -> 4 chars + for i := 8; i < 10; i++ { + o = writeByte(o, u[i]) + } + dst[o] = '-' + o++ + // 6 bytes -> 12 chars + for i := 10; i < 16; i++ { + o = writeByte(o, u[i]) + } + return string(dst[:]) +} diff --git a/pkg/messages/types.go b/pkg/messages/types.go new file mode 100644 index 000000000..2450e8bf0 --- /dev/null +++ b/pkg/messages/types.go @@ -0,0 +1,51 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package messages + +// ToolCall represents a tool invocation from the LLM response. +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +// FunctionCall contains the function name and serialized arguments. +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// UsageInfo tracks token usage for an LLM call. +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// Message represents a conversation message for session storage. +// This is the canonical serialization format persisted to disk. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +// ToolDefinition describes a tool available to the LLM. +type ToolDefinition struct { + Type string `json:"type"` + Function ToolFunctionDefinition `json:"function"` +} + +// ToolFunctionDefinition describes a tool's function signature. +type ToolFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +}