Skip to content

Cache (Valkey)

AnvilBase includes Valkey — the Apache-2.0 community fork of Redis — as a shared cache. Each project gets its own namespace: every key is transparently prefixed with <project_id>:, so projects never see each other’s keys. The platform also uses Valkey for per-project rate limiting.

Use it for ephemeral, fast-access data: session blobs, computed results, rate counters, feature flags, short-lived locks.

Two ways to reach the cache

SurfaceAuthUse it for
Cache data-plane API (/v1/cache/{id}/…)a project service_role keyyour app/backend (the recommended path — same as the SDK client.cache)
Cache management API (/api/v1/projects/{id}/cache/…)a platform PAT / admin tokenthe console + operational scripts (browse/flush/stats)

Both apply (and strip) the <project_id>: namespace for you, so you always work in bare keys. The data-plane is the surface an app uses with only a project key — see below.

Per-project isolation (enforced, not just by convention)

Every project has a dedicated Valkey ACL user (proj_<project_id>) that is physically confined to its <project_id>:* keyspace. The cache data-plane connects to Valkey as that user, so a project cannot read or write another project’s keys, and cannot run cluster-wide commands (FLUSHALL, FLUSHDB, KEYS, CONFIG, …) — Valkey returns NOPERM. The ACL user’s password is derived from the deployment’s CONTROL_PLANE_SECRET (HMAC-SHA256, never stored in plaintext) and the user is created automatically at project provisioning (and lazily back-filled for older projects on first use).

This means namespace isolation is an enforced security boundary, not just a key-prefix convention.

The cache data-plane API (use this from your app)

Authenticate with a project service_role key and call /v1/cache/{id}/…. Keys are bare; the namespace is applied + enforced for you.

OperationEndpointBody / result
GetGET /v1/cache/{id}/get/{key}{ value } (404 if absent)
SetPOST /v1/cache/{id}/set{ key, value, ex?, px?, nx?, xx? }{ ok }
DeletePOST /v1/cache/{id}/del{ keys: [...] }{ deleted }
Incr / DecrPOST /v1/cache/{id}/incr (or /decr){ key, by? }{ value }
ExpirePOST /v1/cache/{id}/expire{ key, seconds }{ applied }
TTLGET /v1/cache/{id}/ttl/{key}{ ttl } (-1 no expiry, -2 absent)
ExistsGET /v1/cache/{id}/exists/{key}{ exists }
MGetPOST /v1/cache/{id}/mget{ keys: [...] }{ values: [...] }
MSetPOST /v1/cache/{id}/mset{ pairs: { k: v } }{ ok }
ScanGET /v1/cache/{id}/scan?cursor=&match=&count={ cursor, keys } (bare keys)

The cache data-plane is service_role-only — an anon or authenticated key gets 403. The SDK alias shape /v1/{id}/cache/v1/… is equivalent.

Terminal window
# set with a 1-hour TTL (service_role key)
curl -X POST http://localhost:39001/v1/cache/<id>/set \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" -H "Content-Type: application/json" \
-d '{"key":"session:abc","value":"{\"user\":\"3f2b\"}","ex":3600}'
# read it back
curl http://localhost:39001/v1/cache/<id>/get/session:abc \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" # → {"value":"..."}

scan only ever returns your keys. Note: a scan with match=* is scoped to your namespace by the API; the per-key ACL is the hard floor for values (another project’s values are always NOPERM).

The cache management API

The control plane exposes a project’s keyspace over the management API. You pass the bare key — the <project_id>: prefix is added and stripped for you.

OperationEndpoint
List keysGET /api/v1/projects/{id}/cache/keys?pattern=*&limit=50
Get a keyGET /api/v1/projects/{id}/cache/keys/{key}
Set a string keyPOST /api/v1/projects/{id}/cache/keys
Delete a keyDELETE /api/v1/projects/{id}/cache/keys/{key}
Flush the projectPOST /api/v1/projects/{id}/cache/flush
Stats (key count)GET /api/v1/projects/{id}/cache/stats

Set a value with a TTL

Terminal window
curl -X POST http://localhost:39001/api/v1/projects/<id>/cache/keys \
-H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \
-d '{"key":"session:abc","value":"{\"user\":\"3f2b\"}","ttl":3600}'

ttl is in seconds; omit it for no expiry. Returns 201.

Read it back

Terminal window
curl http://localhost:39001/api/v1/projects/<id>/cache/keys/session:abc \
-H "Authorization: Bearer $ANVILBASE_TOKEN"
# → value plus type (string/hash/list/set/zset), TTL, size

List and flush

Terminal window
# list keys (pattern default *, limit capped at 200)
curl "http://localhost:39001/api/v1/projects/<id>/cache/keys?pattern=session:*&limit=100" \
-H "Authorization: Bearer $ANVILBASE_TOKEN"
# delete all of THIS project's keys
curl -X POST http://localhost:39001/api/v1/projects/<id>/cache/flush \
-H "Authorization: Bearer $ANVILBASE_TOKEN"
# → { "deleted": <count> }

The list/get endpoints can read all value types (string, hash, list, set, sorted set); the set endpoint writes string keys.

The console surfaces this management API as a Cache inspector page (sidebar → Cache): pick a project, browse keys (with a pattern filter), view a key’s value and TTL, set or delete keys, see a key-count / memory stats strip, and flush the whole project’s keyspace (confirm-guarded).

Using the cache from your app (SDK)

Every AnvilBase SDK exposes client.cache over the data-plane API above. Keys are bare; the namespace is applied + enforced server-side. It is a server-side surface — initialise the client with a service_role key from trusted backend code (an anon/authenticated key gets 403).

import { createClient } from '@anvilbase/client'
const client = createClient('https://your-host', SERVICE_ROLE_KEY, { projectId })
await client.cache.set('user:42:profile', JSON.stringify(profile), { ex: 300 })
const { data } = await client.cache.get('user:42:profile')

The same cache surface exists in the Python, Go, Rust, Elixir, Kotlin, and Swift SDKs (client.cache.set(...) / cache.get(...)), and as MCP tools (anvilbase_cache_get / _set / _del / _incr / _expire / _ttl / _scan).

Caching patterns

Read-through cache

async function getProfile(userId: string) {
const { data: hit } = await client.cache.get(`profile:${userId}`)
if (hit) return JSON.parse(hit)
const { data } = await db.from('profiles').select('*').eq('id', userId).single()
await client.cache.set(`profile:${userId}`, JSON.stringify(data), { ex: 300 })
return data
}

Rate counter

const { data: n } = await client.cache.incr(`rl:${ip}`)
if (n === 1) await client.cache.expire(`rl:${ip}`, 60)
if (n > 100) throw new Error('rate limited')

Short-lived lock

const { data: ok } = await client.cache.set(`lock:job:${id}`, '1', { nx: true, ex: 30 })
if (!ok) return // someone else holds it

Deprecated: raw Redis with the shared VALKEY_PASSWORD

Deprecated — do not use. Earlier guidance suggested connecting to Valkey directly with the shared VALKEY_PASSWORD and prefixing keys by hand. That password belongs to the Valkey admin (default) user, which can read and write every project’s keys and run FLUSHALL — there is no isolation. A leaked or shared VALKEY_PASSWORD is a cross-tenant compromise.

Use the cache data-plane API / client.cache instead (above): it connects as your project’s namespace-isolated ACL user, so a credential leak is bounded to your own namespace. The admin VALKEY_PASSWORD is never exposed to projects and should be treated as an internal platform secret.

Notes

  • Valkey is ephemeral — treat it as a cache, not a database. Anything you can’t recompute belongs in Postgres.
  • Per-project quotas can bound namespace size; flush stale keys and set TTLs.
  • The platform’s rate limiter shares this Valkey instance (as the admin user) — it is unaffected by the per-project ACL users and stays scoped by its own keys.

Next: Cron & Scheduling.