{
  "id": "rust",
  "title": "Redis feature store with redis-rs",
  "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/rust/",
  "summary": "Build a Redis-backed online feature store in Rust with redis-rs",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-06-04T14:49:57+01:00",
  "children": [
    {
      "id": "demo_template",
      "summary": "",
      "title": "",
      "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/rust/demo_template/"
    }
  ],
  "page_type": "content",
  "content_hash": "9fdee298fb180ffd52535578acb6ff29c5cf66eaa73dc2bb222980149fe9acd9",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to build a small Redis-backed online feature store in\nRust with the async [redis-rs](https://redis.io/docs/latest/develop/clients/rust) crate\nand `tokio`. The demo runs on top of the [axum](https://docs.rs/axum/)\nweb framework so you can bulk-load a batch of users with a key-level TTL, run\na streaming worker that overwrites real-time features with per-field TTL,\nretrieve any subset of features for one user under 2 ms, and pipeline `HMGET`\nacross a hundred users for batch scoring."
    },
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Each entity (here, a user) is one Redis\n[Hash](https://redis.io/docs/latest/develop/data-types/hashes) at a deterministic key —\n`fs:user:{id}`. The hash holds every feature for that entity as one field per\nfeature: batch-materialized aggregates (refreshed once a day) alongside\nstreaming-updated signals (refreshed every few seconds). One\n[`HMGET`](https://redis.io/docs/latest/commands/hmget) returns whichever subset the model\nneeds in one network round trip.\n\nTwo TTL layers solve the *mixed staleness* problem without an application-side\ncleaner:\n\n* A **key-level** [`EXPIRE`](https://redis.io/docs/latest/commands/expire) aligned with the\n  batch materialization cycle (24 hours in the demo). If the batch refresher\n  fails, the whole entity disappears at the next cycle and inference sees a\n  missing entity — which the model handler can detect and fall back on —\n  rather than silently outdated values.\n* A **per-field** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) (Redis 7.4+) on\n  each streaming feature gives that field its own shorter expiry, independent\n  of the rest of the hash. If the streaming pipeline stops updating a feature,\n  the field self-cleans while the batch fields stay populated.\n\nThat gives you:\n\n* A single round trip for retrieval — any subset of features for one entity\n  in one [`HMGET`](https://redis.io/docs/latest/commands/hmget).\n* Sub-millisecond hot path. The Redis-side work is microseconds; in practice\n  the bottleneck is the network round trip plus the model's own feature-prep.\n* Pipelined batch scoring — one round trip for `N` users at once.\n* Independent freshness per feature, expressed as a server-side TTL rather\n  than as application logic.\n* Self-cleanup on pipeline failure: a stalled batch refresher lets entities\n  expire on schedule, and a stalled streaming worker lets each affected field\n  expire on its own timer."
    },
    {
      "id": "how-redis-rs-fits-the-demo",
      "title": "How redis-rs fits the demo",
      "role": "content",
      "text": "Two crate facts shape the helper:\n\n* **`ConnectionManager` is the canonical async connection.** It owns a\n  multiplexed `MultiplexedConnection` underneath and transparently reconnects\n  on a dropped socket. The type is `Clone` — handing it to one HTTP handler,\n  the streaming worker, and the batch builder is just three `clone()` calls,\n  and they all share the same underlying connection. There's no pool to\n  manage.\n* **The `redis::cmd(\"HEXPIRE\")` builder is how you reach commands not yet\n  typed on the `AsyncCommands` trait.** Per-field TTL bindings (`hexpire`,\n  `httl`, `hpersist`) aren't part of the typed surface on redis-rs 0.27, so\n  the helper issues them with the generic command builder. The wire bytes\n  are identical to the typed form.\n\nIn this example, the batch features describe a user's longer-term shape\n(`country_iso`, `risk_segment`, `account_age_days`, `tx_count_7d`,\n`avg_amount_30d`, `chargeback_count_180d`) and are bulk-loaded by\n`build_features.rs` — the demo's stand-in for a nightly Spark / Feast\nmaterialization job. The streaming features describe what the user is doing\nright now (`last_login_ts`, `last_device_id`, `tx_count_5m`,\n`failed_logins_15m`, `session_country`) and are written by\n`streaming_worker.rs` — a tokio task that stands in for a Flink / Kafka\nStreams job. The HTTP handlers in `demo_server.rs` read any subset of those\nfeatures through `feature_store.rs`'s helper struct."
    },
    {
      "id": "how-it-works",
      "title": "How it works",
      "role": "content",
      "text": "There are three paths: a **batch path** that bulk-loads features once per\nmaterialization cycle, a **streaming path** that updates real-time features\nas events arrive, and an **inference path** that reads features on the\nrequest side."
    },
    {
      "id": "batch-path-per-materialization-cycle",
      "title": "Batch path (per materialization cycle)",
      "role": "content",
      "text": "1. The batch job calls `synthesize_users(N, seed)` (in production, the\n   equivalent computation lives in an offline pipeline against the\n   warehouse). The result is a `Vec<(String, FeatureMap)>` for every user\n   in this cycle.\n2. `store.bulk_load(&rows, ttl_seconds).await` queues one\n   [`HSET`](https://redis.io/docs/latest/commands/hset) plus one\n   [`EXPIRE`](https://redis.io/docs/latest/commands/expire) per user through a\n   non-transactional `redis::pipe()`, so the whole batch ships in a single\n   round trip."
    },
    {
      "id": "streaming-path-per-event",
      "title": "Streaming path (per event)",
      "role": "content",
      "text": "When a user does something (login, transaction, page view) the streaming\nlayer computes whatever real-time signals fall out of that event and\ncalls `store.update_streaming(user_id, &fields, ttl_seconds).await`. That\nqueues:\n\n1. An [`HSET`](https://redis.io/docs/latest/commands/hset) writing the new field\n   values. Redis is single-threaded per shard, so this is atomic against\n   any concurrent batch write on the same hash — no version columns, no\n   locks.\n2. An [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) over exactly the\n   fields that were written, with the streaming TTL. Each streaming field\n   carries its own per-field expiry independent of the rest of the hash.\n   Stop the worker and these fields drop out one by one as their TTLs\n   elapse, while the batch fields remain populated under the longer\n   key-level TTL."
    },
    {
      "id": "inference-path-per-request",
      "title": "Inference path (per request)",
      "role": "content",
      "text": "1. The model server picks the feature subset it needs (the schema is\n   owned by the model, not the store).\n2. It calls `store.get_features(user_id, &names).await`, which is one\n   [`HMGET`](https://redis.io/docs/latest/commands/hmget). Redis returns the values\n   in the same order as the requested fields, with `None` for any field\n   that doesn't exist (or has expired).\n3. For batch inference, the model server calls\n   `store.batch_get_features(&user_ids, &names).await`, which pipelines\n   one [`HMGET`](https://redis.io/docs/latest/commands/hmget) per user across all\n   `N` users in a single network round trip via `redis::pipe()`."
    },
    {
      "id": "the-feature-store-helper",
      "title": "The feature-store helper",
      "role": "content",
      "text": "The `FeatureStore` struct wraps the read/write paths\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/rust/feature_store.rs)):\n\n[code example]"
    },
    {
      "id": "project-layout",
      "title": "Project layout",
      "role": "content",
      "text": "The crate is a small lib + two binaries:\n\n[code example]\n\nRun with `cargo run --release --bin demo_server` or\n`cargo run --release --bin build_features -- --count 500`."
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each user is one Redis Hash. Every value is stored as a string — Redis hash\nfields are bytes on the wire, so the helper encodes booleans as `\"true\"` /\n`\"false\"` and renders numbers via `i64::to_string` / `f64::to_string`. The\nmodel server is responsible for parsing back to the right type, the same way\nit would when reading any serialized feature store.\n\n[code example]"
    },
    {
      "id": "bulk-loading-batch-features",
      "title": "Bulk-loading batch features",
      "role": "content",
      "text": "`bulk_load` pipelines one `HSET` and one `EXPIRE` per user into a single\nnon-transactional batch through `redis::pipe()`. With 500 users that's 1000\ncommands in one network call — Redis processes them sequentially on the\nserver side but the client only pays one RTT.\n\n[code example]\n\n`redis::pipe()` is a non-transactional builder: commands queue up and ship in\none round trip, but they don't run inside a `MULTI/EXEC` block. That's the\nright choice here because each user's `HSET` + `EXPIRE` pair is independent\nof every other user's, and an all-or-nothing transaction would block the\nserver for the duration of the batch. For the rare case where the pair has\nto be inseparable, swap to `redis::pipe().atomic()` (which wraps in\n`MULTI/EXEC`) or a Lua script via\n[`EVAL`](https://redis.io/docs/latest/commands/eval) /\n[Eval scripting](https://redis.io/docs/latest/develop/programmability/eval-intro).\n\nIn production, the equivalent of this script runs as an offline pipeline\n(a Spark or Feast `materialize` job) that reads from the warehouse and\nwrites into Redis. The\n[Feast `RedisOnlineStore`](https://docs.feast.dev/reference/online-stores/redis)\nprovider does exactly this under the hood; the in-house\n[Redis Feature Form](https://redis.io/docs/latest/develop/ai/featureform) integration\ncovers the materialize + serve path end-to-end."
    },
    {
      "id": "streaming-writes-with-per-field-ttl",
      "title": "Streaming writes with per-field TTL",
      "role": "content",
      "text": "`update_streaming` is the linchpin of the mixed-staleness story:\n\n[code example]\n\n[`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) sets the TTL on *individual*\nhash fields, not on the whole key. The two commands are queued under one\nflush so Redis runs them in pipeline order: the `HSET` first creates or\noverwrites the fields, then `HEXPIRE` attaches a TTL to each of those same\nfields. `HEXPIRE` returns one status code per field — `1` if the TTL was\nset, `2` if the expiry was 0 or in the past (so Redis deleted the field\ninstead), `0` if an `NX | XX | GT | LT` conditional flag was specified and\nnot met (we never use one here), `-2` if the field doesn't exist on the\nkey. The helper returns a `RedisError` if any code is anything other than\n`1`, so the \"every streaming write renews its TTL\" invariant fails loudly\nrather than silently leaving a streaming field with no expiry attached.\n\nThe pipeline reply shape — `Vec<Vec<i64>>` — is the one tricky bit. redis-rs\nwraps each non-ignored command's reply in the outer `Vec`, even when there\nis only one such command. The HEXPIRE reply itself is an array, so we\nend up with one outer `Vec` containing one inner `Vec<i64>` of codes.\n\nIf a streaming pipeline stops, the streaming fields drop out one by one as\ntheir per-field TTLs elapse. `field_ttls_seconds` (which wraps `HTTL`) lets\nthe model side inspect the remaining TTL on any field — useful both for\ndebugging and as a freshness signal in the model itself.\n\n> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level\n> TTL commands (`HTTL`, `HPERSIST`, `HEXPIREAT`, `HPEXPIRE`, `HPEXPIREAT`,\n> `HPTTL`, `HEXPIRETIME`, `HPEXPIRETIME`) were added in Redis 7.4. The\n> demo's `Cargo.toml` pins `redis = \"0.27\"` and uses\n> `redis::cmd(\"HEXPIRE\")` because the typed binding doesn't ship on that\n> client line yet — the wire bytes are identical."
    },
    {
      "id": "inference-reads-with-hmget",
      "title": "Inference reads with HMGET",
      "role": "content",
      "text": "`get_features` is one `HMGET`:\n\n[code example]\n\n`conn.hget` with a slice of field names is redis-rs's way of issuing\n`HMGET` (the typed `hmget` and `hget(slice)` produce the same wire bytes).\nThe reply is `Vec<Option<String>>` — fields that don't exist on the hash\ncome back as `None`, which the helper drops from the result map."
    },
    {
      "id": "batch-scoring-with-pipelined-hmget",
      "title": "Batch scoring with pipelined HMGET",
      "role": "content",
      "text": "For batch inference, the same `HMGET` shape pipelines across users:\n\n[code example]\n\nOne round trip for the whole batch. The demo returns a 30-user batch in\nunder 1 ms against a local Redis.\n\nA Redis Cluster is different: a single `redis::pipe()` is bound to one\nconnection, and a `ConnectionManager` holds one connection to one node.\nFor batch reads on a cluster, use redis-rs's\n[`cluster_async`](https://docs.rs/redis/0.27/redis/cluster_async/index.html)\nclient and either fan out parallel `hget` calls (the cluster client routes\neach one to the right shard) or, for tighter control, group entity IDs by\nhash slot and run one pipeline per shard in parallel. A hash tag like\n`fs:user:{vip}:u0001` forces a known set of keys onto the same shard so\none pipeline can cover them all in a single round trip."
    },
    {
      "id": "the-streaming-worker",
      "title": "The streaming worker",
      "role": "content",
      "text": "`streaming_worker.rs` is the demo's stand-in for whatever Flink, Kafka\nStreams, or bespoke service computes the real-time features\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/rust/streaming_worker.rs)).\nIt runs as a tokio task next to the demo server so the UI can start,\npause, and resume it.\n\n[code example]\n\nThe same pre-flight-`tick_in_flight` + drop-`Guard` pattern as every other\nclient in this use case closes the pause/in-flight race: a reset that's\nabout to `DEL` every key calls `worker.pause()` to stop *future* ticks\n*and* `worker.wait_for_idle().await` to flush a mid-flight tick before\nissuing the DEL sweep.\n\nPausing the worker is what shows off the mixed-staleness behavior: leave\nit paused for longer than `streaming_ttl_seconds` and the streaming fields\ndisappear from every user's hash one by one, while the batch fields remain\nunder the longer key-level `EXPIRE`. The demo's `Pause / resume` button\nlets you see this happen in real time."
    },
    {
      "id": "the-batch-builder",
      "title": "The batch builder",
      "role": "content",
      "text": "`build_features.rs` is the demo's nightly materializer\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/rust/build_features.rs)).\nIt generates synthetic feature rows and calls `store.bulk_load` once. The\nsynthesis itself is not the point — in a real deployment the equivalent\ncode reads from the offline store (Snowflake, BigQuery, Iceberg) and writes\nthe resulting hashes into Redis.\n\nRun the builder on its own (independently of the demo server) to populate\nRedis from the command line:\n\n[code example]\n\nThat writes 500 users at `fs:user:*` with a one-hour key-level TTL, which\nis how a typical operator would pre-seed a feature store from the command\nline when debugging."
    },
    {
      "id": "the-interactive-demo",
      "title": "The interactive demo",
      "role": "content",
      "text": "`demo_server.rs` runs the axum HTTP server on port 8090. The HTML page lets\nyou:\n\n* **Bulk-load** any number of users (default 200) with a configurable\n  key-level TTL.\n* See the **store state**: user count, batch / streaming TTLs, cumulative\n  read/write counters.\n* See the **streaming worker** status and **pause or resume** it.\n* Run an **inference read** for any user with a chosen feature subset, and\n  see the value, the per-field TTL, and the read latency.\n* Run **batch scoring** with a pipelined `HMGET` across `N` users.\n* **Inspect** any user's full hash with field-level TTLs and the key-level\n  TTL.\n\nThe server holds one `FeatureStore` and one `StreamingWorker` for the\nlifetime of the process. Both wrap clones of the same `ConnectionManager`,\nso every HTTP handler and the streaming worker share the underlying\nmultiplexed socket. Endpoints:\n\n| Endpoint                  | What it does                                                                        |\n|---------------------------|-------------------------------------------------------------------------------------|\n| `GET  /state`             | User count, TTL config, stats counters, worker status.                              |\n| `POST /bulk-load`         | Pipelined `HSET` + `EXPIRE` over N synthetic users with a chosen TTL.               |\n| `POST /worker/toggle`     | Pause / resume the streaming worker.                                                |\n| `POST /read`              | `HMGET` a chosen feature subset for one user; report latency and per-field TTLs.    |\n| `POST /batch-read`        | Pipeline `HMGET` across N users; report total latency and per-entity field counts.  |\n| `GET  /inspect`           | `HGETALL` + `HTTL` for one user; full hash view with per-field TTLs.                |\n| `POST /reset`             | Drop every user under the key prefix (used by the demo's reset button).             |"
    },
    {
      "id": "prerequisites",
      "title": "Prerequisites",
      "role": "content",
      "text": "* **Redis 7.4 or later.** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) and\n  [`HTTL`](https://redis.io/docs/latest/commands/httl) were added in Redis 7.4; the\n  demo relies on per-field TTL for the mixed-staleness story.\n* **Rust 1.74 or later.** The demo uses `async fn` in traits, `let-else`,\n  and other recent ergonomics. Earlier stable Rust may compile after small\n  tweaks.\n* **redis-rs 0.27 or later.** The demo's `Cargo.toml` pins 0.27 with the\n  `tokio-comp`, `aio`, and `connection-manager` features. Per-field TTL\n  commands are issued via `redis::cmd(\"HEXPIRE\")`.\n\nIf your Redis server is running elsewhere, start the demo with\n`--redis-url redis://host:port/`."
    },
    {
      "id": "running-the-demo",
      "title": "Running the demo",
      "role": "content",
      "text": ""
    },
    {
      "id": "get-the-source-files",
      "title": "Get the source files",
      "role": "content",
      "text": "The demo lives in a small Cargo project under\n[`feature-store/rust`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/rust).\nClone the repo or copy the directory:\n\n[code example]"
    },
    {
      "id": "start-the-demo-server",
      "title": "Start the demo server",
      "role": "content",
      "text": "From the project directory:\n\n[code example]\n\nYou should see:\n\n[code example]\n\nOpen [http://127.0.0.1:8090](http://127.0.0.1:8090). Useful things to try:\n\n* Pick a user and click **Read features** with a mixed batch/streaming\n  subset — you'll see batch fields with no per-field TTL (covered by the\n  key-level TTL) and streaming fields with a positive per-field TTL.\n* Click **Pipeline HMGET** with `count=100` to see the latency of a\n  100-user batch read.\n* Click **Pause / resume** on the streaming worker and leave it paused\n  for ~5 minutes (or restart the server with\n  `--streaming-ttl-seconds 30` to make it visible in seconds). Re-run\n  **Read features** on any user and watch the streaming fields disappear\n  while the batch fields stay.\n* Click **Inspect** on a user to see the full hash with field-level TTLs.\n* Click **Reset** to drop every user and start over."
    },
    {
      "id": "production-usage",
      "title": "Production usage",
      "role": "content",
      "text": "The guidance below focuses on the production concerns specific to running\na feature store on Redis. For the generic redis-rs production checklist\n— TLS, AUTH, retry/backoff, error handling — see the\n[redis-rs client guide](https://redis.io/docs/latest/develop/clients/rust) and the\n[error-handling notes](https://redis.io/docs/latest/develop/clients/rust/error-handling).\nThe feature-store demo runs against `localhost` with the defaults; a real\ndeployment should harden the client first."
    },
    {
      "id": "pick-the-batch-ttl-to-outlast-a-failed-refresher",
      "title": "Pick the batch TTL to outlast a failed refresher",
      "role": "content",
      "text": "The whole-entity `EXPIRE` is your safety net against silent staleness\nfrom a broken batch pipeline. Set it longer than your worst-case batch\noutage so a single missed run doesn't take the feature store offline,\nbut short enough that a sustained outage causes loud failures (missing\nentities) rather than quiet ones (yesterday's features being scored as\ntoday's). The standard choice is one cycle of \"expected refresh\ninterval × 2\" — for a daily batch, 48 hours; for a 6-hour batch, 12\nhours.\n\nThe same logic applies to the per-field streaming TTL: a few times the\nexpected update interval so a slow-but-alive streaming worker doesn't\nchurn features needlessly, but short enough that a stalled worker\ncauses visible freshness failures."
    },
    {
      "id": "co-locate-the-online-store-with-serving-not-with-training",
      "title": "Co-locate the online store with serving, not with training",
      "role": "content",
      "text": "The online store's hash representation does *not* have to match the\nschema in your offline store. The batch materialization step is your\nchance to flatten joins, encode categoricals, and project to whatever\nshape the model server wants — so the request path is exactly one\n`HMGET` and zero transforms.\n\nThe training pipeline reads from the offline store with its own schema;\nthe serving pipeline reads from Redis with the flattened serving\nschema. Keeping those two pipelines as the same code path is what\nprevents training-serving skew."
    },
    {
      "id": "pipeline-batch-reads-across-shards",
      "title": "Pipeline batch reads across shards",
      "role": "content",
      "text": "On a single Redis instance, a pipelined `HMGET` across `N` users is one\nround trip. A Redis Cluster is different: a single `redis::pipe()` ships\nthrough one connection to one node, so on a cluster you need redis-rs's\n[`cluster_async`](https://docs.rs/redis/0.27/redis/cluster_async/index.html)\nclient. Either fan out parallel `hget` calls (the cluster client routes\neach one to the right shard) or group entity IDs by hash slot and issue\none pipeline against each shard in parallel.\n\nA hash tag like `fs:user:{vip}:u0001` forces a known set of keys onto\nthe same shard so one pipeline can cover them all in a single round\ntrip."
    },
    {
      "id": "make-hexpire-part-of-every-streaming-write",
      "title": "Make HEXPIRE part of every streaming write",
      "role": "content",
      "text": "The single biggest correctness lever in this design is that the\nstreaming write applies `HEXPIRE` *every time*. If a streaming worker\nwrites a field without renewing its TTL, the field carries whatever\nexpiry was there before — possibly none, possibly stale — and the\nmixed-staleness invariant breaks. Keep the `HSET` and `HEXPIRE` in the\nsame pipeline (or, even safer, in the same\n[Lua script](https://redis.io/docs/latest/develop/programmability/eval-intro) if\nyou don't trust the call site)."
    },
    {
      "id": "avoid-hgetall-on-the-request-path",
      "title": "Avoid HGETALL on the request path",
      "role": "content",
      "text": "`HGETALL` reads every field on the hash, including ones the model\ndoesn't need. With dozens of features per entity, that is wasted\nserialization work on the server and wasted bandwidth on the wire.\nAlways specify the field list explicitly with `hget(&[...])` (or the\ntyped `hmget`) in the model server.\n\nThe exception is debugging and feature-set discovery, where you\ngenuinely want the full hash. The demo's \"Inspect\" button uses\n`hgetall` for exactly this reason."
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli",
      "title": "Inspect the store directly with redis-cli",
      "role": "content",
      "text": "When testing or troubleshooting, the cli tells you everything:\n\n[code example]\n\nA streaming field that returns `-2` from `HTTL` doesn't exist on the\nhash (either it was never written, or it expired); `-1` means the field\nhas no TTL set (and is therefore covered only by the key-level\n`EXPIRE`); any positive value is the remaining TTL in seconds."
    },
    {
      "id": "learn-more",
      "title": "Learn more",
      "role": "related",
      "text": "This example uses the following Redis commands:\n\n* [`HSET`](https://redis.io/docs/latest/commands/hset) to write a feature or a whole\n  feature row in one call.\n* [`HMGET`](https://redis.io/docs/latest/commands/hmget) to retrieve any subset of\n  features for one entity in one round trip.\n* [`HGETALL`](https://redis.io/docs/latest/commands/hgetall) for debugging and\n  feature-set discovery.\n* [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) and\n  [`HTTL`](https://redis.io/docs/latest/commands/httl) for per-field TTL on\n  streaming features (Redis 7.4+).\n* [`EXPIRE`](https://redis.io/docs/latest/commands/expire) and\n  [`TTL`](https://redis.io/docs/latest/commands/ttl) for the whole-entity TTL\n  aligned with the batch materialization cycle.\n* Pipelined `HMGET` across many entities for batch scoring with one\n  network round trip — see\n  [transactions and pipelining](https://redis.io/docs/latest/develop/clients/rust/transpipe).\n\nSee the [redis-rs documentation](https://redis.io/docs/latest/develop/clients/rust)\nfor the full client reference, and the\n[Hashes overview](https://redis.io/docs/latest/develop/data-types/hashes) for the\ndeeper conceptual model — including the listpack encoding that makes\nsmall hashes particularly compact in memory, which matters at\nfeature-store scale."
    }
  ],
  "examples": [
    {
      "id": "the-feature-store-helper-ex0",
      "language": "rust",
      "code": "use redis::aio::ConnectionManager;\nuse feature_store_demo::feature_store::{FeatureStore, FeatureMap, FeatureValue};\nuse std::collections::BTreeMap;\n\nlet client = redis::Client::open(\"redis://127.0.0.1/\")?;\nlet conn = ConnectionManager::new(client).await?;\nlet store = FeatureStore::new(\n    conn,\n    \"fs:user:\",\n    24 * 60 * 60,    // whole-entity TTL aligned with the daily batch cycle\n    5 * 60,          // per-field TTL on each streaming feature\n);\n\n// Batch materialization: one HSET + EXPIRE per user, all pipelined\n// through one round trip.\nlet mut row: FeatureMap = BTreeMap::new();\nrow.insert(\"country_iso\".into(), FeatureValue::Str(\"US\".into()));\nrow.insert(\"risk_segment\".into(), FeatureValue::Str(\"low\".into()));\nrow.insert(\"tx_count_7d\".into(), FeatureValue::Int(14));\nrow.insert(\"avg_amount_30d\".into(), FeatureValue::Float(92.40));\nstore.bulk_load(&[(\"u0001\".into(), row)], 24 * 60 * 60).await?;\n\n// Streaming write: HSET + HEXPIRE on just the fields that changed.\nlet mut s: FeatureMap = BTreeMap::new();\ns.insert(\"last_login_ts\".into(), FeatureValue::Int(1716998413541));\ns.insert(\"tx_count_5m\".into(), FeatureValue::Int(3));\nstore.update_streaming(\"u0001\", &s, 5 * 60).await?;\n\n// Inference read: HMGET of whatever the model needs.\nlet features = store.get_features(\n    \"u0001\",\n    &[\"risk_segment\", \"tx_count_7d\", \"avg_amount_30d\",\n      \"tx_count_5m\", \"last_login_ts\"],\n).await?;\n\n// Batch scoring: pipelined HMGET across many users.\nlet batch = store.batch_get_features(\n    &[\"u0001\".into(), \"u0002\".into()],\n    &[\"risk_segment\", \"tx_count_5m\"],\n).await?;",
      "section_id": "the-feature-store-helper"
    },
    {
      "id": "project-layout-ex0",
      "language": "text",
      "code": "feature-store/rust/\n├── Cargo.toml\n├── lib.rs                  (pub mod feature_store; pub mod streaming_worker; pub mod build_features;)\n├── feature_store.rs        — FeatureStore struct + methods\n├── streaming_worker.rs     — async tokio task worker\n├── build_features.rs       — SynthesizeUsers + cli_main()\n├── demo_server.rs          — main() for the demo server (axum)\n├── build_features_bin.rs   — main() for the CLI builder\n└── demo_template.html      — HTML page, baked in via include_str!",
      "section_id": "project-layout"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "fs:user:u0001                                   TTL = 86400 s (key-level)\n  country_iso=US                                <no field TTL>\n  risk_segment=low                              <no field TTL>\n  account_age_days=612                          <no field TTL>\n  tx_count_7d=14                                <no field TTL>\n  avg_amount_30d=92.40                          <no field TTL>\n  chargeback_count_180d=0                       <no field TTL>\n  last_login_ts=1716998413541                   TTL = 300 s (per field, HEXPIRE)\n  last_device_id=ios-9f02                       TTL = 300 s (per field, HEXPIRE)\n  tx_count_5m=3                                 TTL = 300 s (per field, HEXPIRE)\n  failed_logins_15m=0                           TTL = 300 s (per field, HEXPIRE)\n  session_country=US                            TTL = 300 s (per field, HEXPIRE)",
      "section_id": "data-model"
    },
    {
      "id": "bulk-loading-batch-features-ex0",
      "language": "rust",
      "code": "pub async fn bulk_load(\n    &self,\n    rows: &[(String, FeatureMap)],\n    ttl_seconds: u64,\n) -> RedisResult<usize> {\n    if rows.is_empty() { return Ok(0); }\n    let mut pipe = redis::pipe();\n    for (entity_id, fields) in rows {\n        let key = self.key_for(entity_id);\n        let encoded: Vec<(&str, String)> = fields\n            .iter().map(|(k, v)| (k.as_str(), v.encode())).collect();\n        pipe.hset_multiple(&key, &encoded).ignore();\n        pipe.expire(&key, ttl_seconds as i64).ignore();\n    }\n    let mut conn = self.conn.clone();\n    pipe.query_async::<()>(&mut conn).await?;\n    ...\n}",
      "section_id": "bulk-loading-batch-features"
    },
    {
      "id": "streaming-writes-with-per-field-ttl-ex0",
      "language": "rust",
      "code": "pub async fn update_streaming(\n    &self,\n    entity_id: &str,\n    fields: &FeatureMap,\n    ttl_seconds: u64,\n) -> RedisResult<()> {\n    if fields.is_empty() { return Ok(()); }\n    let key = self.key_for(entity_id);\n    let encoded: Vec<(&str, String)> = fields.iter()\n        .map(|(k, v)| (k.as_str(), v.encode())).collect();\n    let names: Vec<&str> = fields.keys().map(|s| s.as_str()).collect();\n\n    let mut pipe = redis::pipe();\n    pipe.hset_multiple(&key, &encoded).ignore();\n    // HEXPIRE wire form: HEXPIRE key seconds FIELDS count field...\n    let mut hexpire = redis::cmd(\"HEXPIRE\");\n    hexpire.arg(&key).arg(ttl_seconds).arg(\"FIELDS\").arg(names.len());\n    for n in &names { hexpire.arg(n); }\n    pipe.add_command(hexpire);\n\n    let mut conn = self.conn.clone();\n    // Pipeline returns one entry per non-ignored command; HSET's\n    // reply was dropped with .ignore(), so the only remaining entry\n    // is HEXPIRE's per-field code list.\n    let pipe_result: Vec<Vec<i64>> = pipe.query_async(&mut conn).await?;\n    let codes = pipe_result.into_iter().next().unwrap_or_default();\n    for code in &codes {\n        if *code != 1 {\n            return Err(redis::RedisError::from((\n                redis::ErrorKind::ResponseError,\n                \"HEXPIRE invariant violated\",\n                format!(\"HEXPIRE did not set every field TTL for {key}: {codes:?}\"),\n            )));\n        }\n    }\n    ...\n}",
      "section_id": "streaming-writes-with-per-field-ttl"
    },
    {
      "id": "inference-reads-with-hmget-ex0",
      "language": "rust",
      "code": "pub async fn get_features(\n    &self,\n    entity_id: &str,\n    field_names: &[&str],\n) -> RedisResult<BTreeMap<String, String>> {\n    if field_names.is_empty() { return Ok(BTreeMap::new()); }\n    let key = self.key_for(entity_id);\n    let mut conn = self.conn.clone();\n    let values: Vec<Option<String>> = conn.hget(&key, field_names).await?;\n    let mut out = BTreeMap::new();\n    for (n, v) in field_names.iter().zip(values.into_iter()) {\n        if let Some(s) = v { out.insert((*n).to_string(), s); }\n    }\n    ...\n}",
      "section_id": "inference-reads-with-hmget"
    },
    {
      "id": "batch-scoring-with-pipelined-hmget-ex0",
      "language": "rust",
      "code": "pub async fn batch_get_features(\n    &self,\n    entity_ids: &[String],\n    field_names: &[&str],\n) -> RedisResult<BTreeMap<String, BTreeMap<String, String>>> {\n    if entity_ids.is_empty() || field_names.is_empty() {\n        return Ok(BTreeMap::new());\n    }\n    let mut pipe = redis::pipe();\n    for id in entity_ids {\n        pipe.hget(self.key_for(id), field_names);\n    }\n    let mut conn = self.conn.clone();\n    let rows: Vec<Vec<Option<String>>> = pipe.query_async(&mut conn).await?;\n    ...\n}",
      "section_id": "batch-scoring-with-pipelined-hmget"
    },
    {
      "id": "the-streaming-worker-ex0",
      "language": "rust",
      "code": "async fn run(state: Arc<State>) {\n    struct Guard<'a>(&'a State);\n    impl Drop for Guard<'_> {\n        fn drop(&mut self) {\n            // Clear running and tick_in_flight no matter how the\n            // task exits — a panic, a manual stop, anything.\n            self.0.running.store(false, Ordering::Relaxed);\n            self.0.tick_in_flight.store(false, Ordering::Relaxed);\n        }\n    }\n    let _guard = Guard(&state);\n\n    let mut interval = time::interval(state.tick);\n    interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);\n    interval.tick().await;  // skip the first immediate tick\n\n    loop {\n        if state.stop.load(Ordering::Relaxed) { return; }\n        interval.tick().await;\n\n        // Set tick_in_flight *before* the pause check so a concurrent\n        // pause()+wait_for_idle() can never see tick_in_flight=false\n        // in the window between the pause check and the actual\n        // do_tick call.\n        state.tick_in_flight.store(true, Ordering::Relaxed);\n        let result = if !state.paused.load(Ordering::Relaxed) {\n            do_tick(&state).await\n        } else { Ok(()) };\n        state.tick_in_flight.store(false, Ordering::Relaxed);\n        if let Err(e) = result {\n            eprintln!(\"[streaming-worker] tick failed: {e}\");\n        }\n    }\n}",
      "section_id": "the-streaming-worker"
    },
    {
      "id": "the-batch-builder-ex0",
      "language": "bash",
      "code": "cargo run --release --bin build_features -- --count 500 --ttl-seconds 3600",
      "section_id": "the-batch-builder"
    },
    {
      "id": "get-the-source-files-ex0",
      "language": "bash",
      "code": "git clone https://github.com/redis/docs.git\ncd docs/content/develop/use-cases/feature-store/rust\ncargo build --release",
      "section_id": "get-the-source-files"
    },
    {
      "id": "start-the-demo-server-ex0",
      "language": "bash",
      "code": "cargo run --release --bin demo_server",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "start-the-demo-server-ex1",
      "language": "text",
      "code": "Dropping any existing users under 'fs:user:*' for a clean demo run (pass --no-reset to keep them).\nRedis feature-store demo server listening on http://127.0.0.1:8090\nUsing Redis at redis://127.0.0.1/ with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)\nMaterialized 200 user(s); streaming worker running.",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli-ex0",
      "language": "bash",
      "code": "# How many users currently in the store\nredis-cli --scan --pattern 'fs:user:*' | wc -l\n\n# One user's full hash and key-level TTL\nredis-cli HGETALL fs:user:u0001\nredis-cli TTL    fs:user:u0001\n\n# Per-field TTL on the streaming fields\nredis-cli HTTL fs:user:u0001 FIELDS 5 \\\n  last_login_ts last_device_id tx_count_5m failed_logins_15m session_country\n\n# Sample HMGET as the model would issue it\nredis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m",
      "section_id": "inspect-the-store-directly-with-redis-cli"
    }
  ]
}
