{
  "id": "dotnet",
  "title": "Redis feature store with StackExchange.Redis",
  "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/dotnet/",
  "summary": "Build a Redis-backed online feature store in .NET with StackExchange.Redis",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-06-04T14:49:57+01:00",
  "children": [],
  "page_type": "content",
  "content_hash": "1bbac03225f59f5b8432df5d6489ae3e5d8c1c5c791ae45c5c07abce22d1e373",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to build a small Redis-backed online feature store\nin .NET with [StackExchange.Redis](https://redis.io/docs/latest/develop/clients/dotnet).\nThe demo runs on top of ASP.NET Core's minimal-API web framework so you can\nbulk-load a batch of users with a key-level TTL, run a streaming worker that\noverwrites real-time features with per-field TTL, retrieve any subset of\nfeatures for one user under 2 ms, and pipeline `HMGET` across a hundred\nusers 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\nmodel needs in one network round trip.\n\nTwo TTL layers solve the *mixed staleness* problem without an\napplication-side cleaner:\n\n* A **key-level** [`EXPIRE`](https://redis.io/docs/latest/commands/expire) aligned with\n  the batch materialization cycle (24 hours in the demo). If the batch\n  refresher fails, the whole entity disappears at the next cycle and\n  inference sees a missing entity — which the model handler can detect and\n  fall back on — rather than silently outdated values.\n* A **per-field** [`HEXPIRE`](https://redis.io/docs/latest/commands/hexpire) (Redis 7.4+)\n  on each streaming feature gives that field its own shorter expiry,\n  independent of the rest of the hash. If the streaming pipeline stops\n  updating a feature, the field self-cleans while the batch fields stay\n  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\n  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\n  field expire on its own timer."
    },
    {
      "id": "how-stackexchange-redis-fits-the-demo",
      "title": "How StackExchange.Redis fits the demo",
      "role": "content",
      "text": "Three client facts shape the helper:\n\n* **`ConnectionMultiplexer` is a single, shared, thread-safe object.** One\n  instance serves the whole process — every HTTP handler in the ASP.NET\n  Core thread pool and the streaming worker pull an `IDatabase` from the\n  same multiplexer with `mux.GetDatabase()`. There is no pool to manage and\n  no per-call connection borrow.\n* **`IBatch` is the canonical pipelining handle.** `db.CreateBatch()`\n  returns a builder; you call the async methods to queue commands (each\n  returns a `Task<T>` that completes when the batch is flushed), then\n  `batch.Execute()` ships the lot in one round trip. The pattern is \"fire\n  all the async methods, *then* call Execute, *then* await the Tasks.\"\n* **Per-field TTL is typed.** StackExchange.Redis 2.8+ exposes\n  `IDatabase.HashFieldExpireAsync` (returns `ExpireResult[]` — an enum\n  whose values map 1:1 to Redis's HEXPIRE return codes) and\n  `IDatabase.HashFieldGetTimeToLiveAsync` (returns `long[]` in\n  milliseconds). The demo pins 2.13.17.\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 the\n`BuildFeatures` static class. The streaming features describe what the user\nis doing right now (`last_login_ts`, `last_device_id`, `tx_count_5m`,\n`failed_logins_15m`, `session_country`) and are written by a `StreamingWorker`\nbackground task. The HTTP handlers in `Program.cs` read any subset of those\nfeatures through `FeatureStore`'s helper class."
    },
    {
      "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 `BuildFeatures.SynthesizeUsers(N, seed)` (in\n   production, the equivalent computation lives in an offline pipeline\n   against the warehouse). The result is\n   `Dictionary<string, IReadOnlyDictionary<string, object>>` keyed by user\n   ID.\n2. `store.BulkLoadAsync(rows, ttlSeconds)` queues one\n   [`HSET`](https://redis.io/docs/latest/commands/hset) plus one\n   [`EXPIRE`](https://redis.io/docs/latest/commands/expire) per user on an `IBatch`,\n   calls `batch.Execute()` to ship the whole thing in one round trip, then\n   `Task.WhenAll` waits for every per-command reply."
    },
    {
      "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.UpdateStreamingAsync(userId, fields, ttlSeconds)`. That queues:\n\n1. An [`HSET`](https://redis.io/docs/latest/commands/hset) writing the new field values.\n   Redis is single-threaded per shard, so this is atomic against any\n   concurrent batch write on the same hash — no version columns, no 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 owned\n   by the model, not the store).\n2. It calls `store.GetFeaturesAsync(userId, names)`, which is one\n   [`HMGET`](https://redis.io/docs/latest/commands/hmget). StackExchange.Redis returns\n   the values in the same order as the requested fields, with\n   `RedisValue.Null` for any field that doesn't exist (or has expired).\n3. For batch inference, the model server calls\n   `store.BatchGetFeaturesAsync(userIds, names)`, which pipelines one\n   [`HMGET`](https://redis.io/docs/latest/commands/hmget) per user across all `N`\n   users in a single network round trip via `IBatch`."
    },
    {
      "id": "project-layout",
      "title": "Project layout",
      "role": "content",
      "text": "The csproj sits at the project root with every C# source file next to it,\nmirroring every other client demo in this use case:\n\n[code example]\n\nBuild and run with `dotnet run -c Release`. The `--mode build-features`\nflag short-circuits to the CLI builder before the HTTP server starts up."
    },
    {
      "id": "the-feature-store-helper",
      "title": "The feature-store helper",
      "role": "content",
      "text": "The `FeatureStore` class wraps the read/write paths\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/dotnet/FeatureStore.cs)):\n\n[code example]"
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each user is one Redis Hash. Every value is stored as a string — Redis hash\nfields are bytes on the wire, so `FeatureStore.EncodeValue` renders\nbooleans as `\"true\"` / `\"false\"` and uses `Object.ToString()` (with\n`InvariantCulture` for doubles, so a `92.40` doesn't become `\"92,40\"` in\nlocales that use a comma decimal separator). The model server is responsible\nfor parsing back to the right type, the same way it would when reading any\nserialized feature store.\n\n[code example]"
    },
    {
      "id": "bulk-loading-batch-features",
      "title": "Bulk-loading batch features",
      "role": "content",
      "text": "`BulkLoadAsync` queues one `HSET` and one `EXPIRE` per user through an\n`IBatch`, then `Execute()` ships the whole batch in one round trip.\n\n[code example]\n\nTwo things worth noticing:\n\n1. **Call the async methods *before* `Execute()`.** They don't run anything\n   yet — they just queue the command and return a `Task` that completes\n   when the batch is flushed. Order matters: a `batch.HashSetAsync(...)`\n   after `batch.Execute()` is just a regular async call against the\n   underlying database (and will fail because the local `IBatch` is now\n   spent).\n2. **`Task.WhenAll(tasks)` after `Execute()`** is how you wait for the\n   server to acknowledge the whole batch. Skipping it would leak any\n   per-command errors (a malformed `EXPIRE`, for example) into the next\n   call instead of the batch.\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": "`UpdateStreamingAsync` 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\n*individual* hash fields, not on the whole key. The two commands are\nqueued under one `IBatch` so Redis runs them in pipeline order: the\n`HSET` first creates or overwrites the fields, then `HEXPIRE` attaches a\nTTL to each of those same fields. `HashFieldExpireAsync` returns one\n`ExpireResult` per field:\n\n* `ExpireResult.Success` (= Redis code `1`): TTL set / updated.\n* `ExpireResult.Due` (= `2`): the expiry was 0 or in the past, so Redis\n  deleted the field instead of applying a TTL.\n* `ExpireResult.ConditionNotMet` (= `0`): an `NX | XX | GT | LT`\n  conditional flag was specified and not met (we never use one here).\n* `ExpireResult.NoSuchField` (= `-2`): no such field, or no such key.\n\nWe always follow `HSET` with `HEXPIRE` so any code other than `Success`\nmeans the per-field TTL invariant didn't hold — the helper throws an\n`InvalidOperationException` rather than silently leaving a streaming\nfield with no expiry attached.\n\nIf a streaming pipeline stops, the streaming fields drop out one by one\nas their per-field TTLs elapse. `FieldTtlsSecondsAsync` (which wraps\n`HashFieldGetTimeToLiveAsync`) lets the model side inspect the\nremaining TTL on any field. Note that the StackExchange.Redis return is\nin **milliseconds** — the helper divides by 1000 to match the\n`TTL` / `HTTL` second-based convention used by every other client in\nthis use case (and `redis-cli`).\n\n> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level\n> TTL commands (`HTTL`, `HPERSIST`, `HEXPIREAT`, `HPEXPIRE`,\n> `HPEXPIREAT`, `HPTTL`, `HEXPIRETIME`, `HPEXPIRETIME`) were added in\n> Redis 7.4. StackExchange.Redis 2.8 was the first release with the\n> typed bindings; the demo pins 2.13.17."
    },
    {
      "id": "inference-reads-with-hmget",
      "title": "Inference reads with HMGET",
      "role": "content",
      "text": "`GetFeaturesAsync` is one `HMGET`:\n\n[code example]\n\n`db.HashGetAsync(key, RedisValue[] fields)` issues `HMGET` and returns\na `RedisValue[]` aligned with the input order. Missing fields come back\nas `RedisValue.Null` (which `IsNull` detects); the helper drops them\nfrom the result dict so the caller sees only the features that actually\nexist on the hash."
    },
    {
      "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\nthrough one `IBatch`:\n\n[code example]\n\nOne round trip for the whole batch. The demo returns a 30-user batch in\n~2 ms against a local Redis after the first-call JIT/connection warm-up.\n\nA Redis Cluster is different: an `IBatch` is bound to one shard,\nbecause all queued commands ship through one connection to one node.\nFor batch reads on a cluster, the\n[StackExchange.Redis cluster client](https://redis.io/docs/latest/develop/clients/dotnet/connect)\nroutes non-batched `HashGetAsync` calls to the right shard\nautomatically — fan out parallel calls with `Task.WhenAll` and the\nmultiplexer handles per-shard routing. For tighter control, group\nentity IDs by hash slot ahead of time and use one `CreateBatch` per\nshard's connection in parallel. A hash tag like `fs:user:{vip}:u0001`\nforces a known set of keys onto the same shard so one batch can cover\nthem all."
    },
    {
      "id": "the-streaming-worker",
      "title": "The streaming worker",
      "role": "content",
      "text": "`StreamingWorker.cs` 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/dotnet/StreamingWorker.cs)).\nIt runs as a background `Task` next to the demo server so the UI can\nstart, pause, and resume it.\n\n[code example]\n\nThe same pre-flight `_tickInFlight` + `finally`-clear pattern as every\nother client in this use case closes the pause/in-flight race: a reset\nthat's about to `DEL` every key calls `worker.Pause()` to stop *future*\nticks *and* `await worker.WaitForIdleAsync()` to flush a mid-flight tick\nbefore issuing the DEL sweep.\n\nPausing the worker is what shows off the mixed-staleness behavior: leave\nit paused for longer than `StreamingTtlSeconds` and the streaming fields\ndisappear from every user's hash one by one, while the batch fields\nremain under the longer key-level `EXPIRE`. The demo's\n`Pause / resume` button lets you see this happen in real time."
    },
    {
      "id": "the-batch-builder",
      "title": "The batch builder",
      "role": "content",
      "text": "`BuildFeatures.cs` is the demo's nightly materializer\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/feature-store/dotnet/BuildFeatures.cs)).\nIt generates synthetic feature rows and calls `store.BulkLoadAsync`\nonce. The synthesis itself is not the point — in a real deployment the\nequivalent code reads from the offline store (Snowflake, BigQuery,\nIceberg) and writes the resulting hashes into Redis.\n\nRun the builder on its own (independently of the demo server) to\npopulate Redis from the command line:\n\n[code example]\n\nThat writes 500 users at `fs:user:*` with a one-hour key-level TTL,\nwhich is how a typical operator would pre-seed a feature store from the\ncommand line when debugging."
    },
    {
      "id": "the-interactive-demo",
      "title": "The interactive demo",
      "role": "content",
      "text": "`Program.cs` runs the ASP.NET Core minimal-API server on port 8091. The\nHTML page lets you:\n\n* **Bulk-load** any number of users (default 200) with a configurable\n  key-level TTL.\n* See the **store state**: user count, batch / streaming TTLs,\n  cumulative 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,\n  and 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\n  key-level TTL.\n\nThe server holds one `FeatureStore`, one `StreamingWorker`, and one\n`ConnectionMultiplexer` for the lifetime of the process. Every handler\nin the ASP.NET Core thread pool and the streaming worker share that\nmultiplexer — StackExchange.Redis handles the per-call multiplexing\nacross the underlying 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)\n  and [`HTTL`](https://redis.io/docs/latest/commands/httl) were added in Redis 7.4;\n  the demo relies on per-field TTL for the mixed-staleness story.\n* **.NET 8 SDK or later.**\n* **StackExchange.Redis 2.8 or later.** The demo's csproj pins 2.13.17.\n  Typed bindings for the field-TTL commands ship from 2.8.\n\nThe connection multiplexer is opened with `AllowAdmin = true` because\nthe demo uses `IServer.Keys()` (SCAN under the hood) to populate UI\ndropdowns and to power the reset path. In a production read/write\nservice you would not enable `AllowAdmin`; instead, maintain an external\nindex of user IDs (a small Redis Set, say, keyed by tenant) and read it\nto discover entities. The demo's `SCAN` use is purely a UI convenience.\n\nIf your Redis server is running elsewhere, start the demo with\n`--redis-uri 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 csproj under\n[`feature-store/dotnet`](https://github.com/redis/docs/tree/main/content/develop/use-cases/feature-store/dotnet).\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:8091](http://127.0.0.1:8091). 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\n  disappear while the batch fields stay.\n* Click **Inspect** on a user to see the full hash with field-level\n  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\nrunning a feature store on Redis. For the generic\nStackExchange.Redis production checklist —\n[`ConfigurationOptions`](https://redis.io/docs/latest/develop/clients/dotnet/connect)\ntuning, AUTH/ACL, retry/backoff, multiplexer lifetime, and exception\nhandling — see the\n[StackExchange.Redis production usage guide](https://redis.io/docs/latest/develop/clients/dotnet/produsage).\nFor TLS specifically, follow the\n[connect-with-TLS recipe](https://redis.io/docs/latest/develop/clients/dotnet/connect#connect-to-your-production-redis-with-tls).\nThe feature-store demo runs against `localhost` with the defaults; a real\ndeployment should harden the client first."
    },
    {
      "id": "adopting-the-helper-outside-asp-net-core",
      "title": "Adopting the helper outside ASP.NET Core",
      "role": "content",
      "text": "`FeatureStore.cs` omits `.ConfigureAwait(false)` on its `await` calls\nbecause ASP.NET Core 8 has no synchronization context — every `await`\nresumes on a thread-pool thread, so the flag is a no-op and just\nclutters the source. If you copy the helper into a context that *does*\nhave a synchronization context (a Windows Forms or WPF app, classic\nASP.NET, a Xamarin or MAUI UI thread, or a library that needs to play\nnicely with any consumer) add `.ConfigureAwait(false)` after every\n`await` to avoid deadlocking the UI thread on the resumption."
    },
    {
      "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\nschema; the serving pipeline reads from Redis with the flattened\nserving schema. Keeping those two pipelines as the same code path is\nwhat prevents training-serving skew."
    },
    {
      "id": "pipeline-batch-reads-across-shards",
      "title": "Pipeline batch reads across shards",
      "role": "content",
      "text": "On a single Redis instance, an `IBatch` of `HMGET`s across `N` users is\none round trip. A Redis Cluster is different: an `IBatch` is bound to\none shard, so on a cluster you need to either fan out the per-user\n`HashGetAsync` calls with `Task.WhenAll` (the multiplexer routes each\none to the right shard) or group entity IDs by hash slot and create\none `IBatch` per shard's connection in parallel.\n\nA hash tag like `fs:user:{vip}:u0001` forces a known set of keys onto\nthe same shard so one `IBatch` 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 `IBatch` (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 `HashGetAsync(key, RedisValue[])`\nin 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`HashGetAllAsync` 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\nfield has 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\n  whole 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/dotnet/transpipe).\n\nSee the [StackExchange.Redis documentation](https://redis.io/docs/latest/develop/clients/dotnet)\nfor the full client reference, and the\n[Hashes overview](https://redis.io/docs/latest/develop/data-types/hashes) for the\ndeeper conceptual model."
    }
  ],
  "examples": [
    {
      "id": "project-layout-ex0",
      "language": "text",
      "code": "feature-store/dotnet/\n├── FeatureStoreDemo.csproj\n├── Program.cs              — main() + ASP.NET Core minimal-API routes\n├── FeatureStore.cs         — FeatureStore class + EncodeValue + Stats record\n├── BuildFeatures.cs        — SynthesizeUsers + RunCliAsync\n├── StreamingWorker.cs      — background-task worker\n└── HtmlTemplate.cs         — inlined HTML page (C# 11 raw string literal)",
      "section_id": "project-layout"
    },
    {
      "id": "the-feature-store-helper-ex0",
      "language": "csharp",
      "code": "using StackExchange.Redis;\nusing FeatureStoreDemo;\n\nvar muxOptions = ConfigurationOptions.Parse(\"localhost:6379\");\nmuxOptions.AllowAdmin = true;   // needed for SCAN via IServer.Keys()\nvar mux = await ConnectionMultiplexer.ConnectAsync(muxOptions);\n\nvar store = new FeatureStore(\n    mux,\n    \"fs:user:\",\n    batchTtlSeconds: 24 * 60 * 60,    // whole-entity TTL aligned with the daily batch cycle\n    streamingTtlSeconds: 5 * 60       // per-field TTL on each streaming feature\n);\n\n// Batch materialization: one HSET + EXPIRE per user, all pipelined.\nvar rows = new Dictionary<string, IReadOnlyDictionary<string, object>>\n{\n    [\"u0001\"] = new Dictionary<string, object>\n    {\n        [\"country_iso\"] = \"US\", [\"risk_segment\"] = \"low\",\n        [\"tx_count_7d\"] = 14, [\"avg_amount_30d\"] = 92.40,\n        [\"account_age_days\"] = 612, [\"chargeback_count_180d\"] = 0,\n    },\n};\nawait store.BulkLoadAsync(rows, 24 * 60 * 60);\n\n// Streaming write: HSET + HEXPIRE on just the fields that changed.\nawait store.UpdateStreamingAsync(\"u0001\", new Dictionary<string, object>\n{\n    [\"last_login_ts\"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),\n    [\"last_device_id\"] = \"ios-9f02\",\n    [\"tx_count_5m\"] = 3,\n    [\"failed_logins_15m\"] = 0,\n    [\"session_country\"] = \"US\",\n}, 5 * 60);\n\n// Inference read: HMGET of whatever the model needs.\nvar features = await store.GetFeaturesAsync(\"u0001\", new[]\n{\n    \"risk_segment\", \"tx_count_7d\", \"avg_amount_30d\",\n    \"tx_count_5m\", \"failed_logins_15m\",\n});\n\n// Batch scoring: pipelined HMGET across many users.\nvar batch = await store.BatchGetFeaturesAsync(\n    new[] { \"u0001\", \"u0002\", \"u0003\" },\n    new[] { \"risk_segment\", \"tx_count_5m\", \"failed_logins_15m\" });",
      "section_id": "the-feature-store-helper"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "fs:user:u0001                                   TTL = 86400 s (key-level)\n  country_iso=US                                <no field TTL>\n  risk_segment=low                              <no field TTL>\n  account_age_days=612                          <no field TTL>\n  tx_count_7d=14                                <no field TTL>\n  avg_amount_30d=92.40                          <no field TTL>\n  chargeback_count_180d=0                       <no field TTL>\n  last_login_ts=1716998413541                   TTL = 300 s (per field, HEXPIRE)\n  last_device_id=ios-9f02                       TTL = 300 s (per field, HEXPIRE)\n  tx_count_5m=3                                 TTL = 300 s (per field, HEXPIRE)\n  failed_logins_15m=0                           TTL = 300 s (per field, HEXPIRE)\n  session_country=US                            TTL = 300 s (per field, HEXPIRE)",
      "section_id": "data-model"
    },
    {
      "id": "bulk-loading-batch-features-ex0",
      "language": "csharp",
      "code": "public async Task<int> BulkLoadAsync(\n    IReadOnlyDictionary<string, IReadOnlyDictionary<string, object>> rows,\n    long ttlSeconds)\n{\n    if (rows.Count == 0) return 0;\n    var batch = _db.CreateBatch();\n    var tasks = new List<Task>(rows.Count * 2);\n    foreach (var (entityId, fields) in rows)\n    {\n        var key = (RedisKey)KeyFor(entityId);\n        var entries = new HashEntry[fields.Count];\n        int i = 0;\n        foreach (var (name, value) in fields)\n            entries[i++] = new HashEntry(name, EncodeValue(value));\n        tasks.Add(batch.HashSetAsync(key, entries));\n        tasks.Add(batch.KeyExpireAsync(key, TimeSpan.FromSeconds(ttlSeconds)));\n    }\n    batch.Execute();\n    await Task.WhenAll(tasks);\n    ...\n}",
      "section_id": "bulk-loading-batch-features"
    },
    {
      "id": "streaming-writes-with-per-field-ttl-ex0",
      "language": "csharp",
      "code": "public async Task UpdateStreamingAsync(\n    string entityId,\n    IReadOnlyDictionary<string, object> fields,\n    long ttlSeconds)\n{\n    if (fields.Count == 0) return;\n    var key = (RedisKey)KeyFor(entityId);\n    var entries = new HashEntry[fields.Count];\n    var names = new RedisValue[fields.Count];\n    int i = 0;\n    foreach (var (name, value) in fields)\n    {\n        entries[i] = new HashEntry(name, EncodeValue(value));\n        names[i] = name;\n        i++;\n    }\n\n    var batch = _db.CreateBatch();\n    var hsetTask = batch.HashSetAsync(key, entries);\n    var hexpireTask = batch.HashFieldExpireAsync(\n        key, names, TimeSpan.FromSeconds(ttlSeconds));\n    batch.Execute();\n    await hsetTask;\n    var codes = await hexpireTask;\n    foreach (var code in codes)\n    {\n        if (code != ExpireResult.Success)\n        {\n            throw new InvalidOperationException(\n                $\"HEXPIRE did not set every field TTL for {key}: [{string.Join(\",\", codes)}]\");\n        }\n    }\n    ...\n}",
      "section_id": "streaming-writes-with-per-field-ttl"
    },
    {
      "id": "inference-reads-with-hmget-ex0",
      "language": "csharp",
      "code": "public async Task<Dictionary<string, string>> GetFeaturesAsync(\n    string entityId, IReadOnlyList<string> fieldNames)\n{\n    var key = (RedisKey)KeyFor(entityId);\n    var out_ = new Dictionary<string, string>();\n    if (fieldNames.Count == 0) return out_;\n    var values = await _db.HashGetAsync(\n        key, fieldNames.Select(f => (RedisValue)f).ToArray());\n    for (int i = 0; i < fieldNames.Count; i++)\n    {\n        if (!values[i].IsNull)\n            out_[fieldNames[i]] = values[i].ToString();\n    }\n    ...\n}",
      "section_id": "inference-reads-with-hmget"
    },
    {
      "id": "batch-scoring-with-pipelined-hmget-ex0",
      "language": "csharp",
      "code": "public async Task<Dictionary<string, Dictionary<string, string>>> BatchGetFeaturesAsync(\n    IReadOnlyList<string> entityIds, IReadOnlyList<string> fieldNames)\n{\n    if (entityIds.Count == 0 || fieldNames.Count == 0)\n        return new Dictionary<string, Dictionary<string, string>>();\n\n    var fieldValues = fieldNames.Select(f => (RedisValue)f).ToArray();\n    var batch = _db.CreateBatch();\n    var tasks = new Task<RedisValue[]>[entityIds.Count];\n    for (int i = 0; i < entityIds.Count; i++)\n        tasks[i] = batch.HashGetAsync(KeyFor(entityIds[i]), fieldValues);\n    batch.Execute();\n    var rows = await Task.WhenAll(tasks);\n    ...\n}",
      "section_id": "batch-scoring-with-pipelined-hmget"
    },
    {
      "id": "the-streaming-worker-ex0",
      "language": "csharp",
      "code": "private async Task RunAsync(CancellationToken ct)\n{\n    try\n    {\n        while (!ct.IsCancellationRequested)\n        {\n            try { await Task.Delay(_tick, ct); }\n            catch (OperationCanceledException) { break; }\n            if (ct.IsCancellationRequested) break;\n\n            // Set tick_in_flight *before* the pause check so a\n            // concurrent pause+wait can never see tick_in_flight=0\n            // in the window between the pause check and the actual\n            // DoTick call. The finally block clears the flag whether\n            // we paused, succeeded, or threw.\n            Interlocked.Exchange(ref _tickInFlight, 1);\n            try\n            {\n                if (Volatile.Read(ref _paused) == 0)\n                    await DoTickAsync();\n            }\n            catch (Exception e)\n            {\n                Console.Error.WriteLine($\"[streaming-worker] tick failed: {e.Message}\");\n            }\n            finally\n            {\n                Interlocked.Exchange(ref _tickInFlight, 0);\n            }\n        }\n    }\n    finally\n    {\n        // Clear running and tick_in_flight no matter how the task\n        // exits so a later Start() can spin a fresh task.\n        Interlocked.Exchange(ref _running, 0);\n        Interlocked.Exchange(ref _tickInFlight, 0);\n    }\n}",
      "section_id": "the-streaming-worker"
    },
    {
      "id": "the-batch-builder-ex0",
      "language": "bash",
      "code": "dotnet run --project . -- --mode 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/dotnet\ndotnet build -c Release",
      "section_id": "get-the-source-files"
    },
    {
      "id": "start-the-demo-server-ex0",
      "language": "bash",
      "code": "dotnet run -c Release",
      "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:8091\nUsing Redis at localhost:6379 with key prefix 'fs:user:' (batch TTL 86400s, streaming TTL 300s)\nMaterialized 200 user(s); streaming worker running.",
      "section_id": "start-the-demo-server"
    },
    {
      "id": "inspect-the-store-directly-with-redis-cli-ex0",
      "language": "bash",
      "code": "# How many users currently in the store\nredis-cli --scan --pattern 'fs:user:*' | wc -l\n\n# One user's full hash and key-level TTL\nredis-cli HGETALL fs:user:u0001\nredis-cli TTL    fs:user:u0001\n\n# Per-field TTL on the streaming fields\nredis-cli HTTL fs:user:u0001 FIELDS 5 \\\n  last_login_ts last_device_id tx_count_5m failed_logins_15m session_country\n\n# Sample HMGET as the model would issue it\nredis-cli HMGET fs:user:u0001 risk_segment tx_count_7d avg_amount_30d tx_count_5m",
      "section_id": "inspect-the-store-directly-with-redis-cli"
    }
  ]
}
