Files
vigilcare-clinical/docs/guides/05-redis-clinical-state-store.md
2026-06-25 00:25:31 +08:00

24 KiB
Raw Permalink Blame History

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

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect(
        sp.GetRequiredService<IConfiguration>()["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

{
  "Redis": {
    "ConnectionString": "localhost:6382"
  }
}

Health Check

public sealed class RedisHealthCheck : IHealthCheck
{
    private readonly IConnectionMultiplexer _redis;

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken cancellationToken = default)
    {
        var db = _redis.GetDatabase();
        var latency = await db.PingAsync();
        var data = new Dictionary<string, object> { ["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

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:302:00, at 2:05 it covers 1:352:05, at 2:10 it covers 1:402: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

public static class QsofaCalculator
{
    public static readonly IReadOnlyList<string> 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

public async Task<QsofaResult> 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.

private async Task<QsofaResult> 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:

// 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:

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<News2CachedParam>(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:

private async Task<int?> 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<News2CachedParam>(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:

public static class GcsCalculator
{
    public static readonly IReadOnlyList<string> 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:

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<Dictionary<string, SofaCachedValue>> 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 (1224 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:

public async Task<TrendResult> 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<List<TrendHistoryEntry>>(historyJson!) ?? new()
        : new List<TrendHistoryEntry>();

    // 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:

{
  "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:

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<bool> 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:

if (alertType == AlertType.News2Warning)
{
    var suppression = _services.GetRequiredService<IAlertSuppressionService>();
    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.