Skip to main content
Version: 2.5

Redis

Redis acts as the coordination layer of the tSM platform: it holds state that must be visible across replicas and services — distributed locks, idempotency records, shared short-lived values, and an opt-in distributed cache. Transactional data stays in each microservice's own database, and queries run against Elasticsearch.

Two design decisions shape everything in this chapter:

  1. Redis is optional for services built on tsm-commons. They run without it (tsm.redis.cache.enabled=false), and the coordination helpers degrade gracefully when Redis is not configured (see Fail-Open Design). The Management Service is the exception: it stores its registry in Redis only (see the table below).
  2. Redis is never a system of record for business data. It holds locks, counters, cached values, and other short-lived state with a TTL. Losing the Redis content must never lose business data.

1. Where Redis Is Used​

ConcernMechanismTypical use case
Distributed lockswithDistributedLockOnly one replica may process a given business key at a time
Idempotent executionexecuteIdempotentlyRetried create operations return the first result instead of running twice
Master/sub lockswithMasterLock / withSublockWait until all asynchronous Kafka branches of an operation finish (e.g., Elasticsearch indexing confirmed)
Shared values & signallingsetBucketValue / waitForBucketValueCross-replica progress flags, single-flight guards, waiting for a value change without polling
Distributed cache (opt-in)Spring Cache with the Redis cache managerCaching expensive lookups shared by all replicas
Rate limitingRateLimitServiceRedis-backed request/message throttling — see Rate Limit Usage
Runtime registrySpring Data Redis repositoriesThe Management Service stores the live microservice/instance registry in Redis — see Management Service

Until the 2.4 release the helpers were named with a datalite prefix (dataliteLock, dataliteIdempotency, dataliteMasterLock, …). The old names remain as @Deprecated aliases with the same defaults; use the new ones in new code.


2. Client and Configuration​

tSM uses Redisson as the Redis client. The dependency ships with tsm-commons, so it is on the classpath of every microservice — but the client is disabled by default and activated per service:

tsm:
redis:
cache:
enabled: true # default is false — no Redis connection is opened

When enabled, the platform exposes the RedissonClientTsmService bean — a thin façade over the Redisson client that all helpers described below build upon.

Connection Configuration​

The Redisson configuration file is provided by the deployment, not by the application:

  • The TSM_REDIS_CONFIG environment variable points to a redisson.yaml file; without it, redisson.yaml is loaded from the classpath.
  • The file supports environment-variable templating, for example {{ REDISSON_NETTY_THREADS:-64 }}; the available variables are defined by the deployment's template.
  • The topology is a property of the deployment. A single-server configuration puts the high availability into the infrastructure (multiple Redis instances behind a proxy/load balancer, the application sees one address). CETIN environments use Redis Cluster (clusterServersConfig with the node addresses).

The Redis-related application properties are listed in the Configuration Properties Reference.

Resilience​

RedissonClientTsmService can replace the underlying Redisson client with a fresh one (a reconnect waits 6 seconds first and is serialized: concurrent callers queue on one monitor). The platform handles the failure cases as follows:

  • -READONLY retry. The write operations of the helpers (set, fastPut, publish, …) run through retryOnReadOnly. When a write fails with READONLY (including the RedisReadonlyException that plain commands raise), the client is replaced once and the operation is repeated on the new client, so such a write includes the 6 s pause. A READONLY that follows a reconnect done by another thread is only repeated on the new client. The get* accessors (getBucket, getMapCache, …) are lazy in Redisson and perform no I/O, so they cannot detect the failure themselves.
  • Lock acquisition reconnects on any exception, including one that is not a connection failure. A failing Redis therefore makes each caller wait for the Redisson retry cycle and then for the reconnect.
  • Redis Cluster and other topologies. After a reconnect the warm-up step chooses the node view according to the configuration (cluster, sentinel, single server, master/slave). It is best-effort: a failure is logged as WARN and the operation is repeated anyway.
  • Deployments with a single-server proxy. Pooled connections can stay on a degraded master behind a proxy. The idempotency filter of the HTTP API uses a fail-fast client without reconnect, so it does not heal this state itself (see the Idempotency chapter).

Callers should expect an exception from these helpers while Redis is down. See Fail-Open Design for the behaviour of each helper.

The actuator health indicator is opt-in as well (management.health.redis.enabled) — keep it off unless the service actually depends on Redis, so that a Redis failover cannot cascade into pod restarts.


3. Fail-Open Design​

All the helpers below are extension functions on a nullable RedissonClientTsmService?. When Redis is not configured (tsm.redis.cache.enabled=false), the protected block still executes — the lock is simply skipped. withSublock follows the same rule: without Redis, or when registering the sublock fails (for example because another thread has just replaced the client), it runs its block and logs a WARN; the master block then waits for the branches at most until its lease expires.

  • withDistributedLock without Redis runs the block unguarded (and logs Redis not active. Lock skipped).
  • executeIdempotently without Redis executes the action every time.
  • The same applies when the lock cannot be acquired within the wait timeout: the block proceeds anyway.
  • A Redis outage is a different case and differs per helper. executeIdempotently and withMasterLock do not run the action at all and throw the Redis error. withDistributedLock runs its block without the lock, discards the block's result and rethrows the Redis error, so the caller sees a failure although the block ran with its side effects, and a retry (a Kafka redelivery, a Camunda job) runs it again. If the block itself throws, that exception replaces the Redis error. See Resilience.

This is intentional: Redis coordination is a best-effort protection against duplicate or concurrent work, not a correctness guarantee. Business operations must remain correct (typically via database constraints and idempotent design) even if two replicas occasionally run the same block concurrently. If a use case requires a hard mutual-exclusion guarantee, it must be enforced in the transactional database, not in Redis.


4. Distributed Locks — withDistributedLock​

withDistributedLock protects a block of code so that, across all pods and services sharing the Redis instance, only one execution per key runs at a time:

redissonClientTsmService.withDistributedLock("ticket-recalc-${ticket.id}") {
recalculateSla(ticket)
}

Parameters:

ParameterDefaultMeaning
key—Global lock name; include the business key (entity ID, code)
waitSeconds30How long to wait for the lock before proceeding anyway
leaseSeconds7Auto-release after this time, protecting against a crashed holder
fairLockfalsetrue grants the lock in FIFO order — use when processing order matters (e.g., applying CDC events per entity)

The lock is always released in a finally block, and only if the current thread still holds it — a lease expiry on a long-running block cannot cause a foreign unlock.

The lock covers the block, not the surrounding transaction: it is released when the block ends (the lease is 7 s by default), which inside a process-engine transaction can happen before that transaction commits. A second transaction can then enter the block while the first is still uncommitted, so OptimisticLockingException and the retry of external calls remain possible. For a lock that spans the whole database transaction use unlockAfterCompletion(key): it acquires the lock immediately but releases it only in the transaction's after completion phase (commit or rollback).

Locking at the Business Key Level​

The process engine's optimistic locking detects conflicting updates but retries them, which can re-invoke external calls (see Process Engine Transactions). Wrapping such a call in withDistributedLock keyed by the business entity serializes the competing executions of the call; it does not serialize their transactions (see above).


5. Idempotent Execution — executeIdempotently​

executeIdempotently makes a repeated call with the same key return the first result instead of executing again. It is designed for create operations that clients may retry after a timeout:

redissonClientTsmService.executeIdempotently("ticket-create-${requestHash}") {
createTicket(request)
}

The mechanism combines two Redis structures:

  1. An RLock on the key serializes concurrent duplicates — the second caller waits until the first one finishes.
  2. The result is stored in the idempotency-cache map with a TTL (releaseAfterFinishedSeconds, default 90 s). Any call with the same key within that window returns the cached value without running the action.

Failure semantics: if the action throws, nothing is cached and the lock is released immediately, so waiting callers retry the action right away. Inside a transaction the lock is otherwise held until the transaction completes; outside a transaction (AfterCommitUtil finds no active synchronization) it is released right after the action. The result is written to the cache before the commit, and this is intentional: when the transaction rolls back afterwards, duplicates within releaseAfterFinishedSeconds still receive the result of the rolled-back operation. Callers such as B2B notification de-duplication or runOncePerWindow perform external side effects that a rollback does not undo, and deleting the result would repeat them. For a purely transactional create, where the repeated call should run again after a rollback, this would have to be an opt-in of the caller.

ParameterDefaultMeaning
timeoutSeconds200Max time a duplicate waits for the first execution; also the lock lease
releaseAfterFinishedSeconds90How long the result stays cached for further duplicates
manualUnlockfalsetrue leaves the unlock to the caller (the lock is passed to the action)
delayedStartMillis—Optional delay before executing, to consolidate bursts of identical requests

Without Redis configured, every call executes the action. It is a best-effort deduplication, not an exactly-once guarantee: a process crash after the commit, a failed cache write, or an elapsed wait for the lock lets the action run again.


6. Waiting for Asynchronous Branches — withMasterLock​

Some operations fan out asynchronously — a REST call updates an entity, the change flows through Kafka, and a consumer writes it to Elasticsearch. When the caller must not return (or must hold a lock) until all branches finish, tSM uses a master/sub-lock pattern:

  1. withMasterLock(key) { subkey -> ... } acquires the main lock and creates a per-invocation subkey (a counter plus a semaphore in Redis).
  2. withSublock(subkey) { ... } — still inside the master block, the producer wraps each send that starts an asynchronous branch. It registers the branch (increments the counter) and puts the subkey into the MDC context, from where it is propagated in the Kafka message headers. Registering must happen here, on the producer side — the master block reads the counter when it finishes.
  3. releaseSublock() — the consumer of each branch (e.g., after the Elasticsearch write is confirmed) picks the subkey up from the message and releases one permit.

When the master block finishes, it waits until all registered branches have released their permits (bounded by the lease time), then cleans up and releases the main lock. A typical use is guaranteeing read-your-writes: the next request, guarded by the same lock key, does not start until the previous change is visible in Elasticsearch.


7. Shared State and Signalling​

For short-lived state shared between replicas, the platform offers small helpers over Redis buckets, maps, and topics:

  • setBucketValue(key, value, duration) / getBucketValue(key) — a value bucket with a TTL. setBucketValue also publishes a change notification to a Redis topic.
  • waitForBucketValue(key, expected, timeout) — subscribes to that topic and blocks until the bucket reaches the expected value. This lets many waiters block on one computation instead of polling or stampeding it (thundering-herd protection).
  • putToMap / getFromMap / clearMap — a TTL map for small key-value state.
  • addToQueue / getAllItemsFromQueue — a simple JSON-serialized Redis queue for handing items between replicas.

A common pattern built on the same primitives: a single-flight guard — setIfAbsent on a bucket with a TTL marks "a refresh is already running somewhere", and unlike a lock, the flag survives the crash of the pod that set it until the TTL expires, while progress is shared via another bucket.


8. Distributed Cache (Opt-In)​

The default cache in tSM is Caffeine — local to each pod and fast; entities annotated for eviction publish to a Kafka cache-maintenance topic after commit, which drives invalidation in the other services (cache invalidation uses Kafka rather than Redis pub/sub). The consumer group is per service, so only one replica of a service receives an eviction event; the local Caffeine caches of the other replicas stay stale until their TTL (5 minutes by default).

For caches that should be shared by all replicas — expensive lookups, third-party API results — a service can place entries in Redis instead by selecting the opt-in cache manager:

@Cacheable(cacheNames = ["userGroupsByCode"], cacheManager = CACHE_MANAGER_REDIS_OPTIN)
fun findUserGroup(code: String): UserGroupDto? { ... }

Implementation notes that matter operationally:

  • Each named cache is stored as a single Redis hash with per-entry TTLs (default tsm.redis.cache.ttl-minutes, 60). Clearing a cache deletes one key — it never scans the keyspace, which would block Redis at scale.
  • Values are serialized as typed JSON. An entry that can no longer be deserialized (e.g., after a DTO changed between versions) is treated as a cache miss and overwritten — a deploy never has to flush Redis.
  • With tsm.redis.cache.enabled=false, the opt-in manager becomes a no-op cache: the annotated code works, it just does not cache.

9. Testing​

Integration tests do not require a shared Redis:

  • Cache and reconnect tests run against a Testcontainers Redis (skipped automatically when Docker is unavailable).
  • Lightweight tests use an embedded Redis server.

Services that do not use Redis exclude the Redisson auto-configuration entirely (the platform does this automatically while tsm.redis.cache.enabled is false).


Summary​

  • Redis is tSM's cross-replica coordination layer: locks, idempotency, signalling, shared state, opt-in cache, and rate limiting.
  • It is optional for tsm-commons services and fail-open — when Redis is not configured, helpers degrade to unguarded execution, so correctness must never depend on Redis alone.
  • The Redisson client is platform-managed: deployment-provided configuration, reconnect on failure (with the limits described above), one feature flag (tsm.redis.cache.enabled).
  • Related chapters: Rate Limit Usage, Management Service, Process Engine Transactions, Elasticsearch.