From fefe3994d1671b9a90171a001d2af5b7e8d1d8c1 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 21 Jul 2026 07:33:49 +0000 Subject: [PATCH 1/2] feat: implement cache backend with TTL support and integrate into context --- backends.go | 32 +++++++++++++++++++ context.go | 9 +++++- context_test.go | 4 +-- dispatch.go | 1 + native_backends.go | 21 ++++++++++--- plugintest/backends.go | 63 +++++++++++++++++++++++++++++++++++++ plugintest/backends_test.go | 52 +++++++++++++++++++++++++++++- plugintest/plugintest.go | 6 +++- testing.go | 4 +-- wasm_backends.go | 37 ++++++++++++++++++++++ wasm_imports.go | 18 +++++++++++ 11 files changed, 235 insertions(+), 12 deletions(-) diff --git a/backends.go b/backends.go index 19e0d61..902897f 100644 --- a/backends.go +++ b/backends.go @@ -1,5 +1,7 @@ package plugin +import "time" + // ── DB ──────────────────────────────────────────────────────────────────────── // DBBackend is the interface implemented by the WASM host runtime and test @@ -56,6 +58,36 @@ func (k *KV) Set(key, value string) { k.backend.Set(key, value) } // Delete removes key from the store. func (k *KV) Delete(key string) { k.backend.Delete(key) } +// ── Cache ───────────────────────────────────────────────────────────────────── + +// CacheBackend is the interface implemented by the WASM host runtime and test +// stubs for the plugin's Valkey/Redis-backed cache. +type CacheBackend interface { + Get(key string) (string, bool) + Set(key, value string, ttl time.Duration) + Delete(key string) +} + +// Cache is a string key-value store backed by the host's shared Valkey/Redis +// instance, namespaced per plugin so different plugins' keys never collide. +// Unlike KV (backed by Postgres, durable, no expiry), entries here expire +// after their TTL — use it for derived/recomputable data (e.g. expensive +// query results) rather than authoritative state. +type Cache struct { + backend CacheBackend +} + +// Get retrieves the value for key. Returns ("", false) on a cache miss, +// including when the entry has expired. +func (c *Cache) Get(key string) (string, bool) { return c.backend.Get(key) } + +// Set stores value under key for the given TTL. A zero TTL stores the value +// without expiry. +func (c *Cache) Set(key, value string, ttl time.Duration) { c.backend.Set(key, value, ttl) } + +// Delete removes key from the cache. +func (c *Cache) Delete(key string) { c.backend.Delete(key) } + // ── Logger ──────────────────────────────────────────────────────────────────── // LogBackend is the interface used by [Logger] to emit log messages. diff --git a/context.go b/context.go index 34b0fa1..3d594e2 100644 --- a/context.go +++ b/context.go @@ -10,6 +10,7 @@ type Context struct { events map[string]EventHandler db *DB kv *KV + cache *Cache log *Logger cfg *Config perm *Permissions @@ -46,6 +47,11 @@ func (c *Context) DB() *DB { return c.db } // KV returns a helper for simple key-value persistence. func (c *Context) KV() *KV { return c.kv } +// Cache returns a helper for the plugin's TTL-based Valkey/Redis-backed +// cache — use it for expensive-to-recompute data that can tolerate being +// briefly stale, as opposed to KV's durable, non-expiring storage. +func (c *Context) Cache() *Cache { return c.cache } + // Log returns a structured logger. func (c *Context) Log() *Logger { return c.log } @@ -65,12 +71,13 @@ type EventHandler func(evt *Event) // newContext constructs a Context backed by the provided implementations. // Called by the WASM runtime (with host-function backends) and by // [plugintest] (with in-memory backends). -func newContext(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { +func newContext(db DBBackend, kv KVBackend, cache CacheBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { return &Context{ routes: make(map[routeKey]RouteHandler), events: make(map[string]EventHandler), db: &DB{backend: db}, kv: &KV{backend: kv}, + cache: &Cache{backend: cache}, log: &Logger{backend: log}, cfg: &Config{backend: cfg}, perm: &Permissions{backend: perm}, diff --git a/context_test.go b/context_test.go index 9c17fa5..4383051 100644 --- a/context_test.go +++ b/context_test.go @@ -13,7 +13,7 @@ import "testing" // literal ":panelId" pattern string as the path, so it never exercised this // branch — this test calls matchRoute with a real resolved path instead. func TestMatchRoute_PrefersLiteralSegmentOverWildcard(t *testing.T) { - ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend()) + ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMCacheBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend()) var gotByPanelID, gotLayout bool ctx.Route("PATCH", "/views/:viewId/panels/:panelId", func(_ *Request, _ *Response) { @@ -61,7 +61,7 @@ func TestMatchRoute_PrefersLiteralSegmentOverWildcard(t *testing.T) { // iteration order; after stripping, the wildcard pattern scores 1 and the // literal one correctly and deterministically wins. func TestMatchRoute_PrefersLiteralOverWildcardAcrossRegistrationStyles(t *testing.T) { - ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend()) + ctx := newContext(newWASMDBBackend(), newWASMKVBackend(), newWASMCacheBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend()) var gotWildcard, gotLiteral bool ctx.Route("GET", "/projects/:projectId/tasks/:taskId", func(_ *Request, _ *Response) { diff --git a/dispatch.go b/dispatch.go index 5e77e0a..589fa3a 100644 --- a/dispatch.go +++ b/dispatch.go @@ -23,6 +23,7 @@ func (d *dispatcher) init() error { d.ctx = newContext( newWASMDBBackend(), newWASMKVBackend(), + newWASMCacheBackend(), newWASMLogBackend(), newWASMConfigBackend(), newWASMPermissionBackend(), diff --git a/native_backends.go b/native_backends.go index a496cce..2cff359 100644 --- a/native_backends.go +++ b/native_backends.go @@ -2,13 +2,17 @@ // Package plugin — stub backends for non-WASM builds. // -// These stubs satisfy the DBBackend/KVBackend/LogBackend/ConfigBackend -// interfaces and the EmitEvent function when compiling for native platforms -// (e.g. go test, go vet). They are intentionally no-ops / error-returners -// so that tests must inject real implementations via plugintest.NewContext(). +// These stubs satisfy the DBBackend/KVBackend/CacheBackend/LogBackend/ +// ConfigBackend interfaces and the EmitEvent function when compiling for +// native platforms (e.g. go test, go vet). They are intentionally no-ops / +// error-returners so that tests must inject real implementations via +// plugintest.NewContext(). package plugin -import "errors" +import ( + "errors" + "time" +) var errNotWASM = errors.New("plugin: this function is only available in a WASM module") @@ -16,6 +20,7 @@ var errNotWASM = errors.New("plugin: this function is only available in a WASM m func newWASMDBBackend() DBBackend { return &stubDBBackend{} } func newWASMKVBackend() KVBackend { return &stubKVBackend{} } +func newWASMCacheBackend() CacheBackend { return &stubCacheBackend{} } func newWASMLogBackend() LogBackend { return &stubLogBackend{} } func newWASMConfigBackend() ConfigBackend { return &stubConfigBackend{} } func newWASMPermissionBackend() PermissionBackend { return &stubPermissionBackend{} } @@ -35,6 +40,12 @@ func (b *stubKVBackend) Get(_ string) (string, bool) { return "", false } func (b *stubKVBackend) Set(_, _ string) {} func (b *stubKVBackend) Delete(_ string) {} +type stubCacheBackend struct{} + +func (b *stubCacheBackend) Get(_ string) (string, bool) { return "", false } +func (b *stubCacheBackend) Set(_, _ string, _ time.Duration) {} +func (b *stubCacheBackend) Delete(_ string) {} + type stubLogBackend struct{} func (b *stubLogBackend) Log(_ int, _ string) {} diff --git a/plugintest/backends.go b/plugintest/backends.go index f879cec..9bdfa2c 100644 --- a/plugintest/backends.go +++ b/plugintest/backends.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "sync" + "time" plugin "github.com/Paca-AI/plugin-sdk-go" ) @@ -514,6 +515,68 @@ func (k *InMemoryKV) Delete(key string) { delete(k.data, key) } +// ── InMemoryCache ───────────────────────────────────────────────────────────── + +// InMemoryCache is a map-backed plugin.CacheBackend for tests, simulating +// TTL expiry against a fake clock instead of a real Valkey/Redis instance. +type InMemoryCache struct { + mu sync.Mutex + data map[string]cacheEntry + now func() time.Time +} + +type cacheEntry struct { + value string + expireAt time.Time // zero means no expiry +} + +func newInMemoryCache() *InMemoryCache { + return &InMemoryCache{data: make(map[string]cacheEntry), now: time.Now} +} + +// Get implements plugin.CacheBackend. Returns ("", false) on a miss or when +// the entry's TTL has elapsed (per the fake clock set via Advance). +func (c *InMemoryCache) Get(key string) (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.data[key] + if !ok { + return "", false + } + if !entry.expireAt.IsZero() && !c.now().Before(entry.expireAt) { + delete(c.data, key) + return "", false + } + return entry.value, true +} + +// Set implements plugin.CacheBackend. A zero ttl stores the value without expiry. +func (c *InMemoryCache) Set(key, value string, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + var expireAt time.Time + if ttl > 0 { + expireAt = c.now().Add(ttl) + } + c.data[key] = cacheEntry{value: value, expireAt: expireAt} +} + +// Delete implements plugin.CacheBackend. +func (c *InMemoryCache) Delete(key string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.data, key) +} + +// Advance moves the fake clock forward by d, so tests can assert that an +// entry expires after its TTL without a real sleep. +func (c *InMemoryCache) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + base := c.now() + c.now = func() time.Time { return base.Add(d) } +} + // ── CapturingLogger ─────────────────────────────────────────────────────────── // LogEntry is a single captured log message. diff --git a/plugintest/backends_test.go b/plugintest/backends_test.go index 021c6dc..21c24bb 100644 --- a/plugintest/backends_test.go +++ b/plugintest/backends_test.go @@ -1,6 +1,9 @@ package plugintest -import "testing" +import ( + "testing" + "time" +) // These tests cover InMemoryDB's WHERE-clause evaluation directly (SELECT, // UPDATE, DELETE), since a regression here silently produces false negatives @@ -84,3 +87,50 @@ func TestInMemoryDB_Delete_DoesNotMatchOtherRowsSharingOneCondition(t *testing.T t.Fatalf("expected no rows deleted (id/owner mismatch), got %+v", remaining) } } + +func TestInMemoryCache_GetMiss(t *testing.T) { + c := newInMemoryCache() + if _, ok := c.Get("missing"); ok { + t.Fatal("expected miss for a key that was never set") + } +} + +func TestInMemoryCache_SetThenGet(t *testing.T) { + c := newInMemoryCache() + c.Set("k", "v", time.Minute) + got, ok := c.Get("k") + if !ok || got != "v" { + t.Fatalf("expected hit with value %q, got (%q, %v)", "v", got, ok) + } +} + +func TestInMemoryCache_ZeroTTLNeverExpires(t *testing.T) { + c := newInMemoryCache() + c.Set("k", "v", 0) + c.Advance(24 * time.Hour) + if _, ok := c.Get("k"); !ok { + t.Fatal("expected a zero-TTL entry to survive any amount of elapsed time") + } +} + +func TestInMemoryCache_ExpiresAfterTTL(t *testing.T) { + c := newInMemoryCache() + c.Set("k", "v", 5*time.Minute) + c.Advance(4 * time.Minute) + if _, ok := c.Get("k"); !ok { + t.Fatal("expected entry to still be present before its TTL elapses") + } + c.Advance(2 * time.Minute) // total 6m > 5m TTL + if _, ok := c.Get("k"); ok { + t.Fatal("expected entry to be gone after its TTL elapses") + } +} + +func TestInMemoryCache_Delete(t *testing.T) { + c := newInMemoryCache() + c.Set("k", "v", time.Minute) + c.Delete("k") + if _, ok := c.Get("k"); ok { + t.Fatal("expected key to be gone after Delete") + } +} diff --git a/plugintest/plugintest.go b/plugintest/plugintest.go index c9afbc7..cafc0f3 100644 --- a/plugintest/plugintest.go +++ b/plugintest/plugintest.go @@ -40,6 +40,8 @@ type Context struct { DB *InMemoryDB // KV is the in-memory key-value store. KV *InMemoryKV + // Cache is the in-memory TTL cache store. + Cache *InMemoryCache // Log captures log messages emitted by the plugin. Log *CapturingLogger // Config is an in-memory config store. @@ -60,16 +62,18 @@ func NewContext(t testing.TB) *Context { t.Helper() db := newInMemoryDB() kv := newInMemoryKV() + cache := newInMemoryCache() log := newCapturingLogger() cfg := newInMemoryConfig() perm := newFakePermissions() - pCtx := plugin.NewContextForTest(db, kv, log, cfg, perm) + pCtx := plugin.NewContextForTest(db, kv, cache, log, cfg, perm) d := &testDispatcher{pluginCtx: pCtx} tc := &Context{ DB: db, KV: kv, + Cache: cache, Log: log, Config: cfg, Permissions: perm, diff --git a/testing.go b/testing.go index df2e8a6..5a0b1d1 100644 --- a/testing.go +++ b/testing.go @@ -5,8 +5,8 @@ package plugin // // Production code should use [Run], which installs the WASM host-function // backends automatically. This function is safe to call from any build target. -func NewContextForTest(db DBBackend, kv KVBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { - return newContext(db, kv, log, cfg, perm) +func NewContextForTest(db DBBackend, kv KVBackend, cache CacheBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context { + return newContext(db, kv, cache, log, cfg, perm) } // DispatchRoute calls the handler registered at method+path in ctx and writes diff --git a/wasm_backends.go b/wasm_backends.go index 84292d6..f61c15d 100644 --- a/wasm_backends.go +++ b/wasm_backends.go @@ -5,6 +5,7 @@ package plugin import ( "encoding/json" "fmt" + "time" "unsafe" ) @@ -140,6 +141,42 @@ func (b *wasmKVBackend) Delete(key string) { hostStorageDelete(int64(ptrOf(keyBytes)), int64(len(keyBytes))) } +// ── WASM Cache backend ──────────────────────────────────────────────────────── + +type wasmCacheBackend struct{} + +func newWASMCacheBackend() CacheBackend { return &wasmCacheBackend{} } + +func (b *wasmCacheBackend) Get(key string) (string, bool) { + keyBytes := []byte(key) + outputBuf := make([]byte, 8) + hostCacheGet( + int64(ptrOf(keyBytes)), int64(len(keyBytes)), + int64(ptrOf(outputBuf)), int64(ptrOf(outputBuf[4:])), + ) + valPtr := int32(uint32(outputBuf[0]) | uint32(outputBuf[1])<<8 | uint32(outputBuf[2])<<16 | uint32(outputBuf[3])<<24) + valLen := int32(uint32(outputBuf[4]) | uint32(outputBuf[5])<<8 | uint32(outputBuf[6])<<16 | uint32(outputBuf[7])<<24) + if valLen == 0 { + return "", false + } + return string(wasmSlice(valPtr, valLen)), true +} + +func (b *wasmCacheBackend) Set(key, value string, ttl time.Duration) { + keyBytes := []byte(key) + valBytes := []byte(value) + ttlSeconds := int32(ttl / time.Second) + if ttlSeconds < 0 { + ttlSeconds = 0 + } + hostCacheSet(int64(ptrOf(keyBytes)), int64(len(keyBytes)), int64(ptrOf(valBytes)), int64(len(valBytes)), ttlSeconds) +} + +func (b *wasmCacheBackend) Delete(key string) { + keyBytes := []byte(key) + hostCacheDelete(int64(ptrOf(keyBytes)), int64(len(keyBytes))) +} + // ── WASM log backend ────────────────────────────────────────────────────────── type wasmLogBackend struct{} diff --git a/wasm_imports.go b/wasm_imports.go index 10e8f09..e1dde44 100644 --- a/wasm_imports.go +++ b/wasm_imports.go @@ -54,6 +54,24 @@ func hostStorageSet(keyPtr, keyLen, valuePtr, valueLen int64) int32 //go:noescape func hostStorageDelete(keyPtr, keyLen int64) int32 +// paca.cache_get(keyPtr i64, keyLen i64, valuePtrPtr i64, valueLenPtr i64) +// +//go:wasmimport paca cache_get +//go:noescape +func hostCacheGet(keyPtr, keyLen, valuePtrPtr, valueLenPtr int64) + +// paca.cache_set(keyPtr i64, keyLen i64, valuePtr i64, valueLen i64, ttlSeconds i32) -> ok i32 +// +//go:wasmimport paca cache_set +//go:noescape +func hostCacheSet(keyPtr, keyLen, valuePtr, valueLen int64, ttlSeconds int32) int32 + +// paca.cache_delete(keyPtr i64, keyLen i64) -> ok i32 +// +//go:wasmimport paca cache_delete +//go:noescape +func hostCacheDelete(keyPtr, keyLen int64) int32 + // paca.event_emit(topicPtr i64, topicLen i64, payloadPtr i64, payloadLen i64) -> ok i32 // //go:wasmimport paca event_emit From 7d01a337227495cb8f5eed04d3f4926888044ed6 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 21 Jul 2026 07:44:17 +0000 Subject: [PATCH 2/2] feat: add TTL handling for cache entries and implement corresponding tests --- README.md | 3 ++- backends.go | 13 +++++++++++++ backends_test.go | 33 +++++++++++++++++++++++++++++++++ wasm_backends.go | 6 +----- 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 backends_test.go diff --git a/README.md b/README.md index cd2b615..9594372 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ GOARCH=wasm GOOS=wasip1 go build -o plugin.wasm main.go ### `Context` The bridge between your plugin and the Paca host. Available via `Init(ctx)`: - `ctx.DB()` — access the scoped database (PostgreSQL) -- `ctx.KV()` — access the plugin-private key-value store (JSONB) +- `ctx.KV()` — access the plugin-private key-value store (JSONB), durable with no expiry +- `ctx.Cache()` — access the plugin's TTL-based cache (Valkey/Redis), for derived data that can tolerate being briefly stale - `ctx.Log()` — write structured logs to the host - `ctx.Config()` — read plugin configuration and secrets - `ctx.Route(method, path, handler)` — register an HTTP endpoint diff --git a/backends.go b/backends.go index 902897f..0f26083 100644 --- a/backends.go +++ b/backends.go @@ -88,6 +88,19 @@ func (c *Cache) Set(key, value string, ttl time.Duration) { c.backend.Set(key, v // Delete removes key from the cache. func (c *Cache) Delete(key string) { c.backend.Delete(key) } +// ttlToSeconds converts ttl to whole seconds for backends (such as the WASM +// host cache_set import) that only accept second-granularity TTLs. It rounds +// up rather than truncating, so any positive sub-second ttl still maps to a +// positive number of seconds instead of silently becoming 0 — which the host +// treats as "store without expiry", turning a short-lived cache entry into a +// permanent one. Non-positive durations map to 0. +func ttlToSeconds(ttl time.Duration) int32 { + if ttl <= 0 { + return 0 + } + return int32((ttl + time.Second - 1) / time.Second) +} + // ── Logger ──────────────────────────────────────────────────────────────────── // LogBackend is the interface used by [Logger] to emit log messages. diff --git a/backends_test.go b/backends_test.go new file mode 100644 index 0000000..f1a3832 --- /dev/null +++ b/backends_test.go @@ -0,0 +1,33 @@ +package plugin + +import ( + "testing" + "time" +) + +// ttlToSeconds feeds the WASM host's cache_set import, which only accepts +// whole seconds and treats 0 as "store without expiry". These cases guard +// against a sub-second ttl silently truncating to 0 and turning a +// short-lived cache entry into a permanent one. +func TestTTLToSeconds(t *testing.T) { + cases := []struct { + name string + ttl time.Duration + want int32 + }{ + {"zero", 0, 0}, + {"negative", -time.Second, 0}, + {"sub-second rounds up to one second, not zero", 500 * time.Millisecond, 1}, + {"just under a second rounds up", 999 * time.Millisecond, 1}, + {"exact second stays exact", time.Second, 1}, + {"partial second past a whole second rounds up", 1500 * time.Millisecond, 2}, + {"minutes", 5 * time.Minute, 300}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ttlToSeconds(tc.ttl); got != tc.want { + t.Fatalf("ttlToSeconds(%v) = %d, want %d", tc.ttl, got, tc.want) + } + }) + } +} diff --git a/wasm_backends.go b/wasm_backends.go index f61c15d..ef0c557 100644 --- a/wasm_backends.go +++ b/wasm_backends.go @@ -165,11 +165,7 @@ func (b *wasmCacheBackend) Get(key string) (string, bool) { func (b *wasmCacheBackend) Set(key, value string, ttl time.Duration) { keyBytes := []byte(key) valBytes := []byte(value) - ttlSeconds := int32(ttl / time.Second) - if ttlSeconds < 0 { - ttlSeconds = 0 - } - hostCacheSet(int64(ptrOf(keyBytes)), int64(len(keyBytes)), int64(ptrOf(valBytes)), int64(len(valBytes)), ttlSeconds) + hostCacheSet(int64(ptrOf(keyBytes)), int64(len(keyBytes)), int64(ptrOf(valBytes)), int64(len(valBytes)), ttlToSeconds(ttl)) } func (b *wasmCacheBackend) Delete(key string) {