Managed Cache

Zenifra's Cache component currently uses Valkey and is designed for values that can be rebuilt from a source of truth. Storage is disposable: restarts, expiration, and eviction may remove entries without representing application data loss.

Cache-aside pattern

  1. Read the key from Valkey.
  2. On a miss, query the source of truth.
  3. Store the result with a TTL and, when possible, a small jitter.
  4. Invalidate the key when the source of truth changes.
GET catalog:product:42
SET catalog:product:42 <json> EX 300
DEL catalog:product:42

Never store tokens, passwords, or regulated data without an appropriate protection policy. Use namespaces, limit value sizes, and monitor hit/miss rates.

Node.js and TypeScript

npm install iovalkey
import Redis from 'iovalkey'
import { randomInt } from 'node:crypto'

async function loadProductFromSource() {
  return { id: 42, name: 'example' }
}

const url = new URL(process.env.VALKEY_URL ?? '')
const client = new Redis({
  host: url.hostname,
  port: Number(url.port),
  username: decodeURIComponent(url.username),
  password: decodeURIComponent(url.password),
  tls: { servername: url.hostname },
})

const key = 'catalog:product:42'
const cached = await client.get(key)
const value = cached ?? JSON.stringify(await loadProductFromSource())
if (!cached) await client.set(key, value, 'EX', 300 + randomInt(0, 30))
console.log(value)
await client.quit()

Replace loadProductFromSource() with the application’s real query. The example uses jitter to reduce simultaneous expirations; at high volume, combine it with a short lock or request coalescing.

Python

python -m pip install valkey
import json
import os
import random
from urllib.parse import urlparse
from valkey import Valkey

def load_product_from_source():
    return {"id": 42, "name": "example"}

url = urlparse(os.environ["VALKEY_URL"])
client = Valkey(
    host=url.hostname, port=url.port, username=url.username, password=url.password,
    ssl=True, ssl_check_hostname=True, decode_responses=True,
)

key = "catalog:product:42"
cached = client.get(key)
value = cached or json.dumps(load_product_from_source())
if cached is None:
    client.set(key, value, ex=300 + random.randint(0, 30))
print(value)
client.close()

Replace load_product_from_source() with the application query. To avoid a cache stampede, only one worker should rebuild an expiring key; others can wait or serve stale data for a short window.

What not to do

  • Do not treat Cache as permanent storage.
  • Do not use infinite TTLs for data that can grow without bounds.
  • Do not use KEYS * to clear the cache; use namespaces and SCAN or key invalidation.
  • Do not assume a hit means that data is current; define an invalidation policy.

Safe operation

Choose TTLs according to how quickly the source changes, not according to plan size. A very short TTL increases load and latency; a very long TTL serves stale data. Random jitter prevents thousands of keys from expiring in the same second. When rebuilding is expensive, use a short token-based lock and allow readers to receive stale data during a controlled window.

Monitor hit rate, miss rate, latency, serialization errors, and eviction count. More misses can indicate excessive invalidation or insufficient capacity, while a high hit rate does not prove that content is correct. Define entity-level invalidation after writes to the source, and treat Cache failure as a normal path: the application should read the source and continue without disposable data.

Next steps

On this page