# Guide 5: Redis as Clinical State Store ## What is Redis? **Redis** is an in-memory key-value store. Unlike a traditional database (PostgreSQL) that stores data on disk, Redis keeps everything in RAM (your computer's working memory). This makes it extremely fast — reads and writes typically complete in under 1 millisecond. The simplest way to think of Redis is as a giant dictionary (or hash map): you store values under string keys, and you can retrieve them by key almost instantly. For example, `SET "threshold:HEART_RATE" "{...json...}"` stores a value, and `GET "threshold:HEART_RATE"` retrieves it. **TTL (Time-To-Live)** is one of Redis's most powerful features. When you store a value, you can say "automatically delete this after 30 minutes." The value disappears on its own — no cleanup code needed. This makes Redis ideal for temporary state that should expire naturally. **What Redis is NOT**: Redis is not a replacement for PostgreSQL. It doesn't support complex queries, JOINs, or transactions across multiple keys (in the way SQL databases do). Data in Redis can be lost if the server restarts (though it can be configured to persist). Use PostgreSQL as your source of truth; use Redis for fast temporary state and caching. --- ## Why Redis in This Project? Clinical scoring engines (NEWS2, GCS, qSOFA, SOFA, trend detection) need to aggregate multiple observations arriving at different times. A patient's respiratory rate arrives at 14:01, their heart rate at 14:03, blood pressure at 14:05. To compute a NEWS2 score, you need all 7 parameters within a time window. Redis holds this temporary state in memory with automatic TTL expiration, so stale parameters don't produce false scores. Redis also caches alert thresholds (avoiding a database query on every observation ingest) and manages alert suppression windows (preventing repeated warnings after acknowledgment). --- ## Architecture Overview ``` Observation arrives │ ┌────────────────┼────────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌──────────┐ ┌──────────────┐ │ Threshold │ │ qSOFA │ │ NEWS2 │ │ Cache │ │ Criteria│ │ Parameters │ │ │ │ │ │ │ │ threshold: │ │ qsofa: │ │ news2: │ │ {code} │ │ {enc}: │ │ {enc}: │ │ │ │ {code} │ │ {code} │ │ No TTL │ │ 30min TTL│ │ 4hr TTL │ └─────────────┘ └──────────┘ └──────────────┘ ┌────────────────┼────────────────┐ ▼ ▼ ▼ ┌─────────────┐ ┌──────────┐ ┌──────────────┐ │ GCS │ │ SOFA │ │ Trend │ │ Components │ │ Lab │ │ History │ │ │ │ Cache │ │ │ │ gcs: │ │ sofa: │ │ trend: │ │ {enc}: │ │ {enc}: │ │ {enc}: │ │ {comp} │ │ {code} │ │ {code} │ │ No TTL │ │ 24hr TTL │ │ 2hr TTL │ └─────────────┘ └──────────┘ └──────────────┘ ┌──────────────┐ │ Alert │ │ Suppression │ │ │ │ suppress: │ │ {enc}: │ │ {type} │ │ 30min TTL │ └──────────────┘ ``` --- ## Connection Setup ### Program.cs Registration ```csharp builder.Services.AddSingleton(sp => ConnectionMultiplexer.Connect( sp.GetRequiredService()["Redis:ConnectionString"]!)); ``` **What is `IConnectionMultiplexer`?** This is the StackExchange.Redis library's connection class. "Multiplexer" means it manages multiple connections to Redis internally and can handle many concurrent operations over a small number of physical TCP connections. It's registered as a **singleton** — meaning one instance is created and shared across the entire application. This is the correct approach because `ConnectionMultiplexer` is thread-safe and designed to be reused. Creating a new connection for every operation would be wasteful and slow. ### Configuration ```json { "Redis": { "ConnectionString": "localhost:6382" } } ``` ### Health Check ```csharp public sealed class RedisHealthCheck : IHealthCheck { private readonly IConnectionMultiplexer _redis; public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { var db = _redis.GetDatabase(); var latency = await db.PingAsync(); var data = new Dictionary { ["ping_ms"] = latency.TotalMilliseconds }; return HealthCheckResult.Healthy(data: data); } } ``` Used by the `/health/ready` endpoint to verify Redis is reachable. --- ## Key Naming Convention All Redis keys follow the pattern: `{domain}:{encounterId}:{discriminator}` | Domain | Key Pattern | Example | |--------|-------------|---------| | Threshold cache | `threshold:{observationCode}` | `threshold:HEART_RATE` | | qSOFA criteria | `qsofa:{encounterId}:{code}` | `qsofa:3fa85f64-...:RESP_RATE` | | NEWS2 parameters | `news2:{encounterId}:{code}` | `news2:3fa85f64-...:SPO2` | | GCS components | `gcs:{encounterId}:{component}` | `gcs:3fa85f64-...:GCS_EYE` | | SOFA lab values | `sofa:{encounterId}:{code}` | `sofa:3fa85f64-...:PLATELET_K_UL` | | Trend history | `trend:{encounterId}:{code}` | `trend:3fa85f64-...:HEART_RATE` | | Alert suppression | `suppress:{encounterId}:{alertType}` | `suppress:3fa85f64-...:WARNING_HEART_RATE` | Encounter-scoped keys ensure different patients' data never collides — two patients can both have a `qsofa:...:RESP_RATE` key without interfering because the encounter IDs in the middle are different. The `:` separator is a Redis convention for logical namespacing (like folders in a file path). It has no special meaning to Redis itself, but tools like Redis Commander display colon-separated keys as a tree structure. --- ## Usage Pattern 1: Threshold Cache (Pre-Loading) **What is caching?** Caching means storing a copy of frequently-accessed data in a faster location. Instead of querying PostgreSQL every time an observation arrives (which involves network round-trips and disk I/O), we load threshold values into Redis once at startup. Redis serves the data from memory in under 1ms, compared to 5-10ms for a PostgreSQL query. When you're processing hundreds of observations per second, this difference adds up. Alert thresholds are loaded from PostgreSQL into Redis on application startup, so observation ingest doesn't need a database query for every threshold lookup. ### ThresholdCacheLoader — Startup Pre-Load ```csharp public class ThresholdCacheLoader : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { var thresholds = await db.AlertThresholds.ToListAsync(cancellationToken); const int maxAttempts = 3; int[] backoffMs = [2000, 4000, 8000]; for (var attempt = 0; attempt < maxAttempts; attempt++) { try { var cache = _redis.GetDatabase(); var batch = cache.CreateBatch(); foreach (var t in thresholds) { var json = JsonSerializer.Serialize(new { t.ObservationCode, t.CriticalLow, t.WarningLow, t.WarningHigh, t.CriticalHigh }); _ = batch.StringSetAsync($"threshold:{t.ObservationCode}", json); } batch.Execute(); _logger.LogInformation("Loaded {Count} alert thresholds into Redis cache", thresholds.Count); return; } catch (RedisException ex) { _logger.LogWarning(ex, "Redis unavailable during threshold cache load — attempt {Attempt}/{Max}", attempt + 1, maxAttempts); if (attempt < maxAttempts - 1) await Task.Delay(backoffMs[attempt], cancellationToken); } } _logger.LogError( "Failed to load thresholds into Redis after {Max} attempts — " + "application will start without cache; observation ingest falls back to PostgreSQL", maxAttempts); } } ``` Key patterns: - **`CreateBatch()`** groups all the SET commands and sends them to Redis in one network round trip instead of 15+ separate calls. This is called **pipelining** — it dramatically reduces latency when you need to do many operations at once. - **Retry with exponential backoff** (2s, 4s, 8s) — Redis might still be starting up in Docker when the application starts. Instead of failing immediately, we retry with increasing delays (2 seconds, then 4, then 8). This pattern is called "exponential backoff" and is a standard approach for handling transient failures. - **Graceful degradation** — if Redis is unreachable after 3 attempts, the app starts anyway and falls back to PostgreSQL for threshold lookups. The app works, just slightly slower. - **No TTL** — thresholds rarely change, so they stay in cache indefinitely. The cache is updated when an admin modifies thresholds via the API. --- ## Usage Pattern 2: qSOFA Criteria (Sliding Window) **What is a sliding window?** A sliding window is a time-based boundary that moves forward continuously. Imagine a 30-minute window — at 2:00 PM it covers 1:30–2:00, at 2:05 it covers 1:35–2:05, at 2:10 it covers 1:40–2:10. Only data within the current window counts. Redis TTLs implement this naturally: when you store a value with a 30-minute TTL, it automatically disappears after 30 minutes. If a new observation arrives, you overwrite the key with a fresh 30-minute TTL, effectively "sliding" the window forward. qSOFA evaluates 3 criteria (respiratory rate >= 22, systolic BP <= 100, altered mentation). Each criterion has a 30-minute TTL — if no new observation arrives within 30 minutes, the criterion expires automatically. ### QsofaCalculator — Key Generation ```csharp public static class QsofaCalculator { public static readonly IReadOnlyList QsofaCodes = new[] { "RESP_RATE", "SYSTOLIC_BP", "AVPU" }; public static string CriterionKey(Guid encounterId, string code) => $"qsofa:{encounterId}:{code}"; public static RedisKey[] AllCriterionKeys(Guid encounterId) => QsofaCodes.Select(c => (RedisKey)CriterionKey(encounterId, c)).ToArray(); public static bool MeetsCriterion(string observationCode, decimal value) => observationCode switch { "RESP_RATE" => value >= 22m, "SYSTOLIC_BP" => value <= 100m, "AVPU" => value >= 1m, _ => false }; } ``` ### QsofaDetector — Set/Delete Pattern ```csharp public async Task ProcessObservationAsync( Guid encounterId, Guid patientId, string observationCode, decimal value, CancellationToken ct) { var cache = _redis.GetDatabase(); var key = QsofaCalculator.CriterionKey(encounterId, observationCode); if (QsofaCalculator.MeetsCriterion(observationCode, value)) { // Criterion met — set with 30-minute TTL await cache.StringSetAsync( key, value.ToString(), TimeSpan.FromSeconds(1800)); } else { // Criterion not met — delete immediately await cache.KeyDeleteAsync(key); } return await EvaluateAndMaybeAlertAsync(encounterId, patientId, ct); } ``` ### Evaluation — MGET for Batch Read **What is MGET?** MGET ("multi-get") is a Redis command that retrieves multiple keys in a single network round trip. Instead of calling `GET key1`, then `GET key2`, then `GET key3` (3 round trips), `MGET key1 key2 key3` returns all three values at once. In the StackExchange.Redis library, calling `StringGetAsync` with an array of keys automatically uses MGET under the hood. ```csharp private async Task EvaluateAndMaybeAlertAsync( Guid encounterId, Guid patientId, CancellationToken ct) { var cache = _redis.GetDatabase(); var allKeys = QsofaCalculator.AllCriterionKeys(encounterId); // MGET reads all 3 criterion values in one round trip var values = await cache.StringGetAsync(allKeys); var activeCount = QsofaCalculator.CountActiveCriteria(values); if (activeCount >= 2) await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct); // ... } ``` The TTL-based sliding window means: if a patient's respiratory rate was >= 22 at 14:00 but no new reading arrives by 14:30, the criterion expires and the qSOFA score drops — even without an explicit "normal" reading. --- ## Usage Pattern 3: NEWS2 Parameters (7-Parameter Aggregation) NEWS2 needs all 7 parameters to compute a score. Each parameter is cached independently with a 4-hour TTL: ```csharp // Store a parameter value with its individual score var paramData = JsonSerializer.Serialize(new { value, score = individualScore, recordedAt = DateTimeOffset.UtcNow }); await cache.StringSetAsync( News2Calculator.ParameterKey(encounterId, observationCode), paramData, TimeSpan.FromSeconds(14400)); // 4 hours ``` Evaluation reads all 7 with a single MGET: ```csharp var allKeys = News2Calculator.AllParameterKeys(encounterId); var allValues = await cache.StringGetAsync(allKeys); // Check if all 7 parameters are present for (int i = 0; i < 7; i++) { if (!allValues[i].HasValue) { return News2Result.IncompleteParameters(allValues.Count(v => v.HasValue)); } var cached = JsonSerializer.Deserialize(allValues[i]!); scores[i] = cached?.Score; } var totalScore = scores.Select(s => s!.Value).Sum(); ``` The 4-hour TTL is much longer than qSOFA's 30 minutes because NEWS2 parameters (like temperature) may only be measured every few hours. ### Consciousness Resolution: GCS-First, AVPU-Fallback The NEWS2 consciousness parameter prefers GCS over AVPU: ```csharp private async Task ResolveConsciousnessScoreAsync(Guid encounterId) { var cache = _redis.GetDatabase(); // Try GCS first — read all 3 component values var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId)); if (gcsValues.All(v => v.HasValue)) { var eye = decimal.Parse(gcsValues[0]!); var verbal = decimal.Parse(gcsValues[1]!); var motor = decimal.Parse(gcsValues[2]!); var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value; return News2Calculator.ScoreConsciousnessFromGcs(total); } // Fallback to AVPU var avpuVal = await cache.StringGetAsync( News2Calculator.ParameterKey(encounterId, "AVPU")); if (!avpuVal.HasValue) return null; var cached = JsonSerializer.Deserialize(avpuVal!); return cached?.Score; } ``` --- ## Usage Pattern 4: GCS Components (Temporary Assembly) GCS has 3 components (Eye, Verbal, Motor) that may arrive as separate observations. Redis holds each component until all 3 are present: ```csharp public static class GcsCalculator { public static readonly IReadOnlyList ComponentCodes = new[] { "GCS_EYE", "GCS_VERBAL", "GCS_MOTOR" }; public static string ComponentKey(Guid encounterId, string code) => $"gcs:{encounterId}:{code}"; public static RedisKey[] AllComponentKeys(Guid encounterId) => ComponentCodes .Select(code => (RedisKey)$"gcs:{encounterId}:{code}") .ToArray(); public static int? ComputeTotal(decimal? eye, decimal? verbal, decimal? motor) { if (eye is null || verbal is null || motor is null) return null; return (int)(eye.Value + verbal.Value + motor.Value); } } ``` GCS component keys have no explicit TTL — they persist until replaced by a new assessment. When all 3 components are present, the total is computed, persisted to PostgreSQL, and published to the `gcs.scored` Kafka topic for downstream consumers (SOFA CNS, NEWS2 consciousness, qSOFA altered mentation). --- ## Usage Pattern 5: SOFA Lab Cache (Staleness Tracking) SOFA scoring depends on lab values (platelets, bilirubin, creatinine) that arrive infrequently. The cache stores both the value and when it was recorded: ```csharp public class SofaLabCache { public async Task StoreAsync( Guid encounterId, string code, decimal value, DateTimeOffset recordedAt) { var json = JsonSerializer.Serialize(new SofaCachedValue(value, recordedAt)); var key = SofaCalculator.CacheKey(encounterId, code); await _redis.GetDatabase().StringSetAsync( key, json, TimeSpan.FromHours(_options.LabStalenessHours)); // 24 hours } public SofaValueStatus Classify(SofaCachedValue? value) { if (value is null) return SofaValueStatus.Expired; var age = DateTimeOffset.UtcNow - value.RecordedAt; if (age.TotalHours > _options.LabStalenessHours) return SofaValueStatus.Expired; // 24h if (age.TotalHours > _options.LabWarningHours) return SofaValueStatus.Stale; // 12h return SofaValueStatus.Current; } public async Task> GetAllAsync(Guid encounterId) { var cache = _redis.GetDatabase(); var keys = SofaCalculator.SofaObservationCodes .Select(c => (RedisKey)SofaCalculator.CacheKey(encounterId, c)) .ToArray(); var values = await cache.StringGetAsync(keys); // MGET for all codes at once // ... deserialize non-null values into dictionary } } ``` Three-tier staleness classification: - **Current** (< 12 hours): value used as-is - **Stale** (12–24 hours): value carried forward but flagged in SOFA score `staleness_flags` JSONB - **Expired** (> 24 hours): organ score omitted from calculation --- ## Usage Pattern 6: Trend History (Sliding Window with JSON Lists) Trend detection stores a history list of recent values in a single Redis key, serialized as JSON: ```csharp public async Task ProcessObservationAsync( Guid encounterId, Guid patientId, string observationCode, decimal value, DateTimeOffset recordedAt, CancellationToken ct) { var cache = _redis.GetDatabase(); var key = TrendCalculator.HistoryKey(encounterId, observationCode); // Read existing history var historyJson = await cache.StringGetAsync(key); var history = historyJson.HasValue ? JsonSerializer.Deserialize>(historyJson!) ?? new() : new List(); // Append new entry history.Add(new TrendHistoryEntry(value, recordedAt)); // Trim: remove entries outside window, keep max N entries var cutoff = recordedAt.AddMinutes(-_options.WindowMinutes); history = history .Where(e => e.RecordedAt >= cutoff) .TakeLast(_options.MaxHistoryEntries) .ToList(); // Write back with TTL await cache.StringSetAsync( key, JsonSerializer.Serialize(history), TimeSpan.FromSeconds(_options.HistoryTtlSeconds)); // 2 hours // Compute rate of change var rate = TrendCalculator.ComputeRatePerMinute(history, _options.WindowMinutes); // ... } ``` Configuration from `appsettings.json`: ```json { "TrendDetection": { "WindowMinutes": 30, "MaxHistoryEntries": 10, "HistoryTtlSeconds": 7200, "RateThresholdsPerMinute": { "HEART_RATE": 0.5, "RESP_RATE": 0.3, "SYSTOLIC_BP": 1.0, "TEMP_C": 0.05, "SPO2": 0.2 } } } ``` --- ## Usage Pattern 7: Alert Suppression (TTL-Based Windows) When a clinician acknowledges a WARNING-level alert, a suppression key prevents the same alert type from firing again for a configurable period: ```csharp public class AlertSuppressionService : IAlertSuppressionService { public static string SuppressionKey(Guid encounterId, AlertType alertType) => $"suppress:{encounterId}:{alertType.ToDbString()}"; public async Task SetSuppressionAsync( Guid encounterId, AlertType alertType, TimeSpan ttl, CancellationToken ct) { var cache = _redis.GetDatabase(); await cache.StringSetAsync(SuppressionKey(encounterId, alertType), "1", ttl); _metrics.AlertSuppressionsTotal.WithLabels(alertType.ToDbString()).Inc(); } public async Task IsSuppressedAsync( Guid encounterId, AlertType alertType, CancellationToken ct) { var cache = _redis.GetDatabase(); return await cache.KeyExistsAsync(SuppressionKey(encounterId, alertType)); } } ``` Scoring engines check suppression before creating WARNING alerts: ```csharp if (alertType == AlertType.News2Warning) { var suppression = _services.GetRequiredService(); if (await suppression.IsSuppressedAsync(encounterId, alertType, ct)) { _logger.LogDebug("NEWS2_WARNING suppressed for encounter {Id}", encounterId); return false; } } ``` CRITICAL alerts are never suppressed — they always fire regardless of any suppression keys. --- ## Redis Operations Summary | Operation | Redis Command | When Used | |-----------|--------------|-----------| | `StringSetAsync(key, value, ttl)` | `SET key value EX ttl` | Store parameter/criterion with expiry | | `StringGetAsync(key)` | `GET key` | Read a single value | | `StringGetAsync(keys[])` | `MGET key1 key2 ...` | Batch read all criteria/parameters | | `KeyDeleteAsync(key)` | `DEL key` | Remove criterion when no longer met | | `KeyExistsAsync(key)` | `EXISTS key` | Check alert suppression | | `CreateBatch()` + `Execute()` | Pipeline | Bulk threshold cache loading | | `PingAsync()` | `PING` | Health check | --- ## TTL Summary | Domain | TTL | Reason | |--------|-----|--------| | Thresholds | None | Long-lived, invalidated on API update | | qSOFA criteria | 30 min | Bedside screen — criteria expire without refresh | | NEWS2 parameters | 4 hours | Vitals measured every few hours | | GCS components | None | Persist until replaced by new assessment | | SOFA labs | 24 hours | Labs can be infrequent; carry forward with staleness tracking | | Trend history | 2 hours | Only recent velocity matters | | Alert suppression | 30 min (configurable) | Prevents alert fatigue after acknowledgment | --- ## Key Design Principle: Eventual Consistency Is Acceptable **What is eventual consistency?** In a distributed system, "consistency" means all components see the same data at the same time. "Eventual consistency" relaxes this — components might temporarily see stale or missing data, but will converge to the correct state eventually. This is a deliberate tradeoff: you accept slightly stale data in exchange for much faster performance. Redis state is not the source of truth — PostgreSQL is. If Redis loses data (restart, eviction), the worst case is: - A score computation waits for the next observation to refill the cache - A suppression window ends early (alert fires sooner than expected) - Threshold cache falls back to PostgreSQL lookup No clinical data is lost. Redis is an optimization layer, not a durability layer.