diff --git a/.agents/skills/redis-use-case-ports/SKILL.md b/.agents/skills/redis-use-case-ports/SKILL.md
index 904eb3306d..4fd80bfdda 100644
--- a/.agents/skills/redis-use-case-ports/SKILL.md
+++ b/.agents/skills/redis-use-case-ports/SKILL.md
@@ -166,4 +166,5 @@ Keep `SKILL.md` itself focused on the workflow. The concrete artefacts live in `
- [`content/develop/use-cases/job-queue/`](../../../content/develop/use-cases/job-queue/) — the project that introduced rows 11–13 of [`audit-checklist.md`](assets/audit-checklist.md) (token-checked atomic state transitions, crash-window fallback timer, shared-keyspace collision in parallel smoke tests).
- [`content/develop/use-cases/pub-sub/`](../../../content/develop/use-cases/pub-sub/) — the first non-keyspace use case ported. Introduced rows 14–18 of [`audit-checklist.md`](assets/audit-checklist.md) (subscribe-ack race, concurrent-name reservation, detached-worker PID capture, silent timeout fallthrough, server-wide PUBSUB introspection) plus the pub/sub conventions section in [`redis-conventions.md`](assets/redis-conventions.md). Also the project that motivated adding Phase 4b (independent review) after Codex caught four real bugs that Phase 4 cleared.
- [`content/develop/use-cases/recommendation-engine/`](../../../content/develop/use-cases/recommendation-engine/) — the first ML / vector-search use case. Introduced the **ML / vector-search use cases** section in [`redis-conventions.md`](assets/redis-conventions.md) (per-client embedding library table, pre-computed `catalog.json` wire format, FFI / Ruby-version setup blockers, per-port deviation conventions) and rows 24–28 of [`audit-checklist.md`](assets/audit-checklist.md) (vector dim mismatch in client-side blend helpers, L2 normalisation silently skipped by the embedding wrapper, TAG escape must include the backslash itself, connection-wide state toggle race on a shared client, weight=0 must disable not normalise to default). Each of the five new rows came from a real bug — bugbot or Codex caught all of them; the Python reference shipped with the TAG-escape bug originally.
+- [`content/develop/use-cases/feature-store/`](../../../content/develop/use-cases/feature-store/) — the first streaming-feature-store use case (HEXPIRE / HTTL per-field TTL + a long-lived in-process worker thread next to the demo server). Introduced the **Streaming-worker / background-task patterns** section in [`redis-conventions.md`](assets/redis-conventions.md) (pre-flight in-flight flag, worker lifetime decoupled from request lifetime, stop semantics, per-client HEXPIRE pipeline reply-shape table) and rows 35–37 of [`audit-checklist.md`](assets/audit-checklist.md) (HEXPIRE / HTTL per-field reply-code checking, pause-and-wait-idle race in worker-thread reset paths, worker stop with bounded join + silent thread abandonment). The reference Python implementation shipped without the in-flight flag and the stop-timeout recovery; Codex caught both on later ports and the Python retrofit followed.
- [`content/develop/use-cases/semantic-cache/`](../../../content/develop/use-cases/semantic-cache/) — the second ML / vector-search use case. Cache-on-LLM-responses backed by Redis Search KNN with a thresholded hit/miss decision and tenant/locale/model-version metadata filtering. Introduced rows 29–34 of [`audit-checklist.md`](assets/audit-checklist.md): embedder Predictor / Session thread-safety on shared instances (DJL needs `synchronized`, ONNX is fine); library config keys that look real but don't take effect (WEBrick's `MaxRequestBodySize` is not an option name; the body cap must be enforced in user code); lockfile pinning a newer runtime than the manifest declares (composer.lock requiring PHP 8.4 while composer.json said `^8.2`); NaN / Inf parsing via language-specific quirks (PHP `(float)"nan"` → 0.0 silently, must use textual rejection before parsing); per-language strings in HTML that's shared across language demos (badge text, default threshold must be populated via `/state` at first load); docs wire-form snippets must show escaped TAG values (`gpt\-4\.5\-2026`, not `gpt-4.5-2026`). Also the project that motivated the Phase 4b note about verifying independent-review findings against the current file before applying — several Jedis and PHP "missing" findings were actually re-discoveries of fixes that had landed minutes earlier.
diff --git a/.agents/skills/redis-use-case-ports/assets/audit-checklist.md b/.agents/skills/redis-use-case-ports/assets/audit-checklist.md
index 26ca571998..acd885ef6b 100644
--- a/.agents/skills/redis-use-case-ports/assets/audit-checklist.md
+++ b/.agents/skills/redis-use-case-ports/assets/audit-checklist.md
@@ -578,6 +578,74 @@ The robust pattern is **textual rejection before parsing**: lowercase the input,
---
+## 35. HEXPIRE / HTTL per-field reply-code checking
+
+**What to scan for:** every call site of `HEXPIRE`, `HEXPIREAT`, `HPEXPIRE`, `HPEXPIREAT`, `HTTL`, `HPTTL`, or any client-library typed wrapper around them. Look at how the per-field array reply is consumed.
+
+**Pass criterion:** `HEXPIRE`-family commands return one status code per requested field, not a single success/failure. Each code is:
+
+* `1` — TTL set / updated.
+* `2` — the expiry was `0` or in the past, so Redis deleted the field instead of attaching a TTL.
+* `0` — an `NX | XX | GT | LT` conditional flag was specified and not met.
+* `-2` — no such field, or no such key.
+
+The helper must **iterate the reply array and raise/throw on any code other than `1`** (when no conditional flag is in use), so the "every streaming write renews its TTL" invariant fails loudly rather than silently leaving a field with no expiry attached. A naked `await client.hexpire(...)` (or `pipe.hexpire(...)` whose result is discarded) is the wrong shape — the call can "succeed" at the RESP level and still have left every field un-TTL'd.
+
+`HTTL` returns the same array shape (per-field integer seconds, with `-2` for missing fields and `-1` for fields with no TTL). When the key is missing entirely, some libraries return a list-of-`-2` of the right length, others return `nil` / `None` / `null`. The helper must normalise to a per-field array of integers, defaulting missing/short replies to `-2` so callers never index out of range.
+
+**Sample audit prompt:**
+
+> For each port under `content/develop/use-cases/{{USE_CASE_NAME}}/`, locate every `HEXPIRE` (or family) call site and every `HTTL` call site. For HEXPIRE: confirm the helper iterates the per-field array and raises / throws on any code other than `1` (or documents why a specific non-`1` code is acceptable). A discarded reply or a check that only looks at the first element is a bug. For HTTL: confirm the helper normalises the reply to a per-field array even when the key is missing, with `-2` as the default for missing slots. Flag any port where a partial or `null` reply could cause an index-out-of-range error, a silent loss of the dead-letter signal, or a per-field TTL that never actually got set.
+
+**Why on list:** Feature-store use case, Codex independent review. The Python reference originally awaited `hexpire(...)` and discarded the per-field reply; for the streaming-feature-store pattern to work, every streaming write **must** renew the per-field TTL on every call. A single code of `2` (which means "Redis deleted the field because the expiry was already in the past") looks like success but is actually data loss. The defensive shim for HTTL was needed because redis-rs's typed wrapper, redis-rb's `call`-style return, and several of the pipelined clients all surface partial / `nil` arrays differently when the key has expired between the caller's check and the HTTL itself.
+
+---
+
+## 36. Pause-and-wait-idle race in worker-thread reset paths
+
+**What to scan for:** every worker-thread tick loop that supports `pause()` plus an external `reset` / `clear` / `purge` path. Look at where the in-flight flag (`tick_in_flight`, `_tickInFlight`, `Volatile.Read(ref _tickInFlight)`, etc.) is set relative to the `paused` check inside the tick loop.
+
+**Pass criterion:** the in-flight flag must be set to `true` (or `1`) **before** the pause check, with a `finally` / `defer` / `ensure` block clearing it on every exit path. The combination lets an external caller do:
+
+```
+worker.pause() # stop future ticks
+worker.wait_for_idle() # wait for the current tick to drain
+store.reset() # safe to delete keys now
+worker.resume()
+```
+
+If the in-flight flag is set **inside** the `if not paused: ...` branch, there is a window between the pause check and the actual tick where a concurrent `pause()` + `wait_for_idle()` observes `tick_in_flight=false` AND `paused=true`, falls straight through, and runs the `DEL` sweep while the tick is mid-write. The streaming write then recreates a hash entry that was just enumerated for deletion — leaving a streaming-only hash with no key-level TTL. Symptom: "0 leftover keys" smoke test fails sporadically, often only under load.
+
+The lifecycle flags (`running`, `tick_in_flight`) must be cleared in an **outer** `try/finally` / `defer` (around the whole tick loop, not just one iteration) so a thread that exits via an uncaught exception or a panic leaves the worker in a state where `start()` can spin a fresh thread. Without the outer clear, the demo's "is the worker running?" indicator gets stuck on, and a subsequent `start()` becomes a no-op.
+
+**Sample audit prompt:**
+
+> Audit every worker-thread tick loop in the 9 client implementations under `content/develop/use-cases/{{USE_CASE_NAME}}/`. For each, verify (a) the in-flight flag is set to true BEFORE the `paused` check, not inside the `not paused` branch; (b) a finally / defer / ensure clears the in-flight flag on every exit path including the paused-and-skipped path; (c) an outer try/finally around the whole tick loop clears both `running` and the in-flight flag so a panic / uncaught exception doesn't strand the lifecycle state. Run a quick stress test: 5x `reset` + `bulk-load` against an active streaming worker; the final keyspace must contain 0 leftover streaming-only hashes. Flag any port where (a), (b), or (c) is missing — those ports can produce ghost entries under concurrent reset.
+
+**Why on list:** Feature-store use case. Codex flagged the bug first on the Go port; once articulated, the same shape needed fixing in 7 of the 8 sibling ports (only Node.js's single-threaded event loop was immune). The reference Python implementation **shipped without the fix** — Codex caught it on a later client, and Python was retrofitted to match (the in-flight `threading.Event`, the pre-flight set, and the `wait_for_idle()` recovery now match the other 8 ports). Future Phase 1 reference implementations of streaming-worker-style use cases must adopt the pattern from the start.
+
+---
+
+## 37. Worker stop with bounded join + silent thread abandonment
+
+**What to scan for:** every `stop()` / `Stop()` / `StopAsync()` / shutdown method on a worker that owns a thread, task, or goroutine. Look at how the parent waits for the worker to exit.
+
+**Pass criterion:** if the wait is bounded (`thread.join(timeout=2.0)`, `worker.join(2000)`, `task.Wait(2000)`, etc.), the timeout-expired path must escalate, not silently move on. Acceptable shapes:
+
+* **Warn + indefinite wait.** Log a warning and call `thread.join()` (no timeout) so the parent at least observes that the stop took longer than the budget but never returns while the thread is still alive. This is the right shape for demos and well-behaved workers.
+* **Force-interrupt + wait.** Cancel the task's cancellation token, send `Thread.interrupt()`, send `SIGTERM`, etc., and only then return. The right shape for production code where the worker might be stuck in a blocking I/O call.
+* **Recovery via the in-flight flag.** Pair the bounded join with a `waitForIdle()` (polling the in-flight flag) that runs after the join. The in-flight flag's lifecycle (per row 36) is the eventual truth — even if the thread is still alive, once `tick_in_flight=false` the worker is safe to operate on. This is how Jedis and Lettuce ship in the feature-store ports.
+
+A bare `thread.join(timeout=N); self._thread = None` (drop the handle, move on) is the wrong shape. The thread is still running, holding a Redis connection, potentially writing during the next bulk-load. The demo "works" because Python daemon threads die when the process exits — but `stop()` was supposed to be a clean shutdown, and silently abandoning the thread defeats every test that relies on it.
+
+**Sample audit prompt:**
+
+> For each port under `content/develop/use-cases/{{USE_CASE_NAME}}/`, locate the worker's stop / shutdown method. If it uses a bounded join / wait (any timeout, any unit), verify one of these three recovery paths is present: (a) on timeout, log a warning and join indefinitely; (b) on timeout, force-interrupt the worker and then wait; (c) on timeout, fall through to a `waitForIdle()` (or equivalent in-flight-flag poll) that provides the actual safety guarantee. Flag any port where the timeout path is "set the handle to null and return" — that's silent thread abandonment, regardless of how the demo behaves under normal load.
+
+**Why on list:** Feature-store use case, Codex independent review of the Ruby port. The same shape was already in the Python reference (`thread.join(timeout=2.0)` then `self._thread = None`) but no earlier audit flagged it; Codex caught it on Ruby and the Python retrofit followed. Jedis / Lettuce had the bounded join but were saved by an explicit `waitForIdle()` after it — that's recovery shape (c) above, and it's the reason the bug never surfaced in those clients. Go / .NET / Rust / Node.js / PHP all use unbounded waits and are fine. The bug class is real even when masked by the in-flight-flag recovery; future ports should pick one shape and apply it consistently.
+
+---
+
## How to add a new row
When a bug class is identified after this skill has been used:
diff --git a/.agents/skills/redis-use-case-ports/assets/redis-conventions.md b/.agents/skills/redis-use-case-ports/assets/redis-conventions.md
index da5559408b..5ca5d9e39b 100644
--- a/.agents/skills/redis-use-case-ports/assets/redis-conventions.md
+++ b/.agents/skills/redis-use-case-ports/assets/redis-conventions.md
@@ -416,6 +416,71 @@ The reference Python pattern is an `_emit_change_locked(...)` helper called insi
See audit-checklist row 16 for the audit prompt.
+## Streaming-worker / background-task patterns
+
+These apply to any use case whose demo runs a long-lived in-process worker thread alongside the HTTP handler (streaming-feature-store updaters, background sync workers, CDC consumers, scheduled reindexers). The shape is similar to the pub/sub workers above but without the network primitive — the cross-port traps are about thread lifecycle and pause / wait-idle race conditions, not about ack handshakes.
+
+### Pre-flight in-flight flag before the pause check
+
+Every worker that exposes both `pause()` and `wait_for_idle()` (or equivalent) must set its `tick_in_flight` flag to `true` **before** the `paused` check inside the tick loop, with a `finally` / `defer` / `ensure` block that clears it on every exit path. The reference shape (Python-style pseudocode) is:
+
+```python
+while not self._stop_event.is_set():
+ self._stop_event.wait(timeout=self.tick_seconds)
+ if self._stop_event.is_set():
+ break
+ self._tick_in_flight.set()
+ try:
+ if not self._paused.is_set():
+ self._tick()
+ except Exception:
+ ...
+ finally:
+ self._tick_in_flight.clear()
+```
+
+The point is that an external caller can do `pause() + wait_for_idle() + reset()` and be guaranteed the reset's `DEL` sweep runs only after the in-flight tick has drained. If the flag is set **inside** the `not paused` branch, a concurrent `pause` + `wait_for_idle` can fall straight through while the tick is still mid-write, and the streaming `HSET` recreates an entry the reset just enumerated for deletion — leaving a streaming-only hash with no key-level TTL. Audit-checklist row 36 covers this.
+
+The outer `try/finally` (or `defer`, or `ensure`) wrapping the **whole tick loop** must also clear `running` and `tick_in_flight` on every exit path, so a worker that exits via an uncaught exception leaves the lifecycle state where the next `start()` can spin a fresh thread.
+
+### Worker lifetime decoupled from request lifetime
+
+Workers spawned from an HTTP request handler must not inherit the request's cancellation context. In Go specifically, calling `worker.Start(ctx)` with the `*http.Request.Context()` kills the worker as soon as the request completes — a particularly easy mistake because the Go community style strongly encourages passing `ctx` through everything. The fix is for `Start` to derive `context.Background()` (or use `context.WithCancel(context.Background())` for its own cancellation) internally; the HTTP `ctx` is only for the request's own work.
+
+The same shape applies to .NET (`CancellationToken` from the request) and Rust (`tokio_util::sync::CancellationToken` from the handler). The lifecycle of the worker is the lifetime of the **demo server process**, not the lifetime of any single request.
+
+### Worker stop semantics
+
+If the stop path uses a bounded `join` / `wait` / `await`, the timeout-expired branch must escalate — log + indefinite join, interrupt + wait, or fall through to a `waitForIdle()` on the in-flight flag. A bare `thread.join(timeout=N); thread = None` (drop the handle, move on) is silent thread abandonment, regardless of whether the daemon-thread shape lets the process exit cleanly. Audit-checklist row 37 covers this; the reference Python implementation shipped without it and was retrofitted after Codex flagged the same shape in the Ruby port.
+
+### HEXPIRE pipeline reply shapes vary across clients
+
+`HEXPIRE` returns one status code per requested field. Inside a pipeline / multi block the per-client decode shape varies:
+
+| Client | Pipeline-mode HEXPIRE reply | Notes |
+|---|---|---|
+| redis-py | `[int, int, ...]` flat list | `await pipe.execute()` gives back the per-field codes directly. |
+| node-redis 5.x | `MultiErrorReply` per failed code | Inside `multi/exec`. Use `execAsPipeline()` for a plain array reply if no transactional guarantee is needed. |
+| go-redis v9 | `[]int64` from `cmd.Result()` | Inspect via `redis.IntSliceCmd`. |
+| Jedis | `List` from `Response.get()` | After `pipeline.sync()`. |
+| Lettuce | `List` from `RedisFuture>.get()` | Use `async()` then `awaitAll` or `awaitOrCancel`. |
+| StackExchange.Redis | `RedisResult[]` — one per field | `(long)results[i]` to read each code. |
+| Predis 3 | `array` from the `pipeline()` callback's return value | The typed `hexpire()` decode preserves the per-field shape. |
+| redis-rb | `Array` from `redis.pipelined { ... }` | Use `redis.call('HEXPIRE', key, ttl, 'FIELDS', n, *names)`; the typed binding is not stable on 5.4. |
+| redis-rs | `Vec>` — outer pipeline wraps the inner array, take `[0]` | `pipe.cmd("HEXPIRE")...query_async::>>(&mut conn)`. |
+
+In every client the helper must iterate the per-field codes and raise / throw on anything other than `1` (assuming no `NX | XX | GT | LT` flag is in use). A discarded reply or a check that only looks at the first element silently leaves the rest of the fields un-TTL'd. Audit-checklist row 35 covers this.
+
+`HTTL` follows the same per-field-array shape with `-1` (no TTL) and `-2` (missing field/key) sentinels. The helper must normalise to a per-field array even when the reply is `nil` / `None` / `null` for a missing key — default missing slots to `-2` so callers never index out of range.
+
+### Stats counters across stateless and stateful runtimes
+
+Worker tick counts, write counts, and reads-per-second counters live in process memory for the threaded ports (Python, Node, Go, Jedis, Lettuce, Rust, .NET, Ruby). For PHP under `php -S`, where the demo server and the worker run as separate processes, the counters move into Redis under a `fs:control:*` / `:control:*` keyspace and the demo server / worker both `INCRBY` / `GET` against them. This is the same pattern as the prefetch-cache PHP port's cross-process pause flags (row 5 + the PHP-specific section above) but generalised to any counter the UI needs to display.
+
+### Reference projects
+
+* [`content/develop/use-cases/feature-store/`](../../../content/develop/use-cases/feature-store/) — established this section's conventions. Each port has a `StreamingWorker` (or equivalent) implementing the pause-and-wait-idle pattern, the outer lifecycle try/finally, and the per-field HEXPIRE reply check.
+
## ML / vector-search use cases
These apply to any use case whose helper has to embed text (or any other modality) into a vector and store the bytes in a Redis Search VECTOR field — recommendation engines, semantic search, RAG retrieval, classification feature stores. The shape is fundamentally different from the keyspace use cases because each port has to choose its own embedding library, and the library choice has real implications for setup ergonomics, performance, and which sentence encoder it ships.
diff --git a/.gitignore b/.gitignore
index 29e1ed27e9..384d5c9585 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,16 @@ package-lock.json
.DS_Store
.idea
# Rust docs demos
-/content/develop/use-cases/rate-limiter/rust/target/
-/content/develop/use-cases/rate-limiter/rust/Cargo.lock
+/content/develop/use-cases/**/rust/target/
+/content/develop/use-cases/**/rust/Cargo.lock
+# Java / Maven build output for the docs demos
+/content/develop/use-cases/**/java-jedis/target/
+/content/develop/use-cases/**/java-lettuce/target/
+# .NET build output for the docs demos
+/content/develop/use-cases/**/dotnet/bin/
+/content/develop/use-cases/**/dotnet/obj/
+# PHP and Ruby build output for the docs demos
+/content/develop/use-cases/**/php/vendor/
+/content/develop/use-cases/**/php/composer.lock
+/content/develop/use-cases/**/ruby/.gems/
+/content/develop/use-cases/**/ruby/Gemfile.lock
diff --git a/content/develop/use-cases/_index.md b/content/develop/use-cases/_index.md
index ff63deb36e..085389f8eb 100644
--- a/content/develop/use-cases/_index.md
+++ b/content/develop/use-cases/_index.md
@@ -27,4 +27,5 @@ This section provides practical examples and reference implementations for commo
* [Pub/sub messaging]({{< relref "/develop/use-cases/pub-sub" >}}) - Broadcast real-time events to many consumers with channel and pattern subscriptions
* [Streaming]({{< relref "/develop/use-cases/streaming" >}}) - Process ordered event streams with consumer groups, replay, and configurable retention
* [Recommendation engine]({{< relref "/develop/use-cases/recommendation-engine" >}}) - Serve personalized recommendations under tight latency budgets by combining vector similarity with structured filters in a single Redis call
+* [Feature store]({{< relref "/develop/use-cases/feature-store" >}}) - Serve pre-computed ML features on the request path with mixed batch-and-streaming freshness using per-field TTL
* [Semantic cache]({{< relref "/develop/use-cases/semantic-cache" >}}) - Reuse LLM responses for semantically similar queries to cut token costs and skip multi-second model calls on near-duplicate prompts
diff --git a/content/develop/use-cases/feature-store/_index.md b/content/develop/use-cases/feature-store/_index.md
new file mode 100644
index 0000000000..51f6e7f0ff
--- /dev/null
+++ b/content/develop/use-cases/feature-store/_index.md
@@ -0,0 +1,166 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Serve pre-computed ML features on the request path under tight latency budgets, with batch and streaming features kept fresh in the same store.
+hideListLinks: true
+linkTitle: Feature store
+title: Redis feature store
+weight: 7
+---
+
+## When to use Redis as a feature store
+
+Use Redis as the online layer of a feature store when production models — fraud
+scoring, recommendations, dynamic pricing — need dozens of pre-computed features
+per prediction on every request, with sub-millisecond reads, mixed batch-and-streaming
+freshness, and high write throughput from concurrent ingestion pipelines.
+
+## Why the problem is hard
+
+An online feature store has to serve dozens of features per inference call inside
+a request budget measured in milliseconds, while batch jobs and streaming
+pipelines update those same features at very different cadences. Some of the
+obvious workarounds have real drawbacks:
+
+- **Querying the offline warehouse directly** adds hundreds of milliseconds per
+ inference call, which makes real-time serving impossible.
+- **A bespoke cache in front of the warehouse** solves latency but introduces
+ *training-serving skew*: the features served at inference drift from what the
+ model trained on, silently degrading accuracy whenever a transform changes
+ on one side and not the other.
+- **Disk-backed online stores** hit a throughput wall when every user action
+ has to update a dozen features simultaneously across millions of entities —
+ the I/O mix of small concurrent writes is exactly what they are slowest at.
+- **Single-TTL stores** can't handle mixed staleness: batch features refreshed
+ nightly coexist with streaming features updated every few seconds, and a
+ single per-key expiry can't express both. Worse, a failed ingestion
+ pipeline must *expire* its features rather than serve stale values silently.
+
+A workable online feature store needs sub-millisecond reads at request rate,
+high concurrent write throughput from mixed batch and streaming ingestion,
+independent freshness controls per feature, and self-cleaning behavior when an
+upstream pipeline fails — without standing up a dedicated piece of
+infrastructure beside the rest of the model-serving stack.
+
+## What you can expect from a Redis solution
+
+You can:
+
+- Serve feature vectors to inference endpoints under 1 ms P99
+ (99% of requests have a latency of 1 ms or less) at millions of
+ reads per second from a single shard, and scale horizontally beyond that
+ with Redis Cluster.
+- Run batch and streaming ingestion concurrently against the same entities
+ without locking or version columns — Redis is single-threaded per shard, so
+ individual field writes are atomic by construction.
+- Apply *different* freshness guarantees to individual features within the same
+ entity hash: seconds for real-time signals, hours for batch aggregates, with
+ per-field TTL via [`HEXPIRE`]({{< relref "/commands/hexpire" >}}).
+- Let stale streaming features self-expire when their ingestion pipeline
+ fails, so models receive missing features rather than silently outdated ones.
+- Retrieve features for hundreds of entities in a single round trip for batch
+ scoring, using pipelined [`HMGET`]({{< relref "/commands/hmget" >}}).
+- Plug into [Redis Feature Form]({{< relref "/develop/ai/featureform" >}}) —
+ Redis's own materialize / serve layer — or
+ [Feast](https://docs.feast.dev/) with a connection-string change, so no
+ bespoke serving code is required.
+- Co-locate the online feature store on the same Redis instance already
+ handling cache, sessions, or rate limiting in the stack — no additional
+ infrastructure.
+
+## How Redis supports the solution
+
+In practice, each entity (a user, an account, an item) is a single
+[Hash]({{< relref "/develop/data-types/hashes" >}}) at a deterministic key like
+`fs:user:{id}`. The hash holds every feature for that entity as one field per
+feature — batch-materialized aggregates alongside streaming-updated signals —
+so one [`HMGET`]({{< relref "/commands/hmget" >}}) call returns whatever subset
+the model needs in one round trip. A key-level
+[`EXPIRE`]({{< relref "/commands/expire" >}}) aligns with the batch
+materialization cycle so a whole entity self-cleans when its pipeline stops
+refreshing it, and per-field [`HEXPIRE`]({{< relref "/commands/hexpire" >}})
+lets each streaming feature carry its own shorter expiry independent of the
+rest of the hash.
+
+Redis provides the following features that make it a good fit for an online
+feature store:
+
+- [Hashes]({{< relref "/develop/data-types/hashes" >}}) group every feature
+ for an entity under one key, so retrieval reads everything the model needs
+ in a single network round trip with [`HMGET`]({{< relref "/commands/hmget" >}}),
+ and small hashes use *listpack* encoding for compact in-memory representation.
+- [`HSET`]({{< relref "/commands/hset" >}}) writes any subset of fields
+ atomically, so batch and streaming pipelines can update overlapping or
+ disjoint features on the same entity concurrently without locks or version
+ columns.
+- [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) (Redis 7.4+) give per-field TTLs,
+ so streaming features (5-minute freshness) and batch features (24-hour
+ freshness) can live in the same hash with independent expiry — the
+ *mixed-staleness* problem becomes a one-line server-side guarantee.
+- [`EXPIRE`]({{< relref "/commands/expire" >}}) at the key level lets an
+ entity disappear entirely if its batch refresher fails, so inference sees
+ a missing entity (which the model handler can detect and fall back on)
+ rather than silently outdated values.
+- [Pipelining]({{< relref "/develop/using-commands/pipelining" >}}) bundles
+ [`HMGET`]({{< relref "/commands/hmget" >}}) calls for many entities into
+ one round trip, which is the right primitive for batch scoring where the
+ model needs features for hundreds of entities at once.
+- Sub-millisecond reads and writes from memory keep the feature store off the
+ critical path of inference, so the model-server's request budget is spent
+ on the model rather than on feature retrieval.
+
+## Ecosystem
+
+The following libraries and platforms use Redis as their online feature store:
+
+- **[Redis Feature Form]({{< relref "/develop/ai/featureform" >}})** is
+ Redis's own feature-engineering platform. It defines features, labels, and
+ feature views in a Python definitions file, materializes them through a
+ [registered provider]({{< relref "/develop/ai/featureform/providers" >}}),
+ and [serves]({{< relref "/develop/ai/featureform/features-and-labels" >}})
+ them from Redis as the low-latency online store. See the
+ [quickstart]({{< relref "/develop/ai/featureform/quickstart" >}}) for an
+ end-to-end walkthrough.
+- **Python**: [Feast](https://docs.feast.dev/reference/online-stores/redis)
+ ships Redis as a first-class online store provider — point a Feast
+ `online_store` block at a Redis connection string and the
+ `RedisOnlineStore` backend handles materialization and serving.
+- **Compute**: [Apache Spark](https://spark.apache.org/) batch jobs run the
+ nightly materialization, writing into Redis via the Redis Feature Form /
+ Feast materialize commands or directly with the
+ [`spark-redis`](https://github.com/RedisLabs/spark-redis) connector.
+- **Streaming**: [Apache Flink](https://flink.apache.org/) or
+ [Kafka Streams](https://kafka.apache.org/documentation/streams/) compute the
+ real-time features and `HSET` them into Redis with per-field
+ [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) so each streaming signal
+ carries its own freshness window.
+- **Infrastructure**: [Kubernetes](https://kubernetes.io/) co-locates Redis
+ pods alongside the model-serving containers, with horizontal-pod autoscaling
+ on the read replicas to track inference load;
+ [Active-Active geo-distribution]({{< relref "/operate/rs/databases/active-active" >}})
+ on Redis Enterprise / Redis Cloud replicates the online store across
+ regions for low-latency reads close to each inference cluster.
+
+## Code examples to build your own Redis feature store
+
+The following guides show how to build a small Redis-backed online feature
+store for a fraud-scoring model. Each guide includes a runnable interactive
+demo that lets you bulk-load batch features, run a streaming worker that
+updates real-time features with per-field TTL, retrieve any subset of features
+for a single user under 1 ms, and pipeline batch reads across a hundred users.
+
+* [redis-py (Python)]({{< relref "/develop/use-cases/feature-store/redis-py" >}})
+* [node-redis (Node.js)]({{< relref "/develop/use-cases/feature-store/nodejs" >}})
+* [go-redis (Go)]({{< relref "/develop/use-cases/feature-store/go" >}})
+* [Jedis (Java)]({{< relref "/develop/use-cases/feature-store/java-jedis" >}})
+* [Lettuce (Java)]({{< relref "/develop/use-cases/feature-store/java-lettuce" >}})
+* [redis-rs (Rust)]({{< relref "/develop/use-cases/feature-store/rust" >}})
+* [StackExchange.Redis (C#)]({{< relref "/develop/use-cases/feature-store/dotnet" >}})
+* [Predis (PHP)]({{< relref "/develop/use-cases/feature-store/php" >}})
+* [redis-rb (Ruby)]({{< relref "/develop/use-cases/feature-store/ruby" >}})
diff --git a/content/develop/use-cases/feature-store/dotnet/BuildFeatures.cs b/content/develop/use-cases/feature-store/dotnet/BuildFeatures.cs
new file mode 100644
index 0000000000..e95c0003e0
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/BuildFeatures.cs
@@ -0,0 +1,127 @@
+using StackExchange.Redis;
+
+namespace FeatureStoreDemo;
+
+///
+/// Synthesize a small batch of users with realistic-looking features
+/// and bulk-load them into Redis with a 24-hour key-level TTL.
+///
+///
+/// Stands in for the nightly Spark / Feast materialization job in a
+/// real deployment. In production the equivalent of this script lives
+/// in an offline pipeline that reads from the offline store and
+/// writes the serving-time hashes into Redis via HSET +
+/// EXPIRE.
+///
+public static class BuildFeatures
+{
+ private static readonly string[] CountryChoices = {
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL",
+ };
+ private static readonly string[] RiskSegments = { "low", "medium", "high" };
+ private static readonly int[] RiskWeights = { 70, 25, 5 };
+ private static readonly int[] ChargebackBuckets = { 0, 1, 2, 3 };
+ private static readonly int[] ChargebackWeights = { 85, 10, 4, 1 };
+
+ ///
+ /// Generate synthetic user feature rows.
+ ///
+ public static Dictionary> SynthesizeUsers(
+ int count, int seed)
+ {
+ var rng = new Random(seed);
+ var users = new Dictionary>(count);
+ for (int i = 1; i <= count; i++)
+ {
+ var uid = $"u{i:D4}";
+ users[uid] = new Dictionary
+ {
+ ["country_iso"] = CountryChoices[rng.Next(CountryChoices.Length)],
+ ["risk_segment"] = WeightedChoice(rng, RiskSegments, RiskWeights),
+ ["account_age_days"] = 7 + rng.Next(2394),
+ ["tx_count_7d"] = rng.Next(81),
+ ["avg_amount_30d"] = Math.Round(5.0 + rng.NextDouble() * 345.0, 2),
+ ["chargeback_count_180d"] = WeightedChoiceInt(rng, ChargebackBuckets, ChargebackWeights),
+ };
+ }
+ return users;
+ }
+
+ ///
+ /// CLI entry point. Run with:
+ /// dotnet run --project . -- --mode build-features --count 500
+ ///
+ public static async Task RunCliAsync(string[] args)
+ {
+ var redisUri = "localhost:6379";
+ var count = 200;
+ var ttlSeconds = 24L * 60L * 60L;
+ var keyPrefix = "fs:user:";
+ var seed = 42;
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--redis-uri" when i + 1 < args.Length:
+ redisUri = args[++i]; break;
+ case "--count" when i + 1 < args.Length:
+ count = int.Parse(args[++i]); break;
+ case "--ttl-seconds" when i + 1 < args.Length:
+ ttlSeconds = long.Parse(args[++i]); break;
+ case "--key-prefix" when i + 1 < args.Length:
+ keyPrefix = args[++i]; break;
+ case "--seed" when i + 1 < args.Length:
+ seed = int.Parse(args[++i]); break;
+ case "-h":
+ case "--help":
+ Console.WriteLine(
+ "Usage: dotnet run -- --mode build-features [--redis-uri URI] " +
+ "[--count N] [--ttl-seconds S] [--key-prefix PREFIX] [--seed N]");
+ return 0;
+ }
+ }
+
+ var mux = await ConnectionMultiplexer.ConnectAsync(redisUri);
+ try
+ {
+ var store = new FeatureStore(mux, keyPrefix, ttlSeconds,
+ FeatureStore.DefaultStreamingTtlSeconds);
+ var rows = SynthesizeUsers(count, seed);
+ var loaded = await store.BulkLoadAsync(rows, ttlSeconds);
+ Console.WriteLine(
+ $"Materialized {loaded} users at {keyPrefix}* with a {ttlSeconds}s key-level TTL.");
+ }
+ finally
+ {
+ await mux.CloseAsync();
+ }
+ return 0;
+ }
+
+ private static string WeightedChoice(Random rng, string[] items, int[] weights)
+ {
+ int total = 0;
+ foreach (var w in weights) total += w;
+ int r = rng.Next(total);
+ for (int i = 0; i < items.Length; i++)
+ {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[^1];
+ }
+
+ private static int WeightedChoiceInt(Random rng, int[] items, int[] weights)
+ {
+ int total = 0;
+ foreach (var w in weights) total += w;
+ int r = rng.Next(total);
+ for (int i = 0; i < items.Length; i++)
+ {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[^1];
+ }
+}
diff --git a/content/develop/use-cases/feature-store/dotnet/FeatureStore.cs b/content/develop/use-cases/feature-store/dotnet/FeatureStore.cs
new file mode 100644
index 0000000000..923ef44fde
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/FeatureStore.cs
@@ -0,0 +1,449 @@
+using System.Collections.Concurrent;
+using StackExchange.Redis;
+
+namespace FeatureStoreDemo;
+
+///
+/// Redis online feature store backed by per-entity Hashes
+/// (StackExchange.Redis).
+///
+///
+/// Each entity (here, a user) lives at a deterministic key such as
+/// fs:user:{id}. The hash holds every feature for that entity
+/// as one field per feature — batch-materialized aggregates
+/// (refreshed on a daily cycle) alongside streaming-updated signals
+/// (refreshed every few seconds). One HMGET returns whichever
+/// subset the model needs in one network round trip.
+///
+/// Two TTL layers solve the mixed staleness problem:
+///
+///
+/// A key-level EXPIRE aligned with the batch
+/// materialization cycle.
+/// A per-field HEXPIRE on each streaming field gives
+/// that field its own shorter expiry, independent of the rest of
+/// the hash.
+///
+///
+/// HEXPIRE and HTTL require Redis 7.4 or later.
+/// StackExchange.Redis 2.8+ exposes them as
+/// and
+/// . The demo pins
+/// 2.13.17.
+///
+/// The shared ConnectionMultiplexer is thread-safe and
+/// multiplexed — one instance serves the whole process, and every
+/// handler in the ASP.NET Core thread pool plus the streaming
+/// worker call into it without coordination.
+///
+public sealed class FeatureStore
+{
+ public static readonly IReadOnlyList DefaultBatchFields = new[]
+ {
+ "country_iso",
+ "risk_segment",
+ "account_age_days",
+ "tx_count_7d",
+ "avg_amount_30d",
+ "chargeback_count_180d",
+ };
+
+ public static readonly IReadOnlyList DefaultStreamingFields = new[]
+ {
+ "last_login_ts",
+ "last_device_id",
+ "tx_count_5m",
+ "failed_logins_15m",
+ "session_country",
+ };
+
+ public const long DefaultBatchTtlSeconds = 24L * 60L * 60L;
+ public const long DefaultStreamingTtlSeconds = 5L * 60L;
+ public const string DefaultKeyPrefix = "fs:user:";
+
+ private readonly IConnectionMultiplexer _mux;
+ private readonly IDatabase _db;
+ public string KeyPrefix { get; }
+ public long BatchTtlSeconds { get; }
+ public long StreamingTtlSeconds { get; }
+
+ private long _batchWritesTotal;
+ private long _streamingWritesTotal;
+ private long _readsTotal;
+ private long _readFieldsTotal;
+
+ public FeatureStore(
+ IConnectionMultiplexer mux,
+ string keyPrefix = DefaultKeyPrefix,
+ long batchTtlSeconds = DefaultBatchTtlSeconds,
+ long streamingTtlSeconds = DefaultStreamingTtlSeconds)
+ {
+ _mux = mux;
+ _db = mux.GetDatabase();
+ KeyPrefix = keyPrefix;
+ BatchTtlSeconds = batchTtlSeconds;
+ StreamingTtlSeconds = streamingTtlSeconds;
+ }
+
+ public string KeyFor(string entityId) => KeyPrefix + entityId;
+
+ // ---------------------------------------------------------------
+ // Batch ingestion (materialization)
+ // ---------------------------------------------------------------
+
+ ///
+ /// Materialize a batch of entities into Redis.
+ ///
+ ///
+ /// One HSET plus one EXPIRE per entity, all queued
+ /// through an IBatch so the whole batch ships in a single
+ /// network round trip.
+ ///
+ public async Task BulkLoadAsync(
+ IReadOnlyDictionary> rows,
+ long ttlSeconds)
+ {
+ if (rows.Count == 0) return 0;
+ var batch = _db.CreateBatch();
+ var tasks = new List(rows.Count * 2);
+ foreach (var (entityId, fields) in rows)
+ {
+ var key = (RedisKey)KeyFor(entityId);
+ var entries = new HashEntry[fields.Count];
+ int i = 0;
+ foreach (var (name, value) in fields)
+ {
+ entries[i++] = new HashEntry(name, EncodeValue(value));
+ }
+ tasks.Add(batch.HashSetAsync(key, entries));
+ tasks.Add(batch.KeyExpireAsync(key, TimeSpan.FromSeconds(ttlSeconds)));
+ }
+ batch.Execute();
+ await Task.WhenAll(tasks);
+ Interlocked.Add(ref _batchWritesTotal, rows.Count);
+ return rows.Count;
+ }
+
+ // ---------------------------------------------------------------
+ // Streaming ingestion
+ // ---------------------------------------------------------------
+
+ ///
+ /// Write streaming features with a per-field TTL.
+ ///
+ ///
+ /// HSET and HEXPIRE are queued in the same
+ /// IBatch so Redis runs them in pipeline order: the
+ /// HSET first creates or overwrites the fields, then
+ /// HEXPIRE attaches a TTL to each of those same fields.
+ ///
+ ///
+ /// returns one
+ /// per field:
+ ///
+ /// Success (= Redis code 1): TTL set / updated.
+ /// Due (= 2): the expiry was 0 or in the past, so
+ /// Redis deleted the field instead of applying a TTL.
+ /// ConditionNotMet (= 0): NX/XX/GT/LT condition
+ /// not met (we never use one here).
+ /// NoSuchField (= -2): no such field, or no such key.
+ ///
+ /// We always follow HSET with HEXPIRE so any code
+ /// other than Success means the per-field TTL invariant
+ /// didn't hold — the helper throws rather than silently leaving a
+ /// streaming field with no expiry attached.
+ ///
+ ///
+ public async Task UpdateStreamingAsync(
+ string entityId,
+ IReadOnlyDictionary fields,
+ long ttlSeconds)
+ {
+ if (fields.Count == 0) return;
+ var key = (RedisKey)KeyFor(entityId);
+ var entries = new HashEntry[fields.Count];
+ var names = new RedisValue[fields.Count];
+ int i = 0;
+ foreach (var (name, value) in fields)
+ {
+ entries[i] = new HashEntry(name, EncodeValue(value));
+ names[i] = name;
+ i++;
+ }
+
+ var batch = _db.CreateBatch();
+ var hsetTask = batch.HashSetAsync(key, entries);
+ var hexpireTask = batch.HashFieldExpireAsync(
+ key, names, TimeSpan.FromSeconds(ttlSeconds));
+ batch.Execute();
+ await hsetTask;
+ var codes = await hexpireTask;
+ foreach (var code in codes)
+ {
+ if (code != ExpireResult.Success)
+ {
+ throw new InvalidOperationException(
+ $"HEXPIRE did not set every field TTL for {key}: [{string.Join(",", codes)}]");
+ }
+ }
+ Interlocked.Add(ref _streamingWritesTotal, fields.Count);
+ }
+
+ // ---------------------------------------------------------------
+ // Inference reads
+ // ---------------------------------------------------------------
+
+ ///
+ /// Retrieve a subset of features for one entity with HMGET.
+ /// Returns only the fields that actually exist on the hash;
+ /// missing fields are dropped from the result.
+ ///
+ public async Task> GetFeaturesAsync(
+ string entityId, IReadOnlyList fieldNames)
+ {
+ var key = (RedisKey)KeyFor(entityId);
+ var out_ = new Dictionary();
+ if (fieldNames.Count == 0) return out_;
+ var values = await _db.HashGetAsync(
+ key, fieldNames.Select(f => (RedisValue)f).ToArray());
+ for (int i = 0; i < fieldNames.Count; i++)
+ {
+ if (!values[i].IsNull)
+ {
+ out_[fieldNames[i]] = values[i].ToString();
+ }
+ }
+ Interlocked.Increment(ref _readsTotal);
+ Interlocked.Add(ref _readFieldsTotal, out_.Count);
+ return out_;
+ }
+
+ ///
+ /// Full-hash read via HGETALL. Useful for debugging but
+ /// the model server should always go through
+ /// with an explicit field list.
+ ///
+ public async Task> GetAllFeaturesAsync(string entityId)
+ {
+ var entries = await _db.HashGetAllAsync(KeyFor(entityId));
+ var dict = new Dictionary(entries.Length);
+ foreach (var e in entries)
+ {
+ dict[e.Name.ToString()] = e.Value.ToString();
+ }
+ Interlocked.Increment(ref _readsTotal);
+ Interlocked.Add(ref _readFieldsTotal, entries.Length);
+ return dict;
+ }
+
+ ///
+ /// Pipeline HMGET across many entities for batch scoring.
+ /// One round trip for the whole batch via IBatch.
+ ///
+ public async Task>> BatchGetFeaturesAsync(
+ IReadOnlyList entityIds, IReadOnlyList fieldNames)
+ {
+ if (entityIds.Count == 0 || fieldNames.Count == 0)
+ return new Dictionary>();
+
+ var fieldValues = fieldNames.Select(f => (RedisValue)f).ToArray();
+ var batch = _db.CreateBatch();
+ var tasks = new Task[entityIds.Count];
+ for (int i = 0; i < entityIds.Count; i++)
+ {
+ tasks[i] = batch.HashGetAsync(KeyFor(entityIds[i]), fieldValues);
+ }
+ batch.Execute();
+ var rows = await Task.WhenAll(tasks);
+
+ var out_ = new Dictionary>();
+ long seen = 0;
+ for (int i = 0; i < entityIds.Count; i++)
+ {
+ var row = new Dictionary();
+ for (int j = 0; j < fieldNames.Count; j++)
+ {
+ if (!rows[i][j].IsNull)
+ {
+ row[fieldNames[j]] = rows[i][j].ToString();
+ seen++;
+ }
+ }
+ out_[entityIds[i]] = row;
+ }
+ Interlocked.Add(ref _readsTotal, entityIds.Count);
+ Interlocked.Add(ref _readFieldsTotal, seen);
+ return out_;
+ }
+
+ // ---------------------------------------------------------------
+ // TTL inspection (used by the demo UI)
+ // ---------------------------------------------------------------
+
+ ///
+ /// Seconds until the entity key expires. Returns -1 if no TTL is
+ /// set, -2 if the key doesn't exist.
+ ///
+ public async Task KeyTtlSecondsAsync(string entityId)
+ {
+ var ttl = await _db.KeyTimeToLiveAsync(KeyFor(entityId));
+ if (ttl == null)
+ {
+ // StackExchange.Redis returns null both for "no TTL" and
+ // for "key doesn't exist". Disambiguate with KeyExists.
+ return await _db.KeyExistsAsync(KeyFor(entityId)) ? -1L : -2L;
+ }
+ return (long)ttl.Value.TotalSeconds;
+ }
+
+ ///
+ /// Per-field TTL helper (Redis 7.4+). Returns whole seconds for
+ /// parity with the other clients: positive seconds remaining,
+ /// -1 no field TTL, -2 field (or key) missing.
+ ///
+ ///
+ /// HashFieldGetTimeToLiveAsync wraps HPTTL (not
+ /// HTTL), so the API returns milliseconds. We convert to
+ /// whole seconds here so the JSON shape matches Python, Node.js,
+ /// Go, Java, Rust, Ruby, and PHP, which all expose seconds.
+ ///
+ public async Task> FieldTtlsSecondsAsync(
+ string entityId, IReadOnlyList fieldNames)
+ {
+ var out_ = new Dictionary();
+ if (fieldNames.Count == 0) return out_;
+ var values = fieldNames.Select(f => (RedisValue)f).ToArray();
+ var ms = await _db.HashFieldGetTimeToLiveAsync(KeyFor(entityId), values);
+ // SE.Redis 2.13 returns a flat long[] of length == fieldNames.Count
+ // (filled with -2s for a missing key). Coerce defensively against
+ // any future version that might return a shorter or empty array.
+ for (int i = 0; i < fieldNames.Count; i++)
+ {
+ // HPTTL returns ms remaining; negative sentinels pass
+ // through. Convert positive durations to whole seconds
+ // for parity with the other clients' helpers.
+ long v = i < ms.Length ? ms[i] : -2L;
+ out_[fieldNames[i]] = v < 0 ? v : v / 1000;
+ }
+ return out_;
+ }
+
+ // ---------------------------------------------------------------
+ // Demo housekeeping
+ // ---------------------------------------------------------------
+
+ ///
+ /// Enumerate up to entity IDs by
+ /// scanning keyPrefix*. SCAN is non-blocking; the
+ /// demo uses it for UI dropdowns, not as a serving primitive.
+ ///
+ public List ListEntityIds(int limit)
+ {
+ var ids = new List(Math.Min(limit, 1024));
+ foreach (var endPoint in _mux.GetEndPoints())
+ {
+ var server = _mux.GetServer(endPoint);
+ // pageSize=200 mirrors the other clients' SCAN COUNT
+ foreach (var key in server.Keys(
+ pattern: KeyPrefix + "*", pageSize: 200))
+ {
+ var k = key.ToString();
+ if (k.Length > KeyPrefix.Length)
+ {
+ ids.Add(k[KeyPrefix.Length..]);
+ if (ids.Count >= limit) break;
+ }
+ }
+ if (ids.Count >= limit) break;
+ }
+ ids.Sort(StringComparer.Ordinal);
+ return ids;
+ }
+
+ ///
+ /// Count every entity under the key prefix. Iterates SCAN without
+ /// an in-memory cap so the UI can show the true total even when
+ /// more keys exist than returns.
+ ///
+ public long CountEntities()
+ {
+ long count = 0;
+ foreach (var endPoint in _mux.GetEndPoints())
+ {
+ var server = _mux.GetServer(endPoint);
+ foreach (var _ in server.Keys(
+ pattern: KeyPrefix + "*", pageSize: 500))
+ {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ public Task DeleteEntityAsync(string entityId) =>
+ _db.KeyDeleteAsync(KeyFor(entityId)).ContinueWith(t => t.Result ? 1L : 0L);
+
+ ///
+ /// Drop every entity under the key prefix. Used by the demo
+ /// reset path; SCANs and DELs in batches of 500.
+ ///
+ public async Task ResetAsync()
+ {
+ long deleted = 0;
+ foreach (var endPoint in _mux.GetEndPoints())
+ {
+ var server = _mux.GetServer(endPoint);
+ var batch = new List(500);
+ foreach (var key in server.Keys(
+ pattern: KeyPrefix + "*", pageSize: 500))
+ {
+ batch.Add(key);
+ if (batch.Count >= 500)
+ {
+ deleted += await _db.KeyDeleteAsync(batch.ToArray());
+ batch.Clear();
+ }
+ }
+ if (batch.Count > 0)
+ {
+ deleted += await _db.KeyDeleteAsync(batch.ToArray());
+ }
+ }
+ return deleted;
+ }
+
+ public Stats StatsSnapshot() => new(
+ Interlocked.Read(ref _batchWritesTotal),
+ Interlocked.Read(ref _streamingWritesTotal),
+ Interlocked.Read(ref _readsTotal),
+ Interlocked.Read(ref _readFieldsTotal));
+
+ public void ResetStats()
+ {
+ Interlocked.Exchange(ref _batchWritesTotal, 0);
+ Interlocked.Exchange(ref _streamingWritesTotal, 0);
+ Interlocked.Exchange(ref _readsTotal, 0);
+ Interlocked.Exchange(ref _readFieldsTotal, 0);
+ }
+
+ public record Stats(
+ long BatchWritesTotal,
+ long StreamingWritesTotal,
+ long ReadsTotal,
+ long ReadFieldsTotal);
+
+ ///
+ /// Render a feature value as a string for hash storage. Booleans
+ /// become "true"/"false" so they round-trip cleanly through other
+ /// clients and redis-cli.
+ ///
+ public static string EncodeValue(object? value) => value switch
+ {
+ null => "",
+ bool b => b ? "true" : "false",
+ double d when d == Math.Floor(d) => d.ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
+ double d => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ float f => f.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ _ => value.ToString() ?? "",
+ };
+}
diff --git a/content/develop/use-cases/feature-store/dotnet/FeatureStoreDemo.csproj b/content/develop/use-cases/feature-store/dotnet/FeatureStoreDemo.csproj
new file mode 100644
index 0000000000..957c3cc258
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/FeatureStoreDemo.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+ FeatureStoreDemo
+
+
+
+
+
+
+
+
diff --git a/content/develop/use-cases/feature-store/dotnet/HtmlTemplate.cs b/content/develop/use-cases/feature-store/dotnet/HtmlTemplate.cs
new file mode 100644
index 0000000000..f4b808b737
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/HtmlTemplate.cs
@@ -0,0 +1,370 @@
+using System.Text.Json;
+
+namespace FeatureStoreDemo;
+
+///
+/// Inlined HTML page for the demo. Same UI shape as every other
+/// feature-store demo (Python, Node.js, Go, Java, Rust).
+///
+internal static class HtmlTemplate
+{
+ public static string Render(string keyPrefix, long streamingTtl, int usersPerTick)
+ {
+ var batchJson = JsonSerializer.Serialize(FeatureStore.DefaultBatchFields);
+ var streamJson = JsonSerializer.Serialize(FeatureStore.DefaultStreamingFields);
+ return Template
+ .Replace("__KEY_PREFIX__", keyPrefix)
+ .Replace("__STREAM_TTL__", streamingTtl.ToString())
+ .Replace("__USERS_PER_TICK__", usersPerTick.ToString())
+ .Replace("__BATCH_FIELDS_JSON__", batchJson)
+ .Replace("__STREAM_FIELDS_JSON__", streamJson);
+ }
+
+ // C# 11 raw string literals (""") let the JS template literals
+ // (`backticks`) survive without escapes.
+ private const string Template = """
+
+
+
+
+
+ Redis Feature Store Demo (.NET)
+
+
+
+
+
StackExchange.Redis + ASP.NET Core minimal API
+
Redis Feature Store Demo
+
+ A small fraud-scoring feature store. Each user is one Redis hash
+ at __KEY_PREFIX__{id} with a batch-materialized
+ batch half (daily aggregates,
+ 24-hour key-level EXPIRE) and a streaming
+ streaming half (real-time
+ signals, __STREAM_TTL__s per-field HEXPIRE).
+ Inference reads any subset with one HMGET; batch
+ scoring pipelines HMGET across N users through one
+ IBatch.
+
+
+
+
+
Store state
+
Loading...
+
+
+
+
Materialize batch features
+
Calls HSET + EXPIRE for each user
+ through one IBatch — the whole batch ships in
+ one round trip.
+
+
+
+
+
+ Drop the TTL to e.g. 30 s and watch entities disappear on
+ schedule — the same thing that happens if a daily refresher
+ fails.
+
+
+
+
+
+
+
Streaming worker
+
Picks __USERS_PER_TICK__ users per tick, writes the
+ streaming features, applies HEXPIRE
+ __STREAM_TTL__s per field. Pause it and the
+ streaming fields drop out via per-field TTL while the batch
+ fields stay populated.
+
+
+
+
+
+
Inference read (HMGET)
+
Pick a user and a feature subset. One HMGET
+ round trip returns whatever the model needs.
+
+
+
+
+
+
+
+
+
+
+
Feature subset
+
+ Tick to include in the HMGET. Per-field TTL is
+ shown next to each field in the result table.
+
+
+
+
Pick a user and click Read features.
+
+
+
+
+
Batch scoring
+
Pipelined HMGET across N random users via
+ IBatch. One network round trip for the whole
+ batch.
+
+
+
+
+
(no batch read yet)
+
+
+
+
+
Inspect one user
+
HGETALL plus per-field HTTL and
+ key-level TTL. Useful for spotting which
+ streaming fields have already expired.
+
+
+
+
+
(pick a user and click Inspect)
+
+
+
+
+
+
+
+
+
+
+""";
+}
diff --git a/content/develop/use-cases/feature-store/dotnet/Program.cs b/content/develop/use-cases/feature-store/dotnet/Program.cs
new file mode 100644
index 0000000000..5b47042d5e
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/Program.cs
@@ -0,0 +1,299 @@
+using System.Diagnostics;
+using FeatureStoreDemo;
+using Microsoft.AspNetCore.Mvc;
+using StackExchange.Redis;
+
+// CLI: `--mode build-features` shells out to the batch materializer
+// without spinning up the HTTP server. Defaults to running the demo
+// server.
+for (int i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--mode" && i + 1 < args.Length && args[i + 1] == "build-features")
+ {
+ var sub = args.Where((_, idx) => idx != i && idx != i + 1).ToArray();
+ return await BuildFeatures.RunCliAsync(sub);
+ }
+}
+
+var host = "127.0.0.1";
+var port = 8091;
+var redisUri = "localhost:6379";
+var keyPrefix = "fs:user:";
+var batchTtlSeconds = FeatureStore.DefaultBatchTtlSeconds;
+var streamingTtlSeconds = FeatureStore.DefaultStreamingTtlSeconds;
+var usersPerTick = 5;
+var seedUsers = 200;
+var resetOnStart = true;
+
+for (int i = 0; i < args.Length; i++)
+{
+ switch (args[i])
+ {
+ case "--host" when i + 1 < args.Length: host = args[++i]; break;
+ case "--port" when i + 1 < args.Length: port = int.Parse(args[++i]); break;
+ case "--redis-uri" when i + 1 < args.Length: redisUri = args[++i]; break;
+ case "--key-prefix" when i + 1 < args.Length: keyPrefix = args[++i]; break;
+ case "--batch-ttl-seconds" when i + 1 < args.Length: batchTtlSeconds = long.Parse(args[++i]); break;
+ case "--streaming-ttl-seconds" when i + 1 < args.Length: streamingTtlSeconds = long.Parse(args[++i]); break;
+ case "--users-per-tick" when i + 1 < args.Length: usersPerTick = int.Parse(args[++i]); break;
+ case "--seed-users" when i + 1 < args.Length: seedUsers = int.Parse(args[++i]); break;
+ case "--no-reset": resetOnStart = false; break;
+ case "-h":
+ case "--help":
+ Console.WriteLine("Usage: dotnet run [--host H] [--port P] [--redis-uri URI] " +
+ "[--key-prefix PFX] [--batch-ttl-seconds S] [--streaming-ttl-seconds S] " +
+ "[--users-per-tick N] [--seed-users N] [--no-reset] " +
+ "[--mode build-features (...)]");
+ return 0;
+ }
+}
+
+var muxOptions = ConfigurationOptions.Parse(redisUri);
+muxOptions.AllowAdmin = true; // server.Keys() requires AllowAdmin
+var mux = await ConnectionMultiplexer.ConnectAsync(muxOptions);
+
+var store = new FeatureStore(mux, keyPrefix, batchTtlSeconds, streamingTtlSeconds);
+var worker = new StreamingWorker(store, TimeSpan.FromSeconds(1), usersPerTick, 1337);
+// Serializes materialize / reset / toggle-worker against each other
+// so the pause-and-wait-for-idle dance can't race with a concurrent
+// bulk-load.
+var demoLock = new SemaphoreSlim(1, 1);
+var demoSeed = 42;
+
+if (resetOnStart)
+{
+ Console.WriteLine($"Dropping any existing users under '{keyPrefix}*' for a clean demo run (pass --no-reset to keep them).");
+ await store.ResetAsync();
+ store.ResetStats();
+}
+var seeded = await store.BulkLoadAsync(
+ BuildFeatures.SynthesizeUsers(seedUsers, demoSeed),
+ batchTtlSeconds);
+
+await worker.StartAsync();
+
+var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.UseUrls($"http://{host}:{port}");
+builder.Logging.ClearProviders();
+builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Warning);
+var app = builder.Build();
+
+string IndexHtml() =>
+ HtmlTemplate.Render(store.KeyPrefix, store.StreamingTtlSeconds, worker.UsersPerTick);
+
+app.MapGet("/", () => Results.Content(IndexHtml(), "text/html; charset=utf-8"));
+
+app.MapGet("/state", () =>
+{
+ var ids = store.ListEntityIds(500);
+ var count = store.CountEntities();
+ return Results.Json(new
+ {
+ key_prefix = store.KeyPrefix,
+ batch_ttl_seconds = store.BatchTtlSeconds,
+ streaming_ttl_seconds = store.StreamingTtlSeconds,
+ entity_count = count,
+ entity_ids = ids,
+ stats = store.StatsSnapshot(),
+ worker = worker.StatsSnapshot(),
+ });
+});
+
+app.MapGet("/inspect", async ([FromQuery] string user) =>
+{
+ if (string.IsNullOrWhiteSpace(user))
+ return Results.BadRequest(new { error = "user is required" });
+
+ var full = await store.GetAllFeaturesAsync(user);
+ var keyTtl = await store.KeyTtlSecondsAsync(user);
+ if (full.Count == 0)
+ {
+ return Results.Json(new { exists = false, key_ttl_seconds = keyTtl });
+ }
+ // Iterate the known schema (batch + streaming) plus any extras
+ // the hash carries so expired streaming fields surface as
+ // ttl_seconds=-2 in the Inspect view rather than silently
+ // disappearing.
+ var names = new List(FeatureStore.DefaultBatchFields);
+ names.AddRange(FeatureStore.DefaultStreamingFields);
+ foreach (var k in full.Keys) if (!names.Contains(k)) names.Add(k);
+ var ttls = await store.FieldTtlsSecondsAsync(user, names);
+ var fields = names
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .Select(n => new
+ {
+ name = n,
+ value = full.TryGetValue(n, out var v) ? v : "",
+ ttl_seconds = ttls.TryGetValue(n, out var t) ? t : -2L,
+ })
+ .ToArray();
+ return Results.Json(new
+ {
+ exists = true,
+ key_ttl_seconds = keyTtl,
+ fields,
+ });
+});
+
+app.MapPost("/bulk-load", async (HttpRequest req) =>
+{
+ await demoLock.WaitAsync();
+ try
+ {
+ var form = await req.ReadFormAsync();
+ var count = Clamp(IntOr(form["count"], 200), 1, 2000);
+ var ttl = (long)Clamp(IntOr(form["ttl"], 86400), 5, 172_800);
+ var rows = BuildFeatures.SynthesizeUsers(count, demoSeed);
+ var sw = Stopwatch.StartNew();
+ var loaded = await store.BulkLoadAsync(rows, ttl);
+ sw.Stop();
+ return Results.Json(new
+ {
+ loaded,
+ ttl_seconds = ttl,
+ elapsed_ms = sw.Elapsed.TotalMilliseconds,
+ });
+ }
+ finally { demoLock.Release(); }
+});
+
+app.MapPost("/reset", async () =>
+{
+ await demoLock.WaitAsync();
+ try
+ {
+ // Pause + wait-for-idle around the DEL sweep so a concurrent
+ // tick can't recreate a user that was just enumerated for
+ // deletion (streaming HSET creates the key if it's missing).
+ var wasPaused = worker.IsPaused;
+ if (worker.IsRunning)
+ {
+ if (!wasPaused) worker.Pause();
+ await worker.WaitForIdleAsync();
+ }
+ try
+ {
+ var deleted = await store.ResetAsync();
+ store.ResetStats();
+ worker.ResetStats();
+ return Results.Json(new { deleted });
+ }
+ finally
+ {
+ if (worker.IsRunning && !wasPaused) worker.Resume();
+ }
+ }
+ finally { demoLock.Release(); }
+});
+
+app.MapPost("/worker/toggle", async () =>
+{
+ await demoLock.WaitAsync();
+ try
+ {
+ // Three states: stopped → start (and leave unpaused);
+ // running + unpaused → pause; running + paused → resume.
+ // StartAsync() clears the paused flag, so a fall-through
+ // would pause the worker we just brought back up.
+ if (!worker.IsRunning) await worker.StartAsync();
+ else if (worker.IsPaused) worker.Resume();
+ else worker.Pause();
+ return Results.Json(new { paused = worker.IsPaused, running = worker.IsRunning });
+ }
+ finally { demoLock.Release(); }
+});
+
+app.MapPost("/read", async (HttpRequest req) =>
+{
+ var form = await req.ReadFormAsync();
+ var user = form["user"].ToString().Trim();
+ if (string.IsNullOrEmpty(user))
+ return Results.BadRequest(new { error = "user is required" });
+ var fields = form["field"].Where(f => !string.IsNullOrEmpty(f)).ToList();
+ var sw = Stopwatch.StartNew();
+ var values = fields.Count > 0
+ ? await store.GetFeaturesAsync(user, fields!)
+ : new Dictionary();
+ sw.Stop();
+ var ttls = fields.Count > 0
+ ? await store.FieldTtlsSecondsAsync(user, fields!)
+ : new Dictionary();
+ var keyTtl = await store.KeyTtlSecondsAsync(user);
+ return Results.Json(new
+ {
+ requested = fields,
+ values,
+ ttls,
+ key_ttl_seconds = keyTtl,
+ returned_count = values.Count,
+ elapsed_ms = sw.Elapsed.TotalMilliseconds,
+ });
+});
+
+app.MapPost("/batch-read", async (HttpRequest req) =>
+{
+ var form = await req.ReadFormAsync();
+ var count = Clamp(IntOr(form["count"], 100), 1, 500);
+ var fields = form["field"].Where(f => !string.IsNullOrEmpty(f)).Cast().ToList();
+ if (fields.Count == 0)
+ {
+ fields = new List(FeatureStore.DefaultStreamingFields) { "risk_segment" };
+ }
+ var ids = store.ListEntityIds(Math.Max(count * 2, 2000));
+ if (ids.Count > count) ids = ids.Take(count).ToList();
+ var sw = Stopwatch.StartNew();
+ var rows = await store.BatchGetFeaturesAsync(ids, fields);
+ sw.Stop();
+ var sample = ids.Take(10)
+ .Select(id => new
+ {
+ id,
+ field_count = rows.TryGetValue(id, out var r) ? r.Count : 0,
+ })
+ .ToArray();
+ return Results.Json(new
+ {
+ entity_count = ids.Count,
+ field_count = fields.Count,
+ elapsed_ms = sw.Elapsed.TotalMilliseconds,
+ sample,
+ });
+});
+
+Console.WriteLine($"Redis feature-store demo server listening on http://{host}:{port}");
+Console.WriteLine($"Using Redis at {redisUri} with key prefix '{keyPrefix}' " +
+ $"(batch TTL {batchTtlSeconds}s, streaming TTL {streamingTtlSeconds}s)");
+Console.WriteLine($"Materialized {seeded} user(s); streaming worker running.");
+
+var appTask = app.RunAsync();
+var shutdownTcs = new TaskCompletionSource();
+
+// Synchronous handler only — `async void` here would swallow any
+// exception from StopAsync into an unobserved task and return to
+// the runtime before the awaited cleanup completes. Signal a
+// completion source instead and let the main flow await the
+// shutdown chain in order, with normal exception propagation.
+Console.CancelKeyPress += (_, e) =>
+{
+ e.Cancel = true;
+ Console.WriteLine("\nShutting down...");
+ shutdownTcs.TrySetResult();
+};
+
+await Task.WhenAny(appTask, shutdownTcs.Task);
+await worker.StopAsync();
+await app.StopAsync();
+await mux.CloseAsync();
+await appTask;
+return 0;
+
+// ---------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------
+
+static int Clamp(int v, int lo, int hi) => Math.Max(lo, Math.Min(hi, v));
+
+static int IntOr(Microsoft.Extensions.Primitives.StringValues sv, int def)
+{
+ return int.TryParse(sv.ToString(), out var n) ? n : def;
+}
diff --git a/content/develop/use-cases/feature-store/dotnet/StreamingWorker.cs b/content/develop/use-cases/feature-store/dotnet/StreamingWorker.cs
new file mode 100644
index 0000000000..dfcb7cbcb6
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/StreamingWorker.cs
@@ -0,0 +1,262 @@
+namespace FeatureStoreDemo;
+
+///
+/// Streaming feature updater for the demo.
+///
+///
+/// Stands in for whatever Flink, Kafka Streams, or bespoke service
+/// computes the real-time features in a real deployment. In
+/// production this code lives in the streaming layer; here it runs
+/// as a background next to the demo server so the
+/// UI can start, pause, and resume it.
+///
+///
+/// Every tick the worker picks a few random users and writes a new
+/// value for each streaming feature, with a per-field HEXPIRE
+/// so the field self-expires if the worker is paused. Pause it for
+/// longer than StreamingTtlSeconds and the streaming fields
+/// drop out of the hash while the batch fields remain populated
+/// under the longer key-level TTL — the mixed staleness
+/// story made visible.
+///
+///
+public sealed class StreamingWorker
+{
+ private static readonly string[] DeviceIds = {
+ "ios-1a4c", "ios-9f02", "and-7b21", "and-2d18",
+ "web-chr-1", "web-saf-1", "web-ff-2",
+ };
+ private static readonly string[] SessionCountries = {
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL",
+ };
+ private static readonly int[] FailedLoginBuckets = { 0, 1, 2, 5 };
+ private static readonly int[] FailedLoginWeights = { 70, 20, 8, 2 };
+
+ private readonly FeatureStore _store;
+ private readonly TimeSpan _tick;
+ public int UsersPerTick { get; }
+ private readonly Random _rng;
+ private readonly object _rngLock = new();
+
+ // All three lifecycle flags are read by the worker task and the
+ // public API (HTTP handlers + Reset), so they have to be
+ // volatile or Interlocked.
+ private int _running;
+ private int _paused;
+ private int _tickInFlight;
+ private long _tickCount;
+ private long _writesCount;
+
+ private CancellationTokenSource? _cts;
+ private Task? _task;
+ // Serializes start/stop so a Ctrl+C-triggered StopAsync can't
+ // race with a /worker/toggle Start() (or vice versa). Without
+ // this, the two could each be observing or replacing _cts/_task
+ // while the other is mid-flight.
+ private readonly SemaphoreSlim _lifecycleLock = new(1, 1);
+
+ public StreamingWorker(FeatureStore store, TimeSpan tick, int usersPerTick, int seed)
+ {
+ _store = store;
+ _tick = tick <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : tick;
+ UsersPerTick = usersPerTick > 0 ? usersPerTick : 5;
+ _rng = new Random(seed);
+ }
+
+ // ---------------------------------------------------------------
+ // Lifecycle
+ // ---------------------------------------------------------------
+
+ public async Task StartAsync()
+ {
+ await _lifecycleLock.WaitAsync();
+ try
+ {
+ if (Interlocked.CompareExchange(ref _running, 1, 0) != 0) return;
+ Interlocked.Exchange(ref _paused, 0);
+ _cts = new CancellationTokenSource();
+ _task = Task.Run(() => RunAsync(_cts.Token));
+ }
+ finally { _lifecycleLock.Release(); }
+ }
+
+ public async Task StopAsync()
+ {
+ // Hold the lifecycle lock across the entire stop, including
+ // the await for the task to drain. Releasing the lock before
+ // the await would let a concurrent StartAsync spawn a
+ // successor task while the old task's outer finally is still
+ // about to run; the old finally then clears _running, leaving
+ // the new task running with IsRunning=false and unstoppable.
+ await _lifecycleLock.WaitAsync();
+ try
+ {
+ if (Interlocked.Exchange(ref _running, 0) != 1) return;
+ var task = _task;
+ var cts = _cts;
+ _task = null;
+ _cts = null;
+ cts?.Cancel();
+ try { if (task is not null) await task; }
+ catch (OperationCanceledException) { /* expected */ }
+ cts?.Dispose();
+ // The awaited task's outer finally already cleared
+ // _tickInFlight; nothing extra to do here.
+ }
+ finally { _lifecycleLock.Release(); }
+ }
+
+ public void Pause() => Interlocked.Exchange(ref _paused, 1);
+ public void Resume() => Interlocked.Exchange(ref _paused, 0);
+
+ public bool IsRunning => Volatile.Read(ref _running) == 1;
+ public bool IsPaused => Volatile.Read(ref _paused) == 1;
+
+ ///
+ /// Block until any in-flight tick has finished.
+ /// only stops future ticks from running; callers (a reset
+ /// that's about to DEL every entity, for example) use this to
+ /// flush a mid-flight tick before they touch state the tick
+ /// might still be writing to.
+ ///
+ public async Task WaitForIdleAsync()
+ {
+ while (Volatile.Read(ref _tickInFlight) == 1)
+ {
+ await Task.Delay(20);
+ }
+ }
+
+ public WorkerStats StatsSnapshot() => new(
+ IsRunning,
+ IsPaused,
+ Interlocked.Read(ref _tickCount),
+ Interlocked.Read(ref _writesCount));
+
+ public void ResetStats()
+ {
+ Interlocked.Exchange(ref _tickCount, 0);
+ Interlocked.Exchange(ref _writesCount, 0);
+ }
+
+ public record WorkerStats(bool Running, bool Paused, long TickCount, long WritesCount);
+
+ // ---------------------------------------------------------------
+ // Tick
+ // ---------------------------------------------------------------
+
+ private async Task RunAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(_tick, ct); }
+ catch (OperationCanceledException) { break; }
+ if (ct.IsCancellationRequested) break;
+
+ // Set tick_in_flight *before* the pause check so a
+ // concurrent pause+wait can never see
+ // tick_in_flight=0 in the window between the pause
+ // check and the actual DoTick call. The finally
+ // block clears the flag whether we paused, succeeded,
+ // or threw.
+ Interlocked.Exchange(ref _tickInFlight, 1);
+ try
+ {
+ if (Volatile.Read(ref _paused) == 0)
+ {
+ await DoTickAsync();
+ }
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"[streaming-worker] tick failed: {e.Message}");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _tickInFlight, 0);
+ }
+ }
+ }
+ finally
+ {
+ // Clear running and tick_in_flight no matter how the
+ // task exits so a later Start() can spin a fresh task.
+ Interlocked.Exchange(ref _running, 0);
+ Interlocked.Exchange(ref _tickInFlight, 0);
+ }
+ }
+
+ private async Task DoTickAsync()
+ {
+ var ids = _store.ListEntityIds(500);
+ if (ids.Count == 0) return;
+ var picks = Sample(ids, UsersPerTick);
+ var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ long writes = 0;
+ foreach (var id in picks)
+ {
+ var fields = new Dictionary
+ {
+ ["last_login_ts"] = nowMs,
+ ["last_device_id"] = Choice(DeviceIds),
+ ["tx_count_5m"] = Intn(13),
+ ["failed_logins_15m"] = WeightedInt(FailedLoginBuckets, FailedLoginWeights),
+ ["session_country"] = Choice(SessionCountries),
+ };
+ await _store.UpdateStreamingAsync(id, fields, _store.StreamingTtlSeconds);
+ writes += fields.Count;
+ }
+ Interlocked.Increment(ref _tickCount);
+ Interlocked.Add(ref _writesCount, writes);
+ }
+
+ // ---------------------------------------------------------------
+ // RNG helpers (locked so the worker stays deterministic across
+ // concurrent toggles).
+ // ---------------------------------------------------------------
+
+ private List Sample(List items, int k)
+ {
+ lock (_rngLock)
+ {
+ var n = Math.Min(k, items.Count);
+ var pool = new List(items);
+ var outList = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ int idx = _rng.Next(pool.Count);
+ outList.Add(pool[idx]);
+ pool.RemoveAt(idx);
+ }
+ return outList;
+ }
+ }
+
+ private string Choice(string[] items)
+ {
+ lock (_rngLock) { return items[_rng.Next(items.Length)]; }
+ }
+
+ private int Intn(int n)
+ {
+ lock (_rngLock) { return _rng.Next(n); }
+ }
+
+ private int WeightedInt(int[] items, int[] weights)
+ {
+ lock (_rngLock)
+ {
+ int total = 0;
+ foreach (var w in weights) total += w;
+ int r = _rng.Next(total);
+ for (int i = 0; i < items.Length; i++)
+ {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[^1];
+ }
+ }
+}
diff --git a/content/develop/use-cases/feature-store/dotnet/_index.md b/content/develop/use-cases/feature-store/dotnet/_index.md
new file mode 100644
index 0000000000..ccb56ae8b5
--- /dev/null
+++ b/content/develop/use-cases/feature-store/dotnet/_index.md
@@ -0,0 +1,745 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Build a Redis-backed online feature store in .NET with StackExchange.Redis
+linkTitle: StackExchange.Redis example (C#)
+title: Redis feature store with StackExchange.Redis
+weight: 7
+---
+
+This guide shows you how to build a small Redis-backed online feature store
+in .NET with [StackExchange.Redis]({{< relref "/develop/clients/dotnet" >}}).
+The demo runs on top of ASP.NET Core's minimal-API web framework so you can
+bulk-load a batch of users with a key-level TTL, run a streaming worker that
+overwrites real-time features with per-field TTL, retrieve any subset of
+features for one user under 2 ms, and pipeline `HMGET` across a hundred
+users for batch scoring.
+
+## Overview
+
+Each entity (here, a user) is one Redis
+[Hash]({{< relref "/develop/data-types/hashes" >}}) at a deterministic key —
+`fs:user:{id}`. The hash holds every feature for that entity as one field per
+feature: batch-materialized aggregates (refreshed once a day) alongside
+streaming-updated signals (refreshed every few seconds). One
+[`HMGET`]({{< relref "/commands/hmget" >}}) returns whichever subset the
+model needs in one network round trip.
+
+Two TTL layers solve the *mixed staleness* problem without an
+application-side cleaner:
+
+* A **key-level** [`EXPIRE`]({{< relref "/commands/expire" >}}) aligned with
+ the batch materialization cycle (24 hours in the demo). If the batch
+ refresher fails, the whole entity disappears at the next cycle and
+ inference sees a missing entity — which the model handler can detect and
+ fall back on — rather than silently outdated values.
+* A **per-field** [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) (Redis 7.4+)
+ on each streaming feature gives that field its own shorter expiry,
+ independent of the rest of the hash. If the streaming pipeline stops
+ updating a feature, the field self-cleans while the batch fields stay
+ populated.
+
+That gives you:
+
+* A single round trip for retrieval — any subset of features for one entity
+ in one [`HMGET`]({{< relref "/commands/hmget" >}}).
+* Sub-millisecond hot path. The Redis-side work is microseconds; in practice
+ the bottleneck is the network round trip plus the model's own
+ feature-prep.
+* Pipelined batch scoring — one round trip for `N` users at once.
+* Independent freshness per feature, expressed as a server-side TTL rather
+ than as application logic.
+* Self-cleanup on pipeline failure: a stalled batch refresher lets entities
+ expire on schedule, and a stalled streaming worker lets each affected
+ field expire on its own timer.
+
+## How StackExchange.Redis fits the demo
+
+Three client facts shape the helper:
+
+* **`ConnectionMultiplexer` is a single, shared, thread-safe object.** One
+ instance serves the whole process — every HTTP handler in the ASP.NET
+ Core thread pool and the streaming worker pull an `IDatabase` from the
+ same multiplexer with `mux.GetDatabase()`. There is no pool to manage and
+ no per-call connection borrow.
+* **`IBatch` is the canonical pipelining handle.** `db.CreateBatch()`
+ returns a builder; you call the async methods to queue commands (each
+ returns a `Task` that completes when the batch is flushed), then
+ `batch.Execute()` ships the lot in one round trip. The pattern is "fire
+ all the async methods, *then* call Execute, *then* await the Tasks."
+* **Per-field TTL is typed.** StackExchange.Redis 2.8+ exposes
+ `IDatabase.HashFieldExpireAsync` (returns `ExpireResult[]` — an enum
+ whose values map 1:1 to Redis's HEXPIRE return codes) and
+ `IDatabase.HashFieldGetTimeToLiveAsync` (returns `long[]` in
+ milliseconds). The demo pins 2.13.17.
+
+In this example, the batch features describe a user's longer-term shape
+(`country_iso`, `risk_segment`, `account_age_days`, `tx_count_7d`,
+`avg_amount_30d`, `chargeback_count_180d`) and are bulk-loaded by the
+`BuildFeatures` static class. The streaming features describe what the user
+is doing right now (`last_login_ts`, `last_device_id`, `tx_count_5m`,
+`failed_logins_15m`, `session_country`) and are written by a `StreamingWorker`
+background task. The HTTP handlers in `Program.cs` read any subset of those
+features through `FeatureStore`'s helper class.
+
+## How it works
+
+There are three paths: a **batch path** that bulk-loads features once per
+materialization cycle, a **streaming path** that updates real-time features
+as events arrive, and an **inference path** that reads features on the
+request side.
+
+### Batch path (per materialization cycle)
+
+1. The batch job calls `BuildFeatures.SynthesizeUsers(N, seed)` (in
+ production, the equivalent computation lives in an offline pipeline
+ against the warehouse). The result is
+ `Dictionary>` keyed by user
+ ID.
+2. `store.BulkLoadAsync(rows, ttlSeconds)` queues one
+ [`HSET`]({{< relref "/commands/hset" >}}) plus one
+ [`EXPIRE`]({{< relref "/commands/expire" >}}) per user on an `IBatch`,
+ calls `batch.Execute()` to ship the whole thing in one round trip, then
+ `Task.WhenAll` waits for every per-command reply.
+
+### Streaming path (per event)
+
+When a user does something (login, transaction, page view) the streaming
+layer computes whatever real-time signals fall out of that event and
+calls `store.UpdateStreamingAsync(userId, fields, ttlSeconds)`. That queues:
+
+1. An [`HSET`]({{< relref "/commands/hset" >}}) writing the new field values.
+ Redis is single-threaded per shard, so this is atomic against any
+ concurrent batch write on the same hash — no version columns, no locks.
+2. An [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) over exactly the
+ fields that were written, with the streaming TTL. Each streaming field
+ carries its own per-field expiry independent of the rest of the hash.
+ Stop the worker and these fields drop out one by one as their TTLs
+ elapse, while the batch fields remain populated under the longer
+ key-level TTL.
+
+### Inference path (per request)
+
+1. The model server picks the feature subset it needs (the schema is owned
+ by the model, not the store).
+2. It calls `store.GetFeaturesAsync(userId, names)`, which is one
+ [`HMGET`]({{< relref "/commands/hmget" >}}). StackExchange.Redis returns
+ the values in the same order as the requested fields, with
+ `RedisValue.Null` for any field that doesn't exist (or has expired).
+3. For batch inference, the model server calls
+ `store.BatchGetFeaturesAsync(userIds, names)`, which pipelines one
+ [`HMGET`]({{< relref "/commands/hmget" >}}) per user across all `N`
+ users in a single network round trip via `IBatch`.
+
+### Project layout
+
+The csproj sits at the project root with every C# source file next to it,
+mirroring every other client demo in this use case:
+
+```text
+feature-store/dotnet/
+├── FeatureStoreDemo.csproj
+├── Program.cs — main() + ASP.NET Core minimal-API routes
+├── FeatureStore.cs — FeatureStore class + EncodeValue + Stats record
+├── BuildFeatures.cs — SynthesizeUsers + RunCliAsync
+├── StreamingWorker.cs — background-task worker
+└── HtmlTemplate.cs — inlined HTML page (C# 11 raw string literal)
+```
+
+Build and run with `dotnet run -c Release`. The `--mode build-features`
+flag short-circuits to the CLI builder before the HTTP server starts up.
+
+## The feature-store helper
+
+The `FeatureStore` class wraps the read/write paths
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/dotnet/FeatureStore.cs)):
+
+```csharp
+using StackExchange.Redis;
+using FeatureStoreDemo;
+
+var muxOptions = ConfigurationOptions.Parse("localhost:6379");
+muxOptions.AllowAdmin = true; // needed for SCAN via IServer.Keys()
+var mux = await ConnectionMultiplexer.ConnectAsync(muxOptions);
+
+var store = new FeatureStore(
+ mux,
+ "fs:user:",
+ batchTtlSeconds: 24 * 60 * 60, // whole-entity TTL aligned with the daily batch cycle
+ streamingTtlSeconds: 5 * 60 // per-field TTL on each streaming feature
+);
+
+// Batch materialization: one HSET + EXPIRE per user, all pipelined.
+var rows = new Dictionary>
+{
+ ["u0001"] = new Dictionary
+ {
+ ["country_iso"] = "US", ["risk_segment"] = "low",
+ ["tx_count_7d"] = 14, ["avg_amount_30d"] = 92.40,
+ ["account_age_days"] = 612, ["chargeback_count_180d"] = 0,
+ },
+};
+await store.BulkLoadAsync(rows, 24 * 60 * 60);
+
+// Streaming write: HSET + HEXPIRE on just the fields that changed.
+await store.UpdateStreamingAsync("u0001", new Dictionary
+{
+ ["last_login_ts"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ ["last_device_id"] = "ios-9f02",
+ ["tx_count_5m"] = 3,
+ ["failed_logins_15m"] = 0,
+ ["session_country"] = "US",
+}, 5 * 60);
+
+// Inference read: HMGET of whatever the model needs.
+var features = await store.GetFeaturesAsync("u0001", new[]
+{
+ "risk_segment", "tx_count_7d", "avg_amount_30d",
+ "tx_count_5m", "failed_logins_15m",
+});
+
+// Batch scoring: pipelined HMGET across many users.
+var batch = await store.BatchGetFeaturesAsync(
+ new[] { "u0001", "u0002", "u0003" },
+ new[] { "risk_segment", "tx_count_5m", "failed_logins_15m" });
+```
+
+### Data model
+
+Each user is one Redis Hash. Every value is stored as a string — Redis hash
+fields are bytes on the wire, so `FeatureStore.EncodeValue` renders
+booleans as `"true"` / `"false"` and uses `Object.ToString()` (with
+`InvariantCulture` for doubles, so a `92.40` doesn't become `"92,40"` in
+locales that use a comma decimal separator). The model server is responsible
+for parsing back to the right type, the same way it would when reading any
+serialized feature store.
+
+```text
+fs:user:u0001 TTL = 86400 s (key-level)
+ country_iso=US
+ risk_segment=low
+ account_age_days=612
+ tx_count_7d=14
+ avg_amount_30d=92.40
+ chargeback_count_180d=0
+ last_login_ts=1716998413541 TTL = 300 s (per field, HEXPIRE)
+ last_device_id=ios-9f02 TTL = 300 s (per field, HEXPIRE)
+ tx_count_5m=3 TTL = 300 s (per field, HEXPIRE)
+ failed_logins_15m=0 TTL = 300 s (per field, HEXPIRE)
+ session_country=US TTL = 300 s (per field, HEXPIRE)
+```
+
+### Bulk-loading batch features
+
+`BulkLoadAsync` queues one `HSET` and one `EXPIRE` per user through an
+`IBatch`, then `Execute()` ships the whole batch in one round trip.
+
+```csharp
+public async Task BulkLoadAsync(
+ IReadOnlyDictionary> rows,
+ long ttlSeconds)
+{
+ if (rows.Count == 0) return 0;
+ var batch = _db.CreateBatch();
+ var tasks = new List(rows.Count * 2);
+ foreach (var (entityId, fields) in rows)
+ {
+ var key = (RedisKey)KeyFor(entityId);
+ var entries = new HashEntry[fields.Count];
+ int i = 0;
+ foreach (var (name, value) in fields)
+ entries[i++] = new HashEntry(name, EncodeValue(value));
+ tasks.Add(batch.HashSetAsync(key, entries));
+ tasks.Add(batch.KeyExpireAsync(key, TimeSpan.FromSeconds(ttlSeconds)));
+ }
+ batch.Execute();
+ await Task.WhenAll(tasks);
+ ...
+}
+```
+
+Two things worth noticing:
+
+1. **Call the async methods *before* `Execute()`.** They don't run anything
+ yet — they just queue the command and return a `Task` that completes
+ when the batch is flushed. Order matters: a `batch.HashSetAsync(...)`
+ after `batch.Execute()` is just a regular async call against the
+ underlying database (and will fail because the local `IBatch` is now
+ spent).
+2. **`Task.WhenAll(tasks)` after `Execute()`** is how you wait for the
+ server to acknowledge the whole batch. Skipping it would leak any
+ per-command errors (a malformed `EXPIRE`, for example) into the next
+ call instead of the batch.
+
+In production, the equivalent of this script runs as an offline pipeline
+(a Spark or Feast `materialize` job) that reads from the warehouse and
+writes into Redis. The
+[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)
+provider does exactly this under the hood; the in-house
+[Redis Feature Form]({{< relref "/develop/ai/featureform" >}}) integration
+covers the materialize + serve path end-to-end.
+
+### Streaming writes with per-field TTL
+
+`UpdateStreamingAsync` is the linchpin of the mixed-staleness story:
+
+```csharp
+public async Task UpdateStreamingAsync(
+ string entityId,
+ IReadOnlyDictionary fields,
+ long ttlSeconds)
+{
+ if (fields.Count == 0) return;
+ var key = (RedisKey)KeyFor(entityId);
+ var entries = new HashEntry[fields.Count];
+ var names = new RedisValue[fields.Count];
+ int i = 0;
+ foreach (var (name, value) in fields)
+ {
+ entries[i] = new HashEntry(name, EncodeValue(value));
+ names[i] = name;
+ i++;
+ }
+
+ var batch = _db.CreateBatch();
+ var hsetTask = batch.HashSetAsync(key, entries);
+ var hexpireTask = batch.HashFieldExpireAsync(
+ key, names, TimeSpan.FromSeconds(ttlSeconds));
+ batch.Execute();
+ await hsetTask;
+ var codes = await hexpireTask;
+ foreach (var code in codes)
+ {
+ if (code != ExpireResult.Success)
+ {
+ throw new InvalidOperationException(
+ $"HEXPIRE did not set every field TTL for {key}: [{string.Join(",", codes)}]");
+ }
+ }
+ ...
+}
+```
+
+[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on
+*individual* hash fields, not on the whole key. The two commands are
+queued under one `IBatch` so Redis runs them in pipeline order: the
+`HSET` first creates or overwrites the fields, then `HEXPIRE` attaches a
+TTL to each of those same fields. `HashFieldExpireAsync` returns one
+`ExpireResult` per field:
+
+* `ExpireResult.Success` (= Redis code `1`): TTL set / updated.
+* `ExpireResult.Due` (= `2`): the expiry was 0 or in the past, so Redis
+ deleted the field instead of applying a TTL.
+* `ExpireResult.ConditionNotMet` (= `0`): an `NX | XX | GT | LT`
+ conditional flag was specified and not met (we never use one here).
+* `ExpireResult.NoSuchField` (= `-2`): no such field, or no such key.
+
+We always follow `HSET` with `HEXPIRE` so any code other than `Success`
+means the per-field TTL invariant didn't hold — the helper throws an
+`InvalidOperationException` rather than silently leaving a streaming
+field with no expiry attached.
+
+If a streaming pipeline stops, the streaming fields drop out one by one
+as their per-field TTLs elapse. `FieldTtlsSecondsAsync` (which wraps
+`HashFieldGetTimeToLiveAsync`) lets the model side inspect the
+remaining TTL on any field. Note that the StackExchange.Redis return is
+in **milliseconds** — the helper divides by 1000 to match the
+`TTL` / `HTTL` second-based convention used by every other client in
+this use case (and `redis-cli`).
+
+> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level
+> TTL commands (`HTTL`, `HPERSIST`, `HEXPIREAT`, `HPEXPIRE`,
+> `HPEXPIREAT`, `HPTTL`, `HEXPIRETIME`, `HPEXPIRETIME`) were added in
+> Redis 7.4. StackExchange.Redis 2.8 was the first release with the
+> typed bindings; the demo pins 2.13.17.
+
+### Inference reads with HMGET
+
+`GetFeaturesAsync` is one `HMGET`:
+
+```csharp
+public async Task> GetFeaturesAsync(
+ string entityId, IReadOnlyList fieldNames)
+{
+ var key = (RedisKey)KeyFor(entityId);
+ var out_ = new Dictionary();
+ if (fieldNames.Count == 0) return out_;
+ var values = await _db.HashGetAsync(
+ key, fieldNames.Select(f => (RedisValue)f).ToArray());
+ for (int i = 0; i < fieldNames.Count; i++)
+ {
+ if (!values[i].IsNull)
+ out_[fieldNames[i]] = values[i].ToString();
+ }
+ ...
+}
+```
+
+`db.HashGetAsync(key, RedisValue[] fields)` issues `HMGET` and returns
+a `RedisValue[]` aligned with the input order. Missing fields come back
+as `RedisValue.Null` (which `IsNull` detects); the helper drops them
+from the result dict so the caller sees only the features that actually
+exist on the hash.
+
+### Batch scoring with pipelined HMGET
+
+For batch inference, the same `HMGET` shape pipelines across users
+through one `IBatch`:
+
+```csharp
+public async Task>> BatchGetFeaturesAsync(
+ IReadOnlyList entityIds, IReadOnlyList fieldNames)
+{
+ if (entityIds.Count == 0 || fieldNames.Count == 0)
+ return new Dictionary>();
+
+ var fieldValues = fieldNames.Select(f => (RedisValue)f).ToArray();
+ var batch = _db.CreateBatch();
+ var tasks = new Task[entityIds.Count];
+ for (int i = 0; i < entityIds.Count; i++)
+ tasks[i] = batch.HashGetAsync(KeyFor(entityIds[i]), fieldValues);
+ batch.Execute();
+ var rows = await Task.WhenAll(tasks);
+ ...
+}
+```
+
+One round trip for the whole batch. The demo returns a 30-user batch in
+~2 ms against a local Redis after the first-call JIT/connection warm-up.
+
+A Redis Cluster is different: an `IBatch` is bound to one shard,
+because all queued commands ship through one connection to one node.
+For batch reads on a cluster, the
+[StackExchange.Redis cluster client]({{< relref "/develop/clients/dotnet/connect" >}})
+routes non-batched `HashGetAsync` calls to the right shard
+automatically — fan out parallel calls with `Task.WhenAll` and the
+multiplexer handles per-shard routing. For tighter control, group
+entity IDs by hash slot ahead of time and use one `CreateBatch` per
+shard's connection in parallel. A hash tag like `fs:user:{vip}:u0001`
+forces a known set of keys onto the same shard so one batch can cover
+them all.
+
+## The streaming worker
+
+`StreamingWorker.cs` is the demo's stand-in for whatever Flink, Kafka
+Streams, or bespoke service computes the real-time features
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/dotnet/StreamingWorker.cs)).
+It runs as a background `Task` next to the demo server so the UI can
+start, pause, and resume it.
+
+```csharp
+private async Task RunAsync(CancellationToken ct)
+{
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(_tick, ct); }
+ catch (OperationCanceledException) { break; }
+ if (ct.IsCancellationRequested) break;
+
+ // Set tick_in_flight *before* the pause check so a
+ // concurrent pause+wait can never see tick_in_flight=0
+ // in the window between the pause check and the actual
+ // DoTick call. The finally block clears the flag whether
+ // we paused, succeeded, or threw.
+ Interlocked.Exchange(ref _tickInFlight, 1);
+ try
+ {
+ if (Volatile.Read(ref _paused) == 0)
+ await DoTickAsync();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"[streaming-worker] tick failed: {e.Message}");
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _tickInFlight, 0);
+ }
+ }
+ }
+ finally
+ {
+ // Clear running and tick_in_flight no matter how the task
+ // exits so a later Start() can spin a fresh task.
+ Interlocked.Exchange(ref _running, 0);
+ Interlocked.Exchange(ref _tickInFlight, 0);
+ }
+}
+```
+
+The same pre-flight `_tickInFlight` + `finally`-clear pattern as every
+other client in this use case closes the pause/in-flight race: a reset
+that's about to `DEL` every key calls `worker.Pause()` to stop *future*
+ticks *and* `await worker.WaitForIdleAsync()` to flush a mid-flight tick
+before issuing the DEL sweep.
+
+Pausing the worker is what shows off the mixed-staleness behavior: leave
+it paused for longer than `StreamingTtlSeconds` and the streaming fields
+disappear from every user's hash one by one, while the batch fields
+remain under the longer key-level `EXPIRE`. The demo's
+`Pause / resume` button lets you see this happen in real time.
+
+## The batch builder
+
+`BuildFeatures.cs` is the demo's nightly materializer
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/dotnet/BuildFeatures.cs)).
+It generates synthetic feature rows and calls `store.BulkLoadAsync`
+once. The synthesis itself is not the point — in a real deployment the
+equivalent code reads from the offline store (Snowflake, BigQuery,
+Iceberg) and writes the resulting hashes into Redis.
+
+Run the builder on its own (independently of the demo server) to
+populate Redis from the command line:
+
+```bash
+dotnet run --project . -- --mode build-features --count 500 --ttl-seconds 3600
+```
+
+That writes 500 users at `fs:user:*` with a one-hour key-level TTL,
+which is how a typical operator would pre-seed a feature store from the
+command line when debugging.
+
+## The interactive demo
+
+`Program.cs` runs the ASP.NET Core minimal-API server on port 8091. The
+HTML page lets you:
+
+* **Bulk-load** any number of users (default 200) with a configurable
+ key-level TTL.
+* See the **store state**: user count, batch / streaming TTLs,
+ cumulative read/write counters.
+* See the **streaming worker** status and **pause or resume** it.
+* Run an **inference read** for any user with a chosen feature subset,
+ and see the value, the per-field TTL, and the read latency.
+* Run **batch scoring** with a pipelined `HMGET` across `N` users.
+* **Inspect** any user's full hash with field-level TTLs and the
+ key-level TTL.
+
+The server holds one `FeatureStore`, one `StreamingWorker`, and one
+`ConnectionMultiplexer` for the lifetime of the process. Every handler
+in the ASP.NET Core thread pool and the streaming worker share that
+multiplexer — StackExchange.Redis handles the per-call multiplexing
+across the underlying socket. Endpoints:
+
+| Endpoint | What it does |
+|---------------------------|-------------------------------------------------------------------------------------|
+| `GET /state` | User count, TTL config, stats counters, worker status. |
+| `POST /bulk-load` | Pipelined `HSET` + `EXPIRE` over N synthetic users with a chosen TTL. |
+| `POST /worker/toggle` | Pause / resume the streaming worker. |
+| `POST /read` | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs. |
+| `POST /batch-read` | Pipeline `HMGET` across N users; report total latency and per-entity field counts. |
+| `GET /inspect` | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs. |
+| `POST /reset` | Drop every user under the key prefix (used by the demo's reset button). |
+
+## Prerequisites
+
+* **Redis 7.4 or later.** [`HEXPIRE`]({{< relref "/commands/hexpire" >}})
+ and [`HTTL`]({{< relref "/commands/httl" >}}) were added in Redis 7.4;
+ the demo relies on per-field TTL for the mixed-staleness story.
+* **.NET 8 SDK or later.**
+* **StackExchange.Redis 2.8 or later.** The demo's csproj pins 2.13.17.
+ Typed bindings for the field-TTL commands ship from 2.8.
+
+The connection multiplexer is opened with `AllowAdmin = true` because
+the demo uses `IServer.Keys()` (SCAN under the hood) to populate UI
+dropdowns and to power the reset path. In a production read/write
+service you would not enable `AllowAdmin`; instead, maintain an external
+index of user IDs (a small Redis Set, say, keyed by tenant) and read it
+to discover entities. The demo's `SCAN` use is purely a UI convenience.
+
+If your Redis server is running elsewhere, start the demo with
+`--redis-uri host:port`.
+
+## Running the demo
+
+### Get the source files
+
+The demo lives in a small csproj under
+[`feature-store/dotnet`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/dotnet).
+Clone the repo or copy the directory:
+
+```bash
+git clone https://github.com/redis/docs.git
+cd docs/content/develop/use-cases/feature-store/dotnet
+dotnet build -c Release
+```
+
+### Start the demo server
+
+From the project directory:
+
+```bash
+dotnet run -c Release
+```
+
+You should see:
+
+```text
+Dropping any existing users under 'fs:user:*' for a clean demo run (pass --no-reset to keep them).
+Redis feature-store demo server listening on http://127.0.0.1:8091
+Using Redis at localhost:6379 with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)
+Materialized 200 user(s); streaming worker running.
+```
+
+Open [http://127.0.0.1:8091](http://127.0.0.1:8091). Useful things to try:
+
+* Pick a user and click **Read features** with a mixed batch/streaming
+ subset — you'll see batch fields with no per-field TTL (covered by the
+ key-level TTL) and streaming fields with a positive per-field TTL.
+* Click **Pipeline HMGET** with `count=100` to see the latency of a
+ 100-user batch read.
+* Click **Pause / resume** on the streaming worker and leave it paused
+ for ~5 minutes (or restart the server with
+ `--streaming-ttl-seconds 30` to make it visible in seconds). Re-run
+ **Read features** on any user and watch the streaming fields
+ disappear while the batch fields stay.
+* Click **Inspect** on a user to see the full hash with field-level
+ TTLs.
+* Click **Reset** to drop every user and start over.
+
+## Production usage
+
+The guidance below focuses on the production concerns specific to
+running a feature store on Redis. For the generic
+StackExchange.Redis production checklist —
+[`ConfigurationOptions`]({{< relref "/develop/clients/dotnet/connect" >}})
+tuning, AUTH/ACL, retry/backoff, multiplexer lifetime, and exception
+handling — see the
+[StackExchange.Redis production usage guide]({{< relref "/develop/clients/dotnet/produsage" >}}).
+For TLS specifically, follow the
+[connect-with-TLS recipe]({{< relref "/develop/clients/dotnet/connect#connect-to-your-production-redis-with-tls" >}}).
+The feature-store demo runs against `localhost` with the defaults; a real
+deployment should harden the client first.
+
+### Adopting the helper outside ASP.NET Core
+
+`FeatureStore.cs` omits `.ConfigureAwait(false)` on its `await` calls
+because ASP.NET Core 8 has no synchronization context — every `await`
+resumes on a thread-pool thread, so the flag is a no-op and just
+clutters the source. If you copy the helper into a context that *does*
+have a synchronization context (a Windows Forms or WPF app, classic
+ASP.NET, a Xamarin or MAUI UI thread, or a library that needs to play
+nicely with any consumer) add `.ConfigureAwait(false)` after every
+`await` to avoid deadlocking the UI thread on the resumption.
+
+### Pick the batch TTL to outlast a failed refresher
+
+The whole-entity `EXPIRE` is your safety net against silent staleness
+from a broken batch pipeline. Set it longer than your worst-case batch
+outage so a single missed run doesn't take the feature store offline,
+but short enough that a sustained outage causes loud failures (missing
+entities) rather than quiet ones (yesterday's features being scored as
+today's). The standard choice is one cycle of "expected refresh
+interval × 2" — for a daily batch, 48 hours; for a 6-hour batch, 12
+hours.
+
+The same logic applies to the per-field streaming TTL: a few times the
+expected update interval so a slow-but-alive streaming worker doesn't
+churn features needlessly, but short enough that a stalled worker
+causes visible freshness failures.
+
+### Co-locate the online store with serving, not with training
+
+The online store's hash representation does *not* have to match the
+schema in your offline store. The batch materialization step is your
+chance to flatten joins, encode categoricals, and project to whatever
+shape the model server wants — so the request path is exactly one
+`HMGET` and zero transforms.
+
+The training pipeline reads from the offline store with its own
+schema; the serving pipeline reads from Redis with the flattened
+serving schema. Keeping those two pipelines as the same code path is
+what prevents training-serving skew.
+
+### Pipeline batch reads across shards
+
+On a single Redis instance, an `IBatch` of `HMGET`s across `N` users is
+one round trip. A Redis Cluster is different: an `IBatch` is bound to
+one shard, so on a cluster you need to either fan out the per-user
+`HashGetAsync` calls with `Task.WhenAll` (the multiplexer routes each
+one to the right shard) or group entity IDs by hash slot and create
+one `IBatch` per shard's connection in parallel.
+
+A hash tag like `fs:user:{vip}:u0001` forces a known set of keys onto
+the same shard so one `IBatch` can cover them all in a single round
+trip.
+
+### Make HEXPIRE part of every streaming write
+
+The single biggest correctness lever in this design is that the
+streaming write applies `HEXPIRE` *every time*. If a streaming worker
+writes a field without renewing its TTL, the field carries whatever
+expiry was there before — possibly none, possibly stale — and the
+mixed-staleness invariant breaks. Keep the `HSET` and `HEXPIRE` in the
+same `IBatch` (or, even safer, in the same
+[Lua script]({{< relref "/develop/programmability/eval-intro" >}}) if
+you don't trust the call site).
+
+### Avoid HGETALL on the request path
+
+`HGETALL` reads every field on the hash, including ones the model
+doesn't need. With dozens of features per entity, that is wasted
+serialization work on the server and wasted bandwidth on the wire.
+Always specify the field list explicitly with `HashGetAsync(key, RedisValue[])`
+in the model server.
+
+The exception is debugging and feature-set discovery, where you
+genuinely want the full hash. The demo's "Inspect" button uses
+`HashGetAllAsync` for exactly this reason.
+
+### Inspect the store directly with redis-cli
+
+When testing or troubleshooting, the cli tells you everything:
+
+```bash
+# How many users currently in the store
+redis-cli --scan --pattern 'fs:user:*' | wc -l
+
+# One user's full hash and key-level TTL
+redis-cli HGETALL fs:user:u0001
+redis-cli TTL fs:user:u0001
+
+# Per-field TTL on the streaming fields
+redis-cli HTTL fs:user:u0001 FIELDS 5 \
+ last_login_ts last_device_id tx_count_5m failed_logins_15m session_country
+
+# Sample HMGET as the model would issue it
+redis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m
+```
+
+A streaming field that returns `-2` from `HTTL` doesn't exist on the
+hash (either it was never written, or it expired); `-1` means the
+field has no TTL set (and is therefore covered only by the key-level
+`EXPIRE`); any positive value is the remaining TTL in seconds.
+
+## Learn more
+
+This example uses the following Redis commands:
+
+* [`HSET`]({{< relref "/commands/hset" >}}) to write a feature or a
+ whole feature row in one call.
+* [`HMGET`]({{< relref "/commands/hmget" >}}) to retrieve any subset of
+ features for one entity in one round trip.
+* [`HGETALL`]({{< relref "/commands/hgetall" >}}) for debugging and
+ feature-set discovery.
+* [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) for per-field TTL on
+ streaming features (Redis 7.4+).
+* [`EXPIRE`]({{< relref "/commands/expire" >}}) and
+ [`TTL`]({{< relref "/commands/ttl" >}}) for the whole-entity TTL
+ aligned with the batch materialization cycle.
+* Pipelined `HMGET` across many entities for batch scoring with one
+ network round trip — see
+ [transactions and pipelining]({{< relref "/develop/clients/dotnet/transpipe" >}}).
+
+See the [StackExchange.Redis documentation]({{< relref "/develop/clients/dotnet" >}})
+for the full client reference, and the
+[Hashes overview]({{< relref "/develop/data-types/hashes" >}}) for the
+deeper conceptual model.
diff --git a/content/develop/use-cases/feature-store/go/_index.md b/content/develop/use-cases/feature-store/go/_index.md
new file mode 100644
index 0000000000..fcb44622dd
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/_index.md
@@ -0,0 +1,771 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Build a Redis-backed online feature store in Go with go-redis
+linkTitle: go-redis example (Go)
+title: Redis feature store with go-redis
+weight: 3
+---
+
+This guide shows you how to build a small Redis-backed online feature store in
+Go with [`go-redis`]({{< relref "/develop/clients/go" >}}). It includes a
+local web server built with Go's standard `net/http` package so you can
+bulk-load a batch of users with a key-level TTL, run a streaming worker that
+overwrites real-time features with per-field TTL, retrieve any subset of
+features for one user under 1 ms, and pipeline `HMGET` across a hundred users
+for batch scoring.
+
+## Overview
+
+Each entity (here, a user) is one Redis
+[Hash]({{< relref "/develop/data-types/hashes" >}}) at a deterministic key —
+`fs:user:{id}`. The hash holds every feature for that entity as one field per
+feature: batch-materialized aggregates (refreshed once a day) alongside
+streaming-updated signals (refreshed every few seconds). One
+[`HMGET`]({{< relref "/commands/hmget" >}}) returns whichever subset the model
+needs in one network round trip.
+
+Two TTL layers solve the *mixed staleness* problem without an application-side
+cleaner:
+
+* A **key-level** [`EXPIRE`]({{< relref "/commands/expire" >}}) aligned with the
+ batch materialization cycle (24 hours in the demo). If the batch refresher
+ fails, the whole entity disappears at the next cycle and inference sees a
+ missing entity — which the model handler can detect and fall back on —
+ rather than silently outdated values.
+* A **per-field** [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) (Redis 7.4+) on
+ each streaming feature gives that field its own shorter expiry, independent
+ of the rest of the hash. If the streaming pipeline stops updating a feature,
+ the field self-cleans while the batch fields stay populated.
+
+In this example, the batch features describe a user's longer-term shape
+(`country_iso`, `risk_segment`, `account_age_days`, `tx_count_7d`,
+`avg_amount_30d`, `chargeback_count_180d`) and are bulk-loaded by
+`build_features.go` — the demo's stand-in for a nightly Spark / Feast
+materialization job. The streaming features describe what the user is doing
+right now (`last_login_ts`, `last_device_id`, `tx_count_5m`,
+`failed_logins_15m`, `session_country`) and are written by
+`streaming_worker.go` — the demo's stand-in for a Flink / Kafka Streams job.
+The inference handlers of the demo server read any subset of those features
+through `feature_store.go`'s helper type.
+
+That gives you:
+
+* A single round trip for retrieval — any subset of features for one entity in
+ one [`HMGET`]({{< relref "/commands/hmget" >}}).
+* Sub-millisecond hot path. The Redis-side work is microseconds; in practice
+ the bottleneck is the network round trip plus the model's own feature-prep.
+* Pipelined batch scoring — one round trip for `N` users at once.
+* Independent freshness per feature, expressed as a server-side TTL rather
+ than as application logic.
+* Self-cleanup on pipeline failure: a stalled batch refresher lets entities
+ expire on schedule, and a stalled streaming worker lets each affected field
+ expire on its own timer.
+
+## How it works
+
+There are three paths: a **batch path** that bulk-loads features once per
+materialization cycle, a **streaming path** that updates real-time features
+as events arrive, and an **inference path** that reads features on the
+request side.
+
+### Batch path (per materialization cycle)
+
+1. The batch job calls `SynthesizeUsers(N, seed)` (in production, the
+ equivalent computation lives in an offline pipeline against the warehouse).
+ The result is `map[string]FeatureMap` for every user in this cycle.
+2. `store.BulkLoad(ctx, rows, ttl)` batches one
+ [`HSET`]({{< relref "/commands/hset" >}}) plus one
+ [`EXPIRE`]({{< relref "/commands/expire" >}}) per user through go-redis's
+ [`Pipeline`]({{< relref "/develop/clients/go/transpipe" >}}), so the whole
+ batch ships in a single round trip. The `HSET` writes every batch field;
+ the `EXPIRE` is what makes the entity disappear if the next batch run
+ fails, so inference reads a missing entity rather than silently outdated
+ values.
+
+### Streaming path (per event)
+
+When a user does something (login, transaction, page view) the streaming
+layer computes whatever real-time signals fall out of that event and calls
+`store.UpdateStreaming(ctx, userID, fields, ttl)`. That batches:
+
+1. An [`HSET`]({{< relref "/commands/hset" >}}) writing the new field values.
+ Redis is single-threaded per shard, so this is atomic against any
+ concurrent batch write on the same hash — no version columns, no locks.
+2. An [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) over exactly the fields
+ that were written, with the streaming TTL. Each streaming field carries
+ its own per-field expiry independent of the rest of the hash. Stop the
+ worker and these fields drop out one by one as their TTLs elapse, while
+ the batch fields remain populated under the longer key-level TTL.
+
+### Inference path (per request)
+
+1. The model server picks the feature subset it needs (the schema is owned by
+ the model, not the store).
+2. It calls `store.GetFeatures(ctx, userID, names)`, which is one
+ [`HMGET`]({{< relref "/commands/hmget" >}}). Redis returns the values in
+ the same order as the requested fields, with `nil` for any field that
+ doesn't exist (or has expired).
+3. For batch inference, the model server calls
+ `store.BatchGetFeatures(ctx, userIDs, names)`, which pipelines one
+ [`HMGET`]({{< relref "/commands/hmget" >}}) per user across all `N` users
+ in a single network round trip.
+
+## The feature-store helper
+
+The `FeatureStore` type wraps the read/write paths
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/go/feature_store.go)):
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+ fs "featurestore"
+)
+
+func main() {
+ ctx := context.Background()
+ rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
+ defer rdb.Close()
+
+ store := fs.NewFeatureStore(rdb,
+ "fs:user:",
+ 24*time.Hour, // whole-entity TTL aligned with the daily batch cycle
+ 5*time.Minute, // per-field TTL on each streaming feature
+ )
+
+ // Batch materialization: one HSET + EXPIRE per user, all pipelined.
+ rows := map[string]fs.FeatureMap{
+ "u0001": {"country_iso": "US", "risk_segment": "low",
+ "tx_count_7d": 14, "avg_amount_30d": 92.40,
+ "account_age_days": 612, "chargeback_count_180d": 0},
+ "u0002": {"country_iso": "GB", "risk_segment": "medium",
+ "tx_count_7d": 47, "avg_amount_30d": 220.10,
+ "account_age_days": 1840, "chargeback_count_180d": 1},
+ }
+ store.BulkLoad(ctx, rows, store.BatchTTL)
+
+ // Streaming write: HSET + HEXPIRE on just the fields that changed.
+ store.UpdateStreaming(ctx, "u0001", fs.FeatureMap{
+ "last_login_ts": time.Now().UnixMilli(),
+ "last_device_id": "ios-9f02",
+ "tx_count_5m": 3,
+ "failed_logins_15m": 0,
+ "session_country": "US",
+ }, store.StreamingTTL)
+
+ // Inference read: HMGET of whatever the model needs.
+ features, _ := store.GetFeatures(ctx, "u0001", []string{
+ "risk_segment", "tx_count_7d", "avg_amount_30d",
+ "tx_count_5m", "failed_logins_15m",
+ })
+ fmt.Println(features)
+
+ // Batch scoring: pipelined HMGET across many users.
+ batch, _ := store.BatchGetFeatures(ctx,
+ []string{"u0001", "u0002", "u0003"},
+ []string{"risk_segment", "tx_count_5m", "failed_logins_15m"},
+ )
+ fmt.Println(batch)
+}
+```
+
+### Package layout
+
+Go won't let `package main` live in the same directory as another package, so
+the runnable entry points live in `cmd/`:
+
+```text
+feature-store/go/
+├── go.mod
+├── feature_store.go (package featurestore)
+├── build_features.go (package featurestore; SynthesizeUsers + CLI)
+├── streaming_worker.go (package featurestore)
+├── demo_server.go (package featurestore; RunDemoServer)
+└── cmd/
+ ├── build_features/main.go (package main, shim → fs.BuildFeaturesCLI)
+ └── demo_server/main.go (package main, shim → fs.RunDemoServer)
+```
+
+Build and run with `go run ./cmd/demo_server`. The shim is the only `main`
+package; everything else is library code.
+
+### Data model
+
+Each user is one Redis Hash. Every value is stored as a string — Redis hash
+fields are bytes on the wire, so the helper encodes booleans as `"true"` /
+`"false"` and renders numbers with `strconv`. The model server is responsible
+for parsing back to the right type, the same way it would when reading any
+serialized feature store.
+
+```text
+fs:user:u0001 TTL = 86400 s (key-level)
+ country_iso=US
+ risk_segment=low
+ account_age_days=612
+ tx_count_7d=14
+ avg_amount_30d=92.40
+ chargeback_count_180d=0
+ last_login_ts=1716998413541 TTL = 300 s (per field, HEXPIRE)
+ last_device_id=ios-9f02 TTL = 300 s (per field, HEXPIRE)
+ tx_count_5m=3 TTL = 300 s (per field, HEXPIRE)
+ failed_logins_15m=0 TTL = 300 s (per field, HEXPIRE)
+ session_country=US TTL = 300 s (per field, HEXPIRE)
+```
+
+The batch fields sit under the key-level `EXPIRE`. The streaming fields each
+carry their own [`HEXPIRE`]({{< relref "/commands/hexpire" >}}). If the
+streaming pipeline stops, the streaming fields drop one by one as their
+per-field TTLs elapse; the batch fields stay until the daily key-level
+`EXPIRE` fires (or the next batch cycle re-pins them).
+
+### Bulk-loading batch features
+
+`BulkLoad` pipelines one `HSET` and one `EXPIRE` per user. With 500 users
+that's 1000 commands in one network call — Redis processes them sequentially
+on the server side but the client only pays one RTT.
+
+```go
+func (fs *FeatureStore) BulkLoad(ctx context.Context, rows map[string]FeatureMap, ttl time.Duration) (int, error) {
+ if ttl == 0 {
+ ttl = fs.BatchTTL
+ }
+ if len(rows) == 0 {
+ return 0, nil
+ }
+ pipe := fs.rdb.Pipeline()
+ for entityID, fields := range rows {
+ key := fs.KeyFor(entityID)
+ encoded := make(map[string]any, len(fields))
+ for name, value := range fields {
+ encoded[name] = encode(value)
+ }
+ pipe.HSet(ctx, key, encoded)
+ pipe.Expire(ctx, key, ttl)
+ }
+ if _, err := pipe.Exec(ctx); err != nil {
+ return 0, fmt.Errorf("bulk load: %w", err)
+ }
+ ...
+}
+```
+
+go-redis's `Pipeline` is a *non-transactional* batch: commands queue up and
+ship in one round trip, but they don't run inside a `MULTI/EXEC` block.
+That's the right choice here because each user's `HSET` + `EXPIRE` pair is
+independent of every other user's, and an all-or-nothing transaction would
+block the server for the duration of the batch. For the rare case where the
+pair has to be inseparable (a server crash between the two would leave the
+entity without a key-level TTL) you would wrap each user in `rdb.TxPipeline()`
+or a Lua script (see [`EVAL`]({{< relref "/commands/eval" >}}) /
+[Eval scripting]({{< relref "/develop/programmability/eval-intro" >}})). For
+a daily ingestion job that runs end-to-end every cycle, the next run re-pins
+the TTL — no extra machinery needed.
+
+In production, the equivalent of this script runs as an offline pipeline (a
+Spark or Feast `materialize` job) that reads from the warehouse and writes
+into Redis. The
+[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)
+provider does exactly this under the hood; the in-house
+[Redis Feature Form]({{< relref "/develop/ai/featureform" >}}) integration
+covers the materialize + serve path end-to-end.
+
+### Streaming writes with per-field TTL
+
+`UpdateStreaming` is the linchpin of the mixed-staleness story:
+
+```go
+func (fs *FeatureStore) UpdateStreaming(ctx context.Context, entityID string, fields FeatureMap, ttl time.Duration) error {
+ if len(fields) == 0 {
+ return nil
+ }
+ if ttl == 0 {
+ ttl = fs.StreamingTTL
+ }
+ key := fs.KeyFor(entityID)
+ encoded := make(map[string]any, len(fields))
+ names := make([]string, 0, len(fields))
+ for name, value := range fields {
+ encoded[name] = encode(value)
+ names = append(names, name)
+ }
+ pipe := fs.rdb.Pipeline()
+ pipe.HSet(ctx, key, encoded)
+ hexpireCmd := pipe.HExpire(ctx, key, ttl, names...)
+ if _, err := pipe.Exec(ctx); err != nil {
+ return fmt.Errorf("update streaming: %w", err)
+ }
+ codes, _ := hexpireCmd.Result()
+ for _, code := range codes {
+ if code != 1 {
+ return fmt.Errorf("HEXPIRE did not set every field TTL for %s: %v", key, codes)
+ }
+ }
+ ...
+}
+```
+
+[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on *individual*
+hash fields, not on the whole key. The two commands are sent in one round
+trip and Redis executes them in pipeline order: the `HSET` runs first and
+creates or overwrites the fields, then `HEXPIRE` attaches a TTL to each of
+those same fields. `HEXPIRE` returns one status code per field — `1` if the
+TTL was set, `2` if the expiry was 0 or in the past (so Redis deleted the
+field instead), `0` if an `NX | XX | GT | LT` conditional flag was set and
+not met (we never use one here), `-2` if the field doesn't exist on the key.
+The helper returns an error if any code is anything other than `1`, so the
+"every streaming write renews its TTL" invariant fails loudly rather than
+silently leaving a streaming field with no expiry attached.
+
+If a streaming pipeline stops, the streaming fields drop out one by one as
+their per-field TTLs elapse — there is no application-side cleaner involved.
+[`HTTL`]({{< relref "/commands/httl" >}}) lets the model side inspect the
+remaining TTL on any field, which is useful both for debugging ("why is this
+feature missing?" → "it expired three seconds ago") and as a freshness signal
+in the model itself.
+
+> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level TTL
+> commands (`HTTL`, `HPERSIST`, `HEXPIREAT`, `HPEXPIRE`, `HPEXPIREAT`,
+> `HPTTL`, `HEXPIRETIME`, `HPEXPIRETIME`) were added in Redis 7.4. On older
+> Redis builds you would have to put streaming features on their own keys
+> (one key per feature, or one key per feature group) and set a key-level
+> `EXPIRE` instead — at the cost of giving up the single-`HMGET` retrieval.
+
+### Inference reads with HMGET
+
+`GetFeatures` is one `HMGET`:
+
+```go
+func (fs *FeatureStore) GetFeatures(ctx context.Context, entityID string, fieldNames []string) (map[string]string, error) {
+ key := fs.KeyFor(entityID)
+ if fieldNames == nil {
+ return fs.rdb.HGetAll(ctx, key).Result()
+ }
+ if len(fieldNames) == 0 {
+ return map[string]string{}, nil
+ }
+ values, err := fs.rdb.HMGet(ctx, key, fieldNames...).Result()
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]string, len(fieldNames))
+ for i, name := range fieldNames {
+ if s, ok := values[i].(string); ok {
+ out[name] = s
+ }
+ }
+ return out, nil
+}
+```
+
+The model knows exactly which features it consumes, so the request path
+always takes the `HMGET` branch with an explicit field list — that's the
+sub-millisecond path. `HGETALL` is the right call for debugging (which is
+what the demo's "Inspect" panel does) but not for serving: it forces Redis
+to serialize every field, including ones the model doesn't need.
+
+Fields that don't exist (because they were never written, or because they
+expired) come back as `nil` (a typed `nil`, not a `string` empty). The helper
+drops them from the result map so the caller sees only the features that
+are actually available. A real model server would either treat missing
+values as a feature ("this user has no streaming signal yet") or fall back
+to a default from the model's training data.
+
+### Batch scoring with pipelined HMGET
+
+For batch inference, the same `HMGET` shape pipelines across users:
+
+```go
+func (fs *FeatureStore) BatchGetFeatures(ctx context.Context, entityIDs, fieldNames []string) (map[string]map[string]string, error) {
+ if len(entityIDs) == 0 || len(fieldNames) == 0 {
+ return map[string]map[string]string{}, nil
+ }
+ pipe := fs.rdb.Pipeline()
+ cmds := make([]*redis.SliceCmd, len(entityIDs))
+ for i, id := range entityIDs {
+ cmds[i] = pipe.HMGet(ctx, fs.KeyFor(id), fieldNames...)
+ }
+ if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
+ return nil, err
+ }
+ out := make(map[string]map[string]string, len(entityIDs))
+ for i, id := range entityIDs {
+ values, _ := cmds[i].Result()
+ row := make(map[string]string, len(fieldNames))
+ for j, name := range fieldNames {
+ if s, ok := values[j].(string); ok {
+ row[name] = s
+ }
+ }
+ out[id] = row
+ }
+ return out, nil
+}
+```
+
+One round trip for the whole batch — the demo regularly returns 100 users in
+1-2 ms against a local Redis. On a real network the round trip dominates;
+pipelining is what keeps batch scoring practical.
+
+A Redis Cluster is different in two ways: a single `Pipeline.Exec` is bound
+to one shard, because non-cross-slot pipelines can only target one node; and
+the keys for a typical user batch will land on multiple shards. For batch
+reads on a cluster, use the
+[`ClusterClient`]({{< relref "/develop/clients/go/connect" >}}) — its
+`Pipeline` knows how to dispatch per-shard, so you pay one round trip per
+shard rather than one for the whole batch. A hash tag like
+`fs:user:{vip}:u0001` forces a known set of keys onto the same shard so one
+pipeline can cover all of them in a single round trip.
+
+## The streaming worker
+
+`streaming_worker.go` is the demo's stand-in for whatever Flink, Kafka
+Streams, or bespoke service computes the real-time features
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/go/streaming_worker.go)).
+It runs as a goroutine next to the demo server so the UI can start, pause,
+and resume it; in production this code would live in the streaming layer.
+
+Every tick the worker picks a few random users, generates a new value for
+each streaming feature, and calls `store.UpdateStreaming(ctx, userID, fields, 0)`.
+The demo defaults to 5 users per tick at 1-second intervals — so a 200-user
+store sees roughly half its users refreshed in the first minute, and most
+after a few minutes. Raise `--users-per-tick` or drop `--seed-users` if
+you'd rather touch every user quickly.
+
+```go
+func (w *StreamingWorker) doTick(ctx context.Context) error {
+ ids, err := w.store.ListEntityIDs(ctx, 500)
+ if err != nil {
+ return err
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ n := w.usersPerTick
+ if n > len(ids) {
+ n = len(ids)
+ }
+ chosen := w.rng.Perm(len(ids))[:n]
+ nowMs := time.Now().UnixMilli()
+ for _, idx := range chosen {
+ fields := FeatureMap{
+ "last_login_ts": nowMs,
+ "last_device_id": w.choice(deviceIDs),
+ "tx_count_5m": w.intn(13),
+ "failed_logins_15m": w.weightedInt(failedLoginBuckets, failedLoginWeights),
+ "session_country": w.choice(sessionCountries),
+ }
+ if err := w.store.UpdateStreaming(ctx, ids[idx], fields, 0); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+```
+
+Pausing the worker is what shows off the mixed-staleness behavior: leave it
+paused for longer than `streamingTTL` and the streaming fields disappear
+from every user's hash one by one, while the batch fields remain under the
+longer key-level `EXPIRE`. The demo's `Pause / resume` button lets you see
+this happen in real time.
+
+`Pause()` only blocks *future* ticks from running — the goroutine simply
+skips its turn on the next ticker fire. A reset that's about to `DEL` every
+key needs to wait out an already-running tick too, which is what
+`WaitForIdle()` is for: the demo's `Reset` handler calls `worker.Pause()`
+*and* `worker.WaitForIdle()` before it issues the `DEL` sweep, so a
+mid-flight tick can't recreate a user under a streaming-only hash with no
+key-level TTL.
+
+## The batch builder
+
+`build_features.go` is the demo's nightly materializer
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/go/build_features.go)).
+It generates synthetic feature rows and calls `store.BulkLoad` once. The
+synthesis itself is not the point — in a real deployment the equivalent
+code reads from the offline store (Snowflake, BigQuery, Iceberg) and writes
+the resulting hashes into Redis.
+
+```go
+func SynthesizeUsers(count int, seed int64) map[string]FeatureMap {
+ rng := rand.New(rand.NewSource(seed))
+ users := make(map[string]FeatureMap, count)
+ for i := 1; i <= count; i++ {
+ uid := fmt.Sprintf("u%04d", i)
+ users[uid] = FeatureMap{
+ "country_iso": countryChoices[rng.Intn(len(countryChoices))],
+ "risk_segment": weightedChoiceString(rng, riskSegments, riskWeights),
+ "account_age_days": rng.Intn(2400-7+1) + 7,
+ "tx_count_7d": rng.Intn(81),
+ "avg_amount_30d": roundTo2(rng.Float64()*345.0 + 5.0),
+ "chargeback_count_180d": weightedChoiceInt(rng, chargebackBuckets, chargebackWeights),
+ }
+ }
+ return users
+}
+```
+
+You can run the builder on its own (independently of the demo server) to
+populate Redis from the command line:
+
+```bash
+go run ./cmd/build_features --count 500 --ttl-seconds 3600
+```
+
+That writes 500 users at `fs:user:*` with a one-hour key-level TTL, which is
+how a typical operator would pre-seed a feature store from the command line
+when debugging.
+
+## The interactive demo
+
+`demo_server.go` runs a `net/http` server on port 8087. The HTML page lets
+you:
+
+* **Bulk-load** any number of users (default 200) with a configurable
+ key-level TTL. Drop the TTL to 30 s and watch the entire store expire on
+ schedule — the same thing that happens if a daily refresher fails.
+* See the **store state** at a glance: user count, batch / streaming TTLs,
+ cumulative read/write counters.
+* See the **streaming worker** status (running / paused, ticks completed,
+ writes performed) and **pause or resume** it. Leave it paused for longer
+ than the streaming TTL to watch streaming fields drop out.
+* Run an **inference read** for any user with a chosen feature subset, and
+ see the value, the per-field TTL, and the read latency.
+* Run **batch scoring** with a pipelined `HMGET` across `N` users and see
+ the total elapsed time plus the per-user breakdown.
+* **Inspect** any user's full hash with field-level TTLs and the key-level
+ TTL — the right view for debugging "why is this feature missing?" at
+ read time.
+
+The server holds one `FeatureStore` and one `StreamingWorker` for the
+lifetime of the process. Endpoints:
+
+| Endpoint | What it does |
+|---------------------------|-------------------------------------------------------------------------------------|
+| `GET /state` | User count, TTL config, stats counters, worker status. |
+| `POST /bulk-load` | Pipelined `HSET` + `EXPIRE` over N synthetic users with a chosen TTL. |
+| `POST /worker/toggle` | Pause / resume the streaming worker. |
+| `POST /read` | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs. |
+| `POST /batch-read` | Pipeline `HMGET` across N users; report total latency and per-entity field counts. |
+| `GET /inspect` | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs. |
+| `POST /reset` | Drop every user under the key prefix (used by the demo's reset button). |
+
+## Prerequisites
+
+* **Redis 7.4 or later.** [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) were added in Redis 7.4; the
+ demo relies on per-field TTL for the mixed-staleness story.
+* **Go 1.21 or later.**
+* The `go-redis` v9 client. The demo's `go.mod` pins
+ `github.com/redis/go-redis/v9 v9.18.0` or later.
+
+If your Redis server is running elsewhere, start the demo with `--redis-addr`.
+
+## Running the demo
+
+### Get the source files
+
+The demo lives in a small Go module under
+[`feature-store/go`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/go).
+Clone the repo or copy the directory:
+
+```bash
+git clone https://github.com/redis/docs.git
+cd docs/content/develop/use-cases/feature-store/go
+go mod tidy
+```
+
+### Start the demo server
+
+From the module directory:
+
+```bash
+go run ./cmd/demo_server
+```
+
+You should see:
+
+```text
+Dropping any existing users under 'fs:user:*' for a clean demo run (pass --no-reset to keep them).
+Redis feature-store demo server listening on http://127.0.0.1:8087
+Using Redis at localhost:6379 with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)
+Materialized 200 user(s); streaming worker running.
+```
+
+By default the demo wipes the configured key prefix on startup so each run
+starts from a clean state. Pass `--no-reset` to keep any existing data, or
+`--key-prefix ` to point the demo at a different prefix entirely.
+
+Open [http://127.0.0.1:8087](http://127.0.0.1:8087) in a browser. Useful
+things to try:
+
+* Pick a user and click **Read features** with a mixed batch/streaming
+ subset — you'll see batch fields with no per-field TTL (covered by the
+ key-level TTL) and streaming fields with a positive per-field TTL.
+* Click **Pipeline HMGET** with `count=100` to see the latency of a
+ 100-user batch read.
+* Click **Pause / resume** on the streaming worker and leave it paused for
+ ~5 minutes (or restart the server with `--streaming-ttl-seconds 30` to
+ make it visible in seconds). Re-run **Read features** on any user and
+ watch the streaming fields disappear while the batch fields stay.
+* Click **Inspect** on a user to see the full hash with field-level TTLs.
+* Click **Bulk-load** with a short TTL (say 30 seconds) and watch the user
+ count fall to zero on the next minute — the same thing that happens if a
+ daily batch run fails to land.
+* Click **Reset** to drop every user and start over.
+
+The server is read/write against your local Redis. The default key prefix
+is `fs:user:`. Pass `--no-reset` to keep existing data across restarts, or
+`--redis-addr` to point at a different Redis.
+
+## Production usage
+
+The guidance below focuses on the production concerns that are specific to
+running a feature store on Redis. For the generic go-redis production
+checklist — connection-pool sizing, TLS, ACL, context cancellation, and
+retry policy — see the
+[go-redis production usage guide]({{< relref "/develop/clients/go/produsage" >}})
+and the
+[connect-with-TLS recipe]({{< relref "/develop/clients/go/connect#connect-to-your-production-redis-with-tls" >}}).
+The feature-store demo runs against `localhost` with the defaults; a real
+deployment should harden the client first.
+
+### Plumb the right context to each call site
+
+go-redis takes a `context.Context` on every command, and the right context
+depends on the call site:
+
+* **Inference handlers**: pass `r.Context()` (the request context) into the
+ store calls. If the client hangs up, the in-flight `HMGET` is cancelled
+ promptly and the connection is returned to the pool — important under
+ sustained load.
+* **Background workers**: pass a server-lifetime context (a
+ `context.Background()`-derived one stored on the worker struct, as
+ `StreamingWorker` does). A worker driven off `r.Context()` would die on
+ the very next tick after its triggering request completes.
+* **Batch jobs**: a `context.WithTimeout` is the usual choice so a stuck
+ Redis can't hold the materialization pipeline open indefinitely.
+
+### Pick the batch TTL to outlast a failed refresher
+
+The whole-entity `EXPIRE` is your safety net against silent staleness from a
+broken batch pipeline. Set it longer than your worst-case batch outage so a
+single missed run doesn't take the feature store offline, but short enough
+that a sustained outage causes loud failures (missing entities) rather than
+quiet ones (yesterday's features being scored as today's). The standard
+choice is one cycle of "expected refresh interval × 2" — for a daily batch,
+48 hours; for a 6-hour batch, 12 hours.
+
+The same logic applies to the per-field streaming TTL: a few times the
+expected update interval so a slow-but-alive streaming worker doesn't
+churn features needlessly, but short enough that a stalled worker causes
+visible freshness failures.
+
+### Co-locate the online store with serving, not with training
+
+The online store's hash representation does *not* have to match the schema
+in your offline store. The batch materialization step is your chance to
+flatten joins, encode categoricals, and project to whatever shape the model
+server wants — so the request path is exactly one `HMGET` and zero
+transforms.
+
+The training pipeline reads from the offline store with its own schema; the
+serving pipeline reads from Redis with the flattened serving schema.
+Keeping those two pipelines as the same code path is what prevents
+training-serving skew.
+
+### Pipeline batch reads across shards
+
+On a single Redis instance, pipelining `HMGET` across `N` users through
+`Pipeline.Exec` is one round trip. A Redis Cluster is different: a single
+`Pipeline.Exec` is bound to one shard, because non-cross-slot pipelines can
+only target one node, and the keys for a typical user batch will land on
+multiple shards. For batch reads on a cluster, use the
+[`ClusterClient`]({{< relref "/develop/clients/go/connect" >}}) — its
+`Pipeline` knows how to bucket commands per-shard and ship one batch per
+shard in parallel. For a small number of frequently-queried users (a
+top-N customer list, for example), a hash tag like `fs:user:{vip}:u0001`
+forces a known set of keys onto the same shard so one pipeline can cover
+all of them in a single round trip.
+
+### Make HEXPIRE part of every streaming write
+
+The single biggest correctness lever in this design is that the streaming
+write applies `HEXPIRE` *every time*. If a streaming worker writes a field
+without renewing its TTL, the field carries whatever expiry was there
+before — possibly none, possibly stale — and the mixed-staleness invariant
+breaks. Keep the `HSET` and `HEXPIRE` in the same pipeline (or, even safer,
+in the same [Lua script]({{< relref "/develop/programmability/eval-intro" >}})
+if you don't trust the call site).
+
+### Avoid HGETALL on the request path
+
+`HGETALL` reads every field on the hash, including ones the model doesn't
+need. With dozens of features per entity, that is wasted serialization work
+on the server and wasted bandwidth on the wire. Always specify the field
+list explicitly with `HMGet` in the model server.
+
+The exception is debugging and feature-set discovery, where you genuinely
+want the full hash. The demo's "Inspect" button uses `HGetAll` for exactly
+this reason.
+
+### Inspect the store directly with redis-cli
+
+When testing or troubleshooting, the cli tells you everything:
+
+```bash
+# How many users currently in the store
+redis-cli --scan --pattern 'fs:user:*' | wc -l
+
+# One user's full hash and key-level TTL
+redis-cli HGETALL fs:user:u0001
+redis-cli TTL fs:user:u0001
+
+# Per-field TTL on the streaming fields
+redis-cli HTTL fs:user:u0001 FIELDS 5 \
+ last_login_ts last_device_id tx_count_5m failed_logins_15m session_country
+
+# Sample HMGET as the model would issue it
+redis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m
+```
+
+A streaming field that returns `-2` from `HTTL` doesn't exist on the hash
+(either it was never written, or it expired); `-1` means the field has no
+TTL set (and is therefore covered only by the key-level `EXPIRE`); any
+positive value is the remaining TTL in seconds.
+
+## Learn more
+
+This example uses the following Redis commands:
+
+* [`HSET`]({{< relref "/commands/hset" >}}) to write a feature or a whole
+ feature row in one call.
+* [`HMGET`]({{< relref "/commands/hmget" >}}) to retrieve any subset of
+ features for one entity in one round trip.
+* [`HGETALL`]({{< relref "/commands/hgetall" >}}) for debugging and
+ feature-set discovery.
+* [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) for per-field TTL on streaming
+ features (Redis 7.4+).
+* [`EXPIRE`]({{< relref "/commands/expire" >}}) and
+ [`TTL`]({{< relref "/commands/ttl" >}}) for the whole-entity TTL aligned
+ with the batch materialization cycle.
+* Pipelined `HMGET` across many entities for batch scoring with one network
+ round trip — see
+ [transactions and pipelining]({{< relref "/develop/clients/go/transpipe" >}}).
+
+See the [go-redis documentation]({{< relref "/develop/clients/go" >}}) for
+the full client reference, and the
+[Hashes overview]({{< relref "/develop/data-types/hashes" >}}) for the deeper
+conceptual model — including the listpack encoding that makes small hashes
+particularly compact in memory, which matters at feature-store scale.
diff --git a/content/develop/use-cases/feature-store/go/build_features.go b/content/develop/use-cases/feature-store/go/build_features.go
new file mode 100644
index 0000000000..4609cf7787
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/build_features.go
@@ -0,0 +1,116 @@
+package featurestore
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "math/rand"
+ "os"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+)
+
+// Country choices and risk segments used by the synthetic batch
+// generator. These are not the point of the demo — in production the
+// equivalent code reads from the offline store (Snowflake, BigQuery,
+// Iceberg) and writes the resulting hashes into Redis.
+var (
+ countryChoices = []string{"US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL"}
+ riskSegments = []string{"low", "medium", "high"}
+ riskWeights = []int{70, 25, 5}
+ chargebackBuckets = []int{0, 1, 2, 3}
+ chargebackWeights = []int{85, 10, 4, 1}
+)
+
+// SynthesizeUsers generates count synthetic user feature rows.
+//
+// The shape mirrors a small fraud-scoring feature set: country and
+// risk segment as TAG-like categorical features, plus a few numeric
+// aggregates over recent windows.
+func SynthesizeUsers(count int, seed int64) map[string]FeatureMap {
+ rng := rand.New(rand.NewSource(seed))
+ users := make(map[string]FeatureMap, count)
+ for i := 1; i <= count; i++ {
+ uid := fmt.Sprintf("u%04d", i)
+ users[uid] = FeatureMap{
+ "country_iso": countryChoices[rng.Intn(len(countryChoices))],
+ "risk_segment": weightedChoiceString(rng, riskSegments, riskWeights),
+ "account_age_days": rng.Intn(2400-7+1) + 7,
+ "tx_count_7d": rng.Intn(81),
+ "avg_amount_30d": roundTo2(rng.Float64()*345.0 + 5.0),
+ "chargeback_count_180d": weightedChoiceInt(rng, chargebackBuckets, chargebackWeights),
+ }
+ }
+ return users
+}
+
+// BuildFeaturesCLI is the entry point for cmd/build_features/main.go.
+// It parses CLI flags, opens a Redis client, and bulk-loads the
+// synthetic batch into Redis with a configurable key-level TTL.
+func BuildFeaturesCLI(args []string) error {
+ fs := flag.NewFlagSet("build_features", flag.ExitOnError)
+ redisAddr := fs.String("redis-addr", "localhost:6379", "Redis host:port")
+ count := fs.Int("count", 200, "Number of synthetic users to materialize")
+ ttlSeconds := fs.Int("ttl-seconds", int(24*time.Hour/time.Second), "Key-level TTL in seconds (default 24h)")
+ keyPrefix := fs.String("key-prefix", "fs:user:", "Hash key prefix for each user")
+ seed := fs.Int64("seed", 42, "PRNG seed")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ ctx := context.Background()
+ rdb := redis.NewClient(&redis.Options{Addr: *redisAddr})
+ defer rdb.Close()
+
+ store := NewFeatureStore(rdb, *keyPrefix,
+ time.Duration(*ttlSeconds)*time.Second, 0)
+
+ rows := SynthesizeUsers(*count, *seed)
+ loaded, err := store.BulkLoad(ctx, rows, store.BatchTTL)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(os.Stdout,
+ "Materialized %d users at %s* with a %ds key-level TTL.\n",
+ loaded, *keyPrefix, *ttlSeconds)
+ return nil
+}
+
+// ---------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------
+
+func weightedChoiceString(rng *rand.Rand, items []string, weights []int) string {
+ total := 0
+ for _, w := range weights {
+ total += w
+ }
+ r := rng.Intn(total)
+ for i, w := range weights {
+ r -= w
+ if r < 0 {
+ return items[i]
+ }
+ }
+ return items[len(items)-1]
+}
+
+func weightedChoiceInt(rng *rand.Rand, items []int, weights []int) int {
+ total := 0
+ for _, w := range weights {
+ total += w
+ }
+ r := rng.Intn(total)
+ for i, w := range weights {
+ r -= w
+ if r < 0 {
+ return items[i]
+ }
+ }
+ return items[len(items)-1]
+}
+
+func roundTo2(v float64) float64 {
+ return float64(int64(v*100+0.5)) / 100.0
+}
diff --git a/content/develop/use-cases/feature-store/go/cmd/build_features/main.go b/content/develop/use-cases/feature-store/go/cmd/build_features/main.go
new file mode 100644
index 0000000000..198e8ccb23
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/cmd/build_features/main.go
@@ -0,0 +1,18 @@
+// Tiny shim that drives the batch materialization flow from the
+// parent ``featurestore`` package. Run with:
+//
+// go run ./cmd/build_features --count 500 --ttl-seconds 3600
+package main
+
+import (
+ "log"
+ "os"
+
+ fs "featurestore"
+)
+
+func main() {
+ if err := fs.BuildFeaturesCLI(os.Args[1:]); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/content/develop/use-cases/feature-store/go/cmd/demo_server/main.go b/content/develop/use-cases/feature-store/go/cmd/demo_server/main.go
new file mode 100644
index 0000000000..da253e83d0
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/cmd/demo_server/main.go
@@ -0,0 +1,22 @@
+// Tiny shim that runs the demo server defined in the parent
+// ``featurestore`` package. Build with:
+//
+// go build -o demo_server ./cmd/demo_server
+//
+// Or run directly:
+//
+// go run ./cmd/demo_server --port 8087
+package main
+
+import (
+ "log"
+ "os"
+
+ fs "featurestore"
+)
+
+func main() {
+ if err := fs.RunDemoServer(os.Args[1:]); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/content/develop/use-cases/feature-store/go/demo_server.go b/content/develop/use-cases/feature-store/go/demo_server.go
new file mode 100644
index 0000000000..d086990643
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/demo_server.go
@@ -0,0 +1,978 @@
+// Redis feature-store demo server (Go).
+//
+// Create a tiny main.go shim in cmd/demo_server (Go's package main
+// cannot live in the same directory as package featurestore):
+//
+// package main
+//
+// import (
+// "log"
+// "os"
+//
+// fs "featurestore"
+// )
+//
+// func main() {
+// if err := fs.RunDemoServer(os.Args[1:]); err != nil {
+// log.Fatal(err)
+// }
+// }
+//
+// Build and run with:
+//
+// go run ./cmd/demo_server
+//
+// Then visit http://localhost:8087.
+//
+// Use the UI to:
+//
+// - Bulk-load (re-materialize) the batch features, optionally with a
+// short TTL so you can watch a whole entity expire on schedule.
+// - Pause the streaming worker and watch the streaming fields drop
+// out via HEXPIRE while the batch fields remain populated under
+// the longer key-level TTL — the *mixed staleness* story made
+// visible.
+// - Pull features for one user (HMGET) and see the value, per-field
+// TTL, and read latency.
+// - Batch-score N users in one round trip and see the per-entity /
+// per-round-trip latency split.
+// - Inspect a single user's hash in detail with field-level TTLs.
+package featurestore
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+)
+
+// FeatureStoreDemo wires the FeatureStore and StreamingWorker
+// together with reset / materialize / toggle helpers used by the
+// HTTP handlers.
+type FeatureStoreDemo struct {
+ store *FeatureStore
+ worker *StreamingWorker
+ seed int64
+
+ mu sync.Mutex
+}
+
+// NewFeatureStoreDemo bundles the store and worker for the HTTP
+// server. seed is the PRNG seed used by the batch synthesizer.
+func NewFeatureStoreDemo(store *FeatureStore, worker *StreamingWorker, seed int64) *FeatureStoreDemo {
+ return &FeatureStoreDemo{store: store, worker: worker, seed: seed}
+}
+
+// Materialize bulk-loads `count` synthetic users with the supplied
+// key-level TTL.
+func (d *FeatureStoreDemo) Materialize(ctx context.Context, count int, ttl time.Duration) (loaded int, elapsed time.Duration, err error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ rows := SynthesizeUsers(count, d.seed)
+ start := time.Now()
+ loaded, err = d.store.BulkLoad(ctx, rows, ttl)
+ elapsed = time.Since(start)
+ return
+}
+
+// Reset drops every entity under the key prefix. Pauses the
+// streaming worker around the DEL sweep so a concurrent tick can't
+// recreate a user that was just enumerated for deletion (streaming
+// HSET creates the key if it's missing, and that would leave behind
+// a streaming-only hash with no key-level TTL). Pause() only blocks
+// *future* ticks — WaitForIdle() flushes an already-running tick
+// before the DEL sweep starts.
+func (d *FeatureStoreDemo) Reset(ctx context.Context) (int64, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ wasPaused := d.worker.IsPaused()
+ if d.worker.IsRunning() {
+ if !wasPaused {
+ d.worker.Pause()
+ }
+ d.worker.WaitForIdle()
+ }
+ defer func() {
+ if d.worker.IsRunning() && !wasPaused {
+ d.worker.Resume()
+ }
+ }()
+ deleted, err := d.store.Reset(ctx)
+ if err != nil {
+ return deleted, err
+ }
+ d.store.ResetStats()
+ d.worker.ResetStats()
+ return deleted, nil
+}
+
+// ToggleWorker pauses or resumes the streaming worker. Starts the
+// goroutine if it wasn't running. The worker owns its own
+// background-context lifecycle, so we don't plumb the request
+// context in here (it would cancel as soon as the HTTP response
+// completes and the worker would die on the next tick).
+func (d *FeatureStoreDemo) ToggleWorker() (paused, running bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ // Three states: stopped → start (and leave unpaused);
+ // running + unpaused → pause; running + paused → resume.
+ // Start() clears the paused flag, so a fall-through pauses the
+ // worker we just brought back up.
+ if !d.worker.IsRunning() {
+ d.worker.Start()
+ } else if d.worker.IsPaused() {
+ d.worker.Resume()
+ } else {
+ d.worker.Pause()
+ }
+ return d.worker.IsPaused(), d.worker.IsRunning()
+}
+
+// -------------------------------------------------------------------
+// HTTP handlers
+// -------------------------------------------------------------------
+
+type httpServer struct {
+ store *FeatureStore
+ worker *StreamingWorker
+ demo *FeatureStoreDemo
+}
+
+func (s *httpServer) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", s.handleIndex)
+ mux.HandleFunc("/state", s.handleState)
+ mux.HandleFunc("/inspect", s.handleInspect)
+ mux.HandleFunc("/bulk-load", s.handleBulkLoad)
+ mux.HandleFunc("/reset", s.handleReset)
+ mux.HandleFunc("/worker/toggle", s.handleToggleWorker)
+ mux.HandleFunc("/read", s.handleRead)
+ mux.HandleFunc("/batch-read", s.handleBatchRead)
+ return mux
+}
+
+func (s *httpServer) handleIndex(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" && r.URL.Path != "/index.html" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(s.htmlPage()))
+}
+
+func (s *httpServer) handleState(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ ctx := r.Context()
+ ids, err := s.store.ListEntityIDs(ctx, 500)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ // Cap the dropdown list at 500 but report the true count
+ // separately so the UI doesn't silently understate the store.
+ count, err := s.store.CountEntities(ctx)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "key_prefix": s.store.KeyPrefix,
+ "batch_ttl_seconds": int(s.store.BatchTTL.Seconds()),
+ "streaming_ttl_seconds": int(s.store.StreamingTTL.Seconds()),
+ "entity_count": count,
+ "entity_ids": ids,
+ "stats": s.store.Stats(),
+ "worker": s.worker.Stats(),
+ })
+}
+
+func (s *httpServer) handleInspect(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ user := strings.TrimSpace(r.URL.Query().Get("user"))
+ if user == "" {
+ jsonError(w, fmt.Errorf("user is required"), http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ full, err := s.store.GetFeatures(ctx, user, nil)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ keyTTL, err := s.store.KeyTTLSeconds(ctx, user)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ if len(full) == 0 {
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "exists": false,
+ "key_ttl_seconds": keyTTL,
+ })
+ return
+ }
+ // Iterate the known schema (batch + streaming) plus any extras the
+ // hash carries. Expired streaming fields surface as ttl_seconds=-2
+ // in the Inspect view instead of silently disappearing, which is
+ // exactly the debugging view someone hits "Inspect" for.
+ seen := make(map[string]struct{}, len(DefaultBatchFields)+len(DefaultStreamingFields))
+ names := make([]string, 0, len(DefaultBatchFields)+len(DefaultStreamingFields)+len(full))
+ for _, n := range DefaultBatchFields {
+ names = append(names, n)
+ seen[n] = struct{}{}
+ }
+ for _, n := range DefaultStreamingFields {
+ names = append(names, n)
+ seen[n] = struct{}{}
+ }
+ for n := range full {
+ if _, ok := seen[n]; !ok {
+ names = append(names, n)
+ }
+ }
+ ttls, err := s.store.FieldTTLsSeconds(ctx, user, names)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ rows := make([]map[string]any, 0, len(names))
+ for _, n := range names {
+ ttl, ok := ttls[n]
+ if !ok {
+ ttl = -2
+ }
+ rows = append(rows, map[string]any{
+ "name": n,
+ "value": full[n],
+ "ttl_seconds": ttl,
+ })
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ return rows[i]["name"].(string) < rows[j]["name"].(string)
+ })
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "exists": true,
+ "key_ttl_seconds": keyTTL,
+ "fields": rows,
+ })
+}
+
+func (s *httpServer) handleBulkLoad(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ jsonError(w, err, http.StatusBadRequest)
+ return
+ }
+ count := clampInt(parseInt(r.FormValue("count"), 200), 1, 2000)
+ ttlSeconds := clampInt(parseInt(r.FormValue("ttl"), 86400), 5, 172800)
+ loaded, elapsed, err := s.demo.Materialize(r.Context(), count, time.Duration(ttlSeconds)*time.Second)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "loaded": loaded,
+ "ttl_seconds": ttlSeconds,
+ "elapsed_ms": float64(elapsed.Microseconds()) / 1000.0,
+ })
+}
+
+func (s *httpServer) handleReset(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ deleted, err := s.demo.Reset(r.Context())
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{"deleted": deleted})
+}
+
+func (s *httpServer) handleToggleWorker(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ paused, running := s.demo.ToggleWorker()
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "paused": paused,
+ "running": running,
+ })
+}
+
+func (s *httpServer) handleRead(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ jsonError(w, err, http.StatusBadRequest)
+ return
+ }
+ user := strings.TrimSpace(r.FormValue("user"))
+ if user == "" {
+ jsonError(w, fmt.Errorf("user is required"), http.StatusBadRequest)
+ return
+ }
+ fields := nonEmpty(r.Form["field"])
+ ctx := r.Context()
+ start := time.Now()
+ var values map[string]string
+ if len(fields) > 0 {
+ var err error
+ values, err = s.store.GetFeatures(ctx, user, fields)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ } else {
+ values = map[string]string{}
+ }
+ elapsed := time.Since(start)
+ ttls := map[string]int64{}
+ if len(fields) > 0 {
+ var err error
+ ttls, err = s.store.FieldTTLsSeconds(ctx, user, fields)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ }
+ keyTTL, err := s.store.KeyTTLSeconds(ctx, user)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "requested": fields,
+ "values": values,
+ "ttls": ttls,
+ "key_ttl_seconds": keyTTL,
+ "returned_count": len(values),
+ "elapsed_ms": float64(elapsed.Microseconds()) / 1000.0,
+ })
+}
+
+func (s *httpServer) handleBatchRead(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ jsonError(w, err, http.StatusBadRequest)
+ return
+ }
+ count := clampInt(parseInt(r.FormValue("count"), 100), 1, 500)
+ fields := nonEmpty(r.Form["field"])
+ if len(fields) == 0 {
+ fields = append([]string{}, DefaultStreamingFields...)
+ fields = append(fields, "risk_segment")
+ }
+ ctx := r.Context()
+ ids, err := s.store.ListEntityIDs(ctx, int64(count*2))
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ if len(ids) > count {
+ ids = ids[:count]
+ }
+ start := time.Now()
+ rows, err := s.store.BatchGetFeatures(ctx, ids, fields)
+ if err != nil {
+ jsonError(w, err, http.StatusInternalServerError)
+ return
+ }
+ elapsed := time.Since(start)
+ sampleN := 10
+ if sampleN > len(ids) {
+ sampleN = len(ids)
+ }
+ sample := make([]map[string]any, sampleN)
+ for i := 0; i < sampleN; i++ {
+ sample[i] = map[string]any{
+ "id": ids[i],
+ "field_count": len(rows[ids[i]]),
+ }
+ }
+ jsonResponse(w, http.StatusOK, map[string]any{
+ "entity_count": len(ids),
+ "field_count": len(fields),
+ "elapsed_ms": float64(elapsed.Microseconds()) / 1000.0,
+ "sample": sample,
+ })
+}
+
+// -------------------------------------------------------------------
+// HTTP plumbing
+// -------------------------------------------------------------------
+
+func jsonResponse(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func jsonError(w http.ResponseWriter, err error, status int) {
+ jsonResponse(w, status, map[string]any{"error": err.Error()})
+}
+
+func parseInt(s string, def int) int {
+ if s == "" {
+ return def
+ }
+ n, err := strconv.Atoi(s)
+ if err != nil {
+ return def
+ }
+ return n
+}
+
+func clampInt(n, low, high int) int {
+ if n < low {
+ return low
+ }
+ if n > high {
+ return high
+ }
+ return n
+}
+
+func nonEmpty(in []string) []string {
+ out := make([]string, 0, len(in))
+ for _, v := range in {
+ if v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+func (s *httpServer) htmlPage() string {
+ batchFieldsJSON, _ := json.Marshal(DefaultBatchFields)
+ streamFieldsJSON, _ := json.Marshal(DefaultStreamingFields)
+ return strings.NewReplacer(
+ "__KEY_PREFIX__", s.store.KeyPrefix,
+ "__STREAM_TTL__", strconv.Itoa(int(s.store.StreamingTTL.Seconds())),
+ "__USERS_PER_TICK__", strconv.Itoa(s.worker.usersPerTick),
+ "__BATCH_FIELDS_JSON__", string(batchFieldsJSON),
+ "__STREAM_FIELDS_JSON__", string(streamFieldsJSON),
+ ).Replace(htmlTemplate)
+}
+
+// RunDemoServer parses CLI flags, opens a Redis client, seeds the
+// store, starts the streaming worker, and serves HTTP. Intended to be
+// called from cmd/demo_server/main.go.
+func RunDemoServer(args []string) error {
+ fs := flag.NewFlagSet("demo_server", flag.ExitOnError)
+ host := fs.String("host", "127.0.0.1", "HTTP bind host")
+ port := fs.Int("port", 8087, "HTTP bind port")
+ redisAddr := fs.String("redis-addr", "localhost:6379", "Redis host:port")
+ keyPrefix := fs.String("key-prefix", "fs:user:", "Hash key prefix")
+ batchTTLSeconds := fs.Int("batch-ttl-seconds", 24*60*60, "Key-level TTL on bulk-loaded users")
+ streamingTTLSeconds := fs.Int("streaming-ttl-seconds", 5*60, "Per-field TTL on streaming features")
+ usersPerTick := fs.Int("users-per-tick", 5, "Streaming users per tick")
+ seedUsers := fs.Int("seed-users", 200, "Users to materialize on startup")
+ noReset := fs.Bool("no-reset", false, "Keep any existing data under --key-prefix on startup")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ ctx := context.Background()
+ rdb := redis.NewClient(&redis.Options{Addr: *redisAddr})
+ defer rdb.Close()
+
+ store := NewFeatureStore(rdb, *keyPrefix,
+ time.Duration(*batchTTLSeconds)*time.Second,
+ time.Duration(*streamingTTLSeconds)*time.Second)
+ worker := NewStreamingWorker(store, time.Second, *usersPerTick, 1337)
+ demo := NewFeatureStoreDemo(store, worker, 42)
+
+ if !*noReset {
+ fmt.Printf("Dropping any existing users under '%s*' for a clean demo run (pass --no-reset to keep them).\n", *keyPrefix)
+ if _, err := store.Reset(ctx); err != nil {
+ return fmt.Errorf("reset on start: %w", err)
+ }
+ store.ResetStats()
+ }
+ seeded, _, err := demo.Materialize(ctx, *seedUsers, store.BatchTTL)
+ if err != nil {
+ return fmt.Errorf("seed materialize: %w", err)
+ }
+
+ worker.Start()
+ defer worker.Stop()
+
+ srv := &httpServer{store: store, worker: worker, demo: demo}
+ addr := fmt.Sprintf("%s:%d", *host, *port)
+ hs := &http.Server{Addr: addr, Handler: srv.handler()}
+
+ fmt.Printf("Redis feature-store demo server listening on http://%s\n", addr)
+ fmt.Printf("Using Redis at %s with key prefix '%s' (batch TTL %ds, streaming TTL %ds)\n",
+ *redisAddr, *keyPrefix, *batchTTLSeconds, *streamingTTLSeconds)
+ fmt.Printf("Materialized %d user(s); streaming worker running.\n", seeded)
+
+ if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ return fmt.Errorf("listen: %w", err)
+ }
+ return nil
+}
+
+const htmlTemplate = `
+
+
+
+
+ Redis Feature Store Demo (Go)
+
+
+
+
+
go-redis + Go standard net/http
+
Redis Feature Store Demo
+
+ A small fraud-scoring feature store. Each user is one Redis hash
+ at __KEY_PREFIX__{id} with a batch-materialized
+ batch half (daily aggregates,
+ 24-hour key-level EXPIRE) and a streaming
+ streaming half (real-time
+ signals, __STREAM_TTL__s per-field HEXPIRE).
+ Inference reads any subset with one HMGET; batch
+ scoring pipelines HMGET across N users.
+
+
+
+
+
Store state
+
Loading...
+
+
+
+
Materialize batch features
+
Calls HSET + EXPIRE for each user
+ through one go-redis Pipeline, so the whole
+ batch ships in one round trip.
+
+
+
+
+
+ Drop the TTL to e.g. 30 s and watch entities disappear on
+ schedule — the same thing that happens if a daily refresher
+ fails.
+
+
+
+
+
+
+
Streaming worker
+
Picks __USERS_PER_TICK__ users per tick, writes the
+ streaming features, applies HEXPIRE
+ __STREAM_TTL__s per field. Pause it and the
+ streaming fields drop out via per-field TTL while the batch
+ fields stay populated.
+
+
+
+
+
+
Inference read (HMGET)
+
Pick a user and a feature subset. One HMGET
+ round trip returns whatever the model needs.
+
+
+
+
+
+
+
+
+
+
+
Feature subset
+
+ Tick to include in the HMGET. Per-field TTL is
+ shown next to each field in the result table.
+
+
+
+
Pick a user and click Read features.
+
+
+
+
+
Batch scoring
+
Pipelined HMGET across N random users via go-redis
+ Pipeline.Exec. One network round trip for the
+ whole batch.
+
+
+
+
+
(no batch read yet)
+
+
+
+
+
Inspect one user
+
HGETALL plus per-field HTTL and
+ key-level TTL. Useful for spotting which
+ streaming fields have already expired.
+
+
+
+
+
(pick a user and click Inspect)
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/content/develop/use-cases/feature-store/go/feature_store.go b/content/develop/use-cases/feature-store/go/feature_store.go
new file mode 100644
index 0000000000..437148f296
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/feature_store.go
@@ -0,0 +1,495 @@
+// Package featurestore is a Redis online feature store backed by per-entity
+// Hashes.
+//
+// Each entity (here, a user) lives at a deterministic key such as
+// "fs:user:{id}". The hash holds every feature for that entity as one
+// field per feature — batch-materialized aggregates (refreshed on a
+// daily cycle) alongside streaming-updated signals (refreshed every
+// few seconds). One HMGET returns whichever subset the model needs in
+// one network round trip.
+//
+// Two TTL layers solve the *mixed staleness* problem:
+//
+// - A key-level EXPIRE aligned with the batch materialization cycle
+// causes the whole entity to disappear if its batch refresher
+// fails, so inference sees a missing entity (which the model
+// handler can detect and fall back on) rather than silently
+// outdated values.
+// - A per-field HEXPIRE on each streaming field gives that field
+// its own shorter expiry, independent of the rest of the hash.
+// When the streaming pipeline stops updating a field, the field
+// self-cleans while the rest of the entity stays populated.
+//
+// HEXPIRE and HTTL require Redis 7.4 or later. The go-redis v9 client
+// exposes them as HExpire and HTTL on *redis.Client.
+//
+// Concurrency is by construction: Redis is single-threaded per shard,
+// so overlapping HSET calls from a batch job and a streaming worker
+// on the same entity hash are applied atomically without locks or
+// version columns.
+package featurestore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "sync/atomic"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+)
+
+// FeatureValue is the concrete type a single feature may take before
+// it gets serialized as a Redis hash field. Hash field values are
+// strings on the wire; the helper renders these types into strings
+// via encode() so booleans round-trip cleanly through redis-cli.
+type FeatureValue any
+
+// FeatureMap is the set of fields written for one entity.
+type FeatureMap map[string]FeatureValue
+
+// DefaultBatchFields is the schema bulk-loaded once per batch cycle.
+var DefaultBatchFields = []string{
+ "country_iso",
+ "risk_segment",
+ "account_age_days",
+ "tx_count_7d",
+ "avg_amount_30d",
+ "chargeback_count_180d",
+}
+
+// DefaultStreamingFields is the schema updated by the streaming worker
+// with a per-field HEXPIRE so each field self-expires when its
+// upstream pipeline stops.
+var DefaultStreamingFields = []string{
+ "last_login_ts",
+ "last_device_id",
+ "tx_count_5m",
+ "failed_logins_15m",
+ "session_country",
+}
+
+// Stats holds the helper's in-process counters. Read with FeatureStore.Stats.
+type Stats struct {
+ BatchWritesTotal int64 `json:"batch_writes_total"`
+ StreamingWritesTotal int64 `json:"streaming_writes_total"`
+ ReadsTotal int64 `json:"reads_total"`
+ ReadFieldsTotal int64 `json:"read_fields_total"`
+}
+
+// FeatureStore wraps a *redis.Client and exposes the four feature-store
+// paths: batch ingest (BulkLoad), streaming ingest (UpdateStreaming),
+// inference read (GetFeatures), and batch scoring (BatchGetFeatures).
+type FeatureStore struct {
+ rdb *redis.Client
+ KeyPrefix string
+ BatchTTL time.Duration
+ StreamingTTL time.Duration
+
+ batchWritesTotal atomic.Int64
+ streamingWritesTotal atomic.Int64
+ readsTotal atomic.Int64
+ readFieldsTotal atomic.Int64
+}
+
+// NewFeatureStore returns a FeatureStore backed by rdb. Defaults match
+// the Python and Node.js demos: a 24-hour key-level TTL and a 5-minute
+// per-field streaming TTL.
+func NewFeatureStore(rdb *redis.Client, keyPrefix string, batchTTL, streamingTTL time.Duration) *FeatureStore {
+ if keyPrefix == "" {
+ keyPrefix = "fs:user:"
+ }
+ if batchTTL == 0 {
+ batchTTL = 24 * time.Hour
+ }
+ if streamingTTL == 0 {
+ streamingTTL = 5 * time.Minute
+ }
+ return &FeatureStore{
+ rdb: rdb,
+ KeyPrefix: keyPrefix,
+ BatchTTL: batchTTL,
+ StreamingTTL: streamingTTL,
+ }
+}
+
+// KeyFor returns the Redis key for an entity ID.
+func (fs *FeatureStore) KeyFor(entityID string) string {
+ return fs.KeyPrefix + entityID
+}
+
+// -------------------------------------------------------------------
+// Batch ingestion (materialization)
+// -------------------------------------------------------------------
+
+// BulkLoad materializes a batch of entities into Redis. rows is
+// keyed by entity ID. One HSET plus one EXPIRE per entity, batched
+// through go-redis's Pipeline so the whole batch ships in a single
+// round trip. The key-level EXPIRE is what makes the entity
+// disappear if a future batch run fails — inference reads the
+// missing entity rather than silently outdated values.
+func (fs *FeatureStore) BulkLoad(ctx context.Context, rows map[string]FeatureMap, ttl time.Duration) (int, error) {
+ if ttl == 0 {
+ ttl = fs.BatchTTL
+ }
+ if len(rows) == 0 {
+ return 0, nil
+ }
+
+ pipe := fs.rdb.Pipeline()
+ for entityID, fields := range rows {
+ key := fs.KeyFor(entityID)
+ encoded := make(map[string]any, len(fields))
+ for name, value := range fields {
+ encoded[name] = encode(value)
+ }
+ pipe.HSet(ctx, key, encoded)
+ pipe.Expire(ctx, key, ttl)
+ }
+ if _, err := pipe.Exec(ctx); err != nil {
+ return 0, fmt.Errorf("bulk load: %w", err)
+ }
+ fs.batchWritesTotal.Add(int64(len(rows)))
+ return len(rows), nil
+}
+
+// UpdateBatchFeature overwrites one batch feature without touching
+// the key TTL. Used by the demo's "manually refresh one user" lever;
+// real pipelines flow through BulkLoad.
+func (fs *FeatureStore) UpdateBatchFeature(ctx context.Context, entityID, field string, value FeatureValue) error {
+ if err := fs.rdb.HSet(ctx, fs.KeyFor(entityID), field, encode(value)).Err(); err != nil {
+ return err
+ }
+ fs.batchWritesTotal.Add(1)
+ return nil
+}
+
+// -------------------------------------------------------------------
+// Streaming ingestion
+// -------------------------------------------------------------------
+
+// UpdateStreaming writes streaming features with a per-field TTL.
+//
+// Each field carries its own HEXPIRE so it self-expires
+// independently of the rest of the hash. If the streaming pipeline
+// stops, the streaming fields drop out while the batch-materialized
+// fields remain populated under their longer key-level EXPIRE.
+//
+// HEXPIRE returns one status code per field:
+//
+// - 1: TTL set / updated
+// - 2: the expiry was 0 or in the past, so Redis deleted the field
+// instead of applying a TTL
+// - 0: an NX | XX | GT | LT conditional flag was specified and not
+// met (we never use one here)
+// - -2: no such field, or no such key
+//
+// Since we just HSET every field on the same call, any code other
+// than 1 means the per-field TTL invariant did not hold — the
+// mixed-staleness story relies on every streaming field carrying a
+// fresh TTL after the write, so failure is loud.
+func (fs *FeatureStore) UpdateStreaming(ctx context.Context, entityID string, fields FeatureMap, ttl time.Duration) error {
+ if len(fields) == 0 {
+ return nil
+ }
+ if ttl == 0 {
+ ttl = fs.StreamingTTL
+ }
+ key := fs.KeyFor(entityID)
+ encoded := make(map[string]any, len(fields))
+ names := make([]string, 0, len(fields))
+ for name, value := range fields {
+ encoded[name] = encode(value)
+ names = append(names, name)
+ }
+
+ pipe := fs.rdb.Pipeline()
+ pipe.HSet(ctx, key, encoded)
+ hexpireCmd := pipe.HExpire(ctx, key, ttl, names...)
+ if _, err := pipe.Exec(ctx); err != nil {
+ return fmt.Errorf("update streaming: %w", err)
+ }
+ codes, err := hexpireCmd.Result()
+ if err != nil {
+ return fmt.Errorf("update streaming: HEXPIRE: %w", err)
+ }
+ for _, code := range codes {
+ if code != 1 {
+ return fmt.Errorf("HEXPIRE did not set every field TTL for %s: %v", key, codes)
+ }
+ }
+ fs.streamingWritesTotal.Add(int64(len(fields)))
+ return nil
+}
+
+// -------------------------------------------------------------------
+// Inference reads
+// -------------------------------------------------------------------
+
+// GetFeatures returns a subset of features for one entity. Pass
+// fieldNames=nil to fetch the full hash with HGETALL — useful for
+// debugging but rarely the right call on the request path, where the
+// model knows exactly which features it consumes.
+func (fs *FeatureStore) GetFeatures(ctx context.Context, entityID string, fieldNames []string) (map[string]string, error) {
+ key := fs.KeyFor(entityID)
+ if fieldNames == nil {
+ out, err := fs.rdb.HGetAll(ctx, key).Result()
+ if err != nil {
+ return nil, err
+ }
+ fs.readsTotal.Add(1)
+ fs.readFieldsTotal.Add(int64(len(out)))
+ return out, nil
+ }
+ if len(fieldNames) == 0 {
+ return map[string]string{}, nil
+ }
+ values, err := fs.rdb.HMGet(ctx, key, fieldNames...).Result()
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[string]string, len(fieldNames))
+ for i, name := range fieldNames {
+ if values[i] == nil {
+ continue
+ }
+ s, ok := values[i].(string)
+ if !ok {
+ continue
+ }
+ out[name] = s
+ }
+ fs.readsTotal.Add(1)
+ fs.readFieldsTotal.Add(int64(len(out)))
+ return out, nil
+}
+
+// BatchGetFeatures pipelines HMGET across many entities for batch
+// scoring. Returns one map per entity ID, in input order.
+func (fs *FeatureStore) BatchGetFeatures(ctx context.Context, entityIDs, fieldNames []string) (map[string]map[string]string, error) {
+ if len(entityIDs) == 0 || len(fieldNames) == 0 {
+ return map[string]map[string]string{}, nil
+ }
+
+ pipe := fs.rdb.Pipeline()
+ cmds := make([]*redis.SliceCmd, len(entityIDs))
+ for i, id := range entityIDs {
+ cmds[i] = pipe.HMGet(ctx, fs.KeyFor(id), fieldNames...)
+ }
+ if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
+ return nil, fmt.Errorf("batch get features: %w", err)
+ }
+
+ out := make(map[string]map[string]string, len(entityIDs))
+ var seen int64
+ for i, id := range entityIDs {
+ values, err := cmds[i].Result()
+ if err != nil {
+ return nil, fmt.Errorf("batch get features: %s: %w", id, err)
+ }
+ row := make(map[string]string, len(fieldNames))
+ for j, name := range fieldNames {
+ if values[j] == nil {
+ continue
+ }
+ if s, ok := values[j].(string); ok {
+ row[name] = s
+ seen++
+ }
+ }
+ out[id] = row
+ }
+ fs.readsTotal.Add(int64(len(entityIDs)))
+ fs.readFieldsTotal.Add(seen)
+ return out, nil
+}
+
+// -------------------------------------------------------------------
+// TTL inspection (used by the demo UI)
+// -------------------------------------------------------------------
+
+// KeyTTLSeconds returns the seconds until the entity key expires:
+// positive means TTL remaining, -1 means no key-level TTL set,
+// -2 means the key doesn't exist.
+func (fs *FeatureStore) KeyTTLSeconds(ctx context.Context, entityID string) (int64, error) {
+ d, err := fs.rdb.TTL(ctx, fs.KeyFor(entityID)).Result()
+ if err != nil {
+ return 0, err
+ }
+ // go-redis returns time.Duration(-1) for "no TTL" and
+ // time.Duration(-2) for "missing key" (both literal nanosecond
+ // values, not seconds). Positive durations carry the real TTL.
+ if d < 0 {
+ return int64(d), nil
+ }
+ return int64(d.Seconds()), nil
+}
+
+// FieldTTLsSeconds returns the per-field TTL for each named field
+// via HTTL. Each value mirrors the TTL convention: positive means
+// seconds remaining, -1 means the field has no TTL set, -2 means
+// the field doesn't exist on this hash (or the key itself is
+// missing).
+func (fs *FeatureStore) FieldTTLsSeconds(ctx context.Context, entityID string, fieldNames []string) (map[string]int64, error) {
+ if len(fieldNames) == 0 {
+ return map[string]int64{}, nil
+ }
+ codes, err := fs.rdb.HTTL(ctx, fs.KeyFor(entityID), fieldNames...).Result()
+ if err != nil {
+ return nil, err
+ }
+ // HTTL on a missing key returns an array of -2s, one per field, so
+ // the loop below produces the same shape as a present-but-empty
+ // hash would. No defensive shim needed for this client.
+ out := make(map[string]int64, len(fieldNames))
+ for i, name := range fieldNames {
+ if i < len(codes) {
+ out[name] = codes[i]
+ } else {
+ out[name] = -2
+ }
+ }
+ return out, nil
+}
+
+// -------------------------------------------------------------------
+// Demo housekeeping
+// -------------------------------------------------------------------
+
+// ListEntityIDs returns up to limit entity IDs by scanning
+// keyPrefix*. SCAN is non-blocking and is used to populate UI
+// dropdowns, not as a serving primitive. The result is sorted.
+func (fs *FeatureStore) ListEntityIDs(ctx context.Context, limit int64) ([]string, error) {
+ if limit <= 0 {
+ limit = 200
+ }
+ pattern := fs.KeyPrefix + "*"
+ prefixLen := len(fs.KeyPrefix)
+ ids := make([]string, 0, limit)
+ iter := fs.rdb.Scan(ctx, 0, pattern, 200).Iterator()
+ for iter.Next(ctx) {
+ k := iter.Val()
+ if len(k) <= prefixLen {
+ continue
+ }
+ ids = append(ids, k[prefixLen:])
+ if int64(len(ids)) >= limit {
+ break
+ }
+ }
+ if err := iter.Err(); err != nil {
+ return nil, err
+ }
+ sort.Strings(ids)
+ return ids, nil
+}
+
+// CountEntities returns the true count of entities under the key
+// prefix. Iterates SCAN without an in-memory cap so the UI can report
+// the real total even when more keys exist than the dropdown lists.
+func (fs *FeatureStore) CountEntities(ctx context.Context) (int64, error) {
+ var n int64
+ pattern := fs.KeyPrefix + "*"
+ iter := fs.rdb.Scan(ctx, 0, pattern, 500).Iterator()
+ for iter.Next(ctx) {
+ n++
+ }
+ if err := iter.Err(); err != nil {
+ return 0, err
+ }
+ return n, nil
+}
+
+// DeleteEntity drops one entity by ID. Returns 1 if a key was
+// deleted, 0 otherwise.
+func (fs *FeatureStore) DeleteEntity(ctx context.Context, entityID string) (int64, error) {
+ return fs.rdb.Del(ctx, fs.KeyFor(entityID)).Result()
+}
+
+// Reset drops every entity under the key prefix. Used by the demo
+// reset path. Scans in batches and issues one variadic DEL per batch,
+// so a large demo dataset doesn't land on the server as one giant
+// synchronous delete.
+func (fs *FeatureStore) Reset(ctx context.Context) (int64, error) {
+ var deleted int64
+ pattern := fs.KeyPrefix + "*"
+ batch := make([]string, 0, 500)
+ flush := func() error {
+ if len(batch) == 0 {
+ return nil
+ }
+ n, err := fs.rdb.Del(ctx, batch...).Result()
+ if err != nil {
+ return err
+ }
+ deleted += n
+ batch = batch[:0]
+ return nil
+ }
+ iter := fs.rdb.Scan(ctx, 0, pattern, 500).Iterator()
+ for iter.Next(ctx) {
+ batch = append(batch, iter.Val())
+ if len(batch) >= 500 {
+ if err := flush(); err != nil {
+ return deleted, err
+ }
+ }
+ }
+ if err := iter.Err(); err != nil {
+ return deleted, err
+ }
+ if err := flush(); err != nil {
+ return deleted, err
+ }
+ return deleted, nil
+}
+
+// Stats returns a snapshot of the in-process counters.
+func (fs *FeatureStore) Stats() Stats {
+ return Stats{
+ BatchWritesTotal: fs.batchWritesTotal.Load(),
+ StreamingWritesTotal: fs.streamingWritesTotal.Load(),
+ ReadsTotal: fs.readsTotal.Load(),
+ ReadFieldsTotal: fs.readFieldsTotal.Load(),
+ }
+}
+
+// ResetStats zeroes every counter.
+func (fs *FeatureStore) ResetStats() {
+ fs.batchWritesTotal.Store(0)
+ fs.streamingWritesTotal.Store(0)
+ fs.readsTotal.Store(0)
+ fs.readFieldsTotal.Store(0)
+}
+
+// encode renders a feature value as a string for hash storage.
+// Booleans become "true" / "false" so they round-trip cleanly through
+// other clients and redis-cli.
+func encode(value FeatureValue) string {
+ switch v := value.(type) {
+ case nil:
+ return ""
+ case string:
+ return v
+ case bool:
+ if v {
+ return "true"
+ }
+ return "false"
+ case int:
+ return strconv.FormatInt(int64(v), 10)
+ case int32:
+ return strconv.FormatInt(int64(v), 10)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ case float32:
+ return strconv.FormatFloat(float64(v), 'f', -1, 32)
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ default:
+ return fmt.Sprintf("%v", v)
+ }
+}
+
diff --git a/content/develop/use-cases/feature-store/go/go.mod b/content/develop/use-cases/feature-store/go/go.mod
new file mode 100644
index 0000000000..ee884c7a20
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/go.mod
@@ -0,0 +1,11 @@
+module featurestore
+
+go 1.21
+
+require github.com/redis/go-redis/v9 v9.18.0
+
+require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+)
diff --git a/content/develop/use-cases/feature-store/go/go.sum b/content/develop/use-cases/feature-store/go/go.sum
new file mode 100644
index 0000000000..e25b1f4d0a
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/go.sum
@@ -0,0 +1,22 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
+github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
+github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
diff --git a/content/develop/use-cases/feature-store/go/streaming_worker.go b/content/develop/use-cases/feature-store/go/streaming_worker.go
new file mode 100644
index 0000000000..b070627bae
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/streaming_worker.go
@@ -0,0 +1,265 @@
+package featurestore
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "math/rand"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// Streaming feature updater for the demo.
+//
+// Stands in for whatever Flink, Kafka Streams, or bespoke service
+// computes the real-time features in a real deployment. In production
+// this code lives in the streaming layer; here it runs as a goroutine
+// next to the demo server so the page can start, pause, and resume it
+// from the UI.
+//
+// Every tick the worker picks a few random users and writes a new
+// value for each streaming feature, with a per-field HEXPIRE so the
+// field self-expires if the worker is paused. Pause the worker for
+// longer than StreamingTTL and the streaming fields drop out of the
+// hash while the batch fields remain populated under the longer
+// key-level TTL — the *mixed staleness* story made visible.
+
+var (
+ deviceIDs = []string{"ios-1a4c", "ios-9f02", "and-7b21", "and-2d18", "web-chr-1", "web-saf-1", "web-ff-2"}
+ sessionCountries = []string{"US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL"}
+ failedLoginBuckets = []int{0, 1, 2, 5}
+ failedLoginWeights = []int{70, 20, 8, 2}
+)
+
+// WorkerStats is the JSON-friendly view of a StreamingWorker's
+// state. The demo UI polls this every refresh.
+type WorkerStats struct {
+ Running bool `json:"running"`
+ Paused bool `json:"paused"`
+ TickCount int64 `json:"tick_count"`
+ WritesCount int64 `json:"writes_count"`
+}
+
+// StreamingWorker writes random streaming features on a tick.
+type StreamingWorker struct {
+ store *FeatureStore
+ tick time.Duration
+ usersPerTick int
+ rng *rand.Rand
+ rngMu sync.Mutex
+
+ // lifecycleMu serialises Start and Stop so a concurrent Start
+ // (e.g. from /worker/toggle) can't spawn a successor goroutine
+ // while a Stop is mid-wait on doneCh. Without it, the old
+ // goroutine's deferred running.Store(false) would clobber the
+ // new goroutine's running flag, leaving IsRunning() false and
+ // the new goroutine unstoppable.
+ lifecycleMu sync.Mutex
+ running atomic.Bool
+ paused atomic.Bool
+ tickInFlight atomic.Bool
+ tickCount atomic.Int64
+ writesCount atomic.Int64
+ stopCh chan struct{}
+ doneCh chan struct{}
+}
+
+// NewStreamingWorker constructs a worker that touches usersPerTick
+// users every tick.
+func NewStreamingWorker(store *FeatureStore, tick time.Duration, usersPerTick int, seed int64) *StreamingWorker {
+ if tick == 0 {
+ tick = time.Second
+ }
+ if usersPerTick == 0 {
+ usersPerTick = 5
+ }
+ return &StreamingWorker{
+ store: store,
+ tick: tick,
+ usersPerTick: usersPerTick,
+ rng: rand.New(rand.NewSource(seed)),
+ }
+}
+
+// Start launches the goroutine that ticks. Safe to call when the
+// worker is already running (no-op in that case).
+//
+// The worker uses an internal `context.Background()`-derived context
+// rather than one passed in by the caller: the HTTP toggle handler
+// runs on a request-scoped context that cancels as soon as the
+// response completes, which would kill the worker on the very next
+// tick. Lifecycle is owned by ``Stop`` (and the internal ``stopCh``).
+func (w *StreamingWorker) Start() {
+ w.lifecycleMu.Lock()
+ defer w.lifecycleMu.Unlock()
+ if !w.running.CompareAndSwap(false, true) {
+ return
+ }
+ w.paused.Store(false)
+ w.stopCh = make(chan struct{})
+ w.doneCh = make(chan struct{})
+ go w.run(context.Background())
+}
+
+// Stop signals the worker to exit and waits for any in-flight tick
+// to settle. Safe to call multiple times.
+//
+// Holds lifecycleMu across the doneCh wait so a concurrent Start
+// can't reassign stopCh/doneCh while we're waiting on them — that
+// would leak the old goroutine and have the deferred
+// running.Store(false) clobber the new goroutine's running flag.
+func (w *StreamingWorker) Stop() {
+ w.lifecycleMu.Lock()
+ defer w.lifecycleMu.Unlock()
+ if !w.running.CompareAndSwap(true, false) {
+ return
+ }
+ close(w.stopCh)
+ <-w.doneCh
+}
+
+// Pause prevents new ticks from running. An already-running tick is
+// not interrupted; use WaitForIdle to wait for it.
+func (w *StreamingWorker) Pause() { w.paused.Store(true) }
+
+// Resume re-enables ticks.
+func (w *StreamingWorker) Resume() { w.paused.Store(false) }
+
+// IsPaused returns whether the worker is paused.
+func (w *StreamingWorker) IsPaused() bool { return w.paused.Load() }
+
+// IsRunning returns whether the goroutine is active.
+func (w *StreamingWorker) IsRunning() bool { return w.running.Load() }
+
+// WaitForIdle blocks until any in-flight tick has finished its
+// current updateStreaming loop. Pause() only stops *future* ticks
+// from running — it does not interrupt one that is already
+// mid-flight. Callers that need a quiesced worker (a reset that's
+// about to DEL every entity, for example) must Pause() AND
+// WaitForIdle() before they touch state the tick might still be
+// writing to.
+func (w *StreamingWorker) WaitForIdle() {
+ for w.tickInFlight.Load() {
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+// Stats returns a snapshot of the worker's counters and state.
+func (w *StreamingWorker) Stats() WorkerStats {
+ return WorkerStats{
+ Running: w.IsRunning(),
+ Paused: w.IsPaused(),
+ TickCount: w.tickCount.Load(),
+ WritesCount: w.writesCount.Load(),
+ }
+}
+
+// ResetStats zeroes the tick and writes counters.
+func (w *StreamingWorker) ResetStats() {
+ w.tickCount.Store(0)
+ w.writesCount.Store(0)
+}
+
+func (w *StreamingWorker) run(ctx context.Context) {
+ // Whatever exits this goroutine — stopCh, ctx.Done(), or a future
+ // panic-recovery path — must clear `running` so a later Start()
+ // can spin a fresh goroutine. Without this, a one-shot ctx cancel
+ // (or any unexpected exit) leaves IsRunning() returning true
+ // forever, and ToggleWorker's CompareAndSwap refuses to restart.
+ defer func() {
+ w.running.Store(false)
+ w.tickInFlight.Store(false)
+ close(w.doneCh)
+ }()
+ t := time.NewTicker(w.tick)
+ defer t.Stop()
+ for {
+ select {
+ case <-w.stopCh:
+ return
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ // Set tickInFlight *before* the pause check so a
+ // concurrent Pause()+WaitForIdle() can never see
+ // tickInFlight=false in the window between the pause
+ // check and the actual doTick call.
+ w.tickInFlight.Store(true)
+ if !w.paused.Load() {
+ if err := w.doTick(ctx); err != nil {
+ log.Printf("[streaming-worker] tick failed: %v", err)
+ }
+ }
+ w.tickInFlight.Store(false)
+ }
+ }
+}
+
+func (w *StreamingWorker) doTick(ctx context.Context) error {
+ ids, err := w.store.ListEntityIDs(ctx, 500)
+ if err != nil {
+ return fmt.Errorf("list entity ids: %w", err)
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+
+ w.rngMu.Lock()
+ n := w.usersPerTick
+ if n > len(ids) {
+ n = len(ids)
+ }
+ chosen := w.rng.Perm(len(ids))[:n]
+ picks := make([]string, n)
+ for i, idx := range chosen {
+ picks[i] = ids[idx]
+ }
+ w.rngMu.Unlock()
+
+ nowMs := time.Now().UnixMilli()
+ for _, id := range picks {
+ fields := FeatureMap{
+ "last_login_ts": nowMs,
+ "last_device_id": w.choice(deviceIDs),
+ "tx_count_5m": w.intn(13),
+ "failed_logins_15m": w.weightedInt(failedLoginBuckets, failedLoginWeights),
+ "session_country": w.choice(sessionCountries),
+ }
+ if err := w.store.UpdateStreaming(ctx, id, fields, 0); err != nil {
+ return fmt.Errorf("update streaming for %s: %w", id, err)
+ }
+ w.writesCount.Add(int64(len(fields)))
+ }
+ w.tickCount.Add(1)
+ return nil
+}
+
+func (w *StreamingWorker) choice(items []string) string {
+ w.rngMu.Lock()
+ defer w.rngMu.Unlock()
+ return items[w.rng.Intn(len(items))]
+}
+
+func (w *StreamingWorker) intn(n int) int {
+ w.rngMu.Lock()
+ defer w.rngMu.Unlock()
+ return w.rng.Intn(n)
+}
+
+func (w *StreamingWorker) weightedInt(items []int, weights []int) int {
+ w.rngMu.Lock()
+ defer w.rngMu.Unlock()
+ total := 0
+ for _, x := range weights {
+ total += x
+ }
+ r := w.rng.Intn(total)
+ for i, x := range weights {
+ r -= x
+ if r < 0 {
+ return items[i]
+ }
+ }
+ return items[len(items)-1]
+}
diff --git a/content/develop/use-cases/feature-store/java-jedis/BuildFeatures.java b/content/develop/use-cases/feature-store/java-jedis/BuildFeatures.java
new file mode 100644
index 0000000000..670a07cfae
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/BuildFeatures.java
@@ -0,0 +1,113 @@
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import redis.clients.jedis.JedisPool;
+
+/**
+ * Synthesize a small batch of users with realistic-looking features
+ * and bulk-load them into Redis with a 24-hour key-level TTL.
+ *
+ *
Stands in for the nightly Spark / Feast materialization job in a
+ * real deployment. In production the equivalent of this script lives
+ * in an offline pipeline that reads from the offline store and writes
+ * the serving-time hashes into Redis via {@code HSET} + {@code EXPIRE}.
+ *
+ *
Run with: {@code mvn exec:java -Dexec.mainClass=BuildFeatures -Dexec.args="--count 500"}
+ */
+public class BuildFeatures {
+
+ private static final List COUNTRY_CHOICES = List.of(
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL");
+ private static final List RISK_SEGMENTS = List.of("low", "medium", "high");
+ private static final int[] RISK_WEIGHTS = {70, 25, 5};
+ private static final int[] CHARGEBACK_BUCKETS = {0, 1, 2, 3};
+ private static final int[] CHARGEBACK_WEIGHTS = {85, 10, 4, 1};
+
+ /**
+ * Generate {@code count} synthetic user feature rows. The shape
+ * mirrors a small fraud-scoring feature set: country and risk
+ * segment as TAG-like categorical features, plus a few numeric
+ * aggregates over recent windows.
+ */
+ public static Map> synthesizeUsers(int count, long seed) {
+ Random rng = new Random(seed);
+ Map> users = new LinkedHashMap<>(count);
+ for (int i = 1; i <= count; i++) {
+ String uid = String.format("u%04d", i);
+ Map row = new LinkedHashMap<>();
+ row.put("country_iso", COUNTRY_CHOICES.get(rng.nextInt(COUNTRY_CHOICES.size())));
+ row.put("risk_segment", weightedChoice(rng, RISK_SEGMENTS, RISK_WEIGHTS));
+ row.put("account_age_days", 7 + rng.nextInt(2394));
+ row.put("tx_count_7d", rng.nextInt(81));
+ row.put("avg_amount_30d", Math.round((5.0 + rng.nextDouble() * 345.0) * 100.0) / 100.0);
+ row.put("chargeback_count_180d", weightedChoiceInt(rng, CHARGEBACK_BUCKETS, CHARGEBACK_WEIGHTS));
+ users.put(uid, row);
+ }
+ return users;
+ }
+
+ public static void main(String[] args) {
+ String redisHost = "localhost";
+ int redisPort = 6379;
+ int count = 200;
+ long ttlSeconds = 24L * 60L * 60L;
+ String keyPrefix = "fs:user:";
+ long seed = 42L;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--redis-host" -> redisHost = args[++i];
+ case "--redis-port" -> redisPort = Integer.parseInt(args[++i]);
+ case "--count" -> count = Integer.parseInt(args[++i]);
+ case "--ttl-seconds" -> ttlSeconds = Long.parseLong(args[++i]);
+ case "--key-prefix" -> keyPrefix = args[++i];
+ case "--seed" -> seed = Long.parseLong(args[++i]);
+ case "-h", "--help" -> {
+ System.out.println(
+ "Usage: mvn exec:java -Dexec.mainClass=BuildFeatures " +
+ "-Dexec.args=\"[--redis-host H] [--redis-port P] " +
+ "[--count N] [--ttl-seconds S] [--key-prefix PREFIX] [--seed N]\"");
+ return;
+ }
+ default -> {
+ System.err.println("Unknown argument: " + args[i]);
+ System.exit(2);
+ }
+ }
+ }
+
+ try (JedisPool pool = new JedisPool(redisHost, redisPort)) {
+ FeatureStore store = new FeatureStore(pool, keyPrefix, ttlSeconds,
+ FeatureStore.DEFAULT_STREAMING_TTL_SECONDS);
+ Map> rows = synthesizeUsers(count, seed);
+ int loaded = store.bulkLoad(rows, ttlSeconds);
+ System.out.printf(
+ "Materialized %d users at %s* with a %ds key-level TTL.%n",
+ loaded, keyPrefix, ttlSeconds);
+ }
+ }
+
+ private static String weightedChoice(Random rng, List items, int[] weights) {
+ int total = 0;
+ for (int w : weights) total += w;
+ int r = rng.nextInt(total);
+ for (int i = 0; i < items.size(); i++) {
+ r -= weights[i];
+ if (r < 0) return items.get(i);
+ }
+ return items.get(items.size() - 1);
+ }
+
+ private static int weightedChoiceInt(Random rng, int[] items, int[] weights) {
+ int total = 0;
+ for (int w : weights) total += w;
+ int r = rng.nextInt(total);
+ for (int i = 0; i < items.length; i++) {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ }
+}
diff --git a/content/develop/use-cases/feature-store/java-jedis/DemoServer.java b/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
new file mode 100644
index 0000000000..a8939a60eb
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
@@ -0,0 +1,1028 @@
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.ReentrantLock;
+
+import redis.clients.jedis.JedisPool;
+import redis.clients.jedis.JedisPoolConfig;
+
+/**
+ * Redis feature-store demo server (Jedis + JDK HttpServer).
+ *
+ *
Run with {@code mvn exec:java -Dexec.mainClass=DemoServer} and
+ * visit {@code http://localhost:8088} to watch an online feature
+ * store at work: a batch materialization loads N users with a 24-hour
+ * key-level TTL, a streaming worker overwrites a handful of users'
+ * real-time features every second with a per-field {@code HEXPIRE},
+ * and the inference panel reads any subset of features for any user
+ * with {@code HMGET} in a single round trip.
+ */
+public class DemoServer {
+
+ private static FeatureStore store;
+ private static StreamingWorker worker;
+ private static FeatureStoreDemo demo;
+ private static JedisPool jedisPool;
+
+ public static void main(String[] args) throws Exception {
+ String host = "127.0.0.1";
+ int port = 8088;
+ String redisHost = "localhost";
+ int redisPort = 6379;
+ String keyPrefix = "fs:user:";
+ long batchTtlSeconds = 24L * 60L * 60L;
+ long streamingTtlSeconds = 5L * 60L;
+ int usersPerTick = 5;
+ int seedUsers = 200;
+ boolean resetOnStart = true;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--host" -> host = args[++i];
+ case "--port" -> port = Integer.parseInt(args[++i]);
+ case "--redis-host" -> redisHost = args[++i];
+ case "--redis-port" -> redisPort = Integer.parseInt(args[++i]);
+ case "--key-prefix" -> keyPrefix = args[++i];
+ case "--batch-ttl-seconds" -> batchTtlSeconds = Long.parseLong(args[++i]);
+ case "--streaming-ttl-seconds" -> streamingTtlSeconds = Long.parseLong(args[++i]);
+ case "--users-per-tick" -> usersPerTick = Integer.parseInt(args[++i]);
+ case "--seed-users" -> seedUsers = Integer.parseInt(args[++i]);
+ case "--no-reset" -> resetOnStart = false;
+ case "-h", "--help" -> {
+ System.out.println(
+ "Usage: mvn exec:java -Dexec.mainClass=DemoServer " +
+ "-Dexec.args=\"[--host H] [--port P] [--redis-host H] " +
+ "[--redis-port P] [--key-prefix PFX] " +
+ "[--batch-ttl-seconds S] [--streaming-ttl-seconds S] " +
+ "[--users-per-tick N] [--seed-users N] [--no-reset]\"");
+ return;
+ }
+ default -> {
+ System.err.println("Unknown argument: " + args[i]);
+ System.exit(2);
+ }
+ }
+ }
+
+ JedisPoolConfig poolCfg = new JedisPoolConfig();
+ poolCfg.setMaxTotal(64);
+ poolCfg.setMaxIdle(32);
+ poolCfg.setMinIdle(4);
+ jedisPool = new JedisPool(poolCfg, redisHost, redisPort);
+
+ store = new FeatureStore(jedisPool, keyPrefix,
+ batchTtlSeconds, streamingTtlSeconds);
+ worker = new StreamingWorker(store, 1000L, usersPerTick, 1337L);
+ demo = new FeatureStoreDemo(store, worker, 42L);
+
+ if (resetOnStart) {
+ System.out.printf(
+ "Dropping any existing users under '%s*' for a clean demo run (pass --no-reset to keep them).%n",
+ keyPrefix);
+ store.reset();
+ store.resetStats();
+ }
+ int seeded = demo.materialize(seedUsers, batchTtlSeconds).loaded();
+ worker.start();
+
+ HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0);
+ server.createContext("/", new RootHandler());
+ server.createContext("/state", new StateHandler());
+ server.createContext("/inspect", new InspectHandler());
+ server.createContext("/bulk-load", new BulkLoadHandler());
+ server.createContext("/reset", new ResetHandler());
+ server.createContext("/worker/toggle", new ToggleWorkerHandler());
+ server.createContext("/read", new ReadHandler());
+ server.createContext("/batch-read", new BatchReadHandler());
+ server.setExecutor(Executors.newFixedThreadPool(16));
+ server.start();
+
+ System.out.printf("Redis feature-store demo server listening on http://%s:%d%n", host, port);
+ System.out.printf(
+ "Using Redis at %s:%d with key prefix '%s' (batch TTL %ds, streaming TTL %ds)%n",
+ redisHost, redisPort, keyPrefix, batchTtlSeconds, streamingTtlSeconds);
+ System.out.printf("Materialized %d user(s); streaming worker running.%n", seeded);
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ System.out.println("\nShutting down...");
+ worker.stop();
+ server.stop(0);
+ jedisPool.close();
+ }));
+
+ Thread.currentThread().join();
+ }
+
+ // ---------------------------------------------------------------
+ // FeatureStoreDemo wires the store and worker with the lifecycle
+ // operations the HTTP handlers call into.
+ // ---------------------------------------------------------------
+
+ static class FeatureStoreDemo {
+ private final FeatureStore store;
+ private final StreamingWorker worker;
+ private final long seed;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ FeatureStoreDemo(FeatureStore store, StreamingWorker worker, long seed) {
+ this.store = store;
+ this.worker = worker;
+ this.seed = seed;
+ }
+
+ public record MaterializeResult(int loaded, long ttlSeconds, double elapsedMs) {}
+
+ public MaterializeResult materialize(int count, long ttlSeconds) {
+ lock.lock();
+ try {
+ Map> rows = BuildFeatures.synthesizeUsers(count, seed);
+ long t0 = System.nanoTime();
+ int loaded = store.bulkLoad(rows, ttlSeconds);
+ double elapsedMs = (System.nanoTime() - t0) / 1_000_000.0;
+ return new MaterializeResult(loaded, ttlSeconds, elapsedMs);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public long reset() {
+ lock.lock();
+ try {
+ // Pause the streaming worker around the DEL sweep so a
+ // concurrent tick can't recreate a user that was just
+ // enumerated for deletion (streaming HSET creates the
+ // key if it's missing, and that would leave behind a
+ // streaming-only hash with no key-level TTL).
+ // pause() only blocks *future* ticks — waitForIdle()
+ // flushes an already-running tick before the DEL sweep.
+ boolean wasPaused = worker.isPaused();
+ if (worker.isRunning()) {
+ if (!wasPaused) worker.pause();
+ worker.waitForIdle();
+ }
+ try {
+ long deleted = store.reset();
+ store.resetStats();
+ worker.resetStats();
+ return deleted;
+ } finally {
+ if (worker.isRunning() && !wasPaused) worker.resume();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public Map toggleWorker() {
+ lock.lock();
+ try {
+ // Three states: stopped → start (and leave unpaused);
+ // running + unpaused → pause; running + paused → resume.
+ // start() clears the paused flag, so a fall-through
+ // would pause the worker we just brought back up.
+ if (!worker.isRunning()) worker.start();
+ else if (worker.isPaused()) worker.resume();
+ else worker.pause();
+ return Map.of(
+ "paused", worker.isPaused(),
+ "running", worker.isRunning()
+ );
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Handlers
+ // ---------------------------------------------------------------
+
+ static class RootHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!ex.getRequestURI().getPath().equals("/") &&
+ !ex.getRequestURI().getPath().equals("/index.html")) {
+ send(ex, 404, "text/plain", "Not Found");
+ return;
+ }
+ send(ex, 200, "text/html; charset=utf-8", htmlPage());
+ }
+ }
+
+ static class StateHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"GET".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ try {
+ List ids = store.listEntityIds(500);
+ long count = store.countEntities();
+ Map out = new LinkedHashMap<>();
+ out.put("key_prefix", store.getKeyPrefix());
+ out.put("batch_ttl_seconds", store.getBatchTtlSeconds());
+ out.put("streaming_ttl_seconds", store.getStreamingTtlSeconds());
+ out.put("entity_count", count);
+ out.put("entity_ids", ids);
+ out.put("stats", statsToMap(store.stats()));
+ out.put("worker", workerStatsToMap(worker.statsSnapshot()));
+ sendJson(ex, 200, out);
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class InspectHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"GET".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ Map q = parseQuery(ex.getRequestURI());
+ String user = q.getOrDefault("user", "").trim();
+ if (user.isEmpty()) {
+ sendJson(ex, 400, Map.of("error", "user is required")); return;
+ }
+ try {
+ Map full = store.getAllFeatures(user);
+ long keyTTL = store.keyTtlSeconds(user);
+ if (full.isEmpty()) {
+ sendJson(ex, 200, Map.of(
+ "exists", false,
+ "key_ttl_seconds", keyTTL));
+ return;
+ }
+ // Iterate the known schema (batch + streaming) plus
+ // any extras the hash carries. Expired streaming
+ // fields surface as ttl_seconds=-2 in the Inspect
+ // view instead of silently disappearing, which is
+ // exactly the debugging view someone hits "Inspect"
+ // for.
+ List names = new ArrayList<>(FeatureStore.DEFAULT_BATCH_FIELDS);
+ names.addAll(FeatureStore.DEFAULT_STREAMING_FIELDS);
+ for (String n : full.keySet()) {
+ if (!names.contains(n)) names.add(n);
+ }
+ Map ttls = store.fieldTtlsSeconds(user, names);
+ Collections.sort(names);
+ List
+ *
+ *
{@code HEXPIRE} and {@code HTTL} require Redis 7.4 or later.
+ * Jedis exposes them as {@code hexpire} / {@code httl} from 5.2.
+ *
+ *
Concurrency is by construction: Redis is single-threaded per
+ * shard, so overlapping {@code HSET} calls from a batch job and a
+ * streaming worker on the same entity hash are applied atomically
+ * without locks or version columns.
+ */
+public class FeatureStore {
+
+ /** Default batch feature schema. */
+ public static final List DEFAULT_BATCH_FIELDS = List.of(
+ "country_iso",
+ "risk_segment",
+ "account_age_days",
+ "tx_count_7d",
+ "avg_amount_30d",
+ "chargeback_count_180d"
+ );
+
+ /** Default streaming feature schema. */
+ public static final List DEFAULT_STREAMING_FIELDS = List.of(
+ "last_login_ts",
+ "last_device_id",
+ "tx_count_5m",
+ "failed_logins_15m",
+ "session_country"
+ );
+
+ public static final long DEFAULT_BATCH_TTL_SECONDS = 24L * 60L * 60L;
+ public static final long DEFAULT_STREAMING_TTL_SECONDS = 5L * 60L;
+ public static final String DEFAULT_KEY_PREFIX = "fs:user:";
+
+ private final JedisPool pool;
+ private final String keyPrefix;
+ private final long batchTtlSeconds;
+ private final long streamingTtlSeconds;
+
+ private final AtomicLong batchWritesTotal = new AtomicLong();
+ private final AtomicLong streamingWritesTotal = new AtomicLong();
+ private final AtomicLong readsTotal = new AtomicLong();
+ private final AtomicLong readFieldsTotal = new AtomicLong();
+
+ public FeatureStore(JedisPool pool) {
+ this(pool, DEFAULT_KEY_PREFIX,
+ DEFAULT_BATCH_TTL_SECONDS,
+ DEFAULT_STREAMING_TTL_SECONDS);
+ }
+
+ public FeatureStore(JedisPool pool, String keyPrefix,
+ long batchTtlSeconds, long streamingTtlSeconds) {
+ this.pool = pool;
+ this.keyPrefix = keyPrefix;
+ this.batchTtlSeconds = batchTtlSeconds;
+ this.streamingTtlSeconds = streamingTtlSeconds;
+ }
+
+ public String getKeyPrefix() { return keyPrefix; }
+ public long getBatchTtlSeconds() { return batchTtlSeconds; }
+ public long getStreamingTtlSeconds() { return streamingTtlSeconds; }
+
+ public String keyFor(String entityId) {
+ return keyPrefix + entityId;
+ }
+
+ // ---------------------------------------------------------------
+ // Batch ingestion (materialization)
+ // ---------------------------------------------------------------
+
+ /**
+ * Materialize a batch of entities into Redis.
+ *
+ *
{@code rows} is keyed by entity ID. One {@code HSET} plus one
+ * {@code EXPIRE} per entity, all queued through a single
+ * {@link Pipeline} so the whole batch ships in one round trip.
+ * The key-level {@code EXPIRE} is what makes the entity disappear
+ * if a future batch run fails — inference reads the missing entity
+ * rather than silently outdated values.
+ */
+ public int bulkLoad(Map> rows, long ttlSeconds) {
+ if (rows.isEmpty()) return 0;
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ for (Map.Entry> e : rows.entrySet()) {
+ String key = keyFor(e.getKey());
+ Map encoded = encode(e.getValue());
+ pipe.hset(key, encoded);
+ pipe.expire(key, ttlSeconds);
+ }
+ pipe.sync();
+ }
+ batchWritesTotal.addAndGet(rows.size());
+ return rows.size();
+ }
+
+ public int bulkLoad(Map> rows) {
+ return bulkLoad(rows, batchTtlSeconds);
+ }
+
+ /**
+ * Update a single batch feature without touching the key TTL.
+ * Used by the demo's "manually refresh one user" lever; real
+ * pipelines flow through {@link #bulkLoad}.
+ */
+ public void updateBatchFeature(String entityId, String field, Object value) {
+ try (Jedis jedis = pool.getResource()) {
+ jedis.hset(keyFor(entityId), field, encodeValue(value));
+ }
+ batchWritesTotal.incrementAndGet();
+ }
+
+ // ---------------------------------------------------------------
+ // Streaming ingestion
+ // ---------------------------------------------------------------
+
+ /**
+ * Write streaming features with a per-field TTL.
+ *
+ *
Each field carries its own {@code HEXPIRE} so it self-expires
+ * independently of the rest of the hash. If the streaming
+ * pipeline stops, the streaming fields drop out while the
+ * batch-materialized fields remain populated under their longer
+ * key-level {@code EXPIRE}.
+ *
+ *
{@code HEXPIRE} returns one status code per field:
+ *
+ *
{@code 1}: TTL set / updated
+ *
{@code 2}: the expiry was 0 or in the past, so Redis
+ * deleted the field instead of applying a TTL
+ *
{@code 0}: an {@code NX | XX | GT | LT} conditional flag
+ * was specified and not met (we never use one here)
+ *
{@code -2}: no such field, or no such key
+ *
+ * We just {@code HSET} every field on the same call, so any code
+ * other than {@code 1} means the per-field TTL invariant did not
+ * hold — the mixed-staleness story relies on every streaming
+ * field carrying a fresh TTL after the write, so failure is
+ * loud.
+ */
+ public void updateStreaming(String entityId, Map fields, long ttlSeconds) {
+ if (fields.isEmpty()) return;
+ String key = keyFor(entityId);
+ Map encoded = encode(fields);
+ String[] names = encoded.keySet().toArray(new String[0]);
+
+ List expireCodes;
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ pipe.hset(key, encoded);
+ Response> expireResp = pipe.hexpire(key, ttlSeconds, names);
+ pipe.sync();
+ expireCodes = expireResp.get();
+ }
+ for (Long code : expireCodes) {
+ if (code == null || code != 1L) {
+ throw new IllegalStateException(
+ "HEXPIRE did not set every field TTL for " + key + ": " + expireCodes);
+ }
+ }
+ streamingWritesTotal.addAndGet(fields.size());
+ }
+
+ public void updateStreaming(String entityId, Map fields) {
+ updateStreaming(entityId, fields, streamingTtlSeconds);
+ }
+
+ // ---------------------------------------------------------------
+ // Inference reads
+ // ---------------------------------------------------------------
+
+ /**
+ * Retrieve a subset of features for one entity. Pass
+ * {@code fieldNames=null} (or call {@link #getAllFeatures}) to
+ * fetch the full hash with {@code HGETALL} — useful for debugging
+ * but rarely the right call on the request path, where the model
+ * knows exactly which features it consumes.
+ */
+ public Map getFeatures(String entityId, List fieldNames) {
+ String key = keyFor(entityId);
+ Map out = new LinkedHashMap<>();
+ if (fieldNames == null) {
+ try (Jedis jedis = pool.getResource()) {
+ Map all = jedis.hgetAll(key);
+ if (all != null) out.putAll(all);
+ }
+ readsTotal.incrementAndGet();
+ readFieldsTotal.addAndGet(out.size());
+ return out;
+ }
+ if (fieldNames.isEmpty()) return out;
+ List values;
+ try (Jedis jedis = pool.getResource()) {
+ values = jedis.hmget(key, fieldNames.toArray(new String[0]));
+ }
+ for (int i = 0; i < fieldNames.size(); i++) {
+ String v = values.get(i);
+ if (v != null) out.put(fieldNames.get(i), v);
+ }
+ readsTotal.incrementAndGet();
+ readFieldsTotal.addAndGet(out.size());
+ return out;
+ }
+
+ public Map getAllFeatures(String entityId) {
+ return getFeatures(entityId, null);
+ }
+
+ /**
+ * Pipeline {@code HMGET} across many entities for batch scoring.
+ * One round trip for the whole batch.
+ */
+ public Map> batchGetFeatures(
+ List entityIds, List fieldNames) {
+ if (entityIds.isEmpty() || fieldNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ String[] names = fieldNames.toArray(new String[0]);
+ Map> out = new LinkedHashMap<>();
+ List>> responses = new ArrayList<>(entityIds.size());
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ for (String id : entityIds) {
+ responses.add(pipe.hmget(keyFor(id), names));
+ }
+ pipe.sync();
+ }
+ long seenFields = 0;
+ for (int i = 0; i < entityIds.size(); i++) {
+ List values = responses.get(i).get();
+ Map row = new LinkedHashMap<>();
+ for (int j = 0; j < fieldNames.size(); j++) {
+ String v = values.get(j);
+ if (v != null) {
+ row.put(fieldNames.get(j), v);
+ seenFields++;
+ }
+ }
+ out.put(entityIds.get(i), row);
+ }
+ readsTotal.addAndGet(entityIds.size());
+ readFieldsTotal.addAndGet(seenFields);
+ return out;
+ }
+
+ // ---------------------------------------------------------------
+ // TTL inspection (used by the demo UI)
+ // ---------------------------------------------------------------
+
+ /**
+ * Seconds until the entity key expires. Returns {@code -1} if no
+ * key-level TTL is set, {@code -2} if the key doesn't exist.
+ */
+ public long keyTtlSeconds(String entityId) {
+ try (Jedis jedis = pool.getResource()) {
+ return jedis.ttl(keyFor(entityId));
+ }
+ }
+
+ /**
+ * Per-field TTL via {@code HTTL} (Redis 7.4+). Each value mirrors
+ * the {@code TTL} convention: positive means seconds remaining,
+ * {@code -1} means the field has no TTL set, {@code -2} means
+ * the field doesn't exist on this hash (or the key itself is
+ * missing).
+ */
+ public Map fieldTtlsSeconds(String entityId, List fieldNames) {
+ if (fieldNames.isEmpty()) return Collections.emptyMap();
+ List codes;
+ try (Jedis jedis = pool.getResource()) {
+ codes = jedis.httl(keyFor(entityId), fieldNames.toArray(new String[0]));
+ }
+ Map out = new LinkedHashMap<>();
+ for (int i = 0; i < fieldNames.size(); i++) {
+ // HTTL on a missing key returns a flat list of -2s; jedis
+ // surfaces null per element if the reply shape ever changes
+ // upstream, so coerce to -2 defensively.
+ Long c = i < codes.size() ? codes.get(i) : null;
+ out.put(fieldNames.get(i), c == null ? -2L : c);
+ }
+ return out;
+ }
+
+ // ---------------------------------------------------------------
+ // Demo housekeeping
+ // ---------------------------------------------------------------
+
+ /**
+ * Enumerate entity IDs by scanning {@code keyPrefix*}. {@code SCAN}
+ * is non-blocking; the demo uses it to populate UI dropdowns, not
+ * as a serving primitive.
+ */
+ public List listEntityIds(int limit) {
+ List ids = new ArrayList<>();
+ String pattern = keyPrefix + "*";
+ String cursor = "0";
+ try (Jedis jedis = pool.getResource()) {
+ do {
+ redis.clients.jedis.params.ScanParams params = new redis.clients.jedis.params.ScanParams()
+ .match(pattern)
+ .count(200);
+ redis.clients.jedis.resps.ScanResult sr = jedis.scan(cursor, params);
+ for (String k : sr.getResult()) {
+ if (k.length() > keyPrefix.length()) {
+ ids.add(k.substring(keyPrefix.length()));
+ if (ids.size() >= limit) {
+ Collections.sort(ids);
+ return ids;
+ }
+ }
+ }
+ cursor = sr.getCursor();
+ } while (!"0".equals(cursor));
+ }
+ Collections.sort(ids);
+ return ids;
+ }
+
+ /**
+ * Count entities under the key prefix without an in-memory cap so
+ * the UI can report the real total even when more keys exist than
+ * the dropdown lists.
+ */
+ public long countEntities() {
+ long count = 0;
+ String pattern = keyPrefix + "*";
+ String cursor = "0";
+ try (Jedis jedis = pool.getResource()) {
+ do {
+ redis.clients.jedis.params.ScanParams params = new redis.clients.jedis.params.ScanParams()
+ .match(pattern)
+ .count(500);
+ redis.clients.jedis.resps.ScanResult sr = jedis.scan(cursor, params);
+ count += sr.getResult().size();
+ cursor = sr.getCursor();
+ } while (!"0".equals(cursor));
+ }
+ return count;
+ }
+
+ public long deleteEntity(String entityId) {
+ try (Jedis jedis = pool.getResource()) {
+ return jedis.del(keyFor(entityId));
+ }
+ }
+
+ /**
+ * Drop every entity under the key prefix. Used by the demo reset
+ * path. Scans in batches and issues one variadic {@code DEL} per
+ * batch, so a large demo dataset doesn't land on the server as
+ * one giant synchronous delete.
+ */
+ public long reset() {
+ long deleted = 0;
+ String pattern = keyPrefix + "*";
+ String cursor = "0";
+ try (Jedis jedis = pool.getResource()) {
+ do {
+ redis.clients.jedis.params.ScanParams params = new redis.clients.jedis.params.ScanParams()
+ .match(pattern)
+ .count(500);
+ redis.clients.jedis.resps.ScanResult sr = jedis.scan(cursor, params);
+ List batch = sr.getResult();
+ if (!batch.isEmpty()) {
+ deleted += jedis.del(batch.toArray(new String[0]));
+ }
+ cursor = sr.getCursor();
+ } while (!"0".equals(cursor));
+ }
+ return deleted;
+ }
+
+ public Stats stats() {
+ return new Stats(
+ batchWritesTotal.get(),
+ streamingWritesTotal.get(),
+ readsTotal.get(),
+ readFieldsTotal.get()
+ );
+ }
+
+ public void resetStats() {
+ batchWritesTotal.set(0);
+ streamingWritesTotal.set(0);
+ readsTotal.set(0);
+ readFieldsTotal.set(0);
+ }
+
+ // ---------------------------------------------------------------
+ // Encoding helpers
+ // ---------------------------------------------------------------
+
+ private static Map encode(Map fields) {
+ Map out = new LinkedHashMap<>(fields.size());
+ for (Map.Entry e : fields.entrySet()) {
+ out.put(e.getKey(), encodeValue(e.getValue()));
+ }
+ return out;
+ }
+
+ /** Render a feature value as a string for hash storage. */
+ public static String encodeValue(Object value) {
+ if (value == null) return "";
+ if (value instanceof Boolean b) return b ? "true" : "false";
+ return value.toString();
+ }
+
+ /** Immutable snapshot of the helper's in-process counters. */
+ public static record Stats(
+ long batchWritesTotal,
+ long streamingWritesTotal,
+ long readsTotal,
+ long readFieldsTotal
+ ) {}
+}
diff --git a/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java b/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
new file mode 100644
index 0000000000..e8c495ab0f
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
@@ -0,0 +1,244 @@
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Streaming feature updater for the demo.
+ *
+ *
Stands in for whatever Flink, Kafka Streams, or bespoke service
+ * computes the real-time features in a real deployment. In production
+ * this code lives in the streaming layer; here it runs as a daemon
+ * Thread next to the demo server so the page can start, pause, and
+ * resume it from the UI.
+ *
+ *
Every tick it picks a few random users and writes a new value
+ * for each streaming feature, with a per-field {@code HEXPIRE} so the
+ * field self-expires if the worker is paused. Pause the worker for
+ * longer than {@code streamingTtlSeconds} and the streaming fields
+ * drop out of the hash while the batch fields remain populated under
+ * the longer key-level TTL — the mixed staleness story made
+ * visible.
+ */
+public class StreamingWorker {
+
+ private static final List DEVICE_IDS = List.of(
+ "ios-1a4c", "ios-9f02", "and-7b21", "and-2d18",
+ "web-chr-1", "web-saf-1", "web-ff-2");
+ private static final List SESSION_COUNTRIES = List.of(
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL");
+ private static final int[] FAILED_LOGIN_BUCKETS = {0, 1, 2, 5};
+ private static final int[] FAILED_LOGIN_WEIGHTS = {70, 20, 8, 2};
+
+ private final FeatureStore store;
+ private final long tickMillis;
+ private final int usersPerTick;
+ private final Random rng;
+
+ private final Object rngLock = new Object();
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicBoolean paused = new AtomicBoolean(false);
+ private final AtomicBoolean tickInFlight = new AtomicBoolean(false);
+ private final AtomicLong tickCount = new AtomicLong();
+ private final AtomicLong writesCount = new AtomicLong();
+
+ private Thread worker;
+
+ public StreamingWorker(FeatureStore store, long tickMillis, int usersPerTick, long seed) {
+ this.store = store;
+ this.tickMillis = tickMillis > 0 ? tickMillis : 1000L;
+ this.usersPerTick = usersPerTick > 0 ? usersPerTick : 5;
+ this.rng = new Random(seed);
+ }
+
+ public int getUsersPerTick() { return usersPerTick; }
+
+ // ---------------------------------------------------------------
+ // Lifecycle
+ // ---------------------------------------------------------------
+
+ /** Start the worker thread. Safe to call when already running. */
+ public synchronized void start() {
+ if (running.get()) return;
+ running.set(true);
+ paused.set(false);
+ worker = new Thread(this::run, "streaming-worker");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ /** Stop the worker and wait for any in-flight tick to finish. */
+ public synchronized void stop() {
+ if (!running.getAndSet(false)) return;
+ if (worker != null) {
+ try {
+ worker.join(2000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ worker = null;
+ }
+ waitForIdle();
+ }
+
+ public void pause() { paused.set(true); }
+ public void resume() { paused.set(false); }
+
+ public boolean isRunning() { return running.get(); }
+ public boolean isPaused() { return paused.get(); }
+
+ /**
+ * Block until any in-flight tick has finished its current
+ * updateStreaming loop. {@link #pause()} only stops future
+ * ticks from running — it does not interrupt one that is already
+ * mid-flight. Callers that need a quiesced worker (a reset that's
+ * about to DEL every entity, for example) must call {@code pause()}
+ * AND {@code waitForIdle()} before they touch state the tick
+ * might still be writing to.
+ */
+ public void waitForIdle() {
+ // Reset cannot safely proceed while a tick is mid-write, so an
+ // interrupt during the wait must NOT short-circuit out with
+ // tickInFlight still true. Save the interrupt status, keep
+ // looping until the tick clears, then restore the flag so the
+ // caller can act on it if they care.
+ boolean interrupted = false;
+ while (tickInFlight.get()) {
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Tick
+ // ---------------------------------------------------------------
+
+ private void run() {
+ try {
+ while (running.get()) {
+ try {
+ Thread.sleep(tickMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ if (!running.get()) break;
+
+ // Set tickInFlight *before* the pause check so a
+ // concurrent pause()+waitForIdle() can never see
+ // tickInFlight=false in the window between the pause
+ // check and the actual doTick call. The finally
+ // block clears the flag whether we paused, succeeded,
+ // or threw.
+ tickInFlight.set(true);
+ try {
+ if (!paused.get()) {
+ doTick();
+ }
+ } catch (Exception e) {
+ System.err.printf("[streaming-worker] tick failed: %s%n", e.getMessage());
+ } finally {
+ tickInFlight.set(false);
+ }
+ }
+ } finally {
+ // Whatever exits this thread — running flipping false,
+ // an interrupt, or any unexpected throw — must clear
+ // both the running and in-flight flags so a later start()
+ // can spin a fresh thread.
+ running.set(false);
+ tickInFlight.set(false);
+ }
+ }
+
+ private void doTick() {
+ List ids = store.listEntityIds(500);
+ if (ids.isEmpty()) return;
+ List picks = sample(ids, usersPerTick);
+ long nowMs = System.currentTimeMillis();
+ int writes = 0;
+ for (String id : picks) {
+ Map fields = new LinkedHashMap<>();
+ fields.put("last_login_ts", nowMs);
+ fields.put("last_device_id", choice(DEVICE_IDS));
+ fields.put("tx_count_5m", intn(13));
+ fields.put("failed_logins_15m", weightedInt(FAILED_LOGIN_BUCKETS, FAILED_LOGIN_WEIGHTS));
+ fields.put("session_country", choice(SESSION_COUNTRIES));
+ store.updateStreaming(id, fields);
+ writes += fields.size();
+ }
+ tickCount.incrementAndGet();
+ writesCount.addAndGet(writes);
+ }
+
+ // ---------------------------------------------------------------
+ // Stats
+ // ---------------------------------------------------------------
+
+ public Stats statsSnapshot() {
+ return new Stats(isRunning(), isPaused(), tickCount.get(), writesCount.get());
+ }
+
+ public void resetStats() {
+ tickCount.set(0);
+ writesCount.set(0);
+ }
+
+ public static record Stats(
+ boolean running,
+ boolean paused,
+ long tickCount,
+ long writesCount
+ ) {}
+
+ // ---------------------------------------------------------------
+ // RNG helpers (all synchronized on rngLock so the worker stays
+ // deterministic across concurrent toggles from the demo UI).
+ // ---------------------------------------------------------------
+
+ private List sample(List items, int k) {
+ synchronized (rngLock) {
+ int n = Math.min(k, items.size());
+ List pool = new java.util.ArrayList<>(items);
+ List out = new java.util.ArrayList<>(n);
+ for (int i = 0; i < n; i++) {
+ int idx = rng.nextInt(pool.size());
+ out.add(pool.remove(idx));
+ }
+ return out;
+ }
+ }
+
+ private String choice(List items) {
+ synchronized (rngLock) {
+ return items.get(rng.nextInt(items.size()));
+ }
+ }
+
+ private int intn(int n) {
+ synchronized (rngLock) {
+ return rng.nextInt(n);
+ }
+ }
+
+ private int weightedInt(int[] items, int[] weights) {
+ synchronized (rngLock) {
+ int total = 0;
+ for (int w : weights) total += w;
+ int r = rng.nextInt(total);
+ for (int i = 0; i < items.length; i++) {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ }
+ }
+}
diff --git a/content/develop/use-cases/feature-store/java-jedis/_index.md b/content/develop/use-cases/feature-store/java-jedis/_index.md
new file mode 100644
index 0000000000..a2f61eec3c
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/_index.md
@@ -0,0 +1,744 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Build a Redis-backed online feature store in Java with Jedis
+linkTitle: Jedis example (Java)
+title: Redis feature store with Jedis
+weight: 4
+---
+
+This guide shows you how to build a small Redis-backed online feature store in
+Java with [Jedis]({{< relref "/develop/clients/jedis" >}}). It includes a
+local web server built with the JDK's `com.sun.net.httpserver.HttpServer` so
+you can bulk-load a batch of users with a key-level TTL, run a streaming
+worker that overwrites real-time features with per-field TTL, retrieve any
+subset of features for one user under 2 ms, and pipeline `HMGET` across a
+hundred users for batch scoring.
+
+## Overview
+
+Each entity (here, a user) is one Redis
+[Hash]({{< relref "/develop/data-types/hashes" >}}) at a deterministic key —
+`fs:user:{id}`. The hash holds every feature for that entity as one field per
+feature: batch-materialized aggregates (refreshed once a day) alongside
+streaming-updated signals (refreshed every few seconds). One
+[`HMGET`]({{< relref "/commands/hmget" >}}) returns whichever subset the model
+needs in one network round trip.
+
+Two TTL layers solve the *mixed staleness* problem without an application-side
+cleaner:
+
+* A **key-level** [`EXPIRE`]({{< relref "/commands/expire" >}}) aligned with the
+ batch materialization cycle (24 hours in the demo). If the batch refresher
+ fails, the whole entity disappears at the next cycle and inference sees a
+ missing entity — which the model handler can detect and fall back on —
+ rather than silently outdated values.
+* A **per-field** [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) (Redis 7.4+) on
+ each streaming feature gives that field its own shorter expiry, independent
+ of the rest of the hash. If the streaming pipeline stops updating a feature,
+ the field self-cleans while the batch fields stay populated.
+
+In this example, the batch features describe a user's longer-term shape
+(`country_iso`, `risk_segment`, `account_age_days`, `tx_count_7d`,
+`avg_amount_30d`, `chargeback_count_180d`) and are bulk-loaded by
+`BuildFeatures.java` — the demo's stand-in for a nightly Spark / Feast
+materialization job. The streaming features describe what the user is doing
+right now (`last_login_ts`, `last_device_id`, `tx_count_5m`,
+`failed_logins_15m`, `session_country`) and are written by
+`StreamingWorker.java` — the demo's stand-in for a Flink / Kafka Streams job.
+The inference handlers of the demo server read any subset of those features
+through `FeatureStore.java`'s helper class.
+
+That gives you:
+
+* A single round trip for retrieval — any subset of features for one entity
+ in one [`HMGET`]({{< relref "/commands/hmget" >}}).
+* Sub-millisecond hot path. The Redis-side work is microseconds; in practice
+ the bottleneck is the network round trip plus the model's own feature-prep.
+* Pipelined batch scoring — one round trip for `N` users at once.
+* Independent freshness per feature, expressed as a server-side TTL rather
+ than as application logic.
+* Self-cleanup on pipeline failure: a stalled batch refresher lets entities
+ expire on schedule, and a stalled streaming worker lets each affected field
+ expire on its own timer.
+
+## How it works
+
+There are three paths: a **batch path** that bulk-loads features once per
+materialization cycle, a **streaming path** that updates real-time features
+as events arrive, and an **inference path** that reads features on the
+request side.
+
+### Batch path (per materialization cycle)
+
+1. The batch job calls `BuildFeatures.synthesizeUsers(N, seed)` (in
+ production, the equivalent computation lives in an offline pipeline against
+ the warehouse). The result is `Map>` keyed by
+ user ID.
+2. `store.bulkLoad(rows, ttlSeconds)` queues one
+ [`HSET`]({{< relref "/commands/hset" >}}) plus one
+ [`EXPIRE`]({{< relref "/commands/expire" >}}) per user through Jedis's
+ [`Pipeline`]({{< relref "/develop/clients/jedis/transpipe" >}}), then
+ `pipe.sync()` ships the whole batch in a single round trip. The `HSET`
+ writes every batch field; the `EXPIRE` is what makes the entity disappear
+ if the next batch run fails, so inference reads a missing entity rather
+ than silently outdated values.
+
+### Streaming path (per event)
+
+When a user does something (login, transaction, page view) the streaming
+layer computes whatever real-time signals fall out of that event and calls
+`store.updateStreaming(userId, fields, ttlSeconds)`. That batches:
+
+1. An [`HSET`]({{< relref "/commands/hset" >}}) writing the new field values.
+ Redis is single-threaded per shard, so this is atomic against any
+ concurrent batch write on the same hash — no version columns, no locks.
+2. An [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) over exactly the fields
+ that were written, with the streaming TTL. Each streaming field carries
+ its own per-field expiry independent of the rest of the hash. Stop the
+ worker and these fields drop out one by one as their TTLs elapse, while
+ the batch fields remain populated under the longer key-level TTL.
+
+### Inference path (per request)
+
+1. The model server picks the feature subset it needs (the schema is owned by
+ the model, not the store).
+2. It calls `store.getFeatures(userId, names)`, which is one
+ [`HMGET`]({{< relref "/commands/hmget" >}}). Redis returns the values in
+ the same order as the requested fields, with `null` for any field that
+ doesn't exist (or has expired).
+3. For batch inference, the model server calls
+ `store.batchGetFeatures(userIds, names)`, which pipelines one
+ [`HMGET`]({{< relref "/commands/hmget" >}}) per user across all `N` users
+ in a single network round trip via Jedis's `Pipeline.sync()`.
+
+## The feature-store helper
+
+The `FeatureStore` class wraps the read/write paths
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-jedis/FeatureStore.java)):
+
+```java
+import redis.clients.jedis.JedisPool;
+import redis.clients.jedis.JedisPoolConfig;
+
+JedisPoolConfig cfg = new JedisPoolConfig();
+cfg.setMaxTotal(64);
+JedisPool pool = new JedisPool(cfg, "localhost", 6379);
+FeatureStore store = new FeatureStore(pool,
+ "fs:user:",
+ 24L * 60L * 60L, // whole-entity TTL aligned with the daily batch cycle
+ 5L * 60L // per-field TTL on each streaming feature
+);
+
+// Batch materialization: one HSET + EXPIRE per user, all pipelined.
+Map> rows = Map.of(
+ "u0001", Map.of(
+ "country_iso", "US", "risk_segment", "low",
+ "tx_count_7d", 14, "avg_amount_30d", 92.40,
+ "account_age_days", 612, "chargeback_count_180d", 0),
+ "u0002", Map.of(
+ "country_iso", "GB", "risk_segment", "medium",
+ "tx_count_7d", 47, "avg_amount_30d", 220.10,
+ "account_age_days", 1840, "chargeback_count_180d", 1));
+store.bulkLoad(rows);
+
+// Streaming write: HSET + HEXPIRE on just the fields that changed.
+store.updateStreaming("u0001", Map.of(
+ "last_login_ts", System.currentTimeMillis(),
+ "last_device_id", "ios-9f02",
+ "tx_count_5m", 3,
+ "failed_logins_15m", 0,
+ "session_country", "US"));
+
+// Inference read: HMGET of whatever the model needs.
+Map features = store.getFeatures("u0001", List.of(
+ "risk_segment", "tx_count_7d", "avg_amount_30d",
+ "tx_count_5m", "failed_logins_15m"));
+
+// Batch scoring: pipelined HMGET across many users.
+Map> batch = store.batchGetFeatures(
+ List.of("u0001", "u0002", "u0003"),
+ List.of("risk_segment", "tx_count_5m", "failed_logins_15m"));
+```
+
+### Project layout
+
+The four `.java` files and the `pom.xml` live in the same directory — the
+`build-helper-maven-plugin` adds the project root as the source directory so
+the Java sources sit alongside the build descriptor. Run with:
+
+```bash
+mvn package
+mvn exec:java -Dexec.mainClass=DemoServer
+```
+
+### Data model
+
+Each user is one Redis Hash. Every value is stored as a string — Redis hash
+fields are bytes on the wire, so the helper encodes booleans as `"true"` /
+`"false"` (`encodeValue(Object)` in `FeatureStore.java`) and renders
+everything else with `Object.toString()`. The model server is responsible for
+parsing back to the right type, the same way it would when reading any
+serialized feature store.
+
+```text
+fs:user:u0001 TTL = 86400 s (key-level)
+ country_iso=US
+ risk_segment=low
+ account_age_days=612
+ tx_count_7d=14
+ avg_amount_30d=92.40
+ chargeback_count_180d=0
+ last_login_ts=1716998413541 TTL = 300 s (per field, HEXPIRE)
+ last_device_id=ios-9f02 TTL = 300 s (per field, HEXPIRE)
+ tx_count_5m=3 TTL = 300 s (per field, HEXPIRE)
+ failed_logins_15m=0 TTL = 300 s (per field, HEXPIRE)
+ session_country=US TTL = 300 s (per field, HEXPIRE)
+```
+
+The batch fields sit under the key-level `EXPIRE`. The streaming fields each
+carry their own [`HEXPIRE`]({{< relref "/commands/hexpire" >}}). If the
+streaming pipeline stops, the streaming fields drop one by one as their
+per-field TTLs elapse; the batch fields stay until the daily key-level
+`EXPIRE` fires (or the next batch cycle re-pins them).
+
+### Bulk-loading batch features
+
+`bulkLoad` queues one `HSET` and one `EXPIRE` per user into a single
+`Pipeline` and calls `sync()` to ship the lot. With 500 users that's 1000
+commands in one network call — Redis processes them sequentially on the
+server side but the client only pays one RTT.
+
+```java
+public int bulkLoad(Map> rows, long ttlSeconds) {
+ if (rows.isEmpty()) return 0;
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ for (Map.Entry> e : rows.entrySet()) {
+ String key = keyFor(e.getKey());
+ Map encoded = encode(e.getValue());
+ pipe.hset(key, encoded);
+ pipe.expire(key, ttlSeconds);
+ }
+ pipe.sync();
+ }
+ ...
+}
+```
+
+Jedis's `pipelined()` is a non-transactional batch: commands queue up and
+ship in one round trip, but they don't run inside a `MULTI/EXEC` block.
+That's the right choice here because each user's `HSET` + `EXPIRE` pair is
+independent of every other user's, and an all-or-nothing transaction would
+block the server for the duration of the batch. For the rare case where the
+pair has to be inseparable (a server crash between the two would leave the
+entity without a key-level TTL) you'd wrap each user in a `Transaction` or a
+[Lua script]({{< relref "/develop/programmability/eval-intro" >}}); for a
+daily ingestion job that runs end-to-end every cycle, the next run re-pins
+the TTL — no extra machinery needed.
+
+In production, the equivalent of this script runs as an offline pipeline (a
+Spark or Feast `materialize` job) that reads from the warehouse and writes
+into Redis. The
+[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)
+provider does exactly this under the hood; the in-house
+[Redis Feature Form]({{< relref "/develop/ai/featureform" >}}) integration
+covers the materialize + serve path end-to-end.
+
+### Streaming writes with per-field TTL
+
+`updateStreaming` is the linchpin of the mixed-staleness story:
+
+```java
+public void updateStreaming(String entityId, Map fields, long ttlSeconds) {
+ if (fields.isEmpty()) return;
+ String key = keyFor(entityId);
+ Map encoded = encode(fields);
+ String[] names = encoded.keySet().toArray(new String[0]);
+
+ List expireCodes;
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ pipe.hset(key, encoded);
+ Response> expireResp = pipe.hexpire(key, ttlSeconds, names);
+ pipe.sync();
+ expireCodes = expireResp.get();
+ }
+ for (Long code : expireCodes) {
+ if (code == null || code != 1L) {
+ throw new IllegalStateException(
+ "HEXPIRE did not set every field TTL for " + key + ": " + expireCodes);
+ }
+ }
+ ...
+}
+```
+
+[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on *individual*
+hash fields, not on the whole key. The two commands are queued in one
+`Pipeline` and Redis runs them in order: the `HSET` first creates or
+overwrites the fields, then `HEXPIRE` attaches a TTL to each of those same
+fields. `HEXPIRE` returns one status code per field — `1` if the TTL was
+set, `2` if the expiry was 0 or in the past (so Redis deleted the field
+instead), `0` if an `NX | XX | GT | LT` conditional flag was set and not met
+(we never use one here), `-2` if the field doesn't exist on the key. The
+helper throws if any code is anything other than `1`, so the "every
+streaming write renews its TTL" invariant fails loudly rather than silently
+leaving a streaming field with no expiry attached.
+
+`Response>` is Jedis's deferred-result wrapper for pipelined
+commands: queue the command, call `pipe.sync()` to ship the batch, then read
+each result via `.get()`. The `Response` for `hexpire` returns the per-field
+codes; that list is what the helper validates above.
+
+If a streaming pipeline stops, the streaming fields drop out one by one as
+their per-field TTLs elapse — there is no application-side cleaner involved.
+[`HTTL`]({{< relref "/commands/httl" >}}) lets the model side inspect the
+remaining TTL on any field, which is useful both for debugging ("why is this
+feature missing?" → "it expired three seconds ago") and as a freshness signal
+in the model itself.
+
+> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level TTL
+> commands (`HTTL`, `HPERSIST`, `HEXPIREAT`, `HPEXPIRE`, `HPEXPIREAT`,
+> `HPTTL`, `HEXPIRETIME`, `HPEXPIRETIME`) were added in Redis 7.4. Jedis 5.2
+> was the first release with the bindings; the demo's `pom.xml` pins 6.2.
+> On older Redis builds you would have to put streaming features on their
+> own keys (one key per feature, or one key per feature group) and set a
+> key-level `EXPIRE` instead — at the cost of giving up the single-`HMGET`
+> retrieval.
+
+### Inference reads with HMGET
+
+`getFeatures` is one `HMGET`:
+
+```java
+public Map getFeatures(String entityId, List fieldNames) {
+ String key = keyFor(entityId);
+ Map out = new LinkedHashMap<>();
+ if (fieldNames == null) {
+ try (Jedis jedis = pool.getResource()) {
+ Map all = jedis.hgetAll(key);
+ if (all != null) out.putAll(all);
+ }
+ return out;
+ }
+ if (fieldNames.isEmpty()) return out;
+ List values;
+ try (Jedis jedis = pool.getResource()) {
+ values = jedis.hmget(key, fieldNames.toArray(new String[0]));
+ }
+ for (int i = 0; i < fieldNames.size(); i++) {
+ String v = values.get(i);
+ if (v != null) out.put(fieldNames.get(i), v);
+ }
+ return out;
+}
+```
+
+The model knows exactly which features it consumes, so the request path
+always takes the `HMGET` branch with an explicit field list — that's the
+sub-millisecond path. `HGETALL` is the right call for debugging (which is
+what the demo's "Inspect" panel does) but not for serving: it forces Redis
+to serialize every field, including ones the model doesn't need.
+
+Fields that don't exist (because they were never written, or because they
+expired) come back as `null` in the `List` Jedis returns. The helper
+drops them from the result `Map` so the caller sees only the features that
+are actually available. A real model server would either treat missing
+values as a feature ("this user has no streaming signal yet") or fall back
+to a default from the model's training data.
+
+### Batch scoring with pipelined HMGET
+
+For batch inference, the same `HMGET` shape pipelines across users:
+
+```java
+public Map> batchGetFeatures(
+ List entityIds, List fieldNames) {
+ if (entityIds.isEmpty() || fieldNames.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ String[] names = fieldNames.toArray(new String[0]);
+ List>> responses = new ArrayList<>(entityIds.size());
+ try (Jedis jedis = pool.getResource()) {
+ Pipeline pipe = jedis.pipelined();
+ for (String id : entityIds) {
+ responses.add(pipe.hmget(keyFor(id), names));
+ }
+ pipe.sync();
+ }
+ Map> out = new LinkedHashMap<>();
+ for (int i = 0; i < entityIds.size(); i++) {
+ List values = responses.get(i).get();
+ Map row = new LinkedHashMap<>();
+ for (int j = 0; j < fieldNames.size(); j++) {
+ String v = values.get(j);
+ if (v != null) row.put(fieldNames.get(j), v);
+ }
+ out.put(entityIds.get(i), row);
+ }
+ return out;
+}
+```
+
+One round trip for the whole batch — the demo regularly returns 30 users in
+~1 ms against a local Redis. On a real network the round trip dominates;
+pipelining is what keeps batch scoring practical.
+
+A Redis Cluster is different: a single `Pipeline.sync()` is bound to one
+shard, because cross-slot pipelines on a cluster connection don't make sense.
+For batch reads on a cluster, use
+[`JedisCluster`]({{< relref "/develop/clients/jedis" >}}) and either fan out
+parallel `hmget` calls (the cluster client routes per-shard for you) or, for
+tighter control, group the IDs by hash slot ahead of time and issue one
+`Pipeline.sync()` against each shard's connection in parallel. A hash tag
+like `fs:user:{vip}:u0001` forces a known set of keys onto the same shard so
+one pipeline can cover all of them in a single round trip.
+
+## The streaming worker
+
+`StreamingWorker.java` is the demo's stand-in for whatever Flink, Kafka
+Streams, or bespoke service computes the real-time features
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java)).
+It runs as a daemon `Thread` next to the demo server so the UI can start,
+pause, and resume it; in production this code would live in the streaming
+layer.
+
+Every tick the worker picks a few random users, generates a new value for
+each streaming feature, and calls `store.updateStreaming(userId, fields)`.
+The demo defaults to 5 users per tick at 1-second intervals — so a 200-user
+store sees roughly half its users refreshed in the first minute, and most
+after a few minutes. Raise `--users-per-tick` or drop `--seed-users` if
+you'd rather touch every user quickly.
+
+```java
+private void doTick() {
+ List ids = store.listEntityIds(500);
+ if (ids.isEmpty()) return;
+ List picks = sample(ids, usersPerTick);
+ long nowMs = System.currentTimeMillis();
+ for (String id : picks) {
+ Map fields = new LinkedHashMap<>();
+ fields.put("last_login_ts", nowMs);
+ fields.put("last_device_id", choice(DEVICE_IDS));
+ fields.put("tx_count_5m", intn(13));
+ fields.put("failed_logins_15m", weightedInt(FAILED_LOGIN_BUCKETS, FAILED_LOGIN_WEIGHTS));
+ fields.put("session_country", choice(SESSION_COUNTRIES));
+ store.updateStreaming(id, fields);
+ }
+ ...
+}
+```
+
+Pausing the worker is what shows off the mixed-staleness behavior: leave it
+paused for longer than `streamingTtlSeconds` and the streaming fields
+disappear from every user's hash one by one, while the batch fields remain
+under the longer key-level `EXPIRE`. The demo's `Pause / resume` button lets
+you see this happen in real time.
+
+`pause()` only blocks *future* ticks from running — the thread checks the
+flag at the top of the loop and skips its turn. A reset that's about to
+`DEL` every key needs to wait out an already-running tick too, which is
+what `waitForIdle()` is for: the demo's `Reset` handler calls
+`worker.pause()` *and* `worker.waitForIdle()` before it issues the `DEL`
+sweep, so a mid-flight tick can't recreate a user under a streaming-only
+hash with no key-level TTL.
+
+## The batch builder
+
+`BuildFeatures.java` is the demo's nightly materializer
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/java-jedis/BuildFeatures.java)).
+It generates synthetic feature rows and calls `store.bulkLoad` once. The
+synthesis itself is not the point — in a real deployment the equivalent code
+reads from the offline store (Snowflake, BigQuery, Iceberg) and writes the
+resulting hashes into Redis.
+
+```java
+public static Map> synthesizeUsers(int count, long seed) {
+ Random rng = new Random(seed);
+ Map> users = new LinkedHashMap<>(count);
+ for (int i = 1; i <= count; i++) {
+ String uid = String.format("u%04d", i);
+ Map row = new LinkedHashMap<>();
+ row.put("country_iso", COUNTRY_CHOICES.get(rng.nextInt(COUNTRY_CHOICES.size())));
+ row.put("risk_segment", weightedChoice(rng, RISK_SEGMENTS, RISK_WEIGHTS));
+ row.put("account_age_days", 7 + rng.nextInt(2394));
+ row.put("tx_count_7d", rng.nextInt(81));
+ row.put("avg_amount_30d", Math.round((5.0 + rng.nextDouble() * 345.0) * 100.0) / 100.0);
+ row.put("chargeback_count_180d", weightedChoiceInt(rng, CHARGEBACK_BUCKETS, CHARGEBACK_WEIGHTS));
+ users.put(uid, row);
+ }
+ return users;
+}
+```
+
+You can run the builder on its own (independently of the demo server) to
+populate Redis from the command line:
+
+```bash
+mvn exec:java -Dexec.mainClass=BuildFeatures -Dexec.args="--count 500 --ttl-seconds 3600"
+```
+
+That writes 500 users at `fs:user:*` with a one-hour key-level TTL, which is
+how a typical operator would pre-seed a feature store from the command line
+when debugging.
+
+## The interactive demo
+
+`DemoServer.java` runs the JDK `HttpServer` on port 8088 with a fixed thread
+pool. The HTML page lets you:
+
+* **Bulk-load** any number of users (default 200) with a configurable
+ key-level TTL. Drop the TTL to 30 s and watch the entire store expire on
+ schedule — the same thing that happens if a daily refresher fails.
+* See the **store state** at a glance: user count, batch / streaming TTLs,
+ cumulative read/write counters.
+* See the **streaming worker** status (running / paused, ticks completed,
+ writes performed) and **pause or resume** it. Leave it paused for longer
+ than the streaming TTL to watch streaming fields drop out.
+* Run an **inference read** for any user with a chosen feature subset, and
+ see the value, the per-field TTL, and the read latency.
+* Run **batch scoring** with a pipelined `HMGET` across `N` users and see
+ the total elapsed time plus the per-user breakdown.
+* **Inspect** any user's full hash with field-level TTLs and the key-level
+ TTL — the right view for debugging "why is this feature missing?" at
+ read time.
+
+The server holds one `FeatureStore` and one `StreamingWorker` for the
+lifetime of the process, plus a `JedisPool` that all handlers borrow
+connections from. Endpoints:
+
+| Endpoint | What it does |
+|---------------------------|-------------------------------------------------------------------------------------|
+| `GET /state` | User count, TTL config, stats counters, worker status. |
+| `POST /bulk-load` | Pipelined `HSET` + `EXPIRE` over N synthetic users with a chosen TTL. |
+| `POST /worker/toggle` | Pause / resume the streaming worker. |
+| `POST /read` | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs. |
+| `POST /batch-read` | Pipeline `HMGET` across N users; report total latency and per-entity field counts. |
+| `GET /inspect` | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs. |
+| `POST /reset` | Drop every user under the key prefix (used by the demo's reset button). |
+
+## Prerequisites
+
+* **Redis 7.4 or later.** [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) were added in Redis 7.4; the
+ demo relies on per-field TTL for the mixed-staleness story.
+* **Java 17 or later.** The demo uses switch expressions with arrow
+ labels (`case "..." -> ...`), records, and text blocks.
+* **Jedis 5.2 or later.** The demo's `pom.xml` pins
+ `redis.clients:jedis:6.2.0`. Field-level TTL bindings (`hexpire`, `httl`,
+ `hpersist`) ship from Jedis 5.2.
+
+If your Redis server is running elsewhere, start the demo with `--redis-host`
+and `--redis-port`.
+
+## Running the demo
+
+### Get the source files
+
+The demo lives in a small Maven project under
+[`feature-store/java-jedis`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/java-jedis).
+Clone the repo or copy the directory:
+
+```bash
+git clone https://github.com/redis/docs.git
+cd docs/content/develop/use-cases/feature-store/java-jedis
+mvn package
+```
+
+### Start the demo server
+
+From the project directory:
+
+```bash
+mvn exec:java -Dexec.mainClass=DemoServer
+```
+
+You should see:
+
+```text
+Dropping any existing users under 'fs:user:*' for a clean demo run (pass --no-reset to keep them).
+Redis feature-store demo server listening on http://127.0.0.1:8088
+Using Redis at localhost:6379 with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)
+Materialized 200 user(s); streaming worker running.
+```
+
+By default the demo wipes the configured key prefix on startup so each run
+starts from a clean state. Pass `--no-reset` to keep any existing data, or
+`--key-prefix ` to point the demo at a different prefix entirely.
+Maven exec passes CLI args via `-Dexec.args`:
+
+```bash
+mvn exec:java -Dexec.mainClass=DemoServer \
+ -Dexec.args="--port 9000 --streaming-ttl-seconds 30"
+```
+
+Open [http://127.0.0.1:8088](http://127.0.0.1:8088) in a browser. Useful
+things to try:
+
+* Pick a user and click **Read features** with a mixed batch/streaming
+ subset — you'll see batch fields with no per-field TTL (covered by the
+ key-level TTL) and streaming fields with a positive per-field TTL.
+* Click **Pipeline HMGET** with `count=100` to see the latency of a
+ 100-user batch read.
+* Click **Pause / resume** on the streaming worker and leave it paused for
+ ~5 minutes (or restart the server with `--streaming-ttl-seconds 30` to
+ make it visible in seconds). Re-run **Read features** on any user and
+ watch the streaming fields disappear while the batch fields stay.
+* Click **Inspect** on a user to see the full hash with field-level TTLs.
+* Click **Bulk-load** with a short TTL (say 30 seconds) and watch the user
+ count fall to zero on the next minute — the same thing that happens if a
+ daily batch run fails to land.
+* Click **Reset** to drop every user and start over.
+
+The server is read/write against your local Redis. The default key prefix
+is `fs:user:`. Pass `--no-reset` to keep existing data across restarts, or
+`--redis-host` / `--redis-port` to point at a different Redis.
+
+## Production usage
+
+The guidance below focuses on the production concerns that are specific to
+running a feature store on Redis. For the generic Jedis production checklist
+— `JedisPool` sizing, AUTH/ACL, retry policy, sentinel/cluster failover —
+see the
+[Jedis production usage guide]({{< relref "/develop/clients/jedis/produsage" >}}).
+For TLS specifically, follow the
+[connect-with-TLS recipe]({{< relref "/develop/clients/jedis/connect#connect-to-your-production-redis-with-tls" >}}).
+The feature-store demo runs against `localhost` with the defaults; a real
+deployment should harden the client first.
+
+### Pick the batch TTL to outlast a failed refresher
+
+The whole-entity `EXPIRE` is your safety net against silent staleness from a
+broken batch pipeline. Set it longer than your worst-case batch outage so a
+single missed run doesn't take the feature store offline, but short enough
+that a sustained outage causes loud failures (missing entities) rather than
+quiet ones (yesterday's features being scored as today's). The standard
+choice is one cycle of "expected refresh interval × 2" — for a daily batch,
+48 hours; for a 6-hour batch, 12 hours.
+
+The same logic applies to the per-field streaming TTL: a few times the
+expected update interval so a slow-but-alive streaming worker doesn't churn
+features needlessly, but short enough that a stalled worker causes visible
+freshness failures.
+
+### Co-locate the online store with serving, not with training
+
+The online store's hash representation does *not* have to match the schema
+in your offline store. The batch materialization step is your chance to
+flatten joins, encode categoricals, and project to whatever shape the model
+server wants — so the request path is exactly one `HMGET` and zero
+transforms.
+
+The training pipeline reads from the offline store with its own schema; the
+serving pipeline reads from Redis with the flattened serving schema.
+Keeping those two pipelines as the same code path is what prevents
+training-serving skew.
+
+### Pipeline batch reads across shards
+
+On a single Redis instance, `Pipeline.sync()` across `N` `hmget` calls is
+one round trip. A Redis Cluster is different: a single `Pipeline.sync()` is
+bound to one shard, because cross-slot pipelines on a cluster connection
+don't make sense, and the keys for a typical user batch will land on
+multiple shards. For batch reads on a cluster, use
+[`JedisCluster`]({{< relref "/develop/clients/jedis" >}}) — its
+implementation routes per-shard for you. For tighter control, group the IDs
+by hash slot ahead of time and issue one `Pipeline.sync()` per shard's
+connection in parallel. For a small number of frequently-queried users (a
+top-N customer list, for example), a hash tag like `fs:user:{vip}:u0001`
+forces a known set of keys onto the same shard so one pipeline can cover
+all of them in a single round trip.
+
+### Make HEXPIRE part of every streaming write
+
+The single biggest correctness lever in this design is that the streaming
+write applies `HEXPIRE` *every time*. If a streaming worker writes a field
+without renewing its TTL, the field carries whatever expiry was there before
+— possibly none, possibly stale — and the mixed-staleness invariant breaks.
+Keep the `HSET` and `HEXPIRE` in the same pipeline (or, even safer, in the
+same [Lua script]({{< relref "/develop/programmability/eval-intro" >}}) if
+you don't trust the call site).
+
+### Avoid HGETALL on the request path
+
+`HGETALL` reads every field on the hash, including ones the model doesn't
+need. With dozens of features per entity, that is wasted serialization work
+on the server and wasted bandwidth on the wire. Always specify the field
+list explicitly with `hmget` in the model server.
+
+The exception is debugging and feature-set discovery, where you genuinely
+want the full hash. The demo's "Inspect" button uses `hgetAll` for exactly
+this reason.
+
+### Size the JedisPool for the request shape
+
+Every `FeatureStore` helper method borrows a connection from the
+`JedisPool` for the duration of one Redis call (or one `Pipeline.sync()`)
+and returns it via the try-with-resources block. One HTTP handler can
+therefore borrow several connections sequentially — `/read`, for example,
+makes one `hmget` call, one `httl` call, and one `ttl` call, each of
+which is its own borrow.
+
+The demo uses `maxTotal=64`. In production, size `maxTotal` to comfortably
+exceed your peak concurrent borrow count: that's roughly
+`(concurrent HTTP handlers × Redis calls per handler in flight at once) +
+(background worker borrow rate)`. Setting it too low forces some borrows
+to block waiting for a returned connection — a slow read-side cliff that
+doesn't show up under load tests with very few clients.
+
+### Inspect the store directly with redis-cli
+
+When testing or troubleshooting, the cli tells you everything:
+
+```bash
+# How many users currently in the store
+redis-cli --scan --pattern 'fs:user:*' | wc -l
+
+# One user's full hash and key-level TTL
+redis-cli HGETALL fs:user:u0001
+redis-cli TTL fs:user:u0001
+
+# Per-field TTL on the streaming fields
+redis-cli HTTL fs:user:u0001 FIELDS 5 \
+ last_login_ts last_device_id tx_count_5m failed_logins_15m session_country
+
+# Sample HMGET as the model would issue it
+redis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m
+```
+
+A streaming field that returns `-2` from `HTTL` doesn't exist on the hash
+(either it was never written, or it expired); `-1` means the field has no
+TTL set (and is therefore covered only by the key-level `EXPIRE`); any
+positive value is the remaining TTL in seconds.
+
+## Learn more
+
+This example uses the following Redis commands:
+
+* [`HSET`]({{< relref "/commands/hset" >}}) to write a feature or a whole
+ feature row in one call.
+* [`HMGET`]({{< relref "/commands/hmget" >}}) to retrieve any subset of
+ features for one entity in one round trip.
+* [`HGETALL`]({{< relref "/commands/hgetall" >}}) for debugging and
+ feature-set discovery.
+* [`HEXPIRE`]({{< relref "/commands/hexpire" >}}) and
+ [`HTTL`]({{< relref "/commands/httl" >}}) for per-field TTL on streaming
+ features (Redis 7.4+).
+* [`EXPIRE`]({{< relref "/commands/expire" >}}) and
+ [`TTL`]({{< relref "/commands/ttl" >}}) for the whole-entity TTL aligned
+ with the batch materialization cycle.
+* Pipelined `HMGET` across many entities for batch scoring with one network
+ round trip — see
+ [transactions and pipelining]({{< relref "/develop/clients/jedis/transpipe" >}}).
+
+See the [Jedis documentation]({{< relref "/develop/clients/jedis" >}}) for
+the full client reference, and the
+[Hashes overview]({{< relref "/develop/data-types/hashes" >}}) for the deeper
+conceptual model — including the listpack encoding that makes small hashes
+particularly compact in memory, which matters at feature-store scale.
diff --git a/content/develop/use-cases/feature-store/java-jedis/pom.xml b/content/develop/use-cases/feature-store/java-jedis/pom.xml
new file mode 100644
index 0000000000..fbed81d6dd
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/pom.xml
@@ -0,0 +1,87 @@
+
+
+
+ 4.0.0
+
+ com.redis.docs
+ feature-store-jedis
+ 0.1.0
+ jar
+
+
+ 17
+ UTF-8
+
+
+
+
+
+ redis.clients
+ jedis
+ 6.2.0
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+ 3.6.0
+
+
+ add-source
+ generate-sources
+ add-source
+
+
+ ${project.basedir}
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 17
+
+ *.java
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.5.0
+
+
+
+
+ ${project.basedir}
+
+
diff --git a/content/develop/use-cases/feature-store/java-lettuce/BuildFeatures.java b/content/develop/use-cases/feature-store/java-lettuce/BuildFeatures.java
new file mode 100644
index 0000000000..ae89261231
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-lettuce/BuildFeatures.java
@@ -0,0 +1,115 @@
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.api.StatefulRedisConnection;
+
+/**
+ * Synthesize a small batch of users with realistic-looking features
+ * and bulk-load them into Redis with a 24-hour key-level TTL.
+ *
+ *
Stands in for the nightly Spark / Feast materialization job in a
+ * real deployment. In production the equivalent of this script lives
+ * in an offline pipeline that reads from the offline store and writes
+ * the serving-time hashes into Redis via {@code HSET} + {@code EXPIRE}.
+ *
+ *
Run with: {@code mvn exec:java -Dexec.mainClass=BuildFeatures -Dexec.args="--count 500"}
+ */
+public class BuildFeatures {
+
+ private static final List COUNTRY_CHOICES = List.of(
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL");
+ private static final List RISK_SEGMENTS = List.of("low", "medium", "high");
+ private static final int[] RISK_WEIGHTS = {70, 25, 5};
+ private static final int[] CHARGEBACK_BUCKETS = {0, 1, 2, 3};
+ private static final int[] CHARGEBACK_WEIGHTS = {85, 10, 4, 1};
+
+ public static Map> synthesizeUsers(int count, long seed) {
+ Random rng = new Random(seed);
+ Map> users = new LinkedHashMap<>(count);
+ for (int i = 1; i <= count; i++) {
+ String uid = String.format("u%04d", i);
+ Map row = new LinkedHashMap<>();
+ row.put("country_iso", COUNTRY_CHOICES.get(rng.nextInt(COUNTRY_CHOICES.size())));
+ row.put("risk_segment", weightedChoice(rng, RISK_SEGMENTS, RISK_WEIGHTS));
+ row.put("account_age_days", 7 + rng.nextInt(2394));
+ row.put("tx_count_7d", rng.nextInt(81));
+ row.put("avg_amount_30d", Math.round((5.0 + rng.nextDouble() * 345.0) * 100.0) / 100.0);
+ row.put("chargeback_count_180d", weightedChoiceInt(rng, CHARGEBACK_BUCKETS, CHARGEBACK_WEIGHTS));
+ users.put(uid, row);
+ }
+ return users;
+ }
+
+ public static void main(String[] args) {
+ String redisUri = "redis://localhost:6379";
+ int count = 200;
+ long ttlSeconds = 24L * 60L * 60L;
+ String keyPrefix = "fs:user:";
+ long seed = 42L;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--redis-uri" -> redisUri = args[++i];
+ case "--count" -> count = Integer.parseInt(args[++i]);
+ case "--ttl-seconds" -> ttlSeconds = Long.parseLong(args[++i]);
+ case "--key-prefix" -> keyPrefix = args[++i];
+ case "--seed" -> seed = Long.parseLong(args[++i]);
+ case "-h", "--help" -> {
+ System.out.println(
+ "Usage: mvn exec:java -Dexec.mainClass=BuildFeatures " +
+ "-Dexec.args=\"[--redis-uri URI] [--count N] " +
+ "[--ttl-seconds S] [--key-prefix PREFIX] [--seed N]\"");
+ return;
+ }
+ default -> {
+ System.err.println("Unknown argument: " + args[i]);
+ System.exit(2);
+ }
+ }
+ }
+
+ RedisClient client = RedisClient.create(redisUri);
+ // A one-shot CLI doesn't have concurrent callers, but the
+ // FeatureStore helper still expects a dedicated pipeline
+ // connection for its batched paths — open one and let
+ // try-with-resources close both at the end.
+ try (StatefulRedisConnection conn = client.connect();
+ StatefulRedisConnection pipelineConn = client.connect()) {
+ FeatureStore store = new FeatureStore(conn, pipelineConn,
+ keyPrefix, ttlSeconds,
+ FeatureStore.DEFAULT_STREAMING_TTL_SECONDS);
+ Map> rows = synthesizeUsers(count, seed);
+ int loaded = store.bulkLoad(rows, ttlSeconds);
+ System.out.printf(
+ "Materialized %d users at %s* with a %ds key-level TTL.%n",
+ loaded, keyPrefix, ttlSeconds);
+ } finally {
+ client.shutdown();
+ }
+ }
+
+ private static String weightedChoice(Random rng, List items, int[] weights) {
+ int total = 0;
+ for (int w : weights) total += w;
+ int r = rng.nextInt(total);
+ for (int i = 0; i < items.size(); i++) {
+ r -= weights[i];
+ if (r < 0) return items.get(i);
+ }
+ return items.get(items.size() - 1);
+ }
+
+ private static int weightedChoiceInt(Random rng, int[] items, int[] weights) {
+ int total = 0;
+ for (int w : weights) total += w;
+ int r = rng.nextInt(total);
+ for (int i = 0; i < items.length; i++) {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ }
+}
diff --git a/content/develop/use-cases/feature-store/java-lettuce/DemoServer.java b/content/develop/use-cases/feature-store/java-lettuce/DemoServer.java
new file mode 100644
index 0000000000..86ae61ab3d
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-lettuce/DemoServer.java
@@ -0,0 +1,1040 @@
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.ReentrantLock;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.api.StatefulRedisConnection;
+
+/**
+ * Redis feature-store demo server (Lettuce + JDK HttpServer).
+ *
+ *
Run with {@code mvn exec:java -Dexec.mainClass=DemoServer} and
+ * visit {@code http://localhost:8089} to watch an online feature
+ * store at work: a batch materialization loads N users with a 24-hour
+ * key-level TTL, a streaming worker overwrites a handful of users'
+ * real-time features every second with a per-field {@code HEXPIRE},
+ * and the inference panel reads any subset of features for any user
+ * with {@code HMGET} in a single round trip.
+ *
+ *
The Lettuce demo shares a single {@code StatefulRedisConnection}
+ * across the HTTP thread pool and the streaming worker — Lettuce
+ * connections are thread-safe and multiplexed, so no pool is
+ * required for this workload. See the walkthrough for when to add
+ * one anyway (blocking commands, very high contention).
+ */
+public class DemoServer {
+
+ private static FeatureStore store;
+ private static StreamingWorker worker;
+ private static FeatureStoreDemo demo;
+ private static RedisClient redisClient;
+ /** Shared connection for non-pipelined reads (multiplexed across the HTTP pool). */
+ private static StatefulRedisConnection redisConn;
+ /** Dedicated connection for the pipelined batched paths (auto-flush toggled). */
+ private static StatefulRedisConnection redisPipelineConn;
+
+ public static void main(String[] args) throws Exception {
+ String host = "127.0.0.1";
+ int port = 8089;
+ String redisUri = "redis://localhost:6379";
+ String keyPrefix = "fs:user:";
+ long batchTtlSeconds = 24L * 60L * 60L;
+ long streamingTtlSeconds = 5L * 60L;
+ int usersPerTick = 5;
+ int seedUsers = 200;
+ boolean resetOnStart = true;
+
+ for (int i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case "--host" -> host = args[++i];
+ case "--port" -> port = Integer.parseInt(args[++i]);
+ case "--redis-uri" -> redisUri = args[++i];
+ case "--key-prefix" -> keyPrefix = args[++i];
+ case "--batch-ttl-seconds" -> batchTtlSeconds = Long.parseLong(args[++i]);
+ case "--streaming-ttl-seconds" -> streamingTtlSeconds = Long.parseLong(args[++i]);
+ case "--users-per-tick" -> usersPerTick = Integer.parseInt(args[++i]);
+ case "--seed-users" -> seedUsers = Integer.parseInt(args[++i]);
+ case "--no-reset" -> resetOnStart = false;
+ case "-h", "--help" -> {
+ System.out.println(
+ "Usage: mvn exec:java -Dexec.mainClass=DemoServer " +
+ "-Dexec.args=\"[--host H] [--port P] [--redis-uri URI] " +
+ "[--key-prefix PFX] " +
+ "[--batch-ttl-seconds S] [--streaming-ttl-seconds S] " +
+ "[--users-per-tick N] [--seed-users N] [--no-reset]\"");
+ return;
+ }
+ default -> {
+ System.err.println("Unknown argument: " + args[i]);
+ System.exit(2);
+ }
+ }
+ }
+
+ redisClient = RedisClient.create(redisUri);
+ // Two connections: the first is multiplexed across the HTTP
+ // thread pool and the streaming worker for ordinary
+ // (auto-flushed) commands; the second is reserved for the
+ // pipelined batched paths in FeatureStore so the auto-flush
+ // toggle never races with another caller's reads.
+ redisConn = redisClient.connect();
+ redisPipelineConn = redisClient.connect();
+
+ store = new FeatureStore(redisConn, redisPipelineConn, keyPrefix,
+ batchTtlSeconds, streamingTtlSeconds);
+ worker = new StreamingWorker(store, 1000L, usersPerTick, 1337L);
+ demo = new FeatureStoreDemo(store, worker, 42L);
+
+ if (resetOnStart) {
+ System.out.printf(
+ "Dropping any existing users under '%s*' for a clean demo run (pass --no-reset to keep them).%n",
+ keyPrefix);
+ store.reset();
+ store.resetStats();
+ }
+ int seeded = demo.materialize(seedUsers, batchTtlSeconds).loaded();
+ worker.start();
+
+ HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0);
+ server.createContext("/", new RootHandler());
+ server.createContext("/state", new StateHandler());
+ server.createContext("/inspect", new InspectHandler());
+ server.createContext("/bulk-load", new BulkLoadHandler());
+ server.createContext("/reset", new ResetHandler());
+ server.createContext("/worker/toggle", new ToggleWorkerHandler());
+ server.createContext("/read", new ReadHandler());
+ server.createContext("/batch-read", new BatchReadHandler());
+ server.setExecutor(Executors.newFixedThreadPool(16));
+ server.start();
+
+ System.out.printf("Redis feature-store demo server listening on http://%s:%d%n", host, port);
+ System.out.printf(
+ "Using Redis at %s with key prefix '%s' (batch TTL %ds, streaming TTL %ds)%n",
+ redisUri, keyPrefix, batchTtlSeconds, streamingTtlSeconds);
+ System.out.printf("Materialized %d user(s); streaming worker running.%n", seeded);
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ System.out.println("\nShutting down...");
+ worker.stop();
+ server.stop(0);
+ redisConn.close();
+ redisPipelineConn.close();
+ redisClient.shutdown();
+ }));
+
+ Thread.currentThread().join();
+ }
+
+ // ---------------------------------------------------------------
+ // FeatureStoreDemo wires the store and worker with the lifecycle
+ // operations the HTTP handlers call into.
+ // ---------------------------------------------------------------
+
+ static class FeatureStoreDemo {
+ private final FeatureStore store;
+ private final StreamingWorker worker;
+ private final long seed;
+ private final ReentrantLock lock = new ReentrantLock();
+
+ FeatureStoreDemo(FeatureStore store, StreamingWorker worker, long seed) {
+ this.store = store;
+ this.worker = worker;
+ this.seed = seed;
+ }
+
+ public record MaterializeResult(int loaded, long ttlSeconds, double elapsedMs) {}
+
+ public MaterializeResult materialize(int count, long ttlSeconds) {
+ lock.lock();
+ try {
+ Map> rows = BuildFeatures.synthesizeUsers(count, seed);
+ long t0 = System.nanoTime();
+ int loaded = store.bulkLoad(rows, ttlSeconds);
+ double elapsedMs = (System.nanoTime() - t0) / 1_000_000.0;
+ return new MaterializeResult(loaded, ttlSeconds, elapsedMs);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public long reset() {
+ lock.lock();
+ try {
+ // Pause the streaming worker around the DEL sweep so a
+ // concurrent tick can't recreate a user that was just
+ // enumerated for deletion. pause() only blocks
+ // *future* ticks — waitForIdle() flushes an
+ // already-running tick before the DEL sweep starts.
+ boolean wasPaused = worker.isPaused();
+ if (worker.isRunning()) {
+ if (!wasPaused) worker.pause();
+ worker.waitForIdle();
+ }
+ try {
+ long deleted = store.reset();
+ store.resetStats();
+ worker.resetStats();
+ return deleted;
+ } finally {
+ if (worker.isRunning() && !wasPaused) worker.resume();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ public Map toggleWorker() {
+ lock.lock();
+ try {
+ // Three states: stopped → start (and leave unpaused);
+ // running + unpaused → pause; running + paused → resume.
+ // start() clears the paused flag, so a fall-through
+ // would pause the worker we just brought back up.
+ if (!worker.isRunning()) worker.start();
+ else if (worker.isPaused()) worker.resume();
+ else worker.pause();
+ return Map.of(
+ "paused", worker.isPaused(),
+ "running", worker.isRunning()
+ );
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Handlers (identical to the Jedis demo apart from the request URI)
+ // ---------------------------------------------------------------
+
+ static class RootHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!ex.getRequestURI().getPath().equals("/") &&
+ !ex.getRequestURI().getPath().equals("/index.html")) {
+ send(ex, 404, "text/plain", "Not Found");
+ return;
+ }
+ send(ex, 200, "text/html; charset=utf-8", htmlPage());
+ }
+ }
+
+ static class StateHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"GET".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ try {
+ List ids = store.listEntityIds(500);
+ long count = store.countEntities();
+ Map out = new LinkedHashMap<>();
+ out.put("key_prefix", store.getKeyPrefix());
+ out.put("batch_ttl_seconds", store.getBatchTtlSeconds());
+ out.put("streaming_ttl_seconds", store.getStreamingTtlSeconds());
+ out.put("entity_count", count);
+ out.put("entity_ids", ids);
+ out.put("stats", statsToMap(store.stats()));
+ out.put("worker", workerStatsToMap(worker.statsSnapshot()));
+ sendJson(ex, 200, out);
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class InspectHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"GET".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ Map q = parseQuery(ex.getRequestURI());
+ String user = q.getOrDefault("user", "").trim();
+ if (user.isEmpty()) {
+ sendJson(ex, 400, Map.of("error", "user is required")); return;
+ }
+ try {
+ Map full = store.getAllFeatures(user);
+ long keyTTL = store.keyTtlSeconds(user);
+ if (full.isEmpty()) {
+ sendJson(ex, 200, Map.of(
+ "exists", false,
+ "key_ttl_seconds", keyTTL));
+ return;
+ }
+ // Iterate the known schema (batch + streaming) plus
+ // any extras the hash carries. Expired streaming
+ // fields surface as ttl_seconds=-2 in the Inspect
+ // view instead of silently disappearing, which is
+ // exactly the debugging view someone hits "Inspect"
+ // for.
+ List names = new ArrayList<>(FeatureStore.DEFAULT_BATCH_FIELDS);
+ names.addAll(FeatureStore.DEFAULT_STREAMING_FIELDS);
+ for (String n : full.keySet()) {
+ if (!names.contains(n)) names.add(n);
+ }
+ Map ttls = store.fieldTtlsSeconds(user, names);
+ Collections.sort(names);
+ List> fields = new ArrayList<>(names.size());
+ for (String n : names) {
+ Map row = new LinkedHashMap<>();
+ row.put("name", n);
+ row.put("value", full.getOrDefault(n, ""));
+ row.put("ttl_seconds", ttls.getOrDefault(n, -2L));
+ fields.add(row);
+ }
+ sendJson(ex, 200, Map.of(
+ "exists", true,
+ "key_ttl_seconds", keyTTL,
+ "fields", fields));
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class BulkLoadHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ Map form = parseForm(ex);
+ int count = clamp(parseIntOr(form.get("count"), 200), 1, 2000);
+ long ttl = (long) clamp(parseIntOr(form.get("ttl"), 86400), 5, 172_800);
+ try {
+ FeatureStoreDemo.MaterializeResult r = demo.materialize(count, ttl);
+ sendJson(ex, 200, Map.of(
+ "loaded", r.loaded(),
+ "ttl_seconds", r.ttlSeconds(),
+ "elapsed_ms", r.elapsedMs()));
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class ResetHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ try {
+ long deleted = demo.reset();
+ sendJson(ex, 200, Map.of("deleted", deleted));
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class ToggleWorkerHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ sendJson(ex, 200, demo.toggleWorker());
+ }
+ }
+
+ static class ReadHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ Map> form = parseFormMulti(ex);
+ String user = first(form.get("user"), "").trim();
+ if (user.isEmpty()) {
+ sendJson(ex, 400, Map.of("error", "user is required")); return;
+ }
+ List fields = nonEmpty(form.getOrDefault("field", List.of()));
+ try {
+ long t0 = System.nanoTime();
+ Map values = fields.isEmpty()
+ ? Collections.emptyMap()
+ : store.getFeatures(user, fields);
+ double elapsedMs = (System.nanoTime() - t0) / 1_000_000.0;
+ Map ttls = fields.isEmpty()
+ ? Collections.emptyMap()
+ : store.fieldTtlsSeconds(user, fields);
+ long keyTTL = store.keyTtlSeconds(user);
+ Map out = new LinkedHashMap<>();
+ out.put("requested", fields);
+ out.put("values", values);
+ out.put("ttls", ttls);
+ out.put("key_ttl_seconds", keyTTL);
+ out.put("returned_count", values.size());
+ out.put("elapsed_ms", elapsedMs);
+ sendJson(ex, 200, out);
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ static class BatchReadHandler implements HttpHandler {
+ @Override public void handle(HttpExchange ex) throws IOException {
+ if (!"POST".equalsIgnoreCase(ex.getRequestMethod())) {
+ sendJson(ex, 405, Map.of("error", "method not allowed")); return;
+ }
+ Map> form = parseFormMulti(ex);
+ int count = clamp(parseIntOr(first(form.get("count"), "100"), 100), 1, 500);
+ List fields = nonEmpty(form.getOrDefault("field", List.of()));
+ if (fields.isEmpty()) {
+ fields = new ArrayList<>(FeatureStore.DEFAULT_STREAMING_FIELDS);
+ fields.add("risk_segment");
+ }
+ try {
+ List ids = store.listEntityIds(Math.max(count * 2, 2000));
+ if (ids.size() > count) ids = ids.subList(0, count);
+ long t0 = System.nanoTime();
+ Map> rows = store.batchGetFeatures(ids, fields);
+ double elapsedMs = (System.nanoTime() - t0) / 1_000_000.0;
+ int sampleN = Math.min(10, ids.size());
+ List> sample = new ArrayList<>(sampleN);
+ for (int i = 0; i < sampleN; i++) {
+ String id = ids.get(i);
+ Map r = new LinkedHashMap<>();
+ r.put("id", id);
+ r.put("field_count", rows.getOrDefault(id, Collections.emptyMap()).size());
+ sample.add(r);
+ }
+ Map out = new LinkedHashMap<>();
+ out.put("entity_count", ids.size());
+ out.put("field_count", fields.size());
+ out.put("elapsed_ms", elapsedMs);
+ out.put("sample", sample);
+ sendJson(ex, 200, out);
+ } catch (Exception e) {
+ sendJson(ex, 500, Map.of("error", e.getMessage()));
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // HTTP plumbing (mirrors the Jedis demo verbatim)
+ // ---------------------------------------------------------------
+
+ private static void send(HttpExchange ex, int status, String contentType, String body) throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ ex.getResponseHeaders().set("Content-Type", contentType);
+ ex.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
+ }
+
+ private static void sendJson(HttpExchange ex, int status, Object payload) throws IOException {
+ send(ex, status, "application/json", toJson(payload));
+ }
+
+ private static String toJson(Object o) {
+ StringBuilder sb = new StringBuilder();
+ appendJson(sb, o);
+ return sb.toString();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void appendJson(StringBuilder sb, Object o) {
+ if (o == null) { sb.append("null"); return; }
+ if (o instanceof Boolean b) { sb.append(b ? "true" : "false"); return; }
+ if (o instanceof Number n) { sb.append(n.toString()); return; }
+ if (o instanceof Map, ?> m) {
+ sb.append('{');
+ boolean first = true;
+ for (Map.Entry, ?> e : ((Map