diff --git a/docs/plugins/backend-plugin-system.md b/docs/plugins/backend-plugin-system.md index a901a93b2..6c072ecf4 100644 --- a/docs/plugins/backend-plugin-system.md +++ b/docs/plugins/backend-plugin-system.md @@ -50,6 +50,16 @@ Each plugin gets a dedicated namespace in the database (`plugin_data_{pluginId}` | `paca.storage_set(key_ptr, key_len, val_ptr, val_len) → ok` | Set a value in the plugin's key-value store. | | `paca.storage_delete(key_ptr, key_len) → ok` | Delete a key from the plugin's key-value store. | +#### Plugin Cache (TTL, Valkey/Redis-backed) + +Backed by the host's shared Valkey/Redis instance, keyed under a per-plugin namespace so plugins can't read or overwrite each other's entries. Unlike Plugin-Owned Storage above, entries can expire and there is no durability guarantee — treat the cache as recomputable data only. If the host has no cache backend configured, `cache_get` always misses and `cache_set`/`cache_delete` are no-ops, so plugins should always be able to fall back to recomputing a value on a miss. + +| Function | Description | +|---|---| +| `paca.cache_get(key_ptr, key_len, value_ptr_ptr, value_len_ptr)` | Get a value from the plugin's cache. A zero-length response means a miss (never cached, expired, or no cache backend configured). | +| `paca.cache_set(key_ptr, key_len, val_ptr, val_len, ttl_seconds) → ok` | Set a value in the plugin's cache with the given TTL in seconds. `ttl_seconds = 0` stores the value with no expiry; negative values are rejected (`ok = 0`). | +| `paca.cache_delete(key_ptr, key_len) → ok` | Delete a key from the plugin's cache. | + #### Core Data Access (Read-Only, Scoped) | Function | Description | diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go index c1568f279..38762203e 100644 --- a/services/api/internal/bootstrap/app.go +++ b/services/api/internal/bootstrap/app.go @@ -225,6 +225,7 @@ func New(cfg *config.Config) (*App, error) { Publisher: publisher, HTTPClient: &http.Client{Timeout: 30 * time.Second}, Authorizer: authorizer, + Cache: cacheStore, Config: map[string]string{ "ENCRYPTION_KEY": cfg.Security.EncryptionKey, "PUBLIC_URL": cfg.Server.PublicURL, diff --git a/services/api/internal/platform/plugin/runtime.go b/services/api/internal/platform/plugin/runtime.go index 8f621fa15..d0139bac6 100644 --- a/services/api/internal/platform/plugin/runtime.go +++ b/services/api/internal/platform/plugin/runtime.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "sync" "time" @@ -18,6 +19,7 @@ import ( plugindom "github.com/Paca-AI/api/internal/domain/plugin" "github.com/Paca-AI/api/internal/events" "github.com/Paca-AI/api/internal/platform/authz" + "github.com/Paca-AI/api/internal/platform/cache" "github.com/google/uuid" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -75,6 +77,12 @@ type HostServices struct { // map) for the paca.permission_check host function. May be nil, in which // case permission_check always returns false. Authorizer *authz.Authorizer + // Cache backs the paca.cache_get/cache_set/cache_delete host functions + // with the host's shared Valkey/Redis instance. May be nil, in which case + // cache_get always misses and cache_set/cache_delete are no-ops — a + // plugin's Cache is meant for recomputable data, so a missing cache + // backend degrades to "always recompute" rather than failing calls. + Cache *cache.Store } // EventPublisher abstracts the messaging.Publisher to avoid a circular import. @@ -433,6 +441,9 @@ func (r *Runtime) registerHostModule(ctx context.Context, rt wazero.Runtime, p p // --- DB host functions (PLUG-BE-04) ------------------------------------ r.registerDBFunctions(builder, p) + // --- Cache host functions (Valkey/Redis-backed, TTL) -------------------- + r.registerCacheFunctions(builder, p) + // --- Core read-only functions (PLUG-BE-05) ----------------------------- r.registerCoreFunctions(builder, p) @@ -588,7 +599,12 @@ func (r *Runtime) registerDBFunctions(b wazero.HostModuleBuilder, p plugindom.Pl } return } - ptrLen, _ := writeToMemory(m, []byte(value)) + ptrLen, err := writeToMemory(m, []byte(value)) + if err != nil { + m.Memory().WriteUint32Le(uint32(stack[2]), 0) + m.Memory().WriteUint32Le(uint32(stack[3]), 0) + return + } m.Memory().WriteUint32Le(uint32(stack[2]), uint32(ptrLen[0])) m.Memory().WriteUint32Le(uint32(stack[3]), uint32(ptrLen[1])) }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64}, @@ -630,6 +646,112 @@ func (r *Runtime) registerDBFunctions(b wazero.HostModuleBuilder, p plugindom.Pl Export("storage_delete") } +// cacheKeyPrefix namespaces a plugin's cache entries within the shared +// Valkey/Redis instance so two plugins can use the same logical key (e.g. +// "view:abc123") without colliding. Unlike the DB/KV bridge, which gets +// physical isolation for free from each plugin's own Postgres schema, Cache +// has no per-plugin storage of its own — this prefix is the only thing +// standing in for that. p.Name is the caller identity fixed at host-module +// registration time (one module per plugin instance), never plugin-supplied, +// so a plugin cannot forge another plugin's prefix. +// +// The name is embedded length-prefixed ("plugin:::") rather than +// as a bare "plugin::" so that decoding is unambiguous even if a +// plugin name itself contains a colon: the length tells a reader exactly +// how many bytes to consume for the name, so "plugin:3:a:b:x" (name "a:b", +// key "x") can never collide with "plugin:1:a:b:x" (name "a", key "b:x") — +// the two encode to different strings even though a naive "plugin:"+name+":" +// scheme would make them collide. +func cacheKeyPrefix(pluginName string) string { + return "plugin:" + strconv.Itoa(len(pluginName)) + ":" + pluginName + ":" +} + +// registerCacheFunctions adds paca.cache_get, paca.cache_set, and +// paca.cache_delete to the host module builder — a TTL-based cache backed by +// the host's shared Valkey/Redis instance (see HostServices.Cache), as +// opposed to storage_get/set/delete above which persist durably to the +// plugin's own Postgres schema with no expiry. Intended for recomputable +// data (e.g. expensive query results) that can tolerate being briefly stale; +// callers must treat a miss as "not cached" rather than "does not exist". +func (r *Runtime) registerCacheFunctions(b wazero.HostModuleBuilder, p plugindom.Plugin) { + prefix := cacheKeyPrefix(p.Name) + + // paca.cache_get(keyPtr, keyLen, valuePtrPtr, valueLenPtr) + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + key, err := readString(m, stack[0], stack[1]) + if err != nil || key == "" || r.services.Cache == nil { + m.Memory().WriteUint32Le(uint32(stack[2]), 0) + m.Memory().WriteUint32Le(uint32(stack[3]), 0) + return + } + + var value string + hit, err := r.services.Cache.Get(ctx, prefix+key, &value) + if err != nil { + r.log.Error("paca.cache_get", "plugin", p.Name, "error", err) + } + if !hit { + m.Memory().WriteUint32Le(uint32(stack[2]), 0) + m.Memory().WriteUint32Le(uint32(stack[3]), 0) + return + } + ptrLen, err := writeToMemory(m, []byte(value)) + if err != nil { + m.Memory().WriteUint32Le(uint32(stack[2]), 0) + m.Memory().WriteUint32Le(uint32(stack[3]), 0) + return + } + m.Memory().WriteUint32Le(uint32(stack[2]), uint32(ptrLen[0])) + m.Memory().WriteUint32Le(uint32(stack[3]), uint32(ptrLen[1])) + }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64}, + nil). + Export("cache_get") + + // paca.cache_set(keyPtr, keyLen, valuePtr, valueLen, ttlSeconds i32) -> (ok i32) + // A ttlSeconds of 0 stores the value without expiry. A negative + // ttlSeconds is rejected (ok=0) rather than forwarded to Redis, which + // would otherwise reject it as an invalid expire time. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + key, _ := readString(m, stack[0], stack[1]) + value, _ := readString(m, stack[2], stack[3]) + ttlSeconds := int32(stack[4]) + if key == "" || r.services.Cache == nil || ttlSeconds < 0 { + stack[0] = 0 + return + } + + ttl := time.Duration(ttlSeconds) * time.Second + if err := r.services.Cache.Set(ctx, prefix+key, value, ttl); err != nil { + r.log.Error("paca.cache_set", "plugin", p.Name, "error", err) + stack[0] = 0 + return + } + stack[0] = 1 + }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI64, api.ValueTypeI32}, + []api.ValueType{api.ValueTypeI32}). + Export("cache_set") + + // paca.cache_delete(keyPtr, keyLen) -> (ok i32) + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + key, _ := readString(m, stack[0], stack[1]) + if key == "" || r.services.Cache == nil { + stack[0] = 0 + return + } + if err := r.services.Cache.Delete(ctx, prefix+key); err != nil { + r.log.Error("paca.cache_delete", "plugin", p.Name, "error", err) + stack[0] = 0 + return + } + stack[0] = 1 + }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64}, + []api.ValueType{api.ValueTypeI32}). + Export("cache_delete") +} + // execQuery runs a SELECT statement scoped to the plugin schema and returns a // dbQueryResult JSON-encoded result. caller is the requesting plugin, used to // redact sensitive columns (core, or declared by other plugins) the query diff --git a/services/api/internal/platform/plugin/runtime_cache_test.go b/services/api/internal/platform/plugin/runtime_cache_test.go new file mode 100644 index 000000000..e8d66ffbc --- /dev/null +++ b/services/api/internal/platform/plugin/runtime_cache_test.go @@ -0,0 +1,318 @@ +package plugin + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + plugindom "github.com/Paca-AI/api/internal/domain/plugin" + "github.com/Paca-AI/api/internal/platform/cache" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +const cachePluginName = "test.cache" + +var ( + cacheWasmOnce sync.Once + cacheWasmPath string + cacheWasmErr error +) + +// buildCacheFixture compiles testdata/cacheplugin the same way +// buildPoisonFixture compiles testdata/poisonplugin — see that function's +// doc comment for why this happens on demand rather than committing a +// prebuilt binary. +func buildCacheFixture(t *testing.T) string { + t.Helper() + cacheWasmOnce.Do(func() { + dir, err := os.MkdirTemp("", "cacheplugin-*") + if err != nil { + cacheWasmErr = err + return + } + wd, err := os.Getwd() + if err != nil { + cacheWasmErr = err + return + } + out := filepath.Join(dir, "cache.wasm") + cmd := exec.CommandContext(t.Context(), "go", "build", "-buildmode=c-shared", "-o", out, "./testdata/cacheplugin") + cmd.Dir = wd + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if output, buildErr := cmd.CombinedOutput(); buildErr != nil { + cacheWasmErr = fmt.Errorf("build cache fixture: %w: %s", buildErr, output) + return + } + cacheWasmPath = out + }) + if cacheWasmErr != nil { + t.Fatalf("build cache fixture: %v", cacheWasmErr) + } + return cacheWasmPath +} + +// loadCachePlugin compiles and loads the cache fixture into a fresh Runtime +// wired to the given HostServices (in particular, HostServices.Cache). +func loadCachePlugin(t *testing.T, services HostServices) *Runtime { + t.Helper() + + wasmPath := buildCacheFixture(t) + wasmBytes, err := os.ReadFile(wasmPath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + dir := t.TempDir() + pluginDir := filepath.Join(dir, cachePluginName) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatalf("mkdir fixture plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "backend.wasm"), wasmBytes, 0o644); err != nil { + t.Fatalf("write fixture wasm: %v", err) + } + + store := &Store{cfg: StoreConfig{Store: "local", WASMDir: dir}} + rt := NewRuntime(store, services, DefaultResourceLimits(), slog.New(slog.NewTextHandler(io.Discard, nil))) + + p := plugindom.Plugin{ + Name: cachePluginName, + Enabled: true, + Manifest: plugindom.PluginManifest{ + Backend: &plugindom.BackendManifest{}, + }, + } + ctx := context.Background() + if err := rt.Load(ctx, p); err != nil { + t.Fatalf("load fixture plugin: %v", err) + } + t.Cleanup(func() { rt.Unload(ctx, cachePluginName) }) + return rt +} + +// callCacheSet writes key/value into the fixture's own linear memory (via +// its malloc export) and calls its CacheSet export, which forwards to +// paca.cache_set. +func callCacheSet(t *testing.T, inst *pluginInstance, key, value string, ttl time.Duration) int64 { + t.Helper() + ctx := context.Background() + keyPtrLen, err := writeToMemory(inst.mod, []byte(key)) + if err != nil { + t.Fatalf("write key: %v", err) + } + valPtrLen, err := writeToMemory(inst.mod, []byte(value)) + if err != nil { + t.Fatalf("write value: %v", err) + } + fn := inst.mod.ExportedFunction("CacheSet") + results, err := fn.Call(ctx, keyPtrLen[0], keyPtrLen[1], valPtrLen[0], valPtrLen[1], uint64(int32(ttl/time.Second))) + if err != nil { + t.Fatalf("call CacheSet: %v", err) + } + resetFixture(t, inst) + return int64(int32(results[0])) +} + +// callCacheGet writes key into the fixture's memory, calls its CacheGet +// export, and reads back the resulting value (if any) from fixture memory. +func callCacheGet(t *testing.T, inst *pluginInstance, key string) (string, bool) { + t.Helper() + ctx := context.Background() + keyPtrLen, err := writeToMemory(inst.mod, []byte(key)) + if err != nil { + t.Fatalf("write key: %v", err) + } + fn := inst.mod.ExportedFunction("CacheGet") + results, err := fn.Call(ctx, keyPtrLen[0], keyPtrLen[1]) + if err != nil { + t.Fatalf("call CacheGet: %v", err) + } + combined := results[0] + valPtr := uint32(combined >> 32) + valLen := uint32(combined & 0xFFFFFFFF) + defer resetFixture(t, inst) + if valLen == 0 { + return "", false + } + data, err := readFromMemory(inst.mod, uint64(valPtr), uint64(valLen)) + if err != nil { + t.Fatalf("read value: %v", err) + } + return string(data), true +} + +// callCacheDelete writes key into the fixture's memory and calls its +// CacheDelete export. +func callCacheDelete(t *testing.T, inst *pluginInstance, key string) int64 { + t.Helper() + ctx := context.Background() + keyPtrLen, err := writeToMemory(inst.mod, []byte(key)) + if err != nil { + t.Fatalf("write key: %v", err) + } + fn := inst.mod.ExportedFunction("CacheDelete") + results, err := fn.Call(ctx, keyPtrLen[0], keyPtrLen[1]) + if err != nil { + t.Fatalf("call CacheDelete: %v", err) + } + resetFixture(t, inst) + return int64(int32(results[0])) +} + +func resetFixture(t *testing.T, inst *pluginInstance) { + t.Helper() + fn := inst.mod.ExportedFunction("ResetAllocator") + if fn == nil { + return + } + if _, err := fn.Call(context.Background()); err != nil { + t.Fatalf("reset allocator: %v", err) + } +} + +// TestCacheHostFunctions exercises the full WASM<->host round trip for +// paca.cache_get/cache_set/cache_delete against a real (miniredis-backed) +// cache.Store — not just the SDK's in-memory test double — to prove the new +// host functions actually reach Valkey/Redis with the right key/value/TTL. +// +// All subtests share one loadCachePlugin call (one wazero.CompileModule) +// instead of loading a fresh Runtime per scenario: compiling this fixture +// under `go test -race` costs ~7s each time (wazero's optimizing backend is +// expensive under the race detector), and this package's other WASM-fixture +// tests (testdata/poisonplugin) already spend ~30s of the CI job's 60s +// budget — five separate loads here previously pushed the whole package +// over that budget and timed the CI job out. Each data-bearing subtest uses +// its own key so they stay independent without needing a shared store reset +// between them. The nil-Cache subtest reuses the very same Runtime/instance +// by temporarily clearing rt.services.Cache — Runtime.services is read live +// by the registered host-function closures (see registerCacheFunctions), so +// this in-package field mutation takes effect without any extra compile. +func TestCacheHostFunctions(t *testing.T) { + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + store := cache.NewStore(client, "paca:") + + rt := loadCachePlugin(t, HostServices{Cache: store}) + inst := instanceFor(rt, cachePluginName) + + t.Run("SetThenGet", func(t *testing.T) { + if ok := callCacheSet(t, inst, "greeting", "hello", time.Minute); ok != 1 { + t.Fatalf("CacheSet: expected success, got %d", ok) + } + + got, hit := callCacheGet(t, inst, "greeting") + if !hit || got != "hello" { + t.Fatalf("CacheGet: expected hit with %q, got hit=%v value=%q", "hello", hit, got) + } + + // The value must actually be namespaced under this plugin's own + // prefix in the shared store, not some other key — read it back + // directly via cache.Store using the same prefix + // registerCacheFunctions applies. + var direct string + hit, err := store.Get(context.Background(), cacheKeyPrefix(cachePluginName)+"greeting", &direct) + if err != nil || !hit || direct != "hello" { + t.Fatalf("expected cache.Store to hold the namespaced key directly, hit=%v err=%v value=%q", hit, err, direct) + } + }) + + t.Run("GetMiss", func(t *testing.T) { + if _, hit := callCacheGet(t, inst, "never-set"); hit { + t.Fatal("expected a miss for a key that was never set") + } + }) + + t.Run("Delete", func(t *testing.T) { + callCacheSet(t, inst, "to-delete", "v", time.Minute) + if ok := callCacheDelete(t, inst, "to-delete"); ok != 1 { + t.Fatalf("CacheDelete: expected success, got %d", ok) + } + if _, hit := callCacheGet(t, inst, "to-delete"); hit { + t.Fatal("expected key to be gone after CacheDelete") + } + }) + + // TTLExpiry proves the ttlSeconds argument actually reaches Redis's + // EXPIRE mechanism (via cache.Store.Set), using miniredis's FastForward + // instead of a real sleep. FastForward advances the shared miniredis + // clock for every key, but this subtest's own key is the only one with + // a TTL short enough to be affected by a 10s jump (SetThenGet/Delete + // above used a 1-minute TTL). + t.Run("TTLExpiry", func(t *testing.T) { + callCacheSet(t, inst, "expiring", "v", 5*time.Second) + if _, hit := callCacheGet(t, inst, "expiring"); !hit { + t.Fatal("expected key to be present before its TTL elapses") + } + + mr.FastForward(10 * time.Second) + if _, hit := callCacheGet(t, inst, "expiring"); hit { + t.Fatal("expected key to be gone after its TTL elapses") + } + }) + + // NilCacheDegradesGracefully ensures a Runtime with no Cache configured + // (HostServices.Cache == nil) reports misses and failed writes/deletes + // instead of panicking — a plugin whose Cache is unavailable should fall + // back to "always recompute", not crash. Run last and restore the real + // store afterward in case tests are ever reordered. + t.Run("NilCacheDegradesGracefully", func(t *testing.T) { + rt.services.Cache = nil + t.Cleanup(func() { rt.services.Cache = store }) + + if ok := callCacheSet(t, inst, "k", "v", time.Minute); ok != 0 { + t.Fatalf("expected CacheSet to report failure with no Cache configured, got %d", ok) + } + if _, hit := callCacheGet(t, inst, "k"); hit { + t.Fatal("expected CacheGet to miss with no Cache configured") + } + if ok := callCacheDelete(t, inst, "k"); ok != 0 { + t.Fatalf("expected CacheDelete to report failure with no Cache configured, got %d", ok) + } + }) + + // NegativeTTLRejected ensures cache_set refuses a negative ttlSeconds + // (which Redis would otherwise reject as an invalid expire time) up + // front, rather than forwarding it and surfacing a generic store error. + t.Run("NegativeTTLRejected", func(t *testing.T) { + if ok := callCacheSet(t, inst, "negative-ttl", "v", -1*time.Second); ok != 0 { + t.Fatalf("expected CacheSet to reject a negative TTL, got %d", ok) + } + if _, hit := callCacheGet(t, inst, "negative-ttl"); hit { + t.Fatal("expected no value to have been stored for a rejected negative-TTL set") + } + }) +} + +// TestCacheKeyPrefix_DifferentPluginsDoNotCollide pins the namespacing +// contract: two plugins setting the same logical key must land at different +// Redis keys. +func TestCacheKeyPrefix_DifferentPluginsDoNotCollide(t *testing.T) { + if cacheKeyPrefix("com.paca.a") == cacheKeyPrefix("com.paca.b") { + t.Fatal("expected different plugins to get different cache key prefixes") + } + if got, want := cacheKeyPrefix("com.paca.dashboard")+"k", "plugin:18:com.paca.dashboard:k"; got != want { + t.Fatalf("cacheKeyPrefix: got %q, want %q", got, want) + } +} + +// TestCacheKeyPrefix_ColonInPluginNameDoesNotCollide guards against the +// namespacing scheme regressing to a bare "plugin::" concatenation, +// under which a plugin named "a:b" using key "x" would land at the same +// Redis key as a plugin named "a" using key "b:x". The length-prefixed +// encoding in cacheKeyPrefix makes name/key boundaries unambiguous +// regardless of what characters the plugin name contains. +func TestCacheKeyPrefix_ColonInPluginNameDoesNotCollide(t *testing.T) { + a := cacheKeyPrefix("a:b") + "x" + b := cacheKeyPrefix("a") + "b:x" + if a == b { + t.Fatalf("expected no collision, but both encode to %q", a) + } +} diff --git a/services/api/internal/platform/plugin/testdata/cacheplugin/main.go b/services/api/internal/platform/plugin/testdata/cacheplugin/main.go new file mode 100644 index 000000000..fc8a67e63 --- /dev/null +++ b/services/api/internal/platform/plugin/testdata/cacheplugin/main.go @@ -0,0 +1,98 @@ +// Command cacheplugin is a minimal WASI-reactor fixture used by +// runtime_test.go to exercise the paca.cache_get/cache_set/cache_delete host +// functions against a real wazero module instead of mocks. It talks to the +// host imports directly (no plugin-sdk-go dependency), the same way +// testdata/poisonplugin does for malloc/HandleRequest. +// +// Rebuild after editing with: +// +// GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared \ +// -o ../cache.wasm . +package main + +import "unsafe" + +// mallocBuf backs this fixture's bump allocator. 4 KiB is far more than the +// small keys/values these tests exchange. +var mallocBuf [4096]byte +var mallocOffset uint32 +var mallocBase uint32 + +//go:wasmexport malloc +func malloc(size uint32) uint32 { + if mallocBase == 0 { + mallocBase = uint32(uintptr(unsafe.Pointer(&mallocBuf[0]))) + } + if mallocOffset+size > uint32(len(mallocBuf)) { + return 0 + } + ptr := mallocBase + mallocOffset + mallocOffset += size + return ptr +} + +//go:wasmexport ResetAllocator +func resetAllocator() { + mallocOffset = 0 +} + +// paca.cache_get(keyPtr i64, keyLen i64, valuePtrPtr i64, valueLenPtr i64) +// +//go:wasmimport paca cache_get +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 +func hostCacheSet(keyPtr, keyLen, valuePtr, valueLen int64, ttlSeconds int32) int32 + +// paca.cache_delete(keyPtr i64, keyLen i64) -> ok i32 +// +//go:wasmimport paca cache_delete +func hostCacheDelete(keyPtr, keyLen int64) int32 + +// getOutBuf receives the [valuePtr, valueLen] pair the host writes back for +// cache_get, mirroring plugin-sdk-go's own wasm_backends.go pattern of a +// fixed package-level buffer rather than a stack local (so its address is +// stable linear-memory, not something that could get optimized away). +var getOutBuf [8]byte + +// CacheSet stores the value at [valPtr:valPtr+valLen] under the key at +// [keyPtr:keyPtr+keyLen] with the given TTL in seconds (0 = no expiry). +// Returns 1 on success, 0 on failure. +// +//go:wasmexport CacheSet +func CacheSet(keyPtr, keyLen, valPtr, valLen uint32, ttlSeconds int32) int32 { + return hostCacheSet(int64(keyPtr), int64(keyLen), int64(valPtr), int64(valLen), ttlSeconds) +} + +// CacheGet reads the key at [keyPtr:keyPtr+keyLen] and returns +// (valuePtr<<32 | valueLen); a zero length means "miss". +// +//go:wasmexport CacheGet +func CacheGet(keyPtr, keyLen uint32) uint64 { + outPtr := uint32(uintptr(unsafe.Pointer(&getOutBuf[0]))) + hostCacheGet(int64(keyPtr), int64(keyLen), int64(outPtr), int64(outPtr+4)) + valPtr := uint32(getOutBuf[0]) | uint32(getOutBuf[1])<<8 | uint32(getOutBuf[2])<<16 | uint32(getOutBuf[3])<<24 + valLen := uint32(getOutBuf[4]) | uint32(getOutBuf[5])<<8 | uint32(getOutBuf[6])<<16 | uint32(getOutBuf[7])<<24 + return (uint64(valPtr) << 32) | uint64(valLen) +} + +// CacheDelete removes the key at [keyPtr:keyPtr+keyLen]. Returns 1 on +// success, 0 on failure. +// +//go:wasmexport CacheDelete +func CacheDelete(keyPtr, keyLen uint32) int32 { + return hostCacheDelete(int64(keyPtr), int64(keyLen)) +} + +//go:wasmexport HandleRequest +func handleRequest(ptr uint32, length uint32) uint64 { + return 0 +} + +//go:wasmexport HandleEvent +func handleEvent(topicPtr uint32, topicLen uint32, payloadPtr uint32, payloadLen uint32) { +} + +func main() {}