Redis / Valkey
forze[redis] implements fast, ephemeral, and coordination-oriented state on
Redis or Valkey: caching, atomic counters, idempotency records, search-result
snapshots, and distributed locks — all behind Forze contracts.
Install¶
uv add 'forze[redis]'
Needs a reachable Redis or Valkey server.
The client¶
from forze_redis import RedisClient
redis = RedisClient()
Use RoutedRedisClient when the tenant or route selects the endpoint.
Wire it¶
Each resource takes a namespace (a logical key prefix); set
tenant_aware=True when keys must include the current tenant. Register the
resources on the deps module, open the pool from the lifecycle plan:
from forze.application.execution import DepsRegistry, LifecyclePlan
from forze_redis import (
RedisCacheConfig,
RedisConfig,
RedisDepsModule,
RedisIdempotencyConfig,
redis_lifecycle_step,
)
deps = DepsRegistry.from_modules(
RedisDepsModule(
client=redis,
caches={"orders": RedisCacheConfig(namespace="app:orders", tenant_aware=True)},
idempotency=RedisIdempotencyConfig(namespace="app:idempotency"),
),
)
lifecycle = LifecyclePlan.from_steps(
redis_lifecycle_step(dsn="redis://localhost:6379/0", config=RedisConfig(max_size=20)),
)
What it provides¶
| Contract | Keyed by |
|---|---|
| Cache | CacheSpec.name (caches) |
| Counter | CounterSpec.name (counters) |
| Idempotency | IdempotencySpec.name (idempotency, plain or routed) |
| Search-result snapshots | SearchResultSnapshotSpec.name (search_snapshots) |
| Distributed locks | DistributedLockSpec.name (dlocks) |
L1 push invalidation¶
RedisCacheConfig(invalidation_push=True) enables Redis 6+ client-side
caching (CLIENT TRACKING, RESP3 push) for caches backing the
document L1:
one pinned connection per client receives an invalidation push for every
write, expiration, or eviction under the cache's key prefix — by any replica —
and the in-process L1 drops the entry immediately, demoting the L1 TTL to a
backstop. Fails open (stream loss flushes the L1 and falls back to TTL
semantics while reconnecting); tenant-routed clients and dynamic namespaces
stay TTL-only by design.
Fleet-wide resilience state¶
Two builders turn the process-local resilience state into shared, fleet-wide state:
from forze.application.execution import ResilienceDepsModule
from forze_redis import redis_circuit_breaker_store, redis_rate_limit_store
ResilienceDepsModule(
breaker_store=redis_circuit_breaker_store(redis),
rate_limit_store=redis_rate_limit_store(redis),
)
The breaker store makes an open circuit on one replica protect them all; the
rate-limit store keeps the token bucket in a Redis hash mutated atomically by
Lua on the server clock, so the declared permits/per is the fleet's rate
(not per-replica). Both fail open to process-local state on a Redis error —
see Fleet-wide state.
Distributed locks and fencing¶
A lock alone is best-effort exclusion: a holder paused by GC or a network
partition can resume after its lease expired while a new holder runs. To close
that gap, acquire returns an AcquiredLock whose token is a fencing
token — monotonically increasing per key across lock generations. The Redis
adapter issues it atomically with the SET NX PX acquire (a Lua script INCRs
a per-key <lock key>:fence counter; the counter has no TTL and is never
deleted on release, so tokens stay monotonic even after expiry — at the cost of
one small permanent key per lock key).
Protect downstream writes by sending the token with the write and rejecting, storage-side, any token lower than the highest one observed for that resource:
async with dlock_scope.scope("invoice:42") as lock:
# e.g. UPDATE ... SET fence = :token WHERE id = 42 AND fence < :token
await repo.update_invoice(invoice, fence_token=lock.token)
Extending a live lease (reset, the scope's heartbeat) keeps the same token —
only a fresh acquisition starts a new generation. Without the consumer-side
token check the lock remains best-effort exclusion; the check is what upgrades
it to fenced exclusion.
Notes¶
- Namespaces are deliberate. Give each contract its own namespace; don't share one across unrelated resources.
- TTLs are logical.
CacheSpec/IdempotencySpecTTLs are expiry intents — Redis memory eviction can drop keys earlier, so size and policy the server accordingly. - Idempotency needs stable keys and a TTL longer than client retries; every worker that can handle an operation must share the same namespace.
- Routed clients use
routed_redis_lifecycle_step— don't mix routed and non-routed lifecycle steps for one client.