Distributed Cache¶
Guide: a generic Redis-backed L2 distributed cache, exposed as one composable function you call from any layer.
At a glance
- What — a shared key-value cache backed by Redis, exposed as a single composable action function at route
v1.cache.redis. Oneactionheader selects the operation; values are opaquebyte[].- How —
PUT/GET/MGET/MPUT/DELETE/PUT_IF_NOT_PRESENTplus FIFOLIST_PUSH/LIST_POP/LIST_LEN. Every stored key carries a TTL from creation. One shared, multiplexed Lettuce connection — no pool.- Advanced & opt-in — off by default; enable with
redis.cache.enabled=true. Standalone or Redis Cluster with no code change.- For developers who need a cache shared across pods and across the three layers (Platform Core, Event Script, Knowledge Graph).
The cache is a thin composable module over the same Redis client layer that powers
sync-over-async — the shared redis-connection foundation (standalone/cluster
selection, auth, TLS, health probe). It adds only the cache operations and the composability surfaces;
it is not a rendezvous transport (that is sync-over-async) and not a cache-aside framework — your
flow orchestrates read-through / write-through, the module just stores and returns bytes.
Opt-in. The cache function and its health check register only when
redis.cache.enabled=true. The connection is built lazily on first use, so enabling it does not fail application start-up when Redis is briefly unreachable — a cache call then fails fast and the flow's exception handler decides the fallback.
Operations¶
The action header (case-insensitive) selects the operation. The key(s) and TTL ride in headers; the
value(s) ride in the body:
action |
Other headers | Body (input) | Result | Redis |
|---|---|---|---|---|
PUT |
key, ttl? |
value (byte[]) |
true |
SETEX |
GET |
key |
— | value (byte[]), or null on a miss |
GET |
DELETE |
key |
— | count removed (long) |
DEL |
PUT_IF_NOT_PRESENT |
key, ttl? |
value (byte[]) |
boolean — true if stored, false if the key existed |
SET NX EX |
MGET |
— | keys (List<String>) |
Map<String, byte[]> (misses omitted) |
MGET |
MPUT |
ttl? |
entries (Map<String, byte[]>) |
true |
pipelined per-entry SETEX |
LIST_PUSH |
key, ttl? |
value (byte[]) |
new list length (long) |
RPUSH + EXPIRE (atomic) |
LIST_POP |
key |
— | oldest value (byte[]), or null if empty |
LPOP |
LIST_LEN |
key |
— | list length (long) |
LLEN |
Notes for callers:
ttlis a duration string (30s,5m,1h). When a write omits it, the cache usesredis.cache.default.ttl(default1h). Every stored key carries a TTL from creation — there are no un-expiring keys;PUT_IF_NOT_PRESENTis atomic (SET … NX EX, neverSETNXthenEXPIRE).MPUTis a pipelined batch of single-keySETEX— one round trip, and each key keeps its own TTL (a rawMSETsets none). It is not atomic across the map (a partial failure is just cache misses), which is correct for a cache and unavoidable on a cluster.MGETkeys may span cluster hash slots — the Lettuce cluster client scatter-gathers them for you.- An unknown
action, a missingkey, or a missing value raisesIllegalArgumentException, surfaced to the caller as the event's error. - Redis client failures are classified, so a caller — or the flow's / graph's exception handler, which
passes the status through — sees the failure for what it is: a command timeout replies 408 (Lettuce's
Command timed out after N second(s)), an unreachable Redis replies 503Redis unavailable - …(theredis.healthvocabulary), and a genuine command error answered by the server (e.g.WRONGTYPE) keeps the platform's default 500. The Rust engine classifies the same way, so a mixed fleet fails alike. AnMPUTwhose pipelined replies do not all arrive in time is the same 408. - The shared connection is reset after a command timeout. Lettuce reconnects a dropped connection on its
own exponential backoff (capped at 30 s), so after a long outage a pod could keep timing out for up to
~30 s after Redis was back while
redis.health— a fresh probe connection — already read green. The module now resets the connection when a command times out: at once when the connection is not open, on the second consecutive timeout when it is (one slow command on a healthy connection does not drop the commands in flight). The next call reconnects, so recovery is bounded byredis.timeout.msrather than the backoff; while Redis is still down that call fails fast with 503 instead of waiting out another timeout. The Rust engine's client reconnects on the first command after Redis returns, so both engines recover within a command timeout.
Enabling and configuring¶
Add the distributed-cache jar to the classpath and enable it. The connection uses the plain redis.*
namespace (the base namespace of the shared foundation), so it never collides with sync-over-async's
soa.redis.*:
redis.cache.enabled=true # master switch: registers v1.cache.redis + redis.health
redis.cache.default.ttl=1h # default TTL when a write omits 'ttl'
redis.cache.key.prefix=app1: # optional: prepended to every key (namespace apps sharing one Redis)
redis.host=${REDIS_HOST:127.0.0.1}
redis.port=${REDIS_PORT:6379}
redis.username=${REDIS_USERNAME:} # blank = default user; set for an ACL/RBAC user
redis.password=${REDIS_PASSWORD:} # blank = no auth; keep secrets in the environment
redis.cluster.detect=auto # auto = detect at start-up; else use the boolean below
redis.cluster.mode=false # true = cluster, false = standalone (when detect is not auto)
The essentials — see the Configuration Reference for the full list:
| Key | Default | Description |
|---|---|---|
redis.cache.enabled |
false |
Master switch; true registers v1.cache.redis and redis.health. |
redis.cache.instances |
20 |
Virtual-thread worker instances (function concurrency), not a connection count — every instance shares the one multiplexed connection. |
redis.cache.default.ttl |
1h |
TTL applied when a PUT / MPUT / LIST_PUSH omits ttl. |
redis.cache.key.prefix |
— (blank) | Prepended to every key; stripped again from MGET results. Isolate apps sharing one Redis. |
redis.host / redis.port |
127.0.0.1 / 6379 |
Redis connection (or the cluster configuration endpoint). |
redis.username / redis.password |
— (blank) | ACL/RBAC username / auth password. Source from the environment. |
redis.ssl |
false |
Use TLS (rediss://). |
redis.cluster.detect / redis.cluster.mode / redis.cluster.nodes |
auto / false / — |
Standalone-or-cluster selection — the same two-key scheme as sync-over-async. |
redis.timeout.ms |
5000 |
Default command timeout. |
redis.health.timeout |
5s |
Timeout for the redis.health probe. |
redis.health.startup.grace |
30s |
Start-up grace for redis.health. |
Separate Redis clients, by design¶
When an application runs both the cache and sync-over-async, give each its own
Redis client — configure the cache under redis.* and sync-over-async under soa.redis.*, fully.
That is the intended shape, not merely a supported one. Nothing extra is needed to get it:
RedisBackendFactory builds a new client from whatever configuration it is handed, and every
endpoint-defining key — host, port, username, password, ssl, database, timeout.ms, and all
three cluster.* keys — resolves per namespace. The two can therefore differ in server, credentials,
TLS, and even topology: one standalone, one cluster-mode-enabled.
Why it matters:
- Separation of concerns. They are different things. Sync-over-async is a rendezvous transport whose keys live for the duration of one request; the cache is a store whose keys live for their TTL. Their sizing, eviction, and failure characteristics have nothing to do with each other.
- A cache evicts; a rendezvous must not. This is the sharp one. A cache under memory pressure with an
eviction policy configured will evict whatever fits its policy — including a
request:{cid}rendezvous key, mid-request. The caller then waits for a reply that can never arrive. Separate instances make that failure mode structurally impossible; a shared instance only avoids it by configuration discipline. - Independent operations. Restarting, resizing, or failing over the cache should not disturb in-flight synchronous requests.
What the
redis.*fallback is for. Eachsoa.redis.*key falls back to the un-prefixedredis.*form when the prefixed one is absent. That exists for backward compatibility — sync-over-async predates the cache and was configured under plainredis.*, so those deployments keep working untouched, and an application running sync-over-async alone may still useredis.*throughout. It is not an invitation to share one instance between the two modules.Override a namespace completely, or not at all. Because the fallback is per key, a partial override silently mixes the two: setting
soa.redis.hostbut notsoa.redis.passwordpoints sync-over-async at the new host carrying the cache's credentials, and authentication fails. When you decouple, set the wholesoa.redis.*connection set.Both probes, both endpoints. Two clients mean two health checks. List them together —
mandatory.health.dependencies=redis.health, soa.redis.health— or one endpoint goes unmonitored.
Credentials follow the same pattern in both namespaces — resolved from the environment by a
lower-sequence @MainApplication credential bootstrap (see
sync-over-async → auth).
Sharing one server anyway? It is safe for correctness — the key shapes do not collide (
request:{cid}/queue:{cid}versus yourredis.cache.key.prefixnamespace) — but you own the eviction-policy risk above, and the two workloads share one memory budget. Note also thatdatabaseis standalone-only: Redis Cluster is database 0, so "one cluster, two logical databases" is not an alternative to two instances.
Using the cache¶
The same function is reachable from all three layers — it is just "call a route".
Layer 1 — PostOffice¶
var po = PostOffice.trackable(headers, instance);
// PUT: the value is the body; ttl is optional (defaults to redis.cache.default.ttl)
byte[] payload = SimpleMapper.getInstance().getMapper().writeValueAsBytes(profile);
po.request(new EventEnvelope().setTo("v1.cache.redis")
.setHeader("action", "PUT").setHeader("key", "profile:42").setHeader("ttl", "10m")
.setBody(payload), 5000).get(); // .get() suspends the virtual thread, no kernel-thread block
// GET: a miss returns a null body
EventEnvelope res = po.request(new EventEnvelope().setTo("v1.cache.redis")
.setHeader("action", "GET").setHeader("key", "profile:42"), 5000).get();
byte[] cached = res.getBody() instanceof byte[] bytes ? bytes : null; // null = cache miss
Layer 2 — Event Script task¶
Drive it from a flow with input/output data mapping — a constant sets the action, model.* supplies the
key, and the byte[] value rides the whole-body * passthrough:
tasks:
# write-through: cache the serialized profile under a 10-minute TTL
- input:
- 'text(PUT) -> header.action'
- 'model.cacheKey -> header.key'
- 'text(10m) -> header.ttl'
- 'model.profileBytes -> *' # the byte[] value rides in the body
process: 'v1.cache.redis'
output:
- 'result -> model.stored' # the PUT ack (true)
description: 'Cache the profile'
execution: sequential
next:
- 'read.back'
# read: GET returns the value, or null on a miss
- input:
- 'text(GET) -> header.action'
- 'model.cacheKey -> header.key'
process: 'v1.cache.redis'
output:
- 'result -> model.cached' # byte[] value, or null on a miss (branch on it with a decision task)
description: 'Read the cached profile'
execution: sequential
Layer 3 — Knowledge Graph node¶
A graph.task node calls the same route with the same mapping syntax (the node's properties, as
they appear in an exported model — in the Playground: skill=graph.task, task=v1.cache.redis,
input[]=…, output[]=…):
{
"skill": "graph.task",
"task": "v1.cache.redis",
"input": [
"text(GET) -> header.action",
"model.cacheKey -> header.key"
],
"output": [
"result -> model.cached"
]
}
Value type & serialisation¶
Values are opaque byte[] — the cache stores and returns raw bytes and the caller owns serialisation.
This maximises interop: any layer, and any language (the Rust port reads and writes the same keys). A
String body is accepted as a UTF-8 convenience, but the canonical value type is byte[]; use
SimpleMapper (or your own codec) to (de)serialise your objects. A typed convenience helper is deliberately
not provided — the function plus the flow/graph surfaces already cover all three layers.
Standalone or cluster Redis¶
The cache runs against a single-node Redis or a Redis Cluster with no code change — it reuses
sync-over-async's client layer, so the two-key selection (redis.cluster.detect / redis.cluster.mode /
redis.cluster.nodes) and authentication work identically; see
sync-over-async → Standalone or cluster Redis for the full detail.
Every operation is cluster-safe by construction: PUT/GET/DELETE/PUT_IF_NOT_PRESENT and the list
ops are single-key; MGET keys may span slots and are scatter-gathered by the cluster client; MPUT is a
pipeline of independent single-key SETEX (each routes to its own slot). The module uses one shared,
multiplexed Lettuce connection — no connection pool: Lettuce pipelines any number of concurrent
callers over one in-order TCP connection, and this op set has no blocking commands (LPOP, not BLPOP)
or MULTI/EXEC transactions that would warrant a pool. redis.cache.instances is virtual-thread worker
concurrency, not a connection count. The one connection is reset after a command timeout (see the
failure bullet above), and the command handle the store holds is a facade that follows the reset, so no
caller ever sees a stale connection.
Health check¶
The module ships a health-check function at route redis.health (registered with the cache when
redis.cache.enabled=true). Opt in as a health dependency:
mandatory.health.dependencies=redis.health
# or, to report Redis without failing /health:
# optional.health.dependencies=redis.health
The probe is a single Redis PING on a dedicated connection built from the redis.* parameters — one
round trip proves connectivity, TLS, and authentication. Its semantics match
soa.redis.health exactly: config is resolved lazily (so a
vault-published credential that lands after start-up is picked up), an unusable configuration or a rejected
credential (NOAUTH / WRONGPASS) reports a passing Waiting for Redis connection status rather than
failing /health, and only a genuine connectivity failure returns 503. redis.health.timeout
(default 5s) bounds the probe; redis.health.startup.grace (default 30s) is the start-up placeholder
window.
redis.healthis the plain-named counterpart to sync-over-async'ssoa.redis.health; the two coexist, each reporting on its ownredis.*/soa.redis.*server.
When to use it¶
Reach for the distributed cache when you need a shared L2 key-value cache — cross-pod, cross-instance, and reachable from any of the three layers — with TTL'd entries and a small, cache-shaped operation set. It is opt-in: if a per-instance in-memory cache suffices, use that instead. It is not:
- a rendezvous / streaming transport — that is sync-over-async;
- a cache-aside framework — there is no automatic DB read-through/write-through or invalidation; your flow orchestrates that (a cache-miss branch calling the source of truth);
- a general Redis client — the operation set is bounded and cache-shaped, not arbitrary
EVAL/ pub-sub / streams.
The design rationale and the ruled decisions (Q1–Q8) live in the distributed-cache design spec.
See also¶
- Sync-over-Async — the sibling opt-in module that shares the same Redis client layer.
- Configuration Reference — every
redis.cache.*/redis.*key. - Event Script Syntax — the input/output data-mapping syntax the Layer 2 / Layer 3 examples use.
- Observability — tracing a cache call end-to-end.