feat: add foundational utility packages
- pkg/messages: extract Message type from providers package for decoupling - pkg/errors: structured error types with severity levels (Fatal/Retryable/UserFacing) - pkg/ids: UUIDv7 generation and base62 codec for compact, sortable IDs - pkg/cache: generic thread-safe LRU cache with TTL support
This commit is contained in:
parent
3589adab08
commit
d25859b048
7 changed files with 1271 additions and 0 deletions
347
pkg/cache/lru.go
vendored
Normal file
347
pkg/cache/lru.go
vendored
Normal file
|
|
@ -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)
|
||||
}
|
||||
574
pkg/cache/lru_test.go
vendored
Normal file
574
pkg/cache/lru_test.go
vendored
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
70
pkg/errors/errors.go
Normal file
70
pkg/errors/errors.go
Normal file
|
|
@ -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
|
||||
}
|
||||
44
pkg/ids/codec.go
Normal file
44
pkg/ids/codec.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
103
pkg/ids/type.go
Normal file
103
pkg/ids/type.go
Normal file
|
|
@ -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
|
||||
}
|
||||
82
pkg/ids/uuidv7.go
Normal file
82
pkg/ids/uuidv7.go
Normal file
|
|
@ -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[:])
|
||||
}
|
||||
51
pkg/messages/types.go
Normal file
51
pkg/messages/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue