ADR-005: Cache and Lock Pluggability — Keep PostgreSQL default, Add Redis as an Optional Provider
Status
Proposed — Tracking implementation in EW-629. This ADR is forward-looking; nothing changes today. The PostgreSQL backend stays the default and remains fully supported.
Date
- 2026-05-16 — Initial.
Context
Ever Works deliberately runs all stateful concerns on PostgreSQL today:
CacheModule(general-purpose key-value cache) usesCacheFactory.TypeORM(...)over thecache_entriestable via aTypeORMKeyvAdapter— seecaching.md.DistributedTaskLockService(per-resource mutex) uses the samecache_entriestable, with atomicINSERTon the primary key as the acquisition primitive and a token-bound DELETE for release — seedistributed-task-lock.md.WorkScheduleDispatcherServiceuses CAS-style atomic UPDATE on its own schedule rows (the row IS the lock target).- Background jobs run on Trigger.dev — no BullMQ-on-Redis wiring is present on the API or agent side.
This is a strong, simple architecture. One stateful store, one set of operational concerns, one backup story. It scales further than people usually expect — the cache_entries table is just an indexed PK, and PostgreSQL handles tens of thousands of QPS on commodity hardware.
However, as Ever Works grows past the open-source distribution and into hosted multi-tenant deployments at higher scale, some operators will want to:
- Offload hot cache traffic from the primary database. A high-volume Work with frequent webhook bursts, plus the upcoming data-repo instant-sync dispatcher running every minute across thousands of Works, plus per-tick lock heartbeats, plus generic application caching — all hitting
cache_entries— is fine today, but at 10× load some operators will prefer to move this traffic to Redis to keep their primary DB headroom for application data. - Get sub-millisecond cache reads for hot paths (rate limiters, request-scoped caches, etc.) — PostgreSQL is fast but Redis is faster for this workload class.
- Plug into existing Redis investments. Operators running on platforms like AWS ElastiCache, Upstash, or Redis Cloud often already have a tuned Redis tier they want to reuse rather than stand up another PostgreSQL replica.
- Pubsub use cases that may emerge later (cross-instance cache invalidation, distributed event fan-out). Redis is a natural fit if it's already in the stack.
The question isn't "PostgreSQL vs Redis" — it's whether we want to lock ourselves to one option or make the backend pluggable so each deployment can choose.
Decision
Both CacheModule (via CacheFactory) and DistributedTaskLockService will become pluggable with a backend chosen at deployment time. Concretely:
CacheFactorygains a third preset —CacheFactory.Redis(options)— that returns a Keyv-Redis-backedCacheModuleconfiguration. The existingInMemory()andTypeORM(...)presets remain unchanged and continue to be supported.DistributedTaskLockServiceis split into an abstract interface + two implementations —PostgresLockProvider(current implementation, default) andRedisLockProvider(new, optional). The publicrunExclusive/tryAcquire/releaseAPI is unchanged.- Configuration is driven by environment — a new
EVER_WORKS_CACHE_BACKEND={typeorm,redis,memory}andEVER_WORKS_LOCK_BACKEND={postgres,redis}pair selects the provider. Defaults:typeormfor cache,postgresfor lock — i.e. existing deployments need no config change. - Both backends must pass the same test suite. The current
DistributedTaskLockServicetest suite is generalised to run against aLockProviderinterface; both providers must pass it identically.
What does NOT change
- The PostgreSQL backend remains the default and is fully supported. No deployment is forced to add Redis. The self-hosted open-source distribution continues to work with just PostgreSQL as before.
- The existing
cache_entriestable, theTypeORMKeyvAdapter, and the currentDistributedTaskLockServicesemantics (token-bound release, heartbeat refresh, 24h hard cap, stale-row sweep) all remain in place — they become thePostgresLockProvider. - All existing callers (community-PR processor, future
data-repo-instant-syncconsumer) keep using the same injected service and high-level API. They never see a backend selector.
Out of scope for this ADR
- Replacing Trigger.dev with a Redis-based queue (e.g. BullMQ). Trigger.dev stays.
- Distributed pubsub patterns (cross-instance cache invalidation). Can come later as a separate ADR if needed.
- Removing the PostgreSQL backend. Never. It stays the default and a first-class option.
Consequences
Positive
- Operators with Redis investments can opt in without forking.
- High-scale deployments can offload cache + lock heartbeat traffic from the primary database.
- The platform retains its "just PostgreSQL" simplicity for self-hosters and small deployments.
- Decouples the abstract concern (lock / cache contract) from the storage choice — improves testability and lets us swap backends in tests too.
Negative
- Adds a small surface of provider-selection code paths to maintain (one extra abstraction layer).
- Operators choosing Redis pick up its operational concerns (persistence config, eviction policy, failover) — but that's their explicit choice.
- Two backends to keep at feature parity in tests + docs.
Neutral
- No new dependencies on the default path. Redis support arrives behind an optional
@ever-works/redis-providerpackage or as a peer-dep.
Implementation outline
Tracked in EW-629. High-level shape:
- Define
LockProviderinterface (packages/agent/src/cache/lock-provider.ts):acquire(key, ttlMs): Promise<{ token: string } | null>refresh(key, token, ttlMs): Promise<boolean>release(key, token): Promise<void>peek(key): Promise<boolean>
- Extract the current
DistributedTaskLockServicebody that touchescache_entriesintoPostgresLockProvider implements LockProvider.DistributedTaskLockServicebecomes a thin orchestrator (heartbeat timer, error handling,runExclusivewrapper) that consumes anyLockProvider. - Add
RedisLockProvider implements LockProviderusingSET key token NX PX ttl+ Lua script for token-bound DELETE. - Add
CacheFactory.Redis({ url, ttl, namespace })returning a@keyv/redis-backedCacheModule.registerAsync(...)config. - Module wiring:
LockProviderinjection token bound via factory provider that readsEVER_WORKS_LOCK_BACKEND. - Shared test suite (
packages/agent/src/cache/__tests__/lock-provider.contract.spec.ts) that runs against both providers with the same expectations. - Docs: update
caching.mdanddistributed-task-lock.mdto describe the provider selection and theEVER_WORKS_*_BACKENDenv vars.
References
- EW-629 — Add Redis provider for Locks and Caching (optional, additive)
distributed-task-lock.md— current PostgreSQL-backed lock service.caching.md— currentCacheFactory+TypeORMKeyvAdapter.data-repo-instant-syncspec — first feature that explicitly notes both backends as supported (today: PostgreSQL; later: optionally Redis).