From d5e797b909c8f40e7e245cf29aabc3b632101997 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 13:04:50 +0100
Subject: [PATCH 01/20] DOC-6661 draft Python feature store
---
content/develop/use-cases/_index.md | 1 +
.../develop/use-cases/feature-store/_index.md | 158 ++++
.../feature-store/redis-py/_index.md | 644 ++++++++++++++
.../feature-store/redis-py/build_features.py | 89 ++
.../feature-store/redis-py/demo_server.py | 802 ++++++++++++++++++
.../feature-store/redis-py/feature_store.py | 337 ++++++++
.../redis-py/streaming_worker.py | 146 ++++
7 files changed, 2177 insertions(+)
create mode 100644 content/develop/use-cases/feature-store/_index.md
create mode 100644 content/develop/use-cases/feature-store/redis-py/_index.md
create mode 100644 content/develop/use-cases/feature-store/redis-py/build_features.py
create mode 100644 content/develop/use-cases/feature-store/redis-py/demo_server.py
create mode 100644 content/develop/use-cases/feature-store/redis-py/feature_store.py
create mode 100644 content/develop/use-cases/feature-store/redis-py/streaming_worker.py
diff --git a/content/develop/use-cases/_index.md b/content/develop/use-cases/_index.md
index fabd8bfeca..40f7cb9043 100644
--- a/content/develop/use-cases/_index.md
+++ b/content/develop/use-cases/_index.md
@@ -27,3 +27,4 @@ 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
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..e793433eec
--- /dev/null
+++ b/content/develop/use-cases/feature-store/_index.md
@@ -0,0 +1,158 @@
+---
+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 IO 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" >}})
diff --git a/content/develop/use-cases/feature-store/redis-py/_index.md b/content/develop/use-cases/feature-store/redis-py/_index.md
new file mode 100644
index 0000000000..9ca06fb221
--- /dev/null
+++ b/content/develop/use-cases/feature-store/redis-py/_index.md
@@ -0,0 +1,644 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Build a Redis-backed online feature store in Python with redis-py
+linkTitle: redis-py example (Python)
+title: Redis feature store with redis-py
+weight: 1
+---
+
+This guide shows you how to build a small Redis-backed online feature store in
+Python with [`redis-py`]({{< relref "/develop/clients/redis-py" >}}). It
+includes a local web server built with the Python standard library 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.py` — 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.py` — the demo's stand-in for a Flink / Kafka Streams job.
+The inference panel of the demo server reads any subset of those features
+through `feature_store.py`'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 `synthesize_users(N)` (in production, the equivalent
+ computation lives in an offline pipeline against the warehouse). The result
+ is `{user_id: {field: value, ...}}` for every user in this cycle.
+2. `store.bulk_load(rows, ttl_seconds=86400)` pipelines one
+ [`HSET`]({{< relref "/commands/hset" >}}) plus one
+ [`EXPIRE`]({{< relref "/commands/expire" >}}) per user into 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.update_streaming(user_id, fields, ttl_seconds=300)`. That pipelines:
+
+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.get_features(user_id, names)`, which is one
+ [`HMGET`]({{< relref "/commands/hmget" >}}). Redis returns the values in
+ the same order as the requested fields, with `None` for any field that
+ doesn't exist (or has expired).
+3. For batch inference, the model server calls
+ `store.batch_get_features(user_ids, 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 `RedisFeatureStore` class wraps the read/write paths
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/redis-py/feature_store.py)):
+
+```python
+import redis
+from feature_store import RedisFeatureStore
+
+r = redis.Redis(host="localhost", port=6379, decode_responses=True)
+store = RedisFeatureStore(
+ redis_client=r,
+ key_prefix="fs:user:",
+ batch_ttl_seconds=24 * 60 * 60, # whole-entity TTL aligned with the daily batch cycle
+ streaming_ttl_seconds=5 * 60, # per-field TTL on each streaming feature
+)
+
+# Batch materialization: one HSET + EXPIRE per user, all pipelined.
+store.bulk_load({
+ "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},
+})
+
+# Streaming write: HSET + HEXPIRE on just the fields that changed.
+store.update_streaming("u0001", {
+ "last_login_ts": 1716998413541,
+ "last_device_id": "ios-9f02",
+ "tx_count_5m": 3,
+ "failed_logins_15m": 0,
+ "session_country": "US",
+})
+
+# Inference read: HMGET of whatever the model needs.
+features = store.get_features("u0001", [
+ "risk_segment", "tx_count_7d", "avg_amount_30d",
+ "tx_count_5m", "failed_logins_15m",
+])
+
+# Batch scoring: pipelined HMGET across many users.
+batch = store.batch_get_features(
+ user_ids=["u0001", "u0002", "u0003"],
+ field_names=["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 the helper encodes booleans as `"true"` /
+`"false"` and numbers as their `str(...)` form. 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
+
+`bulk_load` pipelines one `HSET` and one `EXPIRE` per user into a single round
+trip. 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.
+
+```python
+def bulk_load(
+ self,
+ rows: Mapping[str, FeatureMap],
+ ttl_seconds: Optional[int] = None,
+) -> int:
+ ttl = self.batch_ttl_seconds if ttl_seconds is None else ttl_seconds
+ pipe = self.redis.pipeline(transaction=False)
+ for entity_id, fields in rows.items():
+ key = self.key_for(entity_id)
+ pipe.hset(key, mapping={k: _encode(v) for k, v in fields.items()})
+ pipe.expire(key, ttl)
+ pipe.execute()
+ ...
+```
+
+`transaction=False` switches the pipeline from `MULTI/EXEC` to a plain command
+batch: there's no all-or-nothing semantic, just a network optimization. That
+is the right choice here — each user's `HSET` + `EXPIRE` is independent, and
+wrapping the whole thing in a transaction would block the server for the
+duration 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.
+
+### Streaming writes with per-field TTL
+
+`update_streaming` is the linchpin of the mixed-staleness story:
+
+```python
+def update_streaming(
+ self,
+ entity_id: str,
+ fields: FeatureMap,
+ ttl_seconds: Optional[int] = None,
+) -> None:
+ if not fields:
+ return
+ ttl = self.streaming_ttl_seconds if ttl_seconds is None else ttl_seconds
+ key = self.key_for(entity_id)
+ encoded = {name: _encode(value) for name, value in fields.items()}
+
+ pipe = self.redis.pipeline(transaction=False)
+ pipe.hset(key, mapping=encoded)
+ pipe.hexpire(key, ttl, *encoded.keys())
+ pipe.execute()
+```
+
+[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on *individual*
+hash fields, not on the whole key. The two commands here are sent in one round
+trip but they could in principle run any order — the `HSET` always wins because
+the field name is the same in both calls; in practice they run in pipeline
+order on the server, so the field is written, then its TTL is applied.
+
+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
+
+`get_features` is one `HMGET`:
+
+```python
+def get_features(
+ self,
+ entity_id: str,
+ field_names: Optional[Iterable[str]] = None,
+) -> dict[str, str]:
+ key = self.key_for(entity_id)
+ if field_names is None:
+ return self.redis.hgetall(key)
+ names = list(field_names)
+ if not names:
+ return {}
+ values = self.redis.hmget(key, names)
+ return {n: v for n, v in zip(names, values) if v is not None}
+```
+
+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 `None`. The helper drops them from the result dict 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:
+
+```python
+def batch_get_features(
+ self,
+ entity_ids: Iterable[str],
+ field_names: Iterable[str],
+) -> dict[str, dict[str, str]]:
+ ids = list(entity_ids)
+ names = list(field_names)
+ if not ids or not names:
+ return {}
+
+ pipe = self.redis.pipeline(transaction=False)
+ for entity_id in ids:
+ pipe.hmget(self.key_for(entity_id), names)
+ rows = pipe.execute()
+
+ out: dict[str, dict[str, str]] = {}
+ for entity_id, values in zip(ids, rows):
+ out[entity_id] = {n: v for n, v in zip(names, values) if v is not None}
+ return out
+```
+
+One round trip for the whole batch — the demo regularly returns 100 users in
+2-3 ms against a local Redis. On a real network the round trip dominates;
+pipelining is what keeps batch scoring practical.
+
+For very large batches on a clustered deployment, the same shape generalises
+to one pipeline per shard: bucket the entity IDs by their hash slot
+(`cluster.keyslot(key)`), then issue one pipeline against each shard in
+parallel. `redis-py`'s
+[`RedisCluster` pipeline](https://redis-py.readthedocs.io/en/stable/clustering.html#redis-cluster-pipeline)
+handles that automatically — the per-user `HMGET` calls are dispatched to the
+right shard transparently.
+
+## The streaming worker
+
+`streaming_worker.py` 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/redis-py/streaming_worker.py)).
+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.update_streaming(user_id, fields)`. The
+demo defaults to 5 users per tick at 1-second intervals — enough that within a
+minute every user in a 200-user store has been touched at least once.
+
+```python
+def _tick(self) -> None:
+ ids = self.store.list_entity_ids(limit=500)
+ if not ids:
+ return
+ chosen = self._rng.sample(ids, k=min(self.users_per_tick, len(ids)))
+ now_ms = int(time.time() * 1000)
+ for entity_id in chosen:
+ fields = {
+ "last_login_ts": now_ms,
+ "last_device_id": self._rng.choice(DEVICE_IDS),
+ "tx_count_5m": self._rng.randint(0, 12),
+ "failed_logins_15m": self._rng.choices(
+ (0, 1, 2, 5), weights=(70, 20, 8, 2), k=1,
+ )[0],
+ "session_country": self._rng.choice(SESSION_COUNTRIES),
+ }
+ self.store.update_streaming(entity_id, fields)
+```
+
+Pausing the worker is what shows off the mixed-staleness behavior: leave it
+paused for longer than `streaming_ttl_seconds` 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
+
+`build_features.py` is the demo's nightly materializer
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/redis-py/build_features.py)).
+It generates synthetic feature rows and calls `store.bulk_load` 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.
+
+```python
+def synthesize_users(count: int, seed: int = 42) -> dict[str, dict]:
+ rng = random.Random(seed)
+ users: dict[str, dict] = {}
+ for i in range(1, count + 1):
+ uid = f"u{i:04d}"
+ users[uid] = {
+ "country_iso": rng.choice(COUNTRY_CHOICES),
+ "risk_segment": rng.choices(
+ RISK_SEGMENTS, weights=(70, 25, 5), k=1,
+ )[0],
+ "account_age_days": rng.randint(7, 2400),
+ "tx_count_7d": rng.randint(0, 80),
+ "avg_amount_30d": round(rng.uniform(5, 350), 2),
+ "chargeback_count_180d": rng.choices(
+ (0, 1, 2, 3), weights=(85, 10, 4, 1), k=1,
+ )[0],
+ }
+ return users
+```
+
+You can run the builder on its own (independently of the demo server) to
+populate Redis from the command line:
+
+```bash
+python3 build_features.py --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.py` runs a `ThreadingHTTPServer` on port 8085. 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 `RedisFeatureStore` instance 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.
+* **Python 3.9 or later.**
+* The `redis-py` client. Install it with:
+
+ ```bash
+ pip install "redis>=5.1"
+ ```
+
+ Field-level TTL commands (`hexpire`, `httl`) were added to `redis-py` in 5.1.
+
+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 consists of four Python files. Download them from the
+[`redis-py` source folder](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/redis-py)
+on GitHub, or grab them with `curl`:
+
+```bash
+mkdir feature-store-demo && cd feature-store-demo
+BASE=https://raw.githubusercontent.com/redis/docs/main/content/develop/use-cases/feature-store/redis-py
+curl -O $BASE/feature_store.py
+curl -O $BASE/build_features.py
+curl -O $BASE/streaming_worker.py
+curl -O $BASE/demo_server.py
+```
+
+### Start the demo server
+
+From that directory:
+
+```bash
+python3 demo_server.py
+```
+
+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:8085
+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:8085](http://127.0.0.1:8085) 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
+
+### 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 is one round
+trip. On a Redis Cluster, the keys land on different shards — `redis-py`'s
+[`RedisCluster` client](https://redis-py.readthedocs.io/en/stable/clustering.html)
+dispatches each `HMGET` to the right shard transparently, but you still pay
+one round trip per shard rather than one for the whole batch. For very
+latency-sensitive batch inference, group users by shard slot
+(`cluster.keyslot(key)`) and issue one pipeline 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 the keys onto the same
+shard and lets one pipeline serve them all in one 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 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 [Pipelining]({{< relref "/develop/using-commands/pipelining" >}}).
+
+See the [`redis-py` documentation]({{< relref "/develop/clients/redis-py" >}})
+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/redis-py/build_features.py b/content/develop/use-cases/feature-store/redis-py/build_features.py
new file mode 100644
index 0000000000..a71b6a770c
--- /dev/null
+++ b/content/develop/use-cases/feature-store/redis-py/build_features.py
@@ -0,0 +1,89 @@
+"""
+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``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import random
+from typing import Iterable
+
+from feature_store import RedisFeatureStore
+
+
+COUNTRY_CHOICES = ("US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL")
+RISK_SEGMENTS = ("low", "medium", "high")
+
+
+def synthesize_users(count: int, seed: int = 42) -> dict[str, dict]:
+ """Generate ``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.
+ """
+ rng = random.Random(seed)
+ users: dict[str, dict] = {}
+ for i in range(1, count + 1):
+ uid = f"u{i:04d}"
+ users[uid] = {
+ "country_iso": rng.choice(COUNTRY_CHOICES),
+ "risk_segment": rng.choices(
+ RISK_SEGMENTS, weights=(70, 25, 5), k=1,
+ )[0],
+ "account_age_days": rng.randint(7, 2400),
+ "tx_count_7d": rng.randint(0, 80),
+ "avg_amount_30d": round(rng.uniform(5, 350), 2),
+ "chargeback_count_180d": rng.choices(
+ (0, 1, 2, 3), weights=(85, 10, 4, 1), k=1,
+ )[0],
+ }
+ return users
+
+
+def main(argv: Iterable[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--redis-host", default="localhost")
+ parser.add_argument("--redis-port", type=int, default=6379)
+ parser.add_argument(
+ "--count", type=int, default=200,
+ help="Number of synthetic users to materialize.",
+ )
+ parser.add_argument(
+ "--ttl-seconds", type=int, default=24 * 60 * 60,
+ help="Key-level TTL for each user hash (default 24h).",
+ )
+ parser.add_argument(
+ "--key-prefix", default="fs:user:",
+ help="Hash key prefix for each user.",
+ )
+ parser.add_argument("--seed", type=int, default=42)
+ args = parser.parse_args(list(argv) if argv is not None else None)
+
+ import redis
+ client = redis.Redis(
+ host=args.redis_host, port=args.redis_port, decode_responses=True,
+ )
+ store = RedisFeatureStore(
+ redis_client=client,
+ key_prefix=args.key_prefix,
+ batch_ttl_seconds=args.ttl_seconds,
+ )
+
+ rows = synthesize_users(args.count, seed=args.seed)
+ store.bulk_load(rows)
+ print(
+ f"Materialized {len(rows)} users at {args.key_prefix}* "
+ f"with a {args.ttl_seconds}s key-level TTL."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/content/develop/use-cases/feature-store/redis-py/demo_server.py b/content/develop/use-cases/feature-store/redis-py/demo_server.py
new file mode 100644
index 0000000000..5a5b1ddd2c
--- /dev/null
+++ b/content/develop/use-cases/feature-store/redis-py/demo_server.py
@@ -0,0 +1,802 @@
+#!/usr/bin/env python3
+"""
+Redis feature-store demo server.
+
+Run this file and visit http://localhost:8085 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 ``HEXPIRE``, and the inference
+panel reads any subset of features for any user with ``HMGET`` in a
+single round trip.
+
+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 pipelined round trip and see the
+ per-entity / per-round-trip latency split.
+* Inspect a single user's hash in detail with field-level TTLs.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+try:
+ import redis
+
+ from build_features import synthesize_users
+ from feature_store import (
+ DEFAULT_BATCH_FIELDS,
+ DEFAULT_STREAMING_FIELDS,
+ RedisFeatureStore,
+ )
+ from streaming_worker import StreamingWorker
+except ImportError as exc:
+ print(f"Error: {exc}")
+ print("Make sure the 'redis' package is installed: pip install redis")
+ sys.exit(1)
+
+
+HTML_TEMPLATE = """
+
+
+
+
+ Redis Feature Store Demo
+
+
+
+
+
redis-py + Python standard library HTTP server
+
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.
+ Pipelined so a 500-user load is one round trip per batch.
+
+
+
+
+
+ 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. 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)
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+class FeatureStoreDemo:
+ """Demo orchestrator: feature store + streaming worker + housekeeping."""
+
+ def __init__(
+ self,
+ store: RedisFeatureStore,
+ worker: StreamingWorker,
+ default_user_count: int,
+ seed: int,
+ ) -> None:
+ self.store = store
+ self.worker = worker
+ self.default_user_count = default_user_count
+ self.seed = seed
+
+ def materialize(self, count: int, ttl_seconds: int) -> dict:
+ rows = synthesize_users(count, seed=self.seed)
+ start = time.perf_counter()
+ loaded = self.store.bulk_load(rows, ttl_seconds=ttl_seconds)
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
+ return {"loaded": loaded, "ttl_seconds": ttl_seconds, "elapsed_ms": elapsed_ms}
+
+ def reset(self) -> dict:
+ deleted = self.store.reset()
+ self.store.reset_stats()
+ self.worker.reset_stats()
+ return {"deleted": deleted}
+
+ def toggle_worker(self) -> dict:
+ if not self.worker.is_running:
+ self.worker.start()
+ if self.worker.is_paused:
+ self.worker.resume()
+ else:
+ self.worker.pause()
+ return {"paused": self.worker.is_paused, "running": self.worker.is_running}
+
+
+class FeatureStoreDemoHandler(BaseHTTPRequestHandler):
+ """HTTP handler. State is hung off class attributes."""
+
+ store: RedisFeatureStore | None = None
+ worker: StreamingWorker | None = None
+ demo: FeatureStoreDemo | None = None
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path in {"/", "/index.html"}:
+ self._send_html(self._html_page())
+ return
+ if parsed.path == "/state":
+ self._send_json(self._build_state(), 200)
+ return
+ if parsed.path == "/inspect":
+ self._handle_inspect(parse_qs(parsed.query))
+ return
+ self.send_error(404)
+
+ def do_POST(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == "/bulk-load":
+ self._handle_bulk_load()
+ return
+ if parsed.path == "/reset":
+ self._send_json(self.demo.reset(), 200)
+ return
+ if parsed.path == "/worker/toggle":
+ self._send_json(self.demo.toggle_worker(), 200)
+ return
+ if parsed.path == "/read":
+ self._handle_read()
+ return
+ if parsed.path == "/batch-read":
+ self._handle_batch_read()
+ return
+ self.send_error(404)
+
+ # ---- POST handlers --------------------------------------------------
+
+ def _handle_bulk_load(self) -> None:
+ params = self._read_form_data()
+ count = max(1, min(2000, int(params.get("count", ["200"])[0] or "200")))
+ ttl = max(5, min(172_800, int(params.get("ttl", ["86400"])[0] or "86400")))
+ self._send_json(self.demo.materialize(count, ttl), 200)
+
+ def _handle_read(self) -> None:
+ params = self._read_form_data()
+ user = (params.get("user", [""])[0] or "").strip()
+ if not user:
+ self._send_json({"error": "user is required"}, 400)
+ return
+ fields = [f for f in params.get("field", []) if f]
+ start = time.perf_counter()
+ values = self.store.get_features(user, fields) if fields else {}
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
+ ttls = self.store.field_ttls_seconds(user, fields) if fields else {}
+ key_ttl = self.store.key_ttl_seconds(user)
+ self._send_json(
+ {
+ "requested": fields,
+ "values": values,
+ "ttls": ttls,
+ "key_ttl_seconds": key_ttl,
+ "returned_count": len(values),
+ "elapsed_ms": elapsed_ms,
+ },
+ 200,
+ )
+
+ def _handle_batch_read(self) -> None:
+ params = self._read_form_data()
+ count = max(1, min(500, int(params.get("count", ["100"])[0] or "100")))
+ fields = [f for f in params.get("field", []) if f]
+ if not fields:
+ fields = list(DEFAULT_STREAMING_FIELDS) + ["risk_segment"]
+ ids = self.store.list_entity_ids(limit=2000)
+ if len(ids) > count:
+ ids = ids[:count]
+ start = time.perf_counter()
+ rows = self.store.batch_get_features(ids, fields)
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
+ sample = [
+ {"id": uid, "field_count": len(rows.get(uid, {}))}
+ for uid in ids[:10]
+ ]
+ self._send_json(
+ {
+ "entity_count": len(ids),
+ "field_count": len(fields),
+ "elapsed_ms": elapsed_ms,
+ "sample": sample,
+ },
+ 200,
+ )
+
+ def _handle_inspect(self, query: dict[str, list[str]]) -> None:
+ user = (query.get("user", [""])[0] or "").strip()
+ if not user:
+ self._send_json({"error": "user is required"}, 400)
+ return
+ full = self.store.get_features(user, field_names=None)
+ if not full:
+ key_ttl = self.store.key_ttl_seconds(user)
+ self._send_json(
+ {"exists": False, "key_ttl_seconds": key_ttl},
+ 200,
+ )
+ return
+ ttls = self.store.field_ttls_seconds(user, full.keys())
+ key_ttl = self.store.key_ttl_seconds(user)
+ fields = sorted(
+ [
+ {"name": n, "value": v, "ttl_seconds": ttls.get(n, -1)}
+ for n, v in full.items()
+ ],
+ key=lambda r: r["name"],
+ )
+ self._send_json(
+ {
+ "exists": True,
+ "key_ttl_seconds": key_ttl,
+ "fields": fields,
+ },
+ 200,
+ )
+
+ # ---- State assembly -------------------------------------------------
+
+ def _build_state(self) -> dict:
+ ids = self.store.list_entity_ids(limit=500)
+ return {
+ "key_prefix": self.store.key_prefix,
+ "batch_ttl_seconds": self.store.batch_ttl_seconds,
+ "streaming_ttl_seconds": self.store.streaming_ttl_seconds,
+ "entity_count": len(ids),
+ "entity_ids": ids,
+ "stats": self.store.stats(),
+ "worker": self.worker.stats(),
+ }
+
+ # ---- HTTP plumbing --------------------------------------------------
+
+ def _read_form_data(self) -> dict[str, list[str]]:
+ content_length = int(self.headers.get("Content-Length", "0"))
+ raw_body = self.rfile.read(content_length).decode("utf-8")
+ return parse_qs(raw_body)
+
+ def _send_html(self, html: str, status: int = 200) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(html.encode("utf-8"))
+
+ def _send_json(self, payload: dict, status: int) -> None:
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(payload).encode("utf-8"))
+
+ def log_message(self, format: str, *args) -> None: # noqa: A002
+ sys.stderr.write(f"[demo] {format % args}\n")
+
+ def _html_page(self) -> str:
+ return (
+ HTML_TEMPLATE
+ .replace("__KEY_PREFIX__", self.store.key_prefix)
+ .replace("__STREAM_TTL__", str(self.store.streaming_ttl_seconds))
+ .replace("__USERS_PER_TICK__", str(self.worker.users_per_tick))
+ .replace("__BATCH_FIELDS_JSON__",
+ json.dumps(list(DEFAULT_BATCH_FIELDS)))
+ .replace("__STREAM_FIELDS_JSON__",
+ json.dumps(list(DEFAULT_STREAMING_FIELDS)))
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Run the Redis feature-store demo server.")
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host")
+ parser.add_argument("--port", type=int, default=8085, help="HTTP bind port")
+ parser.add_argument("--redis-host", default="localhost", help="Redis host")
+ parser.add_argument("--redis-port", type=int, default=6379, help="Redis port")
+ parser.add_argument(
+ "--key-prefix", default="fs:user:",
+ help="Hash key prefix for each user.",
+ )
+ parser.add_argument(
+ "--batch-ttl-seconds", type=int, default=24 * 60 * 60,
+ help="Default key-level TTL applied by bulk-load (default 24h).",
+ )
+ parser.add_argument(
+ "--streaming-ttl-seconds", type=int, default=5 * 60,
+ help="Per-field TTL applied to streaming features (default 5m).",
+ )
+ parser.add_argument(
+ "--users-per-tick", type=int, default=5,
+ help="How many users the streaming worker touches per tick.",
+ )
+ parser.add_argument(
+ "--seed-users", type=int, default=200,
+ help="Number of users to materialize on startup.",
+ )
+ parser.add_argument(
+ "--no-reset",
+ dest="reset_on_start",
+ action="store_false",
+ help=(
+ "Keep any existing data under --key-prefix instead of dropping"
+ " it on startup. By default the demo wipes the prefix so each"
+ " run starts from a clean state."
+ ),
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+
+ redis_client = redis.Redis(
+ host=args.redis_host,
+ port=args.redis_port,
+ decode_responses=True,
+ )
+ store = RedisFeatureStore(
+ redis_client=redis_client,
+ key_prefix=args.key_prefix,
+ batch_ttl_seconds=args.batch_ttl_seconds,
+ streaming_ttl_seconds=args.streaming_ttl_seconds,
+ )
+ worker = StreamingWorker(
+ store=store, users_per_tick=args.users_per_tick,
+ )
+ demo = FeatureStoreDemo(
+ store=store, worker=worker,
+ default_user_count=args.seed_users, seed=42,
+ )
+
+ if args.reset_on_start:
+ print(
+ f"Dropping any existing users under '{args.key_prefix}*'"
+ " for a clean demo run (pass --no-reset to keep them)."
+ )
+ store.reset()
+ store.reset_stats()
+ seeded = demo.materialize(args.seed_users, args.batch_ttl_seconds)["loaded"]
+
+ worker.start()
+
+ FeatureStoreDemoHandler.store = store
+ FeatureStoreDemoHandler.worker = worker
+ FeatureStoreDemoHandler.demo = demo
+
+ print(f"Redis feature-store demo server listening on http://{args.host}:{args.port}")
+ print(
+ f"Using Redis at {args.redis_host}:{args.redis_port}"
+ f" with key prefix '{args.key_prefix}'"
+ f" (batch TTL {args.batch_ttl_seconds}s,"
+ f" streaming TTL {args.streaming_ttl_seconds}s)"
+ )
+ print(f"Materialized {seeded} user(s); streaming worker running.")
+
+ server = ThreadingHTTPServer((args.host, args.port), FeatureStoreDemoHandler)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ worker.stop()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/content/develop/use-cases/feature-store/redis-py/feature_store.py b/content/develop/use-cases/feature-store/redis-py/feature_store.py
new file mode 100644
index 0000000000..57f377e994
--- /dev/null
+++ b/content/develop/use-cases/feature-store/redis-py/feature_store.py
@@ -0,0 +1,337 @@
+"""
+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. ``redis-py``
+exposes them as ``hexpire`` / ``httl`` from version 5.1.
+
+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.
+"""
+
+from __future__ import annotations
+
+from threading import Lock
+from typing import Iterable, Mapping, Optional, Union
+
+import redis
+
+
+FeatureValue = Union[str, int, float, bool]
+FeatureMap = Mapping[str, FeatureValue]
+
+
+# Default batch feature schema. Daily aggregates computed offline and
+# bulk-loaded once per materialization cycle.
+DEFAULT_BATCH_FIELDS: tuple[str, ...] = (
+ "country_iso",
+ "risk_segment",
+ "account_age_days",
+ "tx_count_7d",
+ "avg_amount_30d",
+ "chargeback_count_180d",
+)
+
+# Default streaming feature schema. Updated by the streaming worker as
+# new events arrive, with a per-field TTL so each field self-expires
+# when its upstream pipeline stops.
+DEFAULT_STREAMING_FIELDS: tuple[str, ...] = (
+ "last_login_ts",
+ "last_device_id",
+ "tx_count_5m",
+ "failed_logins_15m",
+ "session_country",
+)
+
+
+class RedisFeatureStore:
+ """Online feature store helper for one entity type (default: user)."""
+
+ def __init__(
+ self,
+ redis_client: Optional[redis.Redis] = None,
+ key_prefix: str = "fs:user:",
+ batch_ttl_seconds: int = 24 * 60 * 60,
+ streaming_ttl_seconds: int = 5 * 60,
+ ) -> None:
+ self.redis = redis_client or redis.Redis(
+ host="localhost",
+ port=6379,
+ decode_responses=True,
+ )
+ self.key_prefix = key_prefix
+ self.batch_ttl_seconds = batch_ttl_seconds
+ self.streaming_ttl_seconds = streaming_ttl_seconds
+
+ self._stats_lock = Lock()
+ self._batch_writes_total = 0
+ self._streaming_writes_total = 0
+ self._reads_total = 0
+ self._read_fields_total = 0
+
+ # ------------------------------------------------------------------
+ # Key helpers
+ # ------------------------------------------------------------------
+
+ def key_for(self, entity_id: str) -> str:
+ return f"{self.key_prefix}{entity_id}"
+
+ # ------------------------------------------------------------------
+ # Batch ingestion (materialization)
+ # ------------------------------------------------------------------
+
+ def bulk_load(
+ self,
+ rows: Mapping[str, FeatureMap],
+ ttl_seconds: Optional[int] = None,
+ ) -> int:
+ """Materialize a batch of entities into Redis.
+
+ ``rows`` is ``{entity_id: {field: value, ...}}``. One ``HSET``
+ plus one ``EXPIRE`` per entity, all pipelined into a single
+ round trip. The key-level ``EXPIRE`` is what makes the whole
+ entity disappear if a future batch run fails — inference reads
+ the missing entity rather than silently outdated values.
+ """
+ ttl = self.batch_ttl_seconds if ttl_seconds is None else ttl_seconds
+ pipe = self.redis.pipeline(transaction=False)
+ for entity_id, fields in rows.items():
+ key = self.key_for(entity_id)
+ pipe.hset(key, mapping={k: _encode(v) for k, v in fields.items()})
+ pipe.expire(key, ttl)
+ pipe.execute()
+ with self._stats_lock:
+ self._batch_writes_total += len(rows)
+ return len(rows)
+
+ def update_batch_feature(
+ self,
+ entity_id: str,
+ field: str,
+ value: FeatureValue,
+ ) -> None:
+ """Update a single batch feature without touching the key TTL.
+
+ Used by the demo's "manually refresh one user" lever; in a real
+ pipeline batch updates always flow through ``bulk_load``.
+ """
+ self.redis.hset(self.key_for(entity_id), field, _encode(value))
+ with self._stats_lock:
+ self._batch_writes_total += 1
+
+ # ------------------------------------------------------------------
+ # Streaming ingestion
+ # ------------------------------------------------------------------
+
+ def update_streaming(
+ self,
+ entity_id: str,
+ fields: FeatureMap,
+ ttl_seconds: Optional[int] = None,
+ ) -> None:
+ """Write 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``.
+ """
+ if not fields:
+ return
+ ttl = self.streaming_ttl_seconds if ttl_seconds is None else ttl_seconds
+ key = self.key_for(entity_id)
+ encoded = {name: _encode(value) for name, value in fields.items()}
+
+ pipe = self.redis.pipeline(transaction=False)
+ pipe.hset(key, mapping=encoded)
+ pipe.hexpire(key, ttl, *encoded.keys())
+ pipe.execute()
+ with self._stats_lock:
+ self._streaming_writes_total += len(encoded)
+
+ # ------------------------------------------------------------------
+ # Inference reads
+ # ------------------------------------------------------------------
+
+ def get_features(
+ self,
+ entity_id: str,
+ field_names: Optional[Iterable[str]] = None,
+ ) -> dict[str, str]:
+ """Retrieve a subset of features for one entity.
+
+ ``HMGET`` returns the requested fields in one round trip. Pass
+ ``field_names=None`` to fetch the entire hash with ``HGETALL``
+ — useful for debugging but rarely the right call on the
+ request path, where the model knows exactly which features it
+ consumes.
+ """
+ key = self.key_for(entity_id)
+ if field_names is None:
+ data = self.redis.hgetall(key)
+ with self._stats_lock:
+ self._reads_total += 1
+ self._read_fields_total += len(data)
+ return data
+
+ names = list(field_names)
+ if not names:
+ return {}
+ values = self.redis.hmget(key, names)
+ with self._stats_lock:
+ self._reads_total += 1
+ self._read_fields_total += sum(1 for v in values if v is not None)
+ return {n: v for n, v in zip(names, values) if v is not None}
+
+ def batch_get_features(
+ self,
+ entity_ids: Iterable[str],
+ field_names: Iterable[str],
+ ) -> dict[str, dict[str, str]]:
+ """Pipeline ``HMGET`` across many entities for batch scoring.
+
+ Hundreds of entities in one round trip. The model can then
+ score them all without further network calls.
+ """
+ ids = list(entity_ids)
+ names = list(field_names)
+ if not ids or not names:
+ return {}
+
+ pipe = self.redis.pipeline(transaction=False)
+ for entity_id in ids:
+ pipe.hmget(self.key_for(entity_id), names)
+ rows = pipe.execute()
+
+ out: dict[str, dict[str, str]] = {}
+ seen_fields = 0
+ for entity_id, values in zip(ids, rows):
+ row = {n: v for n, v in zip(names, values) if v is not None}
+ out[entity_id] = row
+ seen_fields += len(row)
+ with self._stats_lock:
+ self._reads_total += len(ids)
+ self._read_fields_total += seen_fields
+ return out
+
+ # ------------------------------------------------------------------
+ # TTL inspection (used by the demo UI)
+ # ------------------------------------------------------------------
+
+ def key_ttl_seconds(self, entity_id: str) -> int:
+ """Seconds until the entity key expires.
+
+ Returns ``-1`` if no key-level TTL is set, ``-2`` if the key
+ doesn't exist.
+ """
+ return int(self.redis.ttl(self.key_for(entity_id)))
+
+ def field_ttls_seconds(
+ self,
+ entity_id: str,
+ field_names: Iterable[str],
+ ) -> dict[str, int]:
+ """Per-field TTL via ``HTTL`` (Redis 7.4+).
+
+ Each value mirrors the ``TTL`` convention: positive means
+ seconds remaining, ``-1`` means no TTL on the field, ``-2``
+ means the field doesn't exist on this hash.
+ """
+ names = list(field_names)
+ if not names:
+ return {}
+ ttls = self.redis.httl(self.key_for(entity_id), *names)
+ return {n: int(t) for n, t in zip(names, ttls)}
+
+ # ------------------------------------------------------------------
+ # Demo housekeeping
+ # ------------------------------------------------------------------
+
+ def list_entity_ids(self, limit: int = 200) -> list[str]:
+ """Enumerate entity IDs by scanning ``key_prefix*``.
+
+ ``SCAN`` is non-blocking; the demo uses it to populate UI
+ dropdowns, not as a serving primitive.
+ """
+ ids: list[str] = []
+ prefix_len = len(self.key_prefix)
+ for key in self.redis.scan_iter(match=f"{self.key_prefix}*", count=200):
+ ids.append(key[prefix_len:])
+ if len(ids) >= limit:
+ break
+ return sorted(ids)
+
+ def count_entities(self) -> int:
+ """Count entities currently in the store (via ``SCAN``)."""
+ count = 0
+ for _ in self.redis.scan_iter(match=f"{self.key_prefix}*", count=500):
+ count += 1
+ return count
+
+ def delete_entity(self, entity_id: str) -> int:
+ return int(self.redis.delete(self.key_for(entity_id)))
+
+ def reset(self) -> int:
+ """Drop every entity under ``key_prefix``. Used by the demo reset path.
+
+ Scans in batches and ``DEL``s them in one pipeline per batch,
+ so a large demo dataset doesn't load the server with one big
+ synchronous delete.
+ """
+ deleted = 0
+ batch: list[str] = []
+ for key in self.redis.scan_iter(match=f"{self.key_prefix}*", count=500):
+ batch.append(key)
+ if len(batch) >= 500:
+ deleted += int(self.redis.delete(*batch))
+ batch.clear()
+ if batch:
+ deleted += int(self.redis.delete(*batch))
+ return deleted
+
+ def stats(self) -> dict[str, int]:
+ with self._stats_lock:
+ return {
+ "batch_writes_total": self._batch_writes_total,
+ "streaming_writes_total": self._streaming_writes_total,
+ "reads_total": self._reads_total,
+ "read_fields_total": self._read_fields_total,
+ }
+
+ def reset_stats(self) -> None:
+ with self._stats_lock:
+ self._batch_writes_total = 0
+ self._streaming_writes_total = 0
+ self._reads_total = 0
+ self._read_fields_total = 0
+
+
+def _encode(value: FeatureValue) -> str:
+ """Encode a feature value as a string for Hash storage.
+
+ Booleans become ``"true"`` / ``"false"`` (not ``"True"`` / ``"False"``)
+ so they round-trip cleanly through other clients and ``redis-cli``.
+ """
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ return str(value)
diff --git a/content/develop/use-cases/feature-store/redis-py/streaming_worker.py b/content/develop/use-cases/feature-store/redis-py/streaming_worker.py
new file mode 100644
index 0000000000..410a180690
--- /dev/null
+++ b/content/develop/use-cases/feature-store/redis-py/streaming_worker.py
@@ -0,0 +1,146 @@
+"""
+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 ``HEXPIRE`` so the field self-expires
+if the worker is paused. Pause the worker for longer than
+``streaming_ttl_seconds`` 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.
+"""
+
+from __future__ import annotations
+
+import random
+import threading
+import time
+from typing import Optional
+
+from feature_store import RedisFeatureStore
+
+
+DEVICE_IDS = (
+ "ios-1a4c", "ios-9f02", "and-7b21", "and-2d18",
+ "web-chr-1", "web-saf-1", "web-ff-2",
+)
+SESSION_COUNTRIES = ("US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL")
+
+
+class StreamingWorker:
+ """Background thread that updates streaming features on a tick."""
+
+ def __init__(
+ self,
+ store: RedisFeatureStore,
+ tick_seconds: float = 1.0,
+ users_per_tick: int = 5,
+ seed: int = 1337,
+ ) -> None:
+ self.store = store
+ self.tick_seconds = tick_seconds
+ self.users_per_tick = users_per_tick
+ self._rng = random.Random(seed)
+
+ self._thread: Optional[threading.Thread] = None
+ self._stop_event = threading.Event()
+ self._paused = threading.Event()
+
+ self._lock = threading.Lock()
+ self._tick_count = 0
+ self._writes_count = 0
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ def start(self) -> None:
+ if self._thread is not None and self._thread.is_alive():
+ return
+ self._stop_event.clear()
+ self._paused.clear()
+ self._thread = threading.Thread(
+ target=self._run, name="streaming-worker", daemon=True,
+ )
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._stop_event.set()
+ thread = self._thread
+ if thread is not None:
+ thread.join(timeout=2.0)
+ self._thread = None
+
+ def pause(self) -> None:
+ self._paused.set()
+
+ def resume(self) -> None:
+ self._paused.clear()
+
+ @property
+ def is_paused(self) -> bool:
+ return self._paused.is_set()
+
+ @property
+ def is_running(self) -> bool:
+ return self._thread is not None and self._thread.is_alive()
+
+ # ------------------------------------------------------------------
+ # Tick
+ # ------------------------------------------------------------------
+
+ def _run(self) -> None:
+ while not self._stop_event.is_set():
+ if self._paused.is_set():
+ time.sleep(0.05)
+ continue
+ try:
+ self._tick()
+ except Exception as exc:
+ print(f"[streaming-worker] tick failed: {exc}")
+ self._stop_event.wait(timeout=self.tick_seconds)
+
+ def _tick(self) -> None:
+ ids = self.store.list_entity_ids(limit=500)
+ if not ids:
+ return
+ chosen = self._rng.sample(ids, k=min(self.users_per_tick, len(ids)))
+ now_ms = int(time.time() * 1000)
+ writes = 0
+ for entity_id in chosen:
+ fields = {
+ "last_login_ts": now_ms,
+ "last_device_id": self._rng.choice(DEVICE_IDS),
+ "tx_count_5m": self._rng.randint(0, 12),
+ "failed_logins_15m": self._rng.choices(
+ (0, 1, 2, 5), weights=(70, 20, 8, 2), k=1,
+ )[0],
+ "session_country": self._rng.choice(SESSION_COUNTRIES),
+ }
+ self.store.update_streaming(entity_id, fields)
+ writes += len(fields)
+ with self._lock:
+ self._tick_count += 1
+ self._writes_count += writes
+
+ # ------------------------------------------------------------------
+ # Stats
+ # ------------------------------------------------------------------
+
+ def stats(self) -> dict:
+ with self._lock:
+ return {
+ "running": self.is_running,
+ "paused": self.is_paused,
+ "tick_count": self._tick_count,
+ "writes_count": self._writes_count,
+ }
+
+ def reset_stats(self) -> None:
+ with self._lock:
+ self._tick_count = 0
+ self._writes_count = 0
From 3b455e5015eb47b01f90a7fd25ff1efe0f6c61c4 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 13:25:26 +0100
Subject: [PATCH 02/20] DOC-6661 Codex review issues
---
.../feature-store/redis-py/_index.md | 51 ++++++++++++++-----
.../feature-store/redis-py/demo_server.py | 22 ++++++--
.../feature-store/redis-py/feature_store.py | 24 +++++++--
3 files changed, 77 insertions(+), 20 deletions(-)
diff --git a/content/develop/use-cases/feature-store/redis-py/_index.md b/content/develop/use-cases/feature-store/redis-py/_index.md
index 9ca06fb221..82afa60094 100644
--- a/content/develop/use-cases/feature-store/redis-py/_index.md
+++ b/content/develop/use-cases/feature-store/redis-py/_index.md
@@ -213,11 +213,19 @@ def bulk_load(
...
```
-`transaction=False` switches the pipeline from `MULTI/EXEC` to a plain command
-batch: there's no all-or-nothing semantic, just a network optimization. That
-is the right choice here — each user's `HSET` + `EXPIRE` is independent, and
-wrapping the whole thing in a transaction would block the server for the
-duration of the batch.
+`transaction=False` skips the `MULTI/EXEC` wrapper that
+[`pipeline`]({{< relref "/develop/clients/redis-py/transpipe" >}}) defaults to
+— commands still queue and ship in one round trip, but they execute as
+independent commands rather than as one atomic block. That is the right
+choice here: 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. It does *not* make the `HSET` + `EXPIRE` pair
+atomic — in the extremely unlikely event the server crashes between the two,
+the entity exists without a key-level TTL until the next batch run re-pins
+it. For an ingestion script that runs end-to-end every cycle this is fine;
+if you need the pair to be inseparable, wrap each user in its own tiny
+`MULTI/EXEC` or a Lua script (see [`EVAL`]({{< relref "/commands/eval" >}}) /
+[Eval scripting]({{< relref "/develop/programmability/eval-intro" >}})).
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
@@ -249,10 +257,14 @@ def update_streaming(
```
[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on *individual*
-hash fields, not on the whole key. The two commands here are sent in one round
-trip but they could in principle run any order — the `HSET` always wins because
-the field name is the same in both calls; in practice they run in pipeline
-order on the server, so the field is written, then its TTL is applied.
+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 field doesn't exist — so the helper raises if any code is anything
+other than `1`. That makes the "every streaming write renews its TTL"
+invariant fail 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.
@@ -331,7 +343,7 @@ One round trip for the whole batch — the demo regularly returns 100 users in
2-3 ms against a local Redis. On a real network the round trip dominates;
pipelining is what keeps batch scoring practical.
-For very large batches on a clustered deployment, the same shape generalises
+For very large batches on a clustered deployment, the same shape generalizes
to one pipeline per shard: bucket the entity IDs by their hash slot
(`cluster.keyslot(key)`), then issue one pipeline against each shard in
parallel. `redis-py`'s
@@ -349,8 +361,10 @@ 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.update_streaming(user_id, fields)`. The
-demo defaults to 5 users per tick at 1-second intervals — enough that within a
-minute every user in a 200-user store has been touched at least once.
+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. Drop `--seed-users` or raise `--users-per-tick` if you'd rather
+have every user touched quickly.
```python
def _tick(self) -> None:
@@ -532,6 +546,16 @@ The server is read/write against your local Redis. The default key prefix is
## Production usage
+The guidance below focuses on the production concerns that are specific to
+running a feature store on Redis. For the generic redis-py production checklist
+— connection-pool sizing,
+[TLS and AUTH]({{< relref "/develop/clients/redis-py/connect#connect-to-your-production-redis-with-tls" >}}),
+[exception handling]({{< relref "/develop/clients/redis-py/produsage#exception-handling" >}}),
+and the rest — see the
+[redis-py production usage guide]({{< relref "/develop/clients/redis-py/produsage" >}}).
+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
@@ -580,7 +604,8 @@ 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 if you don't trust the call site).
+same [Lua script]({{< relref "/develop/programmability/eval-intro" >}}) if
+you don't trust the call site).
### Avoid HGETALL on the request path
diff --git a/content/develop/use-cases/feature-store/redis-py/demo_server.py b/content/develop/use-cases/feature-store/redis-py/demo_server.py
index 5a5b1ddd2c..268c0e9db1 100644
--- a/content/develop/use-cases/feature-store/redis-py/demo_server.py
+++ b/content/develop/use-cases/feature-store/redis-py/demo_server.py
@@ -512,9 +512,20 @@ def materialize(self, count: int, ttl_seconds: int) -> dict:
return {"loaded": loaded, "ttl_seconds": ttl_seconds, "elapsed_ms": elapsed_ms}
def reset(self) -> dict:
- deleted = self.store.reset()
- self.store.reset_stats()
- self.worker.reset_stats()
+ # 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).
+ was_paused = self.worker.is_paused
+ if self.worker.is_running and not was_paused:
+ self.worker.pause()
+ try:
+ deleted = self.store.reset()
+ self.store.reset_stats()
+ self.worker.reset_stats()
+ finally:
+ if self.worker.is_running and not was_paused:
+ self.worker.resume()
return {"deleted": deleted}
def toggle_worker(self) -> dict:
@@ -658,12 +669,15 @@ def _handle_inspect(self, query: dict[str, list[str]]) -> None:
# ---- State assembly -------------------------------------------------
def _build_state(self) -> dict:
+ # The dropdown only needs a manageable list — cap at 500 — but the
+ # displayed user count should be the real total, not the cap, or the
+ # UI silently understates how many users are in the store.
ids = self.store.list_entity_ids(limit=500)
return {
"key_prefix": self.store.key_prefix,
"batch_ttl_seconds": self.store.batch_ttl_seconds,
"streaming_ttl_seconds": self.store.streaming_ttl_seconds,
- "entity_count": len(ids),
+ "entity_count": self.store.count_entities(),
"entity_ids": ids,
"stats": self.store.stats(),
"worker": self.worker.stats(),
diff --git a/content/develop/use-cases/feature-store/redis-py/feature_store.py b/content/develop/use-cases/feature-store/redis-py/feature_store.py
index 57f377e994..b97280a879 100644
--- a/content/develop/use-cases/feature-store/redis-py/feature_store.py
+++ b/content/develop/use-cases/feature-store/redis-py/feature_store.py
@@ -165,7 +165,17 @@ def update_streaming(
pipe = self.redis.pipeline(transaction=False)
pipe.hset(key, mapping=encoded)
pipe.hexpire(key, ttl, *encoded.keys())
- pipe.execute()
+ _, expire_result = pipe.execute()
+ # HEXPIRE returns one status code per field: 1 = TTL set, 2 = skipped
+ # under a conditional flag (NX/XX/GT/LT), 0 = no such field, -2 = no
+ # such key. We just HSET every field on the same call, so any code
+ # other than 1 means the per-field TTL invariant didn't hold — the
+ # mixed-staleness story relies on every streaming field carrying a
+ # fresh TTL after the write.
+ if expire_result is None or any(int(code) != 1 for code in expire_result):
+ raise RuntimeError(
+ f"HEXPIRE did not set every field TTL for {key}: {expire_result}"
+ )
with self._stats_lock:
self._streaming_writes_total += len(encoded)
@@ -261,6 +271,14 @@ def field_ttls_seconds(
if not names:
return {}
ttls = self.redis.httl(self.key_for(entity_id), *names)
+ # redis-py 7.x returns a flat list of integers — including `-2`s for
+ # every field when the key itself is missing. Older / future versions
+ # have reported `None` for a missing key or a singleton list-of-list
+ # in pipeline contexts, so normalize both shapes before the zip below.
+ if ttls is None:
+ ttls = [-2] * len(names)
+ elif len(ttls) == 1 and isinstance(ttls[0], list):
+ ttls = ttls[0]
return {n: int(t) for n, t in zip(names, ttls)}
# ------------------------------------------------------------------
@@ -294,8 +312,8 @@ def delete_entity(self, entity_id: str) -> int:
def reset(self) -> int:
"""Drop every entity under ``key_prefix``. Used by the demo reset path.
- Scans in batches and ``DEL``s them in one pipeline per batch,
- so a large demo dataset doesn't load the server with one big
+ 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.
"""
deleted = 0
From 6a9815c5310f884e50ec118a4f26488c2512b5d5 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 14:04:58 +0100
Subject: [PATCH 03/20] DOC-6661 draft node-redis example
---
.../develop/use-cases/feature-store/_index.md | 1 +
.../use-cases/feature-store/nodejs/_index.md | 704 +++++++++++++++
.../feature-store/nodejs/buildFeatures.js | 157 ++++
.../feature-store/nodejs/demoServer.js | 804 ++++++++++++++++++
.../feature-store/nodejs/featureStore.js | 451 ++++++++++
.../feature-store/nodejs/package.json | 17 +
.../feature-store/nodejs/streamingWorker.js | 178 ++++
7 files changed, 2312 insertions(+)
create mode 100644 content/develop/use-cases/feature-store/nodejs/_index.md
create mode 100644 content/develop/use-cases/feature-store/nodejs/buildFeatures.js
create mode 100644 content/develop/use-cases/feature-store/nodejs/demoServer.js
create mode 100644 content/develop/use-cases/feature-store/nodejs/featureStore.js
create mode 100644 content/develop/use-cases/feature-store/nodejs/package.json
create mode 100644 content/develop/use-cases/feature-store/nodejs/streamingWorker.js
diff --git a/content/develop/use-cases/feature-store/_index.md b/content/develop/use-cases/feature-store/_index.md
index e793433eec..dcfb2cda24 100644
--- a/content/develop/use-cases/feature-store/_index.md
+++ b/content/develop/use-cases/feature-store/_index.md
@@ -156,3 +156,4 @@ 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" >}})
diff --git a/content/develop/use-cases/feature-store/nodejs/_index.md b/content/develop/use-cases/feature-store/nodejs/_index.md
new file mode 100644
index 0000000000..06c38e9d48
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/_index.md
@@ -0,0 +1,704 @@
+---
+categories:
+- docs
+- develop
+- stack
+- oss
+- rs
+- rc
+description: Build a Redis-backed online feature store in Node.js with node-redis
+linkTitle: node-redis example (Node.js)
+title: Redis feature store with node-redis
+weight: 2
+---
+
+This guide shows you how to build a small Redis-backed online feature store in
+Node.js with [`node-redis`]({{< relref "/develop/clients/nodejs" >}}). It
+includes a local web server built with the Node.js standard `http` module 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
+`buildFeatures.js` — 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.js` — the demo's stand-in for a Flink / Kafka Streams job.
+The inference panel of the demo server reads any subset of those features
+through `featureStore.js`'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 `synthesizeUsers(N)` (in production, the equivalent
+ computation lives in an offline pipeline against the warehouse). The result
+ is `{userId: {field: value, ...}}` for every user in this cycle.
+2. `store.bulkLoad(rows, ttlSeconds)` batches one
+ [`HSET`]({{< relref "/commands/hset" >}}) plus one
+ [`EXPIRE`]({{< relref "/commands/expire" >}}) per user through
+ [`multi().exec()`]({{< relref "/develop/clients/nodejs/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(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
+ [`multi().exec()`]({{< relref "/develop/clients/nodejs/transpipe" >}}).
+
+## 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/nodejs/featureStore.js)):
+
+```javascript
+const { createClient } = require("redis");
+const { FeatureStore } = require("./featureStore");
+
+const client = createClient({ socket: { host: "localhost", port: 6379 } });
+client.on("error", (err) => console.error("Redis client error:", err));
+await client.connect();
+
+const store = new FeatureStore({
+ redisClient: client,
+ keyPrefix: "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 batched.
+await store.bulkLoad({
+ 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 },
+});
+
+// Streaming write: HSET + HEXPIRE on just the fields that changed.
+await store.updateStreaming("u0001", {
+ last_login_ts: 1716998413541,
+ last_device_id: "ios-9f02",
+ tx_count_5m: 3,
+ failed_logins_15m: 0,
+ session_country: "US",
+});
+
+// Inference read: HMGET of whatever the model needs.
+const features = await store.getFeatures("u0001", [
+ "risk_segment", "tx_count_7d", "avg_amount_30d",
+ "tx_count_5m", "failed_logins_15m",
+]);
+
+// Batch scoring: pipelined HMGET across many users.
+const batch = await store.batchGetFeatures(
+ ["u0001", "u0002", "u0003"],
+ ["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 the helper encodes booleans as `"true"` /
+`"false"` and everything else with `String(value)`. 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` batches one `HSET` and one `EXPIRE` per user into a single round
+trip through node-redis's `multi()`. 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.
+
+```javascript
+async bulkLoad(rows, ttlSeconds) {
+ const ttl = ttlSeconds ?? this.batchTtlSeconds;
+ const ids = Object.keys(rows);
+ if (ids.length === 0) return 0;
+
+ const pipe = this.redis.multi();
+ for (const entityId of ids) {
+ const key = this.keyFor(entityId);
+ const encoded = {};
+ for (const [name, value] of Object.entries(rows[entityId])) {
+ encoded[name] = encode(value);
+ }
+ pipe.hSet(key, encoded);
+ pipe.expire(key, ttl);
+ }
+ await pipe.exec();
+ ...
+}
+```
+
+`multi()` in node-redis 5 wraps the batched commands in `MULTI/EXEC`, so the
+whole batch runs as one transaction on the server. That gives all-or-nothing
+semantics inside the batch but does block the server for its duration, which
+is what you want for an ingestion script that runs end-to-end — not for a
+hot-path serving call. (See
+[transactions and pipelining]({{< relref "/develop/clients/nodejs/transpipe" >}})
+for the full mental model.)
+
+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:
+
+```javascript
+async updateStreaming(entityId, fields, ttlSeconds) {
+ const names = Object.keys(fields);
+ if (names.length === 0) return;
+ const ttl = ttlSeconds ?? this.streamingTtlSeconds;
+ const key = this.keyFor(entityId);
+ const encoded = {};
+ for (const [name, value] of Object.entries(fields)) {
+ encoded[name] = encode(value);
+ }
+
+ const [, expireResult] = await this.redis
+ .multi()
+ .hSet(key, encoded)
+ .hExpire(key, names, ttl)
+ .exec();
+ if (!Array.isArray(expireResult) ||
+ expireResult.some((code) => Number(code) !== 1)) {
+ throw new Error(
+ `HEXPIRE did not set every field TTL for ${key}: ` +
+ JSON.stringify(expireResult),
+ );
+ }
+ ...
+}
+```
+
+[`HEXPIRE`]({{< relref "/commands/hexpire" >}}) sets the TTL on *individual*
+hash fields, not on the whole key. Note node-redis's argument order:
+`hExpire(key, fields, seconds)` — the fields come *before* the TTL, the
+opposite of `EXPIRE(key, seconds)`. 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 field doesn't exist — so the helper throws if any
+code is anything other than `1`. That makes the "every streaming write
+renews its TTL" invariant fail 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`:
+
+```javascript
+async getFeatures(entityId, fieldNames = null) {
+ const key = this.keyFor(entityId);
+ if (fieldNames === null || fieldNames === undefined) {
+ return await this.redis.hGetAll(key);
+ }
+ const names = [...fieldNames];
+ if (names.length === 0) return {};
+ const values = await this.redis.hmGet(key, names);
+ const out = {};
+ for (let i = 0; i < names.length; i += 1) {
+ const v = values[i];
+ if (v !== null && v !== undefined) out[names[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`. The helper drops them from the result object
+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:
+
+```javascript
+async batchGetFeatures(entityIds, fieldNames) {
+ const ids = [...entityIds];
+ const names = [...fieldNames];
+ if (ids.length === 0 || names.length === 0) return {};
+
+ const pipe = this.redis.multi();
+ for (const entityId of ids) {
+ pipe.hmGet(this.keyFor(entityId), names);
+ }
+ const rows = await pipe.exec();
+
+ const out = {};
+ for (let i = 0; i < ids.length; i += 1) {
+ const values = rows[i] || [];
+ const row = {};
+ for (let j = 0; j < names.length; j += 1) {
+ const v = values[j];
+ if (v !== null && v !== undefined) row[names[j]] = v;
+ }
+ out[ids[i]] = row;
+ }
+ return out;
+}
+```
+
+One round trip for the whole batch — the demo regularly returns 100 users in
+2-3 ms against a local Redis. On a real network the round trip dominates;
+pipelining is what keeps batch scoring practical.
+
+For very large batches on a clustered deployment, the same shape generalizes
+to one pipeline per shard. node-redis's
+[cluster client](https://github.com/redis/node-redis/blob/master/docs/clustering.md)
+dispatches the per-user `hmGet` calls to the right shard transparently — you
+still pay one round trip per shard rather than one for the whole batch. For
+very latency-sensitive batch inference, group users by hash slot
+(`cluster.calculateSlot(key)`) and issue one `multi().exec()` per shard in
+parallel.
+
+## The streaming worker
+
+`streamingWorker.js` 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/nodejs/streamingWorker.js)).
+It runs as a `setTimeout` loop 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. Drop `--seed-users` or raise `--users-per-tick` if you'd rather
+have every user touched quickly.
+
+```javascript
+async _tick() {
+ const ids = await this.store.listEntityIds(500);
+ if (ids.length === 0) return;
+ const chosen = this.rng.sample(ids, this.usersPerTick);
+ const nowMs = Date.now();
+ for (const entityId of chosen) {
+ const fields = {
+ last_login_ts: nowMs,
+ last_device_id: this.rng.choice(DEVICE_IDS),
+ tx_count_5m: this.rng.int(0, 12),
+ failed_logins_15m: this.rng.weightedChoice(
+ FAILED_LOGIN_BUCKETS, FAILED_LOGIN_WEIGHTS,
+ ),
+ session_country: this.rng.choice(SESSION_COUNTRIES),
+ };
+ await this.store.updateStreaming(entityId, 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.
+
+The worker uses a `setTimeout` chain rather than `setInterval`, so a slow
+tick (or a paused state) never queues up overlapping work — each tick fully
+finishes before the next one schedules. That mirrors how a real Flink
+operator backpressures: don't accept the next input until the current one
+has been committed.
+
+## The batch builder
+
+`buildFeatures.js` is the demo's nightly materializer
+([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/nodejs/buildFeatures.js)).
+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.
+
+```javascript
+function synthesizeUsers(count, seed = 42) {
+ const rng = makeRng(seed);
+ const users = {};
+ for (let i = 1; i <= count; i += 1) {
+ const uid = `u${String(i).padStart(4, "0")}`;
+ users[uid] = {
+ country_iso: rng.choice(COUNTRY_CHOICES),
+ risk_segment: rng.weightedChoice(RISK_SEGMENTS, RISK_WEIGHTS),
+ account_age_days: rng.int(7, 2400),
+ tx_count_7d: rng.int(0, 80),
+ avg_amount_30d: Number(rng.float(5, 350).toFixed(2)),
+ chargeback_count_180d: rng.weightedChoice(
+ CHARGEBACK_BUCKETS, CHARGEBACK_WEIGHTS,
+ ),
+ };
+ }
+ return users;
+}
+```
+
+You can run the builder on its own (independently of the demo server) to
+populate Redis from the command line:
+
+```bash
+node buildFeatures.js --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.js` runs a Node.js `http` server on port 8086. 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` instance 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` | Batched `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.
+* **Node.js 18 or later.**
+* The `node-redis` client. Install it with:
+
+ ```bash
+ npm install "redis@^5"
+ ```
+
+ Field-level TTL bindings (`hExpire`, `hTTL`) ship in node-redis 5.
+
+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 consists of five files. Download them from the
+[`nodejs` source folder](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/nodejs)
+on GitHub, or grab them with `curl`:
+
+```bash
+mkdir feature-store-nodejs-demo && cd feature-store-nodejs-demo
+BASE=https://raw.githubusercontent.com/redis/docs/main/content/develop/use-cases/feature-store/nodejs
+curl -O $BASE/package.json
+curl -O $BASE/featureStore.js
+curl -O $BASE/buildFeatures.js
+curl -O $BASE/streamingWorker.js
+curl -O $BASE/demoServer.js
+npm install
+```
+
+### Start the demo server
+
+From that directory:
+
+```bash
+node demoServer.js
+```
+
+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:8086
+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:8086](http://127.0.0.1:8086) 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 node-redis production
+checklist — connection pooling, TLS and AUTH, error handling, retry policy —
+see the [node-redis client guide]({{< relref "/develop/clients/nodejs" >}})
+and the
+[connect-with-TLS recipe]({{< relref "/develop/clients/nodejs/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, pipelining `HMGET` across `N` users through
+`multi().exec()` is one round trip. On a Redis Cluster, the keys land on
+different shards — node-redis's cluster client dispatches each `hmGet` to
+the right shard transparently, but you still pay one round trip per shard
+rather than one for the whole batch. For very latency-sensitive batch
+inference, group users by hash slot and issue one `multi().exec()` 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 the keys onto the
+same shard and lets one pipeline serve them all in one 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 `multi()` (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/nodejs/transpipe" >}}).
+
+See the [node-redis documentation]({{< relref "/develop/clients/nodejs" >}})
+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/nodejs/buildFeatures.js b/content/develop/use-cases/feature-store/nodejs/buildFeatures.js
new file mode 100644
index 0000000000..0b49fa97cf
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/buildFeatures.js
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+"use strict";
+
+/**
+ * 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`.
+ */
+
+const { createClient } = require("redis");
+const { FeatureStore } = require("./featureStore");
+
+const COUNTRY_CHOICES = [
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL",
+];
+const RISK_SEGMENTS = ["low", "medium", "high"];
+const RISK_WEIGHTS = [70, 25, 5];
+const CHARGEBACK_BUCKETS = [0, 1, 2, 3];
+const CHARGEBACK_WEIGHTS = [85, 10, 4, 1];
+
+/**
+ * Deterministic LCG so the synthetic data is reproducible across runs
+ * without pulling in a third-party PRNG. Not for any other purpose.
+ */
+function makeRng(seed) {
+ let state = (seed >>> 0) || 1;
+ return {
+ next() {
+ // Numerical Recipes LCG constants.
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ return state / 0x1_0000_0000;
+ },
+ int(min, max) {
+ return Math.floor(this.next() * (max - min + 1)) + min;
+ },
+ float(min, max) {
+ return this.next() * (max - min) + min;
+ },
+ choice(items) {
+ return items[this.int(0, items.length - 1)];
+ },
+ weightedChoice(items, weights) {
+ const total = weights.reduce((a, b) => a + b, 0);
+ let r = this.next() * total;
+ for (let i = 0; i < items.length; i += 1) {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ },
+ };
+}
+
+/**
+ * Generate `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.
+ *
+ * @param {number} count
+ * @param {number} [seed=42]
+ */
+function synthesizeUsers(count, seed = 42) {
+ const rng = makeRng(seed);
+ const users = {};
+ for (let i = 1; i <= count; i += 1) {
+ const uid = `u${String(i).padStart(4, "0")}`;
+ users[uid] = {
+ country_iso: rng.choice(COUNTRY_CHOICES),
+ risk_segment: rng.weightedChoice(RISK_SEGMENTS, RISK_WEIGHTS),
+ account_age_days: rng.int(7, 2400),
+ tx_count_7d: rng.int(0, 80),
+ avg_amount_30d: Number(rng.float(5, 350).toFixed(2)),
+ chargeback_count_180d: rng.weightedChoice(
+ CHARGEBACK_BUCKETS,
+ CHARGEBACK_WEIGHTS,
+ ),
+ };
+ }
+ return users;
+}
+
+function parseArgs(argv) {
+ const opts = {
+ redisHost: "localhost",
+ redisPort: 6379,
+ count: 200,
+ ttlSeconds: 24 * 60 * 60,
+ keyPrefix: "fs:user:",
+ seed: 42,
+ };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ const next = () => argv[i + 1];
+ switch (arg) {
+ case "--redis-host": opts.redisHost = next(); i += 1; break;
+ case "--redis-port": opts.redisPort = Number(next()); i += 1; break;
+ case "--count": opts.count = Number(next()); i += 1; break;
+ case "--ttl-seconds": opts.ttlSeconds = Number(next()); i += 1; break;
+ case "--key-prefix": opts.keyPrefix = next(); i += 1; break;
+ case "--seed": opts.seed = Number(next()); i += 1; break;
+ case "-h":
+ case "--help":
+ console.log(
+ "Usage: node buildFeatures.js " +
+ "[--redis-host H] [--redis-port P] [--count N] " +
+ "[--ttl-seconds S] [--key-prefix PREFIX] [--seed N]",
+ );
+ process.exit(0);
+ break;
+ default:
+ console.error(`Unknown argument: ${arg}`);
+ process.exit(2);
+ }
+ }
+ return opts;
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+
+ const client = createClient({
+ socket: { host: opts.redisHost, port: opts.redisPort },
+ });
+ client.on("error", (err) => console.error("Redis client error:", err));
+ await client.connect();
+
+ const store = new FeatureStore({
+ redisClient: client,
+ keyPrefix: opts.keyPrefix,
+ batchTtlSeconds: opts.ttlSeconds,
+ });
+
+ const rows = synthesizeUsers(opts.count, opts.seed);
+ await store.bulkLoad(rows);
+
+ console.log(
+ `Materialized ${Object.keys(rows).length} users at ${opts.keyPrefix}* ` +
+ `with a ${opts.ttlSeconds}s key-level TTL.`,
+ );
+
+ await client.quit();
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+ });
+}
+
+module.exports = { synthesizeUsers };
diff --git a/content/develop/use-cases/feature-store/nodejs/demoServer.js b/content/develop/use-cases/feature-store/nodejs/demoServer.js
new file mode 100644
index 0000000000..0898b5a802
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/demoServer.js
@@ -0,0 +1,804 @@
+#!/usr/bin/env node
+"use strict";
+
+/**
+ * Redis feature-store demo server (Node.js).
+ *
+ * Run this file and visit http://localhost:8086 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 `HEXPIRE`,
+ * and the inference panel reads any subset of features for any user
+ * with `HMGET` in a single round trip.
+ *
+ * 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.
+ */
+
+const http = require("http");
+const { URL, URLSearchParams } = require("url");
+const { performance } = require("perf_hooks");
+const { createClient } = require("redis");
+
+const {
+ FeatureStore,
+ DEFAULT_BATCH_FIELDS,
+ DEFAULT_STREAMING_FIELDS,
+} = require("./featureStore");
+const { StreamingWorker } = require("./streamingWorker");
+const { synthesizeUsers } = require("./buildFeatures");
+
+
+const HTML_TEMPLATE = `
+
+
+
+
+ Redis Feature Store Demo (Node.js)
+
+
+
+
+
node-redis + Node.js standard http module
+
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
+ inside a multi(), 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
+ multi(). 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)
+
+
+
+
+
+
+
+
+
+
+`;
+
+
+class FeatureStoreDemo {
+ /**
+ * @param {object} options
+ * @param {FeatureStore} options.store
+ * @param {StreamingWorker} options.worker
+ * @param {number} options.seed
+ */
+ constructor({ store, worker, seed }) {
+ this.store = store;
+ this.worker = worker;
+ this.seed = seed;
+ }
+
+ async materialize(count, ttlSeconds) {
+ const rows = synthesizeUsers(count, this.seed);
+ const start = performance.now();
+ const loaded = await this.store.bulkLoad(rows, ttlSeconds);
+ const elapsedMs = performance.now() - start;
+ return { loaded, ttl_seconds: ttlSeconds, elapsed_ms: elapsedMs };
+ }
+
+ async reset() {
+ // 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).
+ const wasPaused = this.worker.paused;
+ if (this.worker.running && !wasPaused) this.worker.pause();
+ try {
+ const deleted = await this.store.reset();
+ this.store.resetStats();
+ this.worker.resetStats();
+ return { deleted };
+ } finally {
+ if (this.worker.running && !wasPaused) this.worker.resume();
+ }
+ }
+
+ toggleWorker() {
+ if (!this.worker.running) this.worker.start();
+ if (this.worker.paused) this.worker.resume();
+ else this.worker.pause();
+ return { paused: this.worker.paused, running: this.worker.running };
+ }
+}
+
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on("data", (c) => chunks.push(c));
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ req.on("error", reject);
+ });
+}
+
+
+function sendJson(res, payload, status = 200) {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(payload));
+}
+
+
+function sendHtml(res, html, status = 200) {
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
+ res.end(html);
+}
+
+
+function renderHtmlPage(store, worker) {
+ return HTML_TEMPLATE
+ .replaceAll("__KEY_PREFIX__", store.keyPrefix)
+ .replaceAll("__STREAM_TTL__", String(store.streamingTtlSeconds))
+ .replaceAll("__USERS_PER_TICK__", String(worker.usersPerTick))
+ .replaceAll("__BATCH_FIELDS_JSON__", JSON.stringify([...DEFAULT_BATCH_FIELDS]))
+ .replaceAll("__STREAM_FIELDS_JSON__", JSON.stringify([...DEFAULT_STREAMING_FIELDS]));
+}
+
+
+async function handleRequest(req, res, ctx) {
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
+ const { store, worker, demo } = ctx;
+
+ try {
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
+ sendHtml(res, renderHtmlPage(store, worker));
+ return;
+ }
+ if (req.method === "GET" && url.pathname === "/state") {
+ const ids = await store.listEntityIds(500);
+ // listEntityIds caps at 500 for the dropdown; report the true total
+ // separately so the UI's "users in store" doesn't silently truncate.
+ const entityCount = await store.countEntities();
+ sendJson(res, {
+ key_prefix: store.keyPrefix,
+ batch_ttl_seconds: store.batchTtlSeconds,
+ streaming_ttl_seconds: store.streamingTtlSeconds,
+ entity_count: entityCount,
+ entity_ids: ids,
+ stats: store.stats(),
+ worker: worker.statsSnapshot(),
+ });
+ return;
+ }
+ if (req.method === "GET" && url.pathname === "/inspect") {
+ const user = (url.searchParams.get("user") || "").trim();
+ if (!user) { sendJson(res, { error: "user is required" }, 400); return; }
+ const full = await store.getFeatures(user, null);
+ if (Object.keys(full).length === 0) {
+ const keyTtl = await store.keyTtlSeconds(user);
+ sendJson(res, { exists: false, key_ttl_seconds: keyTtl });
+ return;
+ }
+ const fieldNames = Object.keys(full);
+ const ttls = await store.fieldTtlsSeconds(user, fieldNames);
+ const keyTtl = await store.keyTtlSeconds(user);
+ const fields = fieldNames
+ .map((name) => ({ name, value: full[name], ttl_seconds: ttls[name] ?? -1 }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ sendJson(res, {
+ exists: true,
+ key_ttl_seconds: keyTtl,
+ fields,
+ });
+ return;
+ }
+
+ if (req.method !== "POST") {
+ res.writeHead(404).end();
+ return;
+ }
+
+ const body = await readBody(req);
+ const params = new URLSearchParams(body);
+
+ if (url.pathname === "/bulk-load") {
+ const count = Math.max(1, Math.min(2000, Number(params.get("count") || "200")));
+ const ttl = Math.max(5, Math.min(172_800, Number(params.get("ttl") || "86400")));
+ sendJson(res, await demo.materialize(count, ttl));
+ return;
+ }
+ if (url.pathname === "/reset") {
+ sendJson(res, await demo.reset());
+ return;
+ }
+ if (url.pathname === "/worker/toggle") {
+ sendJson(res, demo.toggleWorker());
+ return;
+ }
+ if (url.pathname === "/read") {
+ const user = (params.get("user") || "").trim();
+ if (!user) { sendJson(res, { error: "user is required" }, 400); return; }
+ const fields = params.getAll("field").filter((f) => f);
+ const start = performance.now();
+ const values = fields.length ? await store.getFeatures(user, fields) : {};
+ const elapsedMs = performance.now() - start;
+ const ttls = fields.length ? await store.fieldTtlsSeconds(user, fields) : {};
+ const keyTtl = await store.keyTtlSeconds(user);
+ sendJson(res, {
+ requested: fields,
+ values,
+ ttls,
+ key_ttl_seconds: keyTtl,
+ returned_count: Object.keys(values).length,
+ elapsed_ms: elapsedMs,
+ });
+ return;
+ }
+ if (url.pathname === "/batch-read") {
+ const count = Math.max(1, Math.min(500, Number(params.get("count") || "100")));
+ let fields = params.getAll("field").filter((f) => f);
+ if (fields.length === 0) {
+ fields = [...DEFAULT_STREAMING_FIELDS, "risk_segment"];
+ }
+ let ids = await store.listEntityIds(2000);
+ if (ids.length > count) ids = ids.slice(0, count);
+ const start = performance.now();
+ const rows = await store.batchGetFeatures(ids, fields);
+ const elapsedMs = performance.now() - start;
+ const sample = ids.slice(0, 10).map((id) => ({
+ id,
+ field_count: Object.keys(rows[id] || {}).length,
+ }));
+ sendJson(res, {
+ entity_count: ids.length,
+ field_count: fields.length,
+ elapsed_ms: elapsedMs,
+ sample,
+ });
+ return;
+ }
+
+ res.writeHead(404).end();
+ } catch (err) {
+ console.error(`[demo] ${req.method} ${url.pathname} failed:`, err);
+ sendJson(res, { error: err.message || "internal error" }, 500);
+ }
+}
+
+
+function parseArgs(argv) {
+ const opts = {
+ host: "127.0.0.1",
+ port: 8086,
+ redisHost: "localhost",
+ redisPort: 6379,
+ keyPrefix: "fs:user:",
+ batchTtlSeconds: 24 * 60 * 60,
+ streamingTtlSeconds: 5 * 60,
+ usersPerTick: 5,
+ seedUsers: 200,
+ resetOnStart: true,
+ };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ const next = () => argv[i + 1];
+ switch (arg) {
+ case "--host": opts.host = next(); i += 1; break;
+ case "--port": opts.port = Number(next()); i += 1; break;
+ case "--redis-host": opts.redisHost = next(); i += 1; break;
+ case "--redis-port": opts.redisPort = Number(next()); i += 1; break;
+ case "--key-prefix": opts.keyPrefix = next(); i += 1; break;
+ case "--batch-ttl-seconds":
+ opts.batchTtlSeconds = Number(next()); i += 1; break;
+ case "--streaming-ttl-seconds":
+ opts.streamingTtlSeconds = Number(next()); i += 1; break;
+ case "--users-per-tick":
+ opts.usersPerTick = Number(next()); i += 1; break;
+ case "--seed-users": opts.seedUsers = Number(next()); i += 1; break;
+ case "--no-reset": opts.resetOnStart = false; break;
+ case "-h":
+ case "--help":
+ console.log(
+ "Usage: node demoServer.js [--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]",
+ );
+ process.exit(0);
+ break;
+ default:
+ console.error(`Unknown argument: ${arg}`);
+ process.exit(2);
+ }
+ }
+ return opts;
+}
+
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+
+ const client = createClient({
+ socket: { host: opts.redisHost, port: opts.redisPort },
+ });
+ client.on("error", (err) => console.error("Redis client error:", err));
+ await client.connect();
+
+ const store = new FeatureStore({
+ redisClient: client,
+ keyPrefix: opts.keyPrefix,
+ batchTtlSeconds: opts.batchTtlSeconds,
+ streamingTtlSeconds: opts.streamingTtlSeconds,
+ });
+ const worker = new StreamingWorker({
+ store,
+ usersPerTick: opts.usersPerTick,
+ });
+ const demo = new FeatureStoreDemo({ store, worker, seed: 42 });
+
+ if (opts.resetOnStart) {
+ console.log(
+ `Dropping any existing users under '${opts.keyPrefix}*' for a` +
+ " clean demo run (pass --no-reset to keep them).",
+ );
+ await store.reset();
+ store.resetStats();
+ }
+ const { loaded: seeded } = await demo.materialize(
+ opts.seedUsers,
+ opts.batchTtlSeconds,
+ );
+
+ worker.start();
+
+ const server = http.createServer((req, res) => {
+ handleRequest(req, res, { store, worker, demo }).catch((err) => {
+ console.error("[demo] handler crashed:", err);
+ try { res.writeHead(500).end(); } catch (_) { /* socket already closed */ }
+ });
+ });
+
+ await new Promise((resolve) => server.listen(opts.port, opts.host, resolve));
+ console.log(
+ `Redis feature-store demo server listening on http://${opts.host}:${opts.port}`,
+ );
+ console.log(
+ `Using Redis at ${opts.redisHost}:${opts.redisPort}` +
+ ` with key prefix '${opts.keyPrefix}'` +
+ ` (batch TTL ${opts.batchTtlSeconds}s,` +
+ ` streaming TTL ${opts.streamingTtlSeconds}s)`,
+ );
+ console.log(`Materialized ${seeded} user(s); streaming worker running.`);
+
+ const shutdown = async (signal) => {
+ console.log(`\nReceived ${signal}, shutting down...`);
+ await worker.stop();
+ server.close();
+ await client.quit();
+ process.exit(0);
+ };
+ process.on("SIGINT", () => shutdown("SIGINT"));
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
+}
+
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+ });
+}
diff --git a/content/develop/use-cases/feature-store/nodejs/featureStore.js b/content/develop/use-cases/feature-store/nodejs/featureStore.js
new file mode 100644
index 0000000000..5ec1467593
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/featureStore.js
@@ -0,0 +1,451 @@
+"use strict";
+
+/**
+ * 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. node-redis 5
+ * exposes them as `hExpire` and `hTTL`.
+ *
+ * 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.
+ */
+
+/**
+ * @typedef {string|number|boolean} FeatureValue
+ * @typedef {Record} FeatureMap
+ */
+
+/**
+ * Default batch feature schema. Daily aggregates computed offline and
+ * bulk-loaded once per materialization cycle.
+ */
+const DEFAULT_BATCH_FIELDS = Object.freeze([
+ "country_iso",
+ "risk_segment",
+ "account_age_days",
+ "tx_count_7d",
+ "avg_amount_30d",
+ "chargeback_count_180d",
+]);
+
+/**
+ * Default streaming feature schema. Updated by the streaming worker as
+ * new events arrive, with a per-field TTL so each field self-expires
+ * when its upstream pipeline stops.
+ */
+const DEFAULT_STREAMING_FIELDS = Object.freeze([
+ "last_login_ts",
+ "last_device_id",
+ "tx_count_5m",
+ "failed_logins_15m",
+ "session_country",
+]);
+
+/**
+ * Encode a feature value as a string for hash storage.
+ *
+ * Booleans become `"true"` / `"false"` (not `"True"` / `"False"`) so
+ * they round-trip cleanly through other clients and `redis-cli`.
+ *
+ * @param {FeatureValue} value
+ * @returns {string}
+ */
+function encode(value) {
+ if (typeof value === "boolean") return value ? "true" : "false";
+ return String(value);
+}
+
+class FeatureStore {
+ /**
+ * @param {object} options
+ * @param {import("redis").RedisClientType} options.redisClient
+ * @param {string} [options.keyPrefix="fs:user:"]
+ * @param {number} [options.batchTtlSeconds=86400]
+ * @param {number} [options.streamingTtlSeconds=300]
+ */
+ constructor({
+ redisClient,
+ keyPrefix = "fs:user:",
+ batchTtlSeconds = 24 * 60 * 60,
+ streamingTtlSeconds = 5 * 60,
+ } = {}) {
+ if (!redisClient) {
+ throw new Error("A connected redisClient is required.");
+ }
+ this.redis = redisClient;
+ this.keyPrefix = keyPrefix;
+ this.batchTtlSeconds = batchTtlSeconds;
+ this.streamingTtlSeconds = streamingTtlSeconds;
+
+ // Node.js is single-threaded for JS execution, so plain numbers
+ // are safe for counters. No lock needed.
+ this.batchWritesTotal = 0;
+ this.streamingWritesTotal = 0;
+ this.readsTotal = 0;
+ this.readFieldsTotal = 0;
+ }
+
+ // --- Key helpers ---------------------------------------------------
+
+ /** @param {string} entityId */
+ keyFor(entityId) {
+ return `${this.keyPrefix}${entityId}`;
+ }
+
+ // --- Batch ingestion (materialization) -----------------------------
+
+ /**
+ * Materialize a batch of entities into Redis.
+ *
+ * `rows` is `{entityId: {field: value, ...}}`. One `HSET` plus one
+ * `EXPIRE` per entity, all batched into a single round trip through
+ * `multi().exec()`. The key-level `EXPIRE` is what makes the whole
+ * entity disappear if a future batch run fails — inference reads
+ * the missing entity rather than silently outdated values.
+ *
+ * @param {Record} rows
+ * @param {number} [ttlSeconds]
+ * @returns {Promise}
+ */
+ async bulkLoad(rows, ttlSeconds) {
+ const ttl = ttlSeconds ?? this.batchTtlSeconds;
+ const ids = Object.keys(rows);
+ if (ids.length === 0) return 0;
+
+ const pipe = this.redis.multi();
+ for (const entityId of ids) {
+ const key = this.keyFor(entityId);
+ const fields = rows[entityId];
+ const encoded = {};
+ for (const [name, value] of Object.entries(fields)) {
+ encoded[name] = encode(value);
+ }
+ pipe.hSet(key, encoded);
+ pipe.expire(key, ttl);
+ }
+ await pipe.exec();
+ this.batchWritesTotal += ids.length;
+ return ids.length;
+ }
+
+ /**
+ * Update a single batch feature without touching the key TTL.
+ *
+ * Used by the demo's "manually refresh one user" lever; in a real
+ * pipeline batch updates always flow through `bulkLoad`.
+ *
+ * @param {string} entityId
+ * @param {string} field
+ * @param {FeatureValue} value
+ * @returns {Promise}
+ */
+ async updateBatchFeature(entityId, field, value) {
+ await this.redis.hSet(this.keyFor(entityId), field, encode(value));
+ this.batchWritesTotal += 1;
+ }
+
+ // --- Streaming ingestion -------------------------------------------
+
+ /**
+ * Write 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,
+ * 2 = skipped under a conditional flag, 0 = no such field,
+ * -2 = no such key. We just `HSET` every field on the same call,
+ * so 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.
+ *
+ * @param {string} entityId
+ * @param {FeatureMap} fields
+ * @param {number} [ttlSeconds]
+ * @returns {Promise}
+ */
+ async updateStreaming(entityId, fields, ttlSeconds) {
+ const names = Object.keys(fields);
+ if (names.length === 0) return;
+ const ttl = ttlSeconds ?? this.streamingTtlSeconds;
+ const key = this.keyFor(entityId);
+ const encoded = {};
+ for (const [name, value] of Object.entries(fields)) {
+ encoded[name] = encode(value);
+ }
+
+ const [, expireResult] = await this.redis
+ .multi()
+ .hSet(key, encoded)
+ .hExpire(key, names, ttl)
+ .exec();
+ if (!Array.isArray(expireResult) ||
+ expireResult.some((code) => Number(code) !== 1)) {
+ throw new Error(
+ `HEXPIRE did not set every field TTL for ${key}: ` +
+ JSON.stringify(expireResult),
+ );
+ }
+ this.streamingWritesTotal += names.length;
+ }
+
+ // --- Inference reads -----------------------------------------------
+
+ /**
+ * Retrieve a subset of features for one entity.
+ *
+ * `HMGET` returns the requested fields in one round trip. Pass
+ * `fieldNames=null` (the default) to fetch the entire hash with
+ * `HGETALL` — useful for debugging but rarely the right call on the
+ * request path, where the model knows exactly which features it
+ * consumes.
+ *
+ * @param {string} entityId
+ * @param {string[] | null} [fieldNames]
+ * @returns {Promise>}
+ */
+ async getFeatures(entityId, fieldNames = null) {
+ const key = this.keyFor(entityId);
+ if (fieldNames === null || fieldNames === undefined) {
+ const data = await this.redis.hGetAll(key);
+ this.readsTotal += 1;
+ this.readFieldsTotal += Object.keys(data).length;
+ return data;
+ }
+ const names = [...fieldNames];
+ if (names.length === 0) return {};
+ const values = await this.redis.hmGet(key, names);
+ const out = {};
+ let returned = 0;
+ for (let i = 0; i < names.length; i += 1) {
+ const v = values[i];
+ if (v !== null && v !== undefined) {
+ out[names[i]] = v;
+ returned += 1;
+ }
+ }
+ this.readsTotal += 1;
+ this.readFieldsTotal += returned;
+ return out;
+ }
+
+ /**
+ * Pipeline `HMGET` across many entities for batch scoring.
+ *
+ * Hundreds of entities in one round trip. The model can then score
+ * them all without further network calls.
+ *
+ * @param {Iterable} entityIds
+ * @param {Iterable} fieldNames
+ * @returns {Promise>>}
+ */
+ async batchGetFeatures(entityIds, fieldNames) {
+ const ids = [...entityIds];
+ const names = [...fieldNames];
+ if (ids.length === 0 || names.length === 0) return {};
+
+ const pipe = this.redis.multi();
+ for (const entityId of ids) {
+ pipe.hmGet(this.keyFor(entityId), names);
+ }
+ const rows = await pipe.exec();
+
+ const out = {};
+ let seenFields = 0;
+ for (let i = 0; i < ids.length; i += 1) {
+ const values = rows[i] || [];
+ const row = {};
+ for (let j = 0; j < names.length; j += 1) {
+ const v = values[j];
+ if (v !== null && v !== undefined) {
+ row[names[j]] = v;
+ seenFields += 1;
+ }
+ }
+ out[ids[i]] = row;
+ }
+ this.readsTotal += ids.length;
+ this.readFieldsTotal += seenFields;
+ return out;
+ }
+
+ // --- TTL inspection (used by the demo UI) --------------------------
+
+ /**
+ * Seconds until the entity key expires.
+ *
+ * Returns `-1` if no key-level TTL is set, `-2` if the key doesn't
+ * exist.
+ *
+ * @param {string} entityId
+ * @returns {Promise}
+ */
+ async keyTtlSeconds(entityId) {
+ return Number(await this.redis.ttl(this.keyFor(entityId)));
+ }
+
+ /**
+ * Per-field TTL via `HTTL` (Redis 7.4+).
+ *
+ * Each value mirrors the `TTL` convention: positive means seconds
+ * remaining, `-1` means no TTL on the field, `-2` means the field
+ * doesn't exist on this hash (or the key itself is missing).
+ *
+ * Normalized for forward-compat: some client versions can report
+ * `null` for a missing key or a singleton list-of-list in pipeline
+ * contexts. Both shapes collapse back to the flat list shape that
+ * matches the field order passed in.
+ *
+ * @param {string} entityId
+ * @param {Iterable} fieldNames
+ * @returns {Promise>}
+ */
+ async fieldTtlsSeconds(entityId, fieldNames) {
+ const names = [...fieldNames];
+ if (names.length === 0) return {};
+ let ttls = await this.redis.hTTL(this.keyFor(entityId), names);
+ if (ttls === null || ttls === undefined) {
+ ttls = names.map(() => -2);
+ } else if (Array.isArray(ttls) && ttls.length === 1 && Array.isArray(ttls[0])) {
+ ttls = ttls[0];
+ }
+ const out = {};
+ for (let i = 0; i < names.length; i += 1) {
+ out[names[i]] = Number(ttls[i]);
+ }
+ return out;
+ }
+
+ // --- Demo housekeeping ---------------------------------------------
+
+ /**
+ * Enumerate entity IDs by scanning `keyPrefix*`.
+ *
+ * `SCAN` is non-blocking; the demo uses it to populate UI dropdowns,
+ * not as a serving primitive.
+ *
+ * @param {number} [limit=200]
+ * @returns {Promise}
+ */
+ async listEntityIds(limit = 200) {
+ const ids = [];
+ const prefixLen = this.keyPrefix.length;
+ for await (const key of this.redis.scanIterator({
+ MATCH: `${this.keyPrefix}*`,
+ COUNT: 200,
+ })) {
+ // node-redis 5 yields each batch as an array; older majors yield one key.
+ if (Array.isArray(key)) {
+ for (const k of key) {
+ ids.push(k.slice(prefixLen));
+ if (ids.length >= limit) break;
+ }
+ } else {
+ ids.push(key.slice(prefixLen));
+ }
+ if (ids.length >= limit) break;
+ }
+ ids.sort();
+ return ids.slice(0, limit);
+ }
+
+ /**
+ * Count entities currently in the store (via `SCAN`).
+ * @returns {Promise}
+ */
+ async countEntities() {
+ let count = 0;
+ for await (const key of this.redis.scanIterator({
+ MATCH: `${this.keyPrefix}*`,
+ COUNT: 500,
+ })) {
+ count += Array.isArray(key) ? key.length : 1;
+ }
+ return count;
+ }
+
+ /**
+ * @param {string} entityId
+ * @returns {Promise}
+ */
+ async deleteEntity(entityId) {
+ return Number(await this.redis.del(this.keyFor(entityId)));
+ }
+
+ /**
+ * Drop every entity under `keyPrefix`. 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.
+ *
+ * @returns {Promise}
+ */
+ async reset() {
+ let deleted = 0;
+ let batch = [];
+ const flush = async () => {
+ if (batch.length === 0) return;
+ deleted += Number(await this.redis.del(batch));
+ batch = [];
+ };
+ for await (const key of this.redis.scanIterator({
+ MATCH: `${this.keyPrefix}*`,
+ COUNT: 500,
+ })) {
+ if (Array.isArray(key)) {
+ batch.push(...key);
+ } else {
+ batch.push(key);
+ }
+ if (batch.length >= 500) await flush();
+ }
+ await flush();
+ return deleted;
+ }
+
+ stats() {
+ return {
+ batch_writes_total: this.batchWritesTotal,
+ streaming_writes_total: this.streamingWritesTotal,
+ reads_total: this.readsTotal,
+ read_fields_total: this.readFieldsTotal,
+ };
+ }
+
+ resetStats() {
+ this.batchWritesTotal = 0;
+ this.streamingWritesTotal = 0;
+ this.readsTotal = 0;
+ this.readFieldsTotal = 0;
+ }
+}
+
+module.exports = {
+ FeatureStore,
+ DEFAULT_BATCH_FIELDS,
+ DEFAULT_STREAMING_FIELDS,
+};
diff --git a/content/develop/use-cases/feature-store/nodejs/package.json b/content/develop/use-cases/feature-store/nodejs/package.json
new file mode 100644
index 0000000000..0cc11c8a7b
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "redis-feature-store-nodejs-demo",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Redis online feature store demo with node-redis and the Node.js standard http module.",
+ "main": "demoServer.js",
+ "scripts": {
+ "start": "node demoServer.js",
+ "build": "node buildFeatures.js"
+ },
+ "dependencies": {
+ "redis": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/content/develop/use-cases/feature-store/nodejs/streamingWorker.js b/content/develop/use-cases/feature-store/nodejs/streamingWorker.js
new file mode 100644
index 0000000000..4b381965e0
--- /dev/null
+++ b/content/develop/use-cases/feature-store/nodejs/streamingWorker.js
@@ -0,0 +1,178 @@
+"use strict";
+
+/**
+ * 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 an async
+ * timer 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 `HEXPIRE` so the field
+ * self-expires if the worker is paused. Pause the worker 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.
+ */
+
+const DEVICE_IDS = [
+ "ios-1a4c", "ios-9f02", "and-7b21", "and-2d18",
+ "web-chr-1", "web-saf-1", "web-ff-2",
+];
+const SESSION_COUNTRIES = [
+ "US", "GB", "DE", "FR", "IN", "BR", "JP", "AU", "CA", "NL",
+];
+const FAILED_LOGIN_BUCKETS = [0, 1, 2, 5];
+const FAILED_LOGIN_WEIGHTS = [70, 20, 8, 2];
+
+function makeRng(seed) {
+ let state = (seed >>> 0) || 1;
+ return {
+ next() {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ return state / 0x1_0000_0000;
+ },
+ int(min, max) {
+ return Math.floor(this.next() * (max - min + 1)) + min;
+ },
+ choice(items) {
+ return items[this.int(0, items.length - 1)];
+ },
+ weightedChoice(items, weights) {
+ const total = weights.reduce((a, b) => a + b, 0);
+ let r = this.next() * total;
+ for (let i = 0; i < items.length; i += 1) {
+ r -= weights[i];
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ },
+ sample(items, k) {
+ const pool = [...items];
+ const out = [];
+ const n = Math.min(k, pool.length);
+ for (let i = 0; i < n; i += 1) {
+ const idx = this.int(0, pool.length - 1);
+ out.push(pool.splice(idx, 1)[0]);
+ }
+ return out;
+ },
+ };
+}
+
+class StreamingWorker {
+ /**
+ * @param {object} options
+ * @param {import("./featureStore").FeatureStore} options.store
+ * @param {number} [options.tickSeconds=1.0]
+ * @param {number} [options.usersPerTick=5]
+ * @param {number} [options.seed=1337]
+ */
+ constructor({ store, tickSeconds = 1.0, usersPerTick = 5, seed = 1337 } = {}) {
+ if (!store) throw new Error("store is required");
+ this.store = store;
+ this.tickSeconds = tickSeconds;
+ this.usersPerTick = usersPerTick;
+ this.rng = makeRng(seed);
+
+ this.running = false;
+ this.paused = false;
+ this.tickCount = 0;
+ this.writesCount = 0;
+ this._timer = null;
+ this._tickInFlight = false;
+ }
+
+ // --- Lifecycle -----------------------------------------------------
+
+ start() {
+ if (this.running) return;
+ this.running = true;
+ this.paused = false;
+ this._schedule();
+ }
+
+ async stop() {
+ this.running = false;
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ // Wait for any in-flight tick to settle so we don't leak a write
+ // that completes after the caller has moved on.
+ while (this._tickInFlight) await new Promise((r) => setTimeout(r, 20));
+ }
+
+ pause() { this.paused = true; }
+ resume() { this.paused = false; }
+
+ // --- Tick ----------------------------------------------------------
+
+ _schedule() {
+ if (!this.running) return;
+ this._timer = setTimeout(
+ () => this._run().catch((err) =>
+ console.error("[streaming-worker] tick failed:", err),
+ ),
+ this.tickSeconds * 1000,
+ );
+ }
+
+ async _run() {
+ if (!this.running) return;
+ if (this.paused) {
+ this._schedule();
+ return;
+ }
+ this._tickInFlight = true;
+ try {
+ await this._tick();
+ } finally {
+ this._tickInFlight = false;
+ this._schedule();
+ }
+ }
+
+ async _tick() {
+ const ids = await this.store.listEntityIds(500);
+ if (ids.length === 0) return;
+ const chosen = this.rng.sample(ids, this.usersPerTick);
+ const nowMs = Date.now();
+ let writes = 0;
+ for (const entityId of chosen) {
+ const fields = {
+ last_login_ts: nowMs,
+ last_device_id: this.rng.choice(DEVICE_IDS),
+ tx_count_5m: this.rng.int(0, 12),
+ failed_logins_15m: this.rng.weightedChoice(
+ FAILED_LOGIN_BUCKETS, FAILED_LOGIN_WEIGHTS,
+ ),
+ session_country: this.rng.choice(SESSION_COUNTRIES),
+ };
+ await this.store.updateStreaming(entityId, fields);
+ writes += Object.keys(fields).length;
+ }
+ this.tickCount += 1;
+ this.writesCount += writes;
+ }
+
+ // --- Stats ---------------------------------------------------------
+
+ statsSnapshot() {
+ return {
+ running: this.running,
+ paused: this.paused,
+ tick_count: this.tickCount,
+ writes_count: this.writesCount,
+ };
+ }
+
+ resetStats() {
+ this.tickCount = 0;
+ this.writesCount = 0;
+ }
+}
+
+module.exports = { StreamingWorker };
From cf2472caec7ed719a859334a1f6796f4eee45632 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 14:18:56 +0100
Subject: [PATCH 04/20] DOC-6661 Codex review issues
---
.../use-cases/feature-store/nodejs/_index.md | 49 ++++++++++++-------
.../feature-store/nodejs/demoServer.js | 9 +++-
.../feature-store/nodejs/featureStore.js | 17 ++++---
.../feature-store/nodejs/streamingWorker.js | 14 ++++++
.../feature-store/redis-py/demo_server.py | 1 +
5 files changed, 64 insertions(+), 26 deletions(-)
diff --git a/content/develop/use-cases/feature-store/nodejs/_index.md b/content/develop/use-cases/feature-store/nodejs/_index.md
index 06c38e9d48..150c06967a 100644
--- a/content/develop/use-cases/feature-store/nodejs/_index.md
+++ b/content/develop/use-cases/feature-store/nodejs/_index.md
@@ -225,11 +225,15 @@ async bulkLoad(rows, ttlSeconds) {
}
```
-`multi()` in node-redis 5 wraps the batched commands in `MULTI/EXEC`, so the
-whole batch runs as one transaction on the server. That gives all-or-nothing
-semantics inside the batch but does block the server for its duration, which
-is what you want for an ingestion script that runs end-to-end — not for a
-hot-path serving call. (See
+`multi().exec()` in node-redis 5 wraps the batched commands in `MULTI/EXEC`,
+so Redis runs the queued commands contiguously and returns the replies in
+order. Note that Redis transactions do *not* roll back commands that already
+succeeded if a later command returns an error — node-redis surfaces those
+errors by rejecting `exec()` with a `MultiErrorReply` whose `replies` array
+still contains the successful results. For independent bulk-ingestion
+commands that don't need the `MULTI/EXEC` wrapper at all,
+`multi().execAsPipeline()` ships the same batch in one round trip with
+slightly lower server-side overhead. (See
[transactions and pipelining]({{< relref "/develop/clients/nodejs/transpipe" >}})
for the full mental model.)
@@ -367,14 +371,19 @@ One round trip for the whole batch — the demo regularly returns 100 users in
2-3 ms against a local Redis. On a real network the round trip dominates;
pipelining is what keeps batch scoring practical.
-For very large batches on a clustered deployment, the same shape generalizes
-to one pipeline per shard. node-redis's
+For very large batches on a clustered deployment, the shape changes: a single
+`multi().exec()` is bound to one shard, because `MULTI/EXEC` cannot span hash
+slots, so the same `batchGetFeatures` call can only serve keys that hash to
+the same shard. node-redis's
[cluster client](https://github.com/redis/node-redis/blob/master/docs/clustering.md)
-dispatches the per-user `hmGet` calls to the right shard transparently — you
-still pay one round trip per shard rather than one for the whole batch. For
-very latency-sensitive batch inference, group users by hash slot
-(`cluster.calculateSlot(key)`) and issue one `multi().exec()` per shard in
-parallel.
+routes non-pipelined `hmGet` calls to the right shard transparently — so on a
+cluster, fan out `await Promise.all(ids.map((id) => client.hmGet(...)))` and
+the client pipelines per-shard for you. For very latency-sensitive batch
+inference where the request-side cost of that fan-out matters, group the IDs
+by hash slot ahead of time and issue one `multi().exec()` per shard in
+parallel: each shard's batch then runs as one round trip. A hash tag like
+`fs:user:{vip}:u0001` forces a known set of keys onto the same shard so one
+`multi()` can cover all of them in a single round trip.
## The streaming worker
@@ -620,16 +629,18 @@ skew.
### Pipeline batch reads across shards
On a single Redis instance, pipelining `HMGET` across `N` users through
-`multi().exec()` is one round trip. On a Redis Cluster, the keys land on
-different shards — node-redis's cluster client dispatches each `hmGet` to
-the right shard transparently, but you still pay one round trip per shard
-rather than one for the whole batch. For very latency-sensitive batch
-inference, group users by hash slot and issue one `multi().exec()` per
-shard in parallel.
+`multi().exec()` is one round trip. A Redis Cluster is different in two ways:
+`MULTI/EXEC` is bound to one shard, so a single `multi()` cannot span keys
+that hash to different shards; and the keys for a typical user batch will
+land on multiple shards. For batch reads on a cluster, fan out parallel
+`hmGet` calls with `Promise.all` — node-redis's cluster client pipelines
+the calls per-shard automatically — or, for tighter control, group the IDs
+by hash slot ahead of time and issue one `multi().exec()` 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 the keys onto the
-same shard and lets one pipeline serve them all in one round trip.
+same shard and lets one `multi().exec()` serve them all in one round trip.
### Make HEXPIRE part of every streaming write
diff --git a/content/develop/use-cases/feature-store/nodejs/demoServer.js b/content/develop/use-cases/feature-store/nodejs/demoServer.js
index 0898b5a802..ff69a35e9e 100644
--- a/content/develop/use-cases/feature-store/nodejs/demoServer.js
+++ b/content/develop/use-cases/feature-store/nodejs/demoServer.js
@@ -373,6 +373,7 @@ const HTML_TEMPLATE = `
if (!confirm("Drop every user from the store?")) return;
const r = await fetch("/reset", { method: "POST" });
const d = await r.json();
+ if (!r.ok) { setStatus(d.error || "Reset failed.", "error"); return; }
setStatus(\`Reset. Dropped \${d.deleted} user(s).\`, "ok");
await refresh();
});
@@ -506,8 +507,14 @@ class FeatureStoreDemo {
// 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 — we also have to await
+ // waitForIdle() so an already-running tick finishes its
+ // updateStreaming loop before we start enumerating keys.
const wasPaused = this.worker.paused;
- if (this.worker.running && !wasPaused) this.worker.pause();
+ if (this.worker.running) {
+ if (!wasPaused) this.worker.pause();
+ await this.worker.waitForIdle();
+ }
try {
const deleted = await this.store.reset();
this.store.resetStats();
diff --git a/content/develop/use-cases/feature-store/nodejs/featureStore.js b/content/develop/use-cases/feature-store/nodejs/featureStore.js
index 5ec1467593..b044aba7d1 100644
--- a/content/develop/use-cases/feature-store/nodejs/featureStore.js
+++ b/content/develop/use-cases/feature-store/nodejs/featureStore.js
@@ -175,12 +175,17 @@ class FeatureStore {
* 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,
- * 2 = skipped under a conditional flag, 0 = no such field,
- * -2 = no such key. We just `HSET` every field on the same call,
- * so 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.
+ * `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.
+ * We just `HSET` every field on the same call, so 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.
*
* @param {string} entityId
* @param {FeatureMap} fields
diff --git a/content/develop/use-cases/feature-store/nodejs/streamingWorker.js b/content/develop/use-cases/feature-store/nodejs/streamingWorker.js
index 4b381965e0..d3b52f0786 100644
--- a/content/develop/use-cases/feature-store/nodejs/streamingWorker.js
+++ b/content/develop/use-cases/feature-store/nodejs/streamingWorker.js
@@ -102,6 +102,20 @@ class StreamingWorker {
}
// Wait for any in-flight tick to settle so we don't leak a write
// that completes after the caller has moved on.
+ await this.waitForIdle();
+ }
+
+ /**
+ * Wait until any in-flight tick has finished its current `await`
+ * sequence. `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 await this before they touch state
+ * the tick might still be writing to.
+ *
+ * @returns {Promise}
+ */
+ async waitForIdle() {
while (this._tickInFlight) await new Promise((r) => setTimeout(r, 20));
}
diff --git a/content/develop/use-cases/feature-store/redis-py/demo_server.py b/content/develop/use-cases/feature-store/redis-py/demo_server.py
index 268c0e9db1..d09f48ad00 100644
--- a/content/develop/use-cases/feature-store/redis-py/demo_server.py
+++ b/content/develop/use-cases/feature-store/redis-py/demo_server.py
@@ -382,6 +382,7 @@
if (!confirm("Drop every user from the store?")) return;
const r = await fetch("/reset", { method: "POST" });
const d = await r.json();
+ if (!r.ok) { setStatus(d.error || "Reset failed.", "error"); return; }
setStatus(`Reset. Dropped ${d.deleted} user(s).`, "ok");
await refresh();
});
From f78e88cdb50a4d041e80ab706c546f70ed958111 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 15:51:55 +0100
Subject: [PATCH 05/20] DOC-6661 Go and Jedis after Codex review
---
.../develop/use-cases/feature-store/_index.md | 2 +
.../use-cases/feature-store/go/_index.md | 751 ++++++++++++
.../feature-store/go/build_features.go | 116 ++
.../go/cmd/build_features/main.go | 18 +
.../feature-store/go/cmd/demo_server/main.go | 22 +
.../use-cases/feature-store/go/demo_server.go | 954 ++++++++++++++++
.../feature-store/go/feature_store.go | 495 ++++++++
.../develop/use-cases/feature-store/go/go.mod | 11 +
.../develop/use-cases/feature-store/go/go.sum | 22 +
.../feature-store/go/streaming_worker.go | 231 ++++
.../java-jedis/BuildFeatures.java | 113 ++
.../feature-store/java-jedis/DemoServer.java | 1014 +++++++++++++++++
.../java-jedis/FeatureStore.java | 450 ++++++++
.../java-jedis/StreamingWorker.java | 220 ++++
.../feature-store/java-jedis/_index.md | 735 ++++++++++++
.../feature-store/java-jedis/pom.xml | 88 ++
16 files changed, 5242 insertions(+)
create mode 100644 content/develop/use-cases/feature-store/go/_index.md
create mode 100644 content/develop/use-cases/feature-store/go/build_features.go
create mode 100644 content/develop/use-cases/feature-store/go/cmd/build_features/main.go
create mode 100644 content/develop/use-cases/feature-store/go/cmd/demo_server/main.go
create mode 100644 content/develop/use-cases/feature-store/go/demo_server.go
create mode 100644 content/develop/use-cases/feature-store/go/feature_store.go
create mode 100644 content/develop/use-cases/feature-store/go/go.mod
create mode 100644 content/develop/use-cases/feature-store/go/go.sum
create mode 100644 content/develop/use-cases/feature-store/go/streaming_worker.go
create mode 100644 content/develop/use-cases/feature-store/java-jedis/BuildFeatures.java
create mode 100644 content/develop/use-cases/feature-store/java-jedis/DemoServer.java
create mode 100644 content/develop/use-cases/feature-store/java-jedis/FeatureStore.java
create mode 100644 content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
create mode 100644 content/develop/use-cases/feature-store/java-jedis/_index.md
create mode 100644 content/develop/use-cases/feature-store/java-jedis/pom.xml
diff --git a/content/develop/use-cases/feature-store/_index.md b/content/develop/use-cases/feature-store/_index.md
index dcfb2cda24..65d8ca2d9c 100644
--- a/content/develop/use-cases/feature-store/_index.md
+++ b/content/develop/use-cases/feature-store/_index.md
@@ -157,3 +157,5 @@ 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" >}})
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..b8be523e93
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/_index.md
@@ -0,0 +1,751 @@
+---
+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
+ }
+ chosen := w.rng.Perm(len(ids))[:w.usersPerTick]
+ 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.
+
+### 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..2777da8a3c
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/demo_server.go
@@ -0,0 +1,954 @@
+// 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"
+ "log"
+ "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.
+func (d *FeatureStoreDemo) ToggleWorker(ctx context.Context) (paused, running bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if !d.worker.IsRunning() {
+ d.worker.Start(ctx)
+ }
+ 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
+ }
+ names := make([]string, 0, len(full))
+ for n := range full {
+ 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 {
+ rows = append(rows, map[string]any{
+ "name": n,
+ "value": full[n],
+ "ttl_seconds": ttls[n],
+ })
+ }
+ 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(r.Context())
+ 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(ctx)
+ 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 {
+ log.Fatalf("listen: %v", 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..5e350899e3
--- /dev/null
+++ b/content/develop/use-cases/feature-store/go/streaming_worker.go
@@ -0,0 +1,231 @@
+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
+
+ 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).
+func (w *StreamingWorker) Start(ctx context.Context) {
+ if !w.running.CompareAndSwap(false, true) {
+ return
+ }
+ w.paused.Store(false)
+ w.stopCh = make(chan struct{})
+ w.doneCh = make(chan struct{})
+ go w.run(ctx)
+}
+
+// Stop signals the worker to exit and waits for any in-flight tick
+// to settle. Safe to call multiple times.
+func (w *StreamingWorker) Stop() {
+ 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) {
+ defer close(w.doneCh)
+ t := time.NewTicker(w.tick)
+ defer t.Stop()
+ for {
+ select {
+ case <-w.stopCh:
+ return
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ if w.paused.Load() {
+ continue
+ }
+ w.tickInFlight.Store(true)
+ 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..e56429e927
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
@@ -0,0 +1,1014 @@
+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 {
+ if (!worker.isRunning()) worker.start();
+ 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;
+ }
+ List names = new ArrayList<>(full.keySet());
+ 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..e0bdb336ac
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
@@ -0,0 +1,220 @@
+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() {
+ while (tickInFlight.get()) {
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Tick
+ // ---------------------------------------------------------------
+
+ private void run() {
+ while (running.get()) {
+ try {
+ Thread.sleep(tickMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ if (!running.get()) break;
+ if (paused.get()) continue;
+
+ tickInFlight.set(true);
+ try {
+ doTick();
+ } catch (Exception e) {
+ System.err.printf("[streaming-worker] tick failed: %s%n", e.getMessage());
+ } finally {
+ 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..71b331a00a
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/_index.md
@@ -0,0 +1,735 @@
+---
+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 pattern-matching `switch`, 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, TLS, AUTH/ACL, retry policy, sentinel/cluster
+failover — see the
+[Jedis production usage guide]({{< relref "/develop/clients/jedis/produsage" >}}).
+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
+
+The demo creates a `JedisPool` with `maxTotal=64` because each HTTP request
+borrows one connection for the duration of the handler. In production, size
+`maxTotal` to at least your expected concurrent request count plus the
+worker pool's borrow rate. Setting it too low forces requests to block
+waiting for a 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..a7d8cd0f5a
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-jedis/pom.xml
@@ -0,0 +1,88 @@
+
+
+
+ 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}
+
+
From 18105f75de04e88f71b09eae9519b410f7f0efa6 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 29 May 2026 16:09:22 +0100
Subject: [PATCH 06/20] DOC-6661 Go and Jedis after Codex changes
---
.../use-cases/feature-store/go/_index.md | 22 ++++++-
.../use-cases/feature-store/go/demo_server.go | 16 ++---
.../feature-store/go/streaming_worker.go | 34 +++++++---
.../java-jedis/StreamingWorker.java | 62 +++++++++++++------
.../feature-store/java-jedis/_index.md | 29 ++++++---
.../feature-store/java-jedis/pom.xml | 3 +-
6 files changed, 119 insertions(+), 47 deletions(-)
diff --git a/content/develop/use-cases/feature-store/go/_index.md b/content/develop/use-cases/feature-store/go/_index.md
index b8be523e93..fcb44622dd 100644
--- a/content/develop/use-cases/feature-store/go/_index.md
+++ b/content/develop/use-cases/feature-store/go/_index.md
@@ -451,7 +451,11 @@ func (w *StreamingWorker) doTick(ctx context.Context) error {
if len(ids) == 0 {
return nil
}
- chosen := w.rng.Perm(len(ids))[:w.usersPerTick]
+ 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{
@@ -636,6 +640,22 @@ and the
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
diff --git a/content/develop/use-cases/feature-store/go/demo_server.go b/content/develop/use-cases/feature-store/go/demo_server.go
index 2777da8a3c..7bb6ce8102 100644
--- a/content/develop/use-cases/feature-store/go/demo_server.go
+++ b/content/develop/use-cases/feature-store/go/demo_server.go
@@ -44,7 +44,6 @@ import (
"encoding/json"
"flag"
"fmt"
- "log"
"net/http"
"sort"
"strconv"
@@ -116,12 +115,15 @@ func (d *FeatureStoreDemo) Reset(ctx context.Context) (int64, error) {
}
// ToggleWorker pauses or resumes the streaming worker. Starts the
-// goroutine if it wasn't running.
-func (d *FeatureStoreDemo) ToggleWorker(ctx context.Context) (paused, running bool) {
+// 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()
if !d.worker.IsRunning() {
- d.worker.Start(ctx)
+ d.worker.Start()
}
if d.worker.IsPaused() {
d.worker.Resume()
@@ -288,7 +290,7 @@ func (s *httpServer) handleToggleWorker(w http.ResponseWriter, r *http.Request)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- paused, running := s.demo.ToggleWorker(r.Context())
+ paused, running := s.demo.ToggleWorker()
jsonResponse(w, http.StatusOK, map[string]any{
"paused": paused,
"running": running,
@@ -495,7 +497,7 @@ func RunDemoServer(args []string) error {
return fmt.Errorf("seed materialize: %w", err)
}
- worker.Start(ctx)
+ worker.Start()
defer worker.Stop()
srv := &httpServer{store: store, worker: worker, demo: demo}
@@ -508,7 +510,7 @@ func RunDemoServer(args []string) error {
fmt.Printf("Materialized %d user(s); streaming worker running.\n", seeded)
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("listen: %v", err)
+ return fmt.Errorf("listen: %w", err)
}
return nil
}
diff --git a/content/develop/use-cases/feature-store/go/streaming_worker.go b/content/develop/use-cases/feature-store/go/streaming_worker.go
index 5e350899e3..f061382149 100644
--- a/content/develop/use-cases/feature-store/go/streaming_worker.go
+++ b/content/develop/use-cases/feature-store/go/streaming_worker.go
@@ -77,14 +77,20 @@ func NewStreamingWorker(store *FeatureStore, tick time.Duration, usersPerTick in
// Start launches the goroutine that ticks. Safe to call when the
// worker is already running (no-op in that case).
-func (w *StreamingWorker) Start(ctx context.Context) {
+//
+// 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() {
if !w.running.CompareAndSwap(false, true) {
return
}
w.paused.Store(false)
w.stopCh = make(chan struct{})
w.doneCh = make(chan struct{})
- go w.run(ctx)
+ go w.run(context.Background())
}
// Stop signals the worker to exit and waits for any in-flight tick
@@ -140,7 +146,16 @@ func (w *StreamingWorker) ResetStats() {
}
func (w *StreamingWorker) run(ctx context.Context) {
- defer close(w.doneCh)
+ // 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 {
@@ -150,12 +165,15 @@ func (w *StreamingWorker) run(ctx context.Context) {
case <-ctx.Done():
return
case <-t.C:
- if w.paused.Load() {
- continue
- }
+ // 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 err := w.doTick(ctx); err != nil {
- log.Printf("[streaming-worker] tick failed: %v", err)
+ if !w.paused.Load() {
+ if err := w.doTick(ctx); err != nil {
+ log.Printf("[streaming-worker] tick failed: %v", err)
+ }
}
w.tickInFlight.Store(false)
}
diff --git a/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java b/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
index e0bdb336ac..e8c495ab0f 100644
--- a/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
+++ b/content/develop/use-cases/feature-store/java-jedis/StreamingWorker.java
@@ -99,14 +99,22 @@ public synchronized void stop() {
* 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) {
- Thread.currentThread().interrupt();
- return;
+ interrupted = true;
}
}
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
}
// ---------------------------------------------------------------
@@ -114,24 +122,40 @@ public void waitForIdle() {
// ---------------------------------------------------------------
private void run() {
- while (running.get()) {
- try {
- Thread.sleep(tickMillis);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- break;
- }
- if (!running.get()) break;
- if (paused.get()) continue;
-
- tickInFlight.set(true);
- try {
- doTick();
- } catch (Exception e) {
- System.err.printf("[streaming-worker] tick failed: %s%n", e.getMessage());
- } finally {
- tickInFlight.set(false);
+ 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);
}
}
diff --git a/content/develop/use-cases/feature-store/java-jedis/_index.md b/content/develop/use-cases/feature-store/java-jedis/_index.md
index 71b331a00a..a2f61eec3c 100644
--- a/content/develop/use-cases/feature-store/java-jedis/_index.md
+++ b/content/develop/use-cases/feature-store/java-jedis/_index.md
@@ -528,8 +528,8 @@ connections from. Endpoints:
* **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 pattern-matching `switch`, records,
- and text blocks.
+* **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.
@@ -604,9 +604,11 @@ is `fs:user:`. Pass `--no-reset` to keep existing data across restarts, or
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, TLS, AUTH/ACL, retry policy, sentinel/cluster
-failover — see the
+— `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.
@@ -676,12 +678,19 @@ this reason.
### Size the JedisPool for the request shape
-The demo creates a `JedisPool` with `maxTotal=64` because each HTTP request
-borrows one connection for the duration of the handler. In production, size
-`maxTotal` to at least your expected concurrent request count plus the
-worker pool's borrow rate. Setting it too low forces requests to block
-waiting for a connection — a slow read-side cliff that doesn't show up
-under load tests with very few clients.
+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
diff --git a/content/develop/use-cases/feature-store/java-jedis/pom.xml b/content/develop/use-cases/feature-store/java-jedis/pom.xml
index a7d8cd0f5a..fbed81d6dd 100644
--- a/content/develop/use-cases/feature-store/java-jedis/pom.xml
+++ b/content/develop/use-cases/feature-store/java-jedis/pom.xml
@@ -30,8 +30,7 @@
+ (hexpire / httl / hpersist); the demo pins 6.2.0. -->
redis.clientsjedis
From f5daa75c82958623308a58d755fecb7c83c4795b Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Mon, 1 Jun 2026 09:34:05 +0100
Subject: [PATCH 07/20] DOC-6661 Lettuce example plus some fixes
---
.../develop/use-cases/feature-store/_index.md | 1 +
.../use-cases/feature-store/go/demo_server.go | 25 +-
.../feature-store/java-jedis/DemoServer.java | 16 +-
.../java-lettuce/BuildFeatures.java | 115 ++
.../java-lettuce/DemoServer.java | 1036 +++++++++++++++++
.../java-lettuce/FeatureStore.java | 529 +++++++++
.../java-lettuce/StreamingWorker.java | 235 ++++
.../feature-store/java-lettuce/_index.md | 657 +++++++++++
.../feature-store/java-lettuce/pom.xml | 82 ++
.../feature-store/nodejs/demoServer.js | 20 +-
.../feature-store/redis-py/demo_server.py | 16 +-
11 files changed, 2719 insertions(+), 13 deletions(-)
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/BuildFeatures.java
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/DemoServer.java
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/StreamingWorker.java
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/_index.md
create mode 100644 content/develop/use-cases/feature-store/java-lettuce/pom.xml
diff --git a/content/develop/use-cases/feature-store/_index.md b/content/develop/use-cases/feature-store/_index.md
index 65d8ca2d9c..6e99909fd4 100644
--- a/content/develop/use-cases/feature-store/_index.md
+++ b/content/develop/use-cases/feature-store/_index.md
@@ -159,3 +159,4 @@ for a single user under 1 ms, and pipeline batch reads across a hundred users.
* [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" >}})
diff --git a/content/develop/use-cases/feature-store/go/demo_server.go b/content/develop/use-cases/feature-store/go/demo_server.go
index 7bb6ce8102..8ce1b63e23 100644
--- a/content/develop/use-cases/feature-store/go/demo_server.go
+++ b/content/develop/use-cases/feature-store/go/demo_server.go
@@ -222,9 +222,24 @@ func (s *httpServer) handleInspect(w http.ResponseWriter, r *http.Request) {
})
return
}
- names := make([]string, 0, len(full))
- for n := range full {
+ // 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 {
@@ -233,10 +248,14 @@ func (s *httpServer) handleInspect(w http.ResponseWriter, r *http.Request) {
}
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": ttls[n],
+ "ttl_seconds": ttl,
})
}
sort.Slice(rows, func(i, j int) bool {
diff --git a/content/develop/use-cases/feature-store/java-jedis/DemoServer.java b/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
index e56429e927..8822a3e186 100644
--- a/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
+++ b/content/develop/use-cases/feature-store/java-jedis/DemoServer.java
@@ -259,15 +259,25 @@ static class InspectHandler implements HttpHandler {
"key_ttl_seconds", keyTTL));
return;
}
- List names = new ArrayList<>(full.keySet());
+ // 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.get(n));
- row.put("ttl_seconds", ttls.getOrDefault(n, -1L));
+ row.put("value", full.getOrDefault(n, ""));
+ row.put("ttl_seconds", ttls.getOrDefault(n, -2L));
fields.add(row);
}
sendJson(ex, 200, Map.of(
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..cee9427c88
--- /dev/null
+++ b/content/develop/use-cases/feature-store/java-lettuce/DemoServer.java
@@ -0,0 +1,1036 @@
+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 {
+ if (!worker.isRunning()) worker.start();
+ 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