{
  "id": "java-lettuce",
  "title": "Redis feature store with Lettuce",
  "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/java-lettuce/",
  "summary": "Build a Redis-backed online feature store in Java with Lettuce",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-06-04T14:49:57+01:00",
  "children": [],
  "page_type": "content",
  "content_hash": "a22eedaeee656bb82bd1eab98a9bc7fc27a8f5f3b874d06f10f9a1a2850b9e55",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to build a small Redis-backed online feature store in\nJava with [Lettuce](https://redis.io/docs/latest/develop/clients/lettuce), the\nasync-by-default Netty-based Redis client. The demo runs on top of the JDK's\n`com.sun.net.httpserver.HttpServer` so you can bulk-load a batch of users\nwith a key-level TTL, run a streaming worker that overwrites real-time\nfeatures with per-field TTL, retrieve any subset of features for one user\nunder 2 ms, and pipeline `HMGET` across a hundred users for batch scoring.\n\nThe [Jedis walkthrough](https://redis.io/docs/latest/develop/use-cases/feature-store/java-jedis)\ncovers the same flow with a synchronous, pool-borrowing client. This page\nfocuses on what's different in Lettuce — the multiplexed connection, the\n`RedisAsyncCommands` surface, and the auto-flush pipelining model — rather\nthan re-explaining the shared concepts."
    },
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Each entity (here, a user) is one Redis\n[Hash](https://redis.io/docs/latest/develop/data-types/hashes) at a deterministic key —\n`fs:user:{id}`. The hash holds every feature for that entity as one field per\nfeature: batch-materialized aggregates (refreshed once a day) alongside\nstreaming-updated signals (refreshed every few seconds). One\n[`HMGET`](https://redis.io/docs/latest/commands/hmget) returns whichever subset the model\nneeds in one network round trip.\n\nTwo TTL layers solve the *mixed staleness* problem without an application-side\ncleaner:\n\n* A **key-level** [`EXPIRE`](https://redis.io/docs/latest/commands/expire) aligned with the\n  batch materialization cycle (24 hours in the demo). If the batch refresher\n  fails, the whole entity disappears at the next cycle and inference sees a\n  missing entity — which the model handler can detect and fall back on —\n  rather than silently outdated values.\n* A **per-field** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) (Redis 7.4+) on\n  each streaming feature gives that field its own shorter expiry, independent\n  of the rest of the hash. If the streaming pipeline stops updating a feature,\n  the field self-cleans while the batch fields stay populated.\n\nThat gives you:\n\n* A single round trip for retrieval — any subset of features for one entity\n  in one [`HMGET`](https://redis.io/docs/latest/commands/hmget).\n* Sub-millisecond hot path. The Redis-side work is microseconds; in practice\n  the bottleneck is the network round trip plus the model's own feature-prep.\n* Pipelined batch scoring — one round trip for `N` users at once.\n* Independent freshness per feature, expressed as a server-side TTL rather\n  than as application logic.\n* Self-cleanup on pipeline failure: a stalled batch refresher lets entities\n  expire on schedule, and a stalled streaming worker lets each affected field\n  expire on its own timer."
    },
    {
      "id": "how-lettuce-differs-from-jedis",
      "title": "How Lettuce differs from Jedis",
      "role": "content",
      "text": "The big mental-model difference for someone arriving from Jedis:\n\n* **One shared, multiplexed connection.** A `StatefulRedisConnection<K, V>`\n  is thread-safe and serves the whole process. There's no `JedisPool`-style\n  per-call borrow — every handler in the HTTP thread pool *and* the\n  streaming worker share the same connection, and Netty handles the\n  serialization onto the underlying socket.\n* **Async-by-default API.** Every method on `RedisAsyncCommands<K, V>`\n  returns a `RedisFuture<T>` (which is a `CompletionStage<T>` and a\n  `Future<T>`). For synchronous code paths the helper blocks with `.get()`;\n  for reactive pipelines you'd compose with `.thenApply()` /\n  `.thenCompose()` or use the `.reactive()` API directly.\n* **Pipelining via connection-level auto-flush.** Lettuce doesn't have a\n  `pipelined()`-style builder. Instead, you toggle\n  `conn.setAutoFlushCommands(false)` on the connection, queue commands as\n  normal async calls (each returns its own `RedisFuture`), call\n  `conn.flushCommands()` to ship the batch, and toggle auto-flush back on.\n  `LettuceFutures.awaitAll(...)` waits for all the futures to resolve.\n\nIn short: reach for **Lettuce** when you need async/reactive composition\nor you're already in a reactive stack (Spring WebFlux, Project Reactor);\nreach for **Jedis** when blocking commands are common or you want a\nsimple sync API with explicit per-call connection lifetime. The\n[Lettuce](https://redis.io/docs/latest/develop/clients/lettuce) and\n[Jedis](https://redis.io/docs/latest/develop/clients/jedis) client guides cover the\ndeeper selection criteria.\n\nIn this example, the batch features describe a user's longer-term shape and\nare bulk-loaded by `BuildFeatures.java`. The streaming features describe\nwhat the user is doing right now and are written by `StreamingWorker.java`\non a daemon thread. The inference handlers of the demo server read any\nsubset of those features through `FeatureStore.java`'s helper class. All\nfour sources share one `StatefulRedisConnection` opened in `DemoServer.java`."
    },
    {
      "id": "the-feature-store-helper",
      "title": "The feature-store helper",
      "role": "content",
      "text": "The `FeatureStore` class wraps the read/write paths\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java)):\n\n[code example]"
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each user is one Redis Hash. Every value is stored as a string — Redis hash\nfields are bytes on the wire, so the helper encodes booleans as `\"true\"` /\n`\"false\"` (`encodeValue(Object)` in `FeatureStore.java`) and renders\neverything else with `Object.toString()`. The model server is responsible\nfor parsing back to the right type, the same way it would when reading any\nserialized feature store.\n\n[code example]"
    },
    {
      "id": "bulk-loading-batch-features",
      "title": "Bulk-loading batch features",
      "role": "content",
      "text": "`bulkLoad` queues one `HSET` and one `EXPIRE` per user with auto-flush\ndisabled, flushes once, and waits for every `RedisFuture` to resolve.\n\n[code example]\n\nThe two important things to notice:\n\n1. **`setAutoFlushCommands(false)` is on the connection, not the async\n   commands.** It affects *every* call going through that\n   `StatefulRedisConnection` until it's flipped back. The `finally` block\n   restores auto-flush even if a queue step throws — failing to do so would\n   silently break every subsequent command in the JVM.\n2. **`LettuceFutures.awaitAll` blocks with a timeout.** With auto-flush off,\n   queued commands can sit in the local pipeline buffer indefinitely if\n   something below the flush goes wrong. The timeout gives `bulkLoad` a\n   clean failure mode rather than hanging forever.\n\nIn production, the equivalent of this script runs as an offline pipeline (a\nSpark or Feast `materialize` job) that reads from the warehouse and writes\ninto Redis. The\n[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)\nprovider does exactly this under the hood; the in-house\n[Redis Feature Form](https://redis.io/docs/latest/develop/ai/featureform) integration\ncovers the materialize + serve path end-to-end."
    },
    {
      "id": "streaming-writes-with-per-field-ttl",
      "title": "Streaming writes with per-field TTL",
      "role": "content",
      "text": "`updateStreaming` is the linchpin of the mixed-staleness story:\n\n[code example]\n\n[`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) sets the TTL on *individual*\nhash fields, not on the whole key. The two commands are queued under one\nflush so Redis runs them in pipeline order: the `HSET` first creates or\noverwrites the fields, then `HEXPIRE` attaches a TTL to each of those same\nfields. `HEXPIRE` returns one status code per field — `1` if the TTL was\nset, `2` if the expiry was 0 or in the past (so Redis deleted the field\ninstead), `0` if an `NX | XX | GT | LT` conditional flag was set and not\nmet (we never use one here), `-2` if the field doesn't exist on the key.\nThe helper throws if any code is anything other than `1`, so the \"every\nstreaming write renews its TTL\" invariant fails loudly rather than silently\nleaving a streaming field with no expiry attached.\n\nIf a streaming pipeline stops, the streaming fields drop out one by one as\ntheir per-field TTLs elapse. [`HTTL`](https://redis.io/docs/latest/commands/httl) lets\nthe model side inspect the remaining TTL on any field, which is useful both\nfor debugging and as a freshness signal in the model itself.\n\n> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level TTL\n> commands were added in Redis 7.4. Lettuce 6.4 was the first release with\n> the bindings; the demo's `pom.xml` pins 7.5.2.RELEASE."
    },
    {
      "id": "inference-reads-with-hmget",
      "title": "Inference reads with HMGET",
      "role": "content",
      "text": "`getFeatures` is one `HMGET`:\n\n[code example]\n\nLettuce's `hmget` returns `List<KeyValue<K, V>>` rather than a parallel\n`List<V>` like Jedis. `KeyValue` is Lettuce's `Optional`-like wrapper:\n`kv.hasValue()` tells you whether Redis returned a value or a nil for that\nfield, and `kv.getValue()` unwraps it. The helper drops `hasValue()==false`\nentries so the caller's `Map<String, String>` only contains fields that\nactually exist on the hash."
    },
    {
      "id": "batch-scoring-with-pipelined-hmget",
      "title": "Batch scoring with pipelined HMGET",
      "role": "content",
      "text": "The same connection-level flush pattern carries over to batch reads:\n\n[code example]\n\nOne round trip for the whole batch. The first call after server startup\nincludes a few milliseconds of Netty event-loop and connection warm-up;\nsteady-state, the demo returns a 100-user batch in 2-5 ms against a local\nRedis.\n\nA Redis Cluster is different: a single auto-flush batch is bound to one\nshard, because all the queued commands ship through one connection to one\nnode. For batch reads on a cluster, use\n[`RedisClusterClient`](https://redis.io/docs/latest/develop/clients/lettuce) — its\n`StatefulRedisClusterConnection` exposes `getConnection(slot)` for\nper-shard auto-flush batching, and the high-level `RedisAdvancedClusterAsyncCommands`\nfans out non-pipelined calls per shard automatically.\n\nA hash tag like `fs:user:{vip}:u0001` forces a known set of keys onto the\nsame shard so one auto-flush batch can cover all of them in a single round\ntrip."
    },
    {
      "id": "the-streaming-worker",
      "title": "The streaming worker",
      "role": "content",
      "text": "`StreamingWorker.java` is the demo's stand-in for whatever Flink, Kafka\nStreams, or bespoke service computes the real-time features\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-lettuce/StreamingWorker.java)).\nIt runs as a daemon `Thread` next to the demo server so the UI can start,\npause, and resume it; in production this code would live in the streaming\nlayer.\n\nThe lifecycle (start / stop / pause / resume / waitForIdle) is identical to\nthe Jedis demo — the worker thread itself doesn't care which client it's\ntalking to, only that `FeatureStore.updateStreaming` pipelines the\n`HSET` + `HEXPIRE` in order within one flush. The Lettuce helper achieves that through\nthe connection-level flush described above.\n\nPausing the worker is what shows off the mixed-staleness behavior: leave\nit paused for longer than `streamingTtlSeconds` and the streaming fields\ndisappear from every user's hash one by one, while the batch fields remain\nunder the longer key-level `EXPIRE`. The demo's `Pause / resume` button\nlets you see this happen in real time.\n\n`pause()` only blocks *future* ticks from running. A reset that's about to\n`DEL` every key also needs to wait out an already-running tick, which is\nwhat `waitForIdle()` is for. The demo's `Reset` handler calls\n`worker.pause()` *and* `worker.waitForIdle()` before it issues the `DEL`\nsweep, so a mid-flight tick can't recreate a user under a streaming-only\nhash with no key-level TTL."
    },
    {
      "id": "the-batch-builder",
      "title": "The batch builder",
      "role": "content",
      "text": "`BuildFeatures.java` is the demo's nightly materializer\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-lettuce/BuildFeatures.java)).\nIt generates synthetic feature rows and calls `store.bulkLoad` once. The\nsynthesis itself is not the point — in a real deployment the equivalent\ncode reads from the offline store (Snowflake, BigQuery, Iceberg) and writes\nthe resulting hashes into Redis.\n\nRun the builder on its own (independently of the demo server) to populate\nRedis from the command line:\n\n[code example]\n\nThat writes 500 users at `fs:user:*` with a one-hour key-level TTL, which\nis how a typical operator would pre-seed a feature store from the command\nline when debugging."
    },
    {
      "id": "the-interactive-demo",
      "title": "The interactive demo",
      "role": "content",
      "text": "`DemoServer.java` runs the JDK `HttpServer` on port 8089 with a fixed\nthread pool. The HTML page lets you:\n\n* **Bulk-load** any number of users (default 200) with a configurable\n  key-level TTL.\n* See the **store state**: user count, batch / streaming TTLs, cumulative\n  read/write counters.\n* See the **streaming worker** status and **pause or resume** it.\n* Run an **inference read** for any user with a chosen feature subset, and\n  see the value, the per-field TTL, and the read latency.\n* Run **batch scoring** with a pipelined `HMGET` across `N` users.\n* **Inspect** any user's full hash with field-level TTLs and the key-level\n  TTL.\n\nThe server holds one `FeatureStore`, one `StreamingWorker`, one\n`RedisClient`, and one `StatefulRedisConnection` for the lifetime of the\nprocess. Every HTTP handler and the streaming worker share that single\nconnection — Lettuce multiplexes the commands across them automatically.\nEndpoints:\n\n| Endpoint                  | What it does                                                                        |\n|---------------------------|-------------------------------------------------------------------------------------|\n| `GET  /state`             | User count, TTL config, stats counters, worker status.                              |\n| `POST /bulk-load`         | Auto-flush batched `HSET` + `EXPIRE` over N synthetic users with a chosen TTL.      |\n| `POST /worker/toggle`     | Pause / resume the streaming worker.                                                |\n| `POST /read`              | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs.    |\n| `POST /batch-read`        | Pipeline `HMGET` across N users; report total latency and per-entity field counts.  |\n| `GET  /inspect`           | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs.                |\n| `POST /reset`             | Drop every user under the key prefix (used by the demo's reset button).             |"
    },
    {
      "id": "prerequisites",
      "title": "Prerequisites",
      "role": "content",
      "text": "* **Redis 7.4 or later.** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) and\n  [`HTTL`](https://redis.io/docs/latest/commands/httl) were added in Redis 7.4; the\n  demo relies on per-field TTL for the mixed-staleness story.\n* **Java 17 or later.** The demo uses switch expressions with arrow labels\n  (`case \"...\" -> ...`), records, and text blocks.\n* **Lettuce 6.4 or later.** The demo's `pom.xml` pins 7.5.2.RELEASE.\n  Field-level TTL bindings (`hexpire`, `httl`, `hpersist`) ship from\n  Lettuce 6.4.\n\nIf your Redis server is running elsewhere, start the demo with\n`--redis-uri redis://host:port`."
    },
    {
      "id": "running-the-demo",
      "title": "Running the demo",
      "role": "content",
      "text": ""
    },
    {
      "id": "get-the-source-files",
      "title": "Get the source files",
      "role": "content",
      "text": "The demo lives in a small Maven project under\n[`feature-store/java-lettuce`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/java-lettuce).\nClone the repo or copy the directory:\n\n[code example]"
    },
    {
      "id": "start-the-demo-server",
      "title": "Start the demo server",
      "role": "content",
      "text": "From the project directory:\n\n[code example]\n\nYou should see:\n\n[code example]\n\nOpen [http://127.0.0.1:8089](http://127.0.0.1:8089). The first inference\nread after startup is a few milliseconds slower than the rest because\nLettuce / Netty are warming up the event loop and the underlying socket;\nsubsequent reads settle into 1-2 ms on a local Redis.\n\nUseful things to try:\n\n* Pick a user and click **Read features** with a mixed batch/streaming\n  subset — you'll see batch fields with no per-field TTL (covered by the\n  key-level TTL) and streaming fields with a positive per-field TTL.\n* Click **Pipeline HMGET** with `count=100` to see the latency of a\n  100-user batch read.\n* Click **Pause / resume** on the streaming worker and leave it paused for\n  ~5 minutes (or restart the server with `--streaming-ttl-seconds 30` to\n  make it visible in seconds). Re-run **Read features** on any user and\n  watch the streaming fields disappear while the batch fields stay.\n* Click **Inspect** on a user to see the full hash with field-level TTLs.\n* Click **Reset** to drop every user and start over.\n\nThe server is read/write against your local Redis. The default key prefix\nis `fs:user:`. Pass `--no-reset` to keep existing data across restarts, or\n`--redis-uri` to point at a different Redis."
    },
    {
      "id": "production-usage",
      "title": "Production usage",
      "role": "content",
      "text": "The guidance below focuses on the production concerns that are specific to\nrunning a feature store on Redis. For the generic Lettuce production\nchecklist — `ClientResources` tuning, AUTH/ACL, retry policy,\nsentinel/cluster failover — see the\n[Lettuce client guide](https://redis.io/docs/latest/develop/clients/lettuce). For TLS\nspecifically, follow the\n[connect-with-TLS recipe](https://redis.io/docs/latest/develop/clients/lettuce/connect#tls-connection).\nThe feature-store demo runs against `localhost` with the defaults; a real\ndeployment should harden the client first."
    },
    {
      "id": "pick-the-batch-ttl-to-outlast-a-failed-refresher",
      "title": "Pick the batch TTL to outlast a failed refresher",
      "role": "content",
      "text": "The whole-entity `EXPIRE` is your safety net against silent staleness from\na broken batch pipeline. Set it longer than your worst-case batch outage\nso a single missed run doesn't take the feature store offline, but short\nenough that a sustained outage causes loud failures (missing entities)\nrather than quiet ones (yesterday's features being scored as today's). The\nstandard choice is one cycle of \"expected refresh interval × 2\" — for a\ndaily batch, 48 hours; for a 6-hour batch, 12 hours.\n\nThe same logic applies to the per-field streaming TTL: a few times the\nexpected update interval so a slow-but-alive streaming worker doesn't\nchurn features needlessly, but short enough that a stalled worker causes\nvisible freshness failures."
    },
    {
      "id": "don-t-share-auto-flush-state-across-unrelated-code-paths",
      "title": "Don't share auto-flush state across unrelated code paths",
      "role": "content",
      "text": "`conn.setAutoFlushCommands(false)` flips a *connection-level* toggle that\naffects every call going through that connection until it's flipped\nback. If two threads run pipelined writes concurrently against the same\nconnection, they will fight over the flag — one thread's `flushCommands()`\nwill ship the other thread's still-being-queued commands, or its\nrestore-to-true will flush the other thread's queue prematurely. Worse,\na single non-pipelined read on that same connection will be silently\nqueued (and never flushed) while the flag is off.\n\nThe demo handles this by opening **two** connections from the same\n`RedisClient`:\n\n* **The shared read connection** stays in default auto-flush=true mode.\n  Every HTTP handler and the streaming worker use it for the\n  non-pipelined commands (`HMGET`, `HTTL`, `TTL`, `SCAN`, `DEL`,\n  `HGETALL`).\n* **The dedicated pipeline connection** is reserved for `bulkLoad`,\n  `updateStreaming`, and `batchGetFeatures`. These all acquire a single\n  `pipelineLock` inside the `FeatureStore` instance before they touch\n  the auto-flush flag, so concurrent batches block each other instead\n  of corrupting the state. With one lock and one connection, you get at\n  most one in-flight batch at a time on the pipeline side; the read\n  connection is unaffected.\n\nFor batch concurrency beyond what one connection sustains, scale this\npattern to a small\n[`BoundedAsyncPool<StatefulRedisConnection<K, V>>`](https://redis.io/docs/latest/develop/clients/lettuce)\nof pipeline connections and lease one per batch."
    },
    {
      "id": "pipeline-batch-reads-across-shards",
      "title": "Pipeline batch reads across shards",
      "role": "content",
      "text": "On a single Redis instance, an auto-flush batched `HMGET` across `N` users\nis one round trip. A Redis Cluster is different: a single auto-flush batch\nis bound to one shard, because all queued commands ship to one node. For\nbatch reads on a cluster, use\n[`RedisClusterClient`](https://redis.io/docs/latest/develop/clients/lettuce) and one\nof:\n\n* Fan-out via `RedisAdvancedClusterAsyncCommands` — the cluster client\n  routes each `hmGet` to the right shard transparently. Easier to write,\n  slightly more overhead per call.\n* Bucket keys by slot with `SlotHash.getSlot(key)` and open one connection\n  per affected shard; auto-flush-batch each bucket separately. More code,\n  but one round trip per shard.\n\nFor a small number of frequently-queried users (a top-N customer list, for\nexample), a hash tag like `fs:user:{vip}:u0001` forces a known set of keys\nonto the same shard so one batch can cover them all."
    },
    {
      "id": "make-hexpire-part-of-every-streaming-write",
      "title": "Make HEXPIRE part of every streaming write",
      "role": "content",
      "text": "The single biggest correctness lever in this design is that the streaming\nwrite applies `HEXPIRE` *every time*. If a streaming worker writes a field\nwithout renewing its TTL, the field carries whatever expiry was there\nbefore — possibly none, possibly stale — and the mixed-staleness invariant\nbreaks. Keep the `HSET` and `HEXPIRE` under the same flush boundary (or,\neven safer, in the same\n[Lua script](https://redis.io/docs/latest/develop/programmability/eval-intro) if you\ndon't trust the call site)."
    },
    {
      "id": "avoid-hgetall-on-the-request-path",
      "title": "Avoid HGETALL on the request path",
      "role": "content",
      "text": "`HGETALL` reads every field on the hash, including ones the model doesn't\nneed. With dozens of features per entity, that is wasted serialization\nwork on the server and wasted bandwidth on the wire. Always specify the\nfield list explicitly with `hmget` in the model server.\n\nThe exception is debugging and feature-set discovery, where you genuinely\nwant the full hash. The demo's \"Inspect\" button uses `hgetall` for exactly\nthis reason."
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli",
      "title": "Inspect the store directly with redis-cli",
      "role": "content",
      "text": "When testing or troubleshooting, the cli tells you everything:\n\n[code example]\n\nA streaming field that returns `-2` from `HTTL` doesn't exist on the hash\n(either it was never written, or it expired); `-1` means the field has no\nTTL set (and is therefore covered only by the key-level `EXPIRE`); any\npositive value is the remaining TTL in seconds."
    },
    {
      "id": "learn-more",
      "title": "Learn more",
      "role": "related",
      "text": "This example uses the following Redis commands:\n\n* [`HSET`](https://redis.io/docs/latest/commands/hset) to write a feature or a whole\n  feature row in one call.\n* [`HMGET`](https://redis.io/docs/latest/commands/hmget) to retrieve any subset of\n  features for one entity in one round trip.\n* [`HGETALL`](https://redis.io/docs/latest/commands/hgetall) for debugging and\n  feature-set discovery.\n* [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) and\n  [`HTTL`](https://redis.io/docs/latest/commands/httl) for per-field TTL on streaming\n  features (Redis 7.4+).\n* [`EXPIRE`](https://redis.io/docs/latest/commands/expire) and\n  [`TTL`](https://redis.io/docs/latest/commands/ttl) for the whole-entity TTL aligned\n  with the batch materialization cycle.\n* Pipelined `HMGET` across many entities for batch scoring with one\n  network round trip via Lettuce's connection-level auto-flush.\n\nSee the [Lettuce documentation](https://redis.io/docs/latest/develop/clients/lettuce)\nfor the full client reference, and the\n[Hashes overview](https://redis.io/docs/latest/develop/data-types/hashes) for the\ndeeper conceptual model."
    }
  ],
  "examples": [
    {
      "id": "the-feature-store-helper-ex0",
      "language": "java",
      "code": "import io.lettuce.core.RedisClient;\nimport io.lettuce.core.api.StatefulRedisConnection;\n\nRedisClient client = RedisClient.create(\"redis://localhost:6379\");\ntry (StatefulRedisConnection<String, String> conn = client.connect()) {\n    FeatureStore store = new FeatureStore(conn,\n        \"fs:user:\",\n        24L * 60L * 60L,    // whole-entity TTL aligned with the daily batch cycle\n        5L * 60L            // per-field TTL on each streaming feature\n    );\n\n    // Batch materialization: one HSET + EXPIRE per user, all pipelined\n    // through a single connection-level flush.\n    Map<String, Map<String, Object>> rows = Map.of(\n        \"u0001\", Map.of(\n            \"country_iso\", \"US\", \"risk_segment\", \"low\",\n            \"tx_count_7d\", 14, \"avg_amount_30d\", 92.40,\n            \"account_age_days\", 612, \"chargeback_count_180d\", 0));\n    store.bulkLoad(rows);\n\n    // Streaming write: HSET + HEXPIRE on just the fields that changed.\n    store.updateStreaming(\"u0001\", Map.of(\n        \"last_login_ts\", System.currentTimeMillis(),\n        \"last_device_id\", \"ios-9f02\",\n        \"tx_count_5m\", 3,\n        \"failed_logins_15m\", 0,\n        \"session_country\", \"US\"));\n\n    // Inference read: HMGET of whatever the model needs.\n    Map<String, String> features = store.getFeatures(\"u0001\", List.of(\n        \"risk_segment\", \"tx_count_7d\", \"avg_amount_30d\",\n        \"tx_count_5m\", \"failed_logins_15m\"));\n\n    // Batch scoring: pipelined HMGET across many users.\n    Map<String, Map<String, String>> batch = store.batchGetFeatures(\n        List.of(\"u0001\", \"u0002\", \"u0003\"),\n        List.of(\"risk_segment\", \"tx_count_5m\", \"failed_logins_15m\"));\n} finally {\n    client.shutdown();\n}",
      "section_id": "the-feature-store-helper"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "fs:user:u0001                                   TTL = 86400 s (key-level)\n  country_iso=US                                <no field TTL>\n  risk_segment=low                              <no field TTL>\n  account_age_days=612                          <no field TTL>\n  tx_count_7d=14                                <no field TTL>\n  avg_amount_30d=92.40                          <no field TTL>\n  chargeback_count_180d=0                       <no field TTL>\n  last_login_ts=1716998413541                   TTL = 300 s (per field, HEXPIRE)\n  last_device_id=ios-9f02                       TTL = 300 s (per field, HEXPIRE)\n  tx_count_5m=3                                 TTL = 300 s (per field, HEXPIRE)\n  failed_logins_15m=0                           TTL = 300 s (per field, HEXPIRE)\n  session_country=US                            TTL = 300 s (per field, HEXPIRE)",
      "section_id": "data-model"
    },
    {
      "id": "bulk-loading-batch-features-ex0",
      "language": "java",
      "code": "public int bulkLoad(Map<String, Map<String, Object>> rows, long ttlSeconds) {\n    if (rows.isEmpty()) return 0;\n\n    List<RedisFuture<?>> futures = new ArrayList<>(rows.size() * 2);\n    conn.setAutoFlushCommands(false);\n    try {\n        for (Map.Entry<String, Map<String, Object>> e : rows.entrySet()) {\n            String key = keyFor(e.getKey());\n            Map<String, String> encoded = encode(e.getValue());\n            futures.add(async.hset(key, encoded));\n            futures.add(async.expire(key, ttlSeconds));\n        }\n        conn.flushCommands();\n    } finally {\n        conn.setAutoFlushCommands(true);\n    }\n    if (!LettuceFutures.awaitAll(BATCH_TIMEOUT, futures.toArray(new RedisFuture[0]))) {\n        throw new IllegalStateException(\"bulkLoad: timed out after \" + BATCH_TIMEOUT);\n    }\n    ...\n}",
      "section_id": "bulk-loading-batch-features"
    },
    {
      "id": "streaming-writes-with-per-field-ttl-ex0",
      "language": "java",
      "code": "public void updateStreaming(String entityId, Map<String, Object> fields, long ttlSeconds) {\n    if (fields.isEmpty()) return;\n    String key = keyFor(entityId);\n    Map<String, String> encoded = encode(fields);\n    String[] names = encoded.keySet().toArray(new String[0]);\n\n    RedisFuture<Long> hsetFut;\n    RedisFuture<List<Long>> hexpireFut;\n    conn.setAutoFlushCommands(false);\n    try {\n        hsetFut = async.hset(key, encoded);\n        hexpireFut = async.hexpire(key, ttlSeconds, names);\n        conn.flushCommands();\n    } finally {\n        conn.setAutoFlushCommands(true);\n    }\n    awaitOne(hsetFut);\n    List<Long> codes = awaitOne(hexpireFut);\n    for (Long code : codes) {\n        if (code == null || code != 1L) {\n            throw new IllegalStateException(\n                \"HEXPIRE did not set every field TTL for \" + key + \": \" + codes);\n        }\n    }\n    ...\n}",
      "section_id": "streaming-writes-with-per-field-ttl"
    },
    {
      "id": "inference-reads-with-hmget-ex0",
      "language": "java",
      "code": "public Map<String, String> getFeatures(String entityId, List<String> fieldNames) {\n    String key = keyFor(entityId);\n    Map<String, String> out = new LinkedHashMap<>();\n    if (fieldNames == null) {\n        Map<String, String> all = awaitOne(async.hgetall(key));\n        if (all != null) out.putAll(all);\n        return out;\n    }\n    if (fieldNames.isEmpty()) return out;\n    List<KeyValue<String, String>> values = awaitOne(\n        async.hmget(key, fieldNames.toArray(new String[0])));\n    for (KeyValue<String, String> kv : values) {\n        if (kv != null && kv.hasValue()) {\n            out.put(kv.getKey(), kv.getValue());\n        }\n    }\n    return out;\n}",
      "section_id": "inference-reads-with-hmget"
    },
    {
      "id": "batch-scoring-with-pipelined-hmget-ex0",
      "language": "java",
      "code": "public Map<String, Map<String, String>> batchGetFeatures(\n        List<String> entityIds, List<String> fieldNames) {\n    if (entityIds.isEmpty() || fieldNames.isEmpty()) {\n        return Collections.emptyMap();\n    }\n    String[] names = fieldNames.toArray(new String[0]);\n\n    List<RedisFuture<List<KeyValue<String, String>>>> futures =\n        new ArrayList<>(entityIds.size());\n    conn.setAutoFlushCommands(false);\n    try {\n        for (String id : entityIds) {\n            futures.add(async.hmget(keyFor(id), names));\n        }\n        conn.flushCommands();\n    } finally {\n        conn.setAutoFlushCommands(true);\n    }\n\n    Map<String, Map<String, String>> out = new LinkedHashMap<>();\n    for (int i = 0; i < entityIds.size(); i++) {\n        List<KeyValue<String, String>> values = awaitOne(futures.get(i));\n        Map<String, String> row = new LinkedHashMap<>();\n        for (KeyValue<String, String> kv : values) {\n            if (kv != null && kv.hasValue()) row.put(kv.getKey(), kv.getValue());\n        }\n        out.put(entityIds.get(i), row);\n    }\n    return out;\n}",
      "section_id": "batch-scoring-with-pipelined-hmget"
    },
    {
      "id": "the-batch-builder-ex0",
      "language": "bash",
      "code": "mvn exec:java -Dexec.mainClass=BuildFeatures -Dexec.args=\"--count 500 --ttl-seconds 3600\"",
      "section_id": "the-batch-builder"
    },
    {
      "id": "get-the-source-files-ex0",
      "language": "bash",
      "code": "git clone https://github.com/redis/docs.git\ncd docs/content/develop/use-cases/feature-store/java-lettuce\nmvn package",
      "section_id": "get-the-source-files"
    },
    {
      "id": "start-the-demo-server-ex0",
      "language": "bash",
      "code": "mvn exec:java -Dexec.mainClass=DemoServer",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "start-the-demo-server-ex1",
      "language": "text",
      "code": "Dropping any existing users under 'fs:user:*' for a clean demo run (pass --no-reset to keep them).\nRedis feature-store demo server listening on http://127.0.0.1:8089\nUsing Redis at redis://localhost:6379 with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)\nMaterialized 200 user(s); streaming worker running.",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli-ex0",
      "language": "bash",
      "code": "# How many users currently in the store\nredis-cli --scan --pattern 'fs:user:*' | wc -l\n\n# One user's full hash and key-level TTL\nredis-cli HGETALL fs:user:u0001\nredis-cli TTL    fs:user:u0001\n\n# Per-field TTL on the streaming fields\nredis-cli HTTL fs:user:u0001 FIELDS 5 \\\n  last_login_ts last_device_id tx_count_5m failed_logins_15m session_country\n\n# Sample HMGET as the model would issue it\nredis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m",
      "section_id": "inspect-the-store-directly-with-redis-cli"
    }
  ]
}
