surf-redis is a Redis-based coordination and synchronization library designed for distributed
JVM applications.
It provides:
- a central Redis API abstraction
- event broadcasting
- request/response messaging
- replicated in-memory data structures (value, list, set, map)
- simple Redis-backed caches
The library is built on top of Redisson, Reactor, and Kotlin coroutines.
RedisApi is the central entry point.
It owns the Redis clients and manages the lifecycle of all Redis-backed components.
A typical application creates exactly one RedisApi instance and shares it across the system.
Lifecycle:
- Create the API
- Register listeners / handlers / sync structures
- Freeze the API
- Connect to Redis
- Disconnect on shutdown
val redisApi = RedisApi.create()
redisApi.subscribeToEvents(SomeListener())
redisApi.registerRequestHandler(SomeRequestHandler())
redisApi.freezeAndConnect()In most applications, RedisApi is wrapped inside a service that owns its lifecycle and exposes
a global access point.
abstract class RedisService {
val redisApi = RedisApi.create()
fun connect() {
register()
redisApi.freezeAndConnect()
}
@MustBeInvokedByOverriders
@ApiStatus.OverrideOnly
protected open fun register() {
// register listeners and request handlers
}
fun disconnect() {
redisApi.disconnect()
}
companion object {
val instance = requiredService<RedisService>()
fun get() = instance
fun publish(event: RedisEvent) =
instance.redisApi.publishEvent(event)
fun namespaced(suffix: String) =
"example-namespace:$suffix"
}
}
val redisApi get() = RedisService.get().redisApi
@AutoService(RedisService::class)
class PaperRedisService : RedisService() {
override fun register() {
super.register()
// redisApi.subscribeToEvents(PaperListener())
}
}Sync structures are typically created where they are used, not inside the Redis service:
object SomeOtherService {
private val counter =
redisApi.createSyncValue<Int>(
RedisService.namespaced("counter"),
defaultValue = 0
)
}Events are broadcasted via Redis Pub/Sub.
@Serializable
class PlayerJoinedEvent(val playerId: UUID) : RedisEvent()Publishing an event:
RedisService.publish(PlayerJoinedEvent(playerId))Handlers are discovered via @OnRedisEvent.
class PlayerListener {
@OnRedisEvent
fun onJoin(event: PlayerJoinedEvent) {
if (event.originatesFromThisClient()) return
println("Player joined on another node: ${event.playerId}")
}
}- Handlers are invoked synchronously on a Redisson/Reactor thread
- Long-running or suspending work must be delegated manually
Requests are broadcasted. Multiple servers may respond. The first response that arrives wins.
@Serializable
class GetPlayerRequest(val playerId: UUID) : RedisRequest()
@Serializable
class PlayerResponse(val name: String) : RedisResponse()class PlayerRequestHandler {
@HandleRedisRequest
fun handle(ctx: RequestContext<GetPlayerRequest>) {
if (ctx.originatesFromThisClient()) return
val player = loadPlayer(ctx.request.playerId)
ctx.respond(PlayerResponse(player.name))
}
}Each handler may respond at most once.
val response: PlayerResponse =
redisApi.sendRequest(GetPlayerRequest(playerId))If no response is received within the timeout, a RequestTimeoutException is thrown.
Sync structures maintain local in-memory state and synchronize it via Redis. They are eventually consistent.
All sync structures:
- are created via
RedisApi - expose a local view
- propagate mutations asynchronously
- provide listener support
Replicated single value.
val onlineCount =
redisApi.createSyncValue(
RedisService.namespaced("online-count"),
defaultValue = 0
)
onlineCount.set(onlineCount.get() + 1)Property delegate support:
var count by onlineCount.asProperty()
count++Replicated ordered list.
val players =
redisApi.createSyncList<String>(
RedisService.namespaced("players")
)
players += "Alice"
players.remove("Bob")Replicated set.
val onlinePlayers =
redisApi.createSyncSet<String>(
RedisService.namespaced("online-players")
)
onlinePlayers += "Alice"
onlinePlayers -= "Bob"Replicated key-value map.
val playerScores =
redisApi.createSyncMap<UUID, Int>(
RedisService.namespaced("scores")
)
playerScores[playerId] = 42All sync structures support listeners:
playerScores.addListener { change ->
when (change) {
is SyncMapChange.Put -> println("Score updated: ${change.key}")
is SyncMapChange.Removed -> println("Score removed: ${change.key}")
is SyncMapChange.Cleared -> println("Scores cleared")
}
}Listener invocation thread depends on whether the change is local or remote.
Key-value cache with TTL.
val cache =
redisApi.createSimpleCache<String, Player>(
namespace = "players",
ttl = 30.seconds
)
cache.put("alice", player)
val cached = cache.get("alice")Set-based cache with optional indexes.
val cache =
redisApi.createSimpleSetRedisCache(
namespace = "players",
ttl = 30.seconds,
idOf = { it.id.toString() }
)Some APIs are annotated with @InternalRedisAPI.
These APIs:
- are not stable
- may change or be removed without notice
- must not be used by consumers
Guaranteed:
- Local operations are non-blocking
- Eventual convergence across nodes
- At-most-once response per request handler
- First response wins for requests
Not guaranteed:
- Strong consistency
- Total ordering across nodes
- Delivery in the presence of network partitions
- Exactly-once semantics
All sync structures (SyncValue, SyncList, SyncSet, SyncMap) must be created before
RedisApi.freeze() is called.
This can be subtle in larger applications, because sync structures are often created in other services that depend on Redis, not inside the Redis service itself.
Problematic setup
class SomeService {
private val counter =
redisApi.createSyncValue("counter", 0) // <-- may run too late
}If SomeService is initialized after RedisApi.freeze() has already been called,
sync structure creation will fail or behave incorrectly.
Correct approach
You must ensure that all services that create sync structures are initialized before the Redis API is frozen.
A common pattern is:
- RedisService owns the
RedisApi - All dependent services are initialized before
connect()is called
class RedisService {
val redisApi = RedisApi.create()
fun connect() {
// Ensure all services that create sync structures are initialized here
SomeService.init()
AnotherService.init()
redisApi.freezeAndConnect()
}
}Alternatively, ensure that service initialization order guarantees that all sync structures are created eagerly during startup.
Rule of thumb:
Tip
If a service creates sync structures, it must be initialized before RedisApi.freeze().
Sync structures are eventually consistent.
Do not assume:
- immediate visibility on other nodes
- global ordering guarantees
- exactly-once delivery
They are designed for coordination and shared state, not transactional consistency.
Event handlers (@OnRedisEvent) and request handlers (@HandleRedisRequest) are invoked
synchronously on a Redisson/Reactor thread.
Do not block:
- database calls
- file I/O
- long computations
Delegate explicitly:
@HandleRedisRequest
fun handle(ctx: RequestContext<MyRequest>) {
coroutineScope.launch {
val result = doBlockingWork()
ctx.respond(MyResponse(result))
}
}Each RequestContext allows exactly one response.
Calling respond() more than once will throw an exception.
This includes responding both synchronously and asynchronously by mistake.
Requests and events are broadcasted.
If a handler should not process self-originated messages, it must explicitly check:
if (event.originatesFromThisClient()) returnThis is especially important to avoid feedback loops.
Sync structures rely on Redis keys with TTL.
If all nodes go offline, the remote state may expire. The next node will start from the default/snapshot behavior defined by the structure.
Design your application logic accordingly.