Misk Redis & Caching: The Lettuce-Backed Client and Cache Patterns


Series: Building Production Services with Misk — Part 16 of 24

Every service eventually grows a piece of state that doesn’t belong in the request and doesn’t belong in your primary database: a rate counter, a session blob, a memoized fan-out result, a leaderboard. The honest answer is usually Redis, and misk redis gives you an injected client for it without making you hand-roll connection management or metrics. The wrinkle worth knowing up front: Misk ships two Redis modules with two different underlying drivers, and which one you pick changes the API you code against. Let’s untangle that, then wire it and use it for some actual misk caching.

Two modules, two drivers, one decision

There are two artifacts, and this is the single most confusing thing about Redis in Misk:

  • misk-redis — the original module, backed by Jedis (JedisPooled / UnifiedJedis). You inject a misk.redis.Redis interface whose methods return okio.ByteString. Synchronous, pooled, boring in the good way.
  • misk-redis-lettuce — the newer module, backed by Lettuce. You inject connection providers (ReadWriteConnectionProvider, ReadOnlyConnectionProvider) and run coroutine, blocking, or async commands through them, with read/write splitting and Redis Cluster support baked in.

Confusingly, both modules expose a class named RedisModule — they just live in different packages (misk.redis.RedisModule vs misk.redis.lettuce.RedisModule) and wire differently. So when you see RedisModule.create(...) in docs, that’s the Lettuce module; the classic Jedis module is a plain constructor, RedisModule(config, poolConfig).

My stance: if you’re starting fresh and you write Kotlin coroutines anyway, misk-redis-lettuce is the better long-term bet — non-blocking I/O, replica reads, cluster support, type-safe codecs. But the bulk of existing Misk services run on the Jedis-backed misk-redis, its Redis interface is the one most tutorials and most code you’ll inherit use, and it’s genuinely simpler for a single-node cache. This post leads with the Jedis client because it’s what you’ll actually meet first, then shows where Lettuce changes the shape.

Setup and wiring

Gradle coordinates, pick your driver:

// Jedis-backed, returns ByteString from a synchronous `Redis`
implementation("com.squareup.misk:misk-redis")

// Lettuce-backed, coroutine/async connection providers
implementation("com.squareup.misk:misk-redis-lettuce")

The Jedis module’s RedisConfig is — and I’m not editorializing here — literally a LinkedHashMap<String, RedisReplicationGroupConfig>. Misk supports exactly one replication group per service, so the map has one entry and RedisModule quietly takes the first one. The YAML mirrors that:

redis:
  my-service-001:                 # replication group id (the map key)
    writer_endpoint:
      hostname: redis.example.com
      port: 6379
    reader_endpoint:
      hostname: redis-replica.example.com
      port: 6379
    redis_auth_password: "secret"
    timeout_ms: 2000

That redis_auth_password is annotated @misk.config.Redact, so it won’t leak into the admin dashboard or logs, the same redaction pattern from the config post. An empty password is only tolerated in fake/local deployments; in a real deployment Misk requires one.

Wiring is a module install. Note the connection pool is a Jedis ConnectionPoolConfig, and the module’s own KDoc warns you not to trust the defaults blindly:

class RedisClientModule(private val config: RedisConfig) : KAbstractModule() {
  override fun configure() {
    val group = config.values.first()              // one replication group
    install(RedisModule(group, ConnectionPoolConfig().apply {
      maxTotal = 8
      maxIdle = 8
      minIdle = 1
    }))
  }
}

RedisModule installs a ServiceModule<RedisService> enhanced by ReadyService, so Redis is part of your readiness gate — the service won’t report ready until the client is up. If another service genuinely needs Redis before it can function, declare the dependency explicitly:

install(ServiceModule<MyService>().dependsOn(keyOf<RedisService>()))

The Lettuce module wires through a factory instead — install(RedisModule.create(config)) for the default UTF-8 String codec, or RedisModule.create<String, JsonNode>(config, codec = JsonCodec()) when you want type-safe custom serialization. Its YAML is shaped differently too (a top-level redis: map of RedisReplicationGroupConfig with primary_endpoint / reader_endpoint, plus a separate redis_cluster: block), so the two modules are not config-compatible — don’t copy YAML between them.

A worked example: get and set

The injected type for misk-redis is misk.redis.Redis, and the thing to internalize is that values are ByteString, not String. That’s deliberate — Redis stores bytes, and Misk refuses to guess your encoding. get and set are even operator functions, so indexing syntax works:

@Singleton
class SessionCache @Inject constructor(
  private val redis: Redis,
) {
  fun put(sessionId: String, payload: String) {
    // operator set: redis[key] = value
    redis[sessionId] = payload.encodeUtf8()
  }

  fun get(sessionId: String): String? =
    redis[sessionId]?.utf8()                 // null if the key is absent
}

For a cache you almost always want a TTL, and there’s a set overload that takes a Duration so you don’t need a separate expire round-trip:

fun cache(key: String, value: String, ttl: Duration) {
  redis.set(key, ttl, value.encodeUtf8())    // SET key value EX <ttl>
}

The interface is broad — del, mget/mset, the hash family (hget, hset, hgetAll, hincrBy), lists (lpush, rpop, lmove, blpop), incr/incrBy, setnx, expire/pExpire, and scan. The cluster-mode caveat from the interface’s own docs is real: multi-key commands like lmove require keys in the same hash slot, which you steer with {hashtag} key naming. If you run clustered Redis, read that doc before you write a multi-key command.

Caching patterns, and the fake for tests

The classic pattern is read-through caching: check the cache, fall back to the source of truth, populate on miss.

fun userProfile(id: String): Profile {
  val key = "profile:$id"
  redis[key]?.let { return Profile.decode(it) }    // hit

  val fresh = profileStore.load(id)                 // miss → source of truth
  redis.set(key, Duration.ofMinutes(5), fresh.encode())
  return fresh
}

A few patterns the Redis interface supports directly and you should reach for instead of improvising:

  • setnx for locks/idempotency — “set if not exists” with a TTL gives you a cheap distributed lock or a one-shot idempotency guard. There’s an overload that takes a Duration so the lock self-expires if your process dies holding it.
  • incr / incrBy for counters — atomic server-side increment beats read-modify-write races. (This is exactly what the rate-limiting story in the next post builds on.)
  • Hashes for structured recordshset/hgetAll store a record as fields rather than one opaque blob, so you can update one field without rewriting the whole value.

Now the part that makes this pleasant to test. misk-redis ships a FakeRedis — a full in-memory implementation of the Redis interface, driven by an injected Clock so TTL expiry is deterministic. Advance the fake clock and watch keys expire on command, no Thread.sleep, no flake. You get it by installing the test module from the module’s test fixtures:

// build.gradle.kts
testImplementation(testFixtures("com.squareup.misk:misk-redis"))
@MiskTest
class SessionCacheTest {
  @MiskTestModule
  val module = object : KAbstractModule() {
    override fun configure() {
      install(RedisTestModule())          // binds Redis -> FakeRedis as a singleton
      // ... your code under test
    }
  }

  @Inject lateinit var redis: Redis       // this is actually a FakeRedis
  @Inject lateinit var cache: SessionCache
}

RedisTestModule binds Redis to a singleton FakeRedis and registers it as a TestFixture so state resets between tests. Because the fake satisfies the same Redis interface, your code is identical in tests and production — no if (testing) branches, no mock setup. That, plus the deterministic clock, is the reason to prefer FakeRedis over a mock or a real test container for unit tests. (There’s a DockerRedis fixture too when you specifically want to test against real Redis behavior.)

Production notes and gotchas

  • Decide on a driver before you write a line. misk-redis (Jedis, ByteString, sync) and misk-redis-lettuce (Lettuce, providers, coroutines) have different APIs and different config YAML. Migrating between them is a rewrite, not a flag flip — choose with intent.
  • Don’t trust the Jedis pool defaults. The module’s own KDoc tells you so. maxTotal defaults to 8; under a synchronous, blocking workload that exhausts fast and requests start queueing on the pool. Size it against your real concurrency.
  • Lettuce mostly doesn’t need a pool — and that surprises people. The Lettuce client is thread-safe over a single connection with non-blocking I/O. Per its own docs, you only need pooling for connection-blocking operations: MULTI/EXEC transactions, blocking commands (BLPOP), and pub/sub. Enabling a pool “for performance” on Lettuce usually buys you nothing but knobs to misconfigure.
  • Always set a TTL on cache entries. Use the set(key, duration, value) overload, not a bare set. A cache without expiry is a memory leak with extra steps, and the difference is one argument.
  • Mind the cluster hash-slot rule. Multi-key commands (lmove, mget, rpoplpush) require all keys in one slot on clustered Redis. Use {hashtag} key naming to co-locate related keys, or those commands will throw at runtime — not at compile time.
  • Watch the metrics. Both modules emit Prometheus metrics out of the box — redis_client_operation_time_millis (per-command timing), plus pool gauges (redis_client_active_connections, redis_client_idle_connections) and redis_client_pool_destroyed_connections_total. A climbing destroyed-connections counter means connections are failing validation and may be in an inconsistent state; alert on it.
  • ByteString is a feature, not friction. It forces you to be explicit about encoding (encodeUtf8() / utf8()). Resist the urge to wrap it in a String-only helper that hard-codes UTF-8 everywhere — the day you cache protobuf bytes you’ll be glad the interface never assumed text.

What’s next

Atomic incr and setnx against Redis are the primitives, but turning them into a real throttle — token buckets, refill rates, per-caller limits — is its own design problem, and Misk has a module for it. In Part 17: Misk Rate Limiting & Tokens we’ll build request throttling on top of the Redis client we just wired.


Target keywords: misk redis, misk caching.

Comments