Valkey Advisory Cache

Reference: SPRINT_8200_0013_0001.

Audience: Concelier operators tuning and troubleshooting the advisory read cache.

Overview

The Valkey advisory cache provides sub-20 ms read latency for canonical advisory lookups by caching advisory data and maintaining fast-path indexes. It integrates with the Concelier CanonicalAdvisoryService as a read-through cache with automatic population and invalidation, and falls back to PostgreSQL when Valkey is unavailable.

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Advisory Cache Flow                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│    ┌───────────┐    miss    ┌───────────┐    fetch    ┌───────────────┐    │
│    │  Client   │ ─────────► │   Valkey  │ ──────────► │   PostgreSQL  │    │
│    │  Request  │            │   Cache   │             │   Canonical   │    │
│    └───────────┘            └───────────┘             │   Store       │    │
│         ▲                        │                    └───────────────┘    │
│         │                        │ hit                      │              │
│         │                        ▼                          │              │
│         └──────────────── (< 20ms) ◄────────────────────────┘              │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Configuration

Configure in concelier.yaml:

ConcelierCache:
  # Valkey connection settings
  ConnectionString: "localhost:6379,abortConnect=false,connectTimeout=5000"
  Database: 0
  InstanceName: "concelier:"

  # TTL settings by interest score tier
  TtlPolicy:
    HighInterestTtlHours: 24    # Interest score >= 0.7
    MediumInterestTtlHours: 4   # Interest score 0.3 - 0.7
    LowInterestTtlHours: 1      # Interest score < 0.3
    DefaultTtlHours: 2

  # Index settings
  HotSetMaxSize: 10000          # Max entries in rank:hot
  EnablePurlIndex: true
  EnableCveIndex: true

  # Connection pool settings
  PoolSize: 20
  ConnectRetryCount: 3
  ReconnectRetryDelayMs: 1000

  # Fallback behavior when Valkey unavailable
  FallbackToPostgres: true
  HealthCheckIntervalSeconds: 30

Key Schema

Advisory Entry

Key: advisory:{merge_hash}

Value: JSON-serialized CanonicalAdvisory

TTL: Based on interest score tier

{
  "id": "uuid",
  "cve": "CVE-2024-1234",
  "affects_key": "pkg:npm/express@4.0.0",
  "merge_hash": "sha256:a1b2c3...",
  "severity": "high",
  "interest_score": 0.85,
  "title": "...",
  "updated_at": "2025-01-15T10:30:00Z"
}

Hot Set (Sorted Set)

Key: rank:hot

Score: Interest score (0.0 - 1.0)

Member: merge_hash

Stores top advisories by interest score for quick access.

PURL Index (Set)

Key: by:purl:{normalized_purl}

Members: Set of merge_hash values

Maps package URLs to affected advisories.

Example: by:purl:pkg:npm/express@4.0.0{sha256:a1b2c3..., sha256:d4e5f6...}

CVE Index (Set)

Key: by:cve:{cve_id}

Members: Set of merge_hash values

Maps CVE IDs to canonical advisories.

Example: by:cve:cve-2024-1234{sha256:a1b2c3...}

Operations

Get Advisory

// Service interface
public interface IAdvisoryCacheService
{
    Task<CanonicalAdvisory?> GetAsync(string mergeHash, CancellationToken ct = default);
    Task SetAsync(CanonicalAdvisory advisory, CancellationToken ct = default);
    Task InvalidateAsync(string mergeHash, CancellationToken ct = default);
    Task<IReadOnlyList<CanonicalAdvisory>> GetByPurlAsync(string purl, CancellationToken ct = default);
    Task<IReadOnlyList<CanonicalAdvisory>> GetHotAsync(int count = 100, CancellationToken ct = default);
    Task IndexPurlAsync(string purl, string mergeHash, CancellationToken ct = default);
    Task IndexCveAsync(string cve, string mergeHash, CancellationToken ct = default);
}

Read-Through Cache

1. GetAsync(mergeHash) called
2. Check Valkey: GET advisory:{mergeHash}
   └─ Hit: deserialize and return
   └─ Miss: fetch from PostgreSQL, cache result, return

Cache Population

Advisories are cached when:

Cache Invalidation

Invalidation occurs when:

// Invalidate single advisory
await cacheService.InvalidateAsync(mergeHash, ct);

// Invalidate multiple (e.g., after bulk import)
await cacheService.InvalidateManyAsync(mergeHashes, ct);

TTL Policy

Interest score determines TTL tier:

Interest ScoreTTLRationale
>= 0.7 (High)24 hoursHot advisories: likely to be queried frequently
0.3 - 0.7 (Medium)4 hoursModerate interest: balance between freshness and cache hits
< 0.3 (Low)1 hourLow interest: evict quickly to save memory

TTL is set when advisory is cached:

var ttl = ttlPolicy.GetTtl(advisory.InterestScore);
await cache.SetAsync(key, advisory, ttl, ct);

Monitoring

Metrics

MetricTypeDescription
concelier_cache_hits_totalCounterTotal cache hits
concelier_cache_misses_totalCounterTotal cache misses
concelier_cache_hit_rateGaugeHit rate (hits / total)
concelier_cache_latency_msHistogramCache operation latency
concelier_cache_size_bytesGaugeEstimated cache memory usage
concelier_cache_hot_set_sizeGaugeEntries in rank:hot

OpenTelemetry Spans

Cache operations emit spans:

concelier.cache.get
  ├── cache.key: "advisory:sha256:..."
  ├── cache.hit: true/false
  └── cache.latency_ms: 2.5

concelier.cache.set
  ├── cache.key: "advisory:sha256:..."
  └── cache.ttl_hours: 24

Health Check

GET /health/cache

Response:

{
  "status": "healthy",
  "valkey_connected": true,
  "latency_ms": 1.2,
  "hot_set_size": 8542,
  "hit_rate_1h": 0.87
}

Performance

Benchmarks

Operationp50p95p99
GetAsync (hit)1.2ms3.5ms8.0ms
GetAsync (miss + populate)12ms25ms45ms
SetAsync1.5ms4.0ms9.0ms
GetByPurlAsync2.5ms6.0ms15ms
GetHotAsync(100)3.0ms8.0ms18ms

Optimization Tips

  1. Connection Pooling: Use shared multiplexer with PoolSize: 20

  2. Pipeline Reads: For bulk operations, use pipelining:

    var batch = cache.CreateBatch();
    foreach (var hash in mergeHashes)
        tasks.Add(batch.GetAsync(hash));
    batch.Execute();
    
  3. Hot Set Preload: Run warmup job on startup to preload hot set

  4. Compression: Enable Valkey LZF compression for large advisories

Fallback Behavior

When Valkey is unavailable:

  1. FallbackToPostgres: true (default)

    • All reads go directly to PostgreSQL
    • Performance degrades but system remains operational
    • Reconnection attempts continue in background
  2. FallbackToPostgres: false

    • Cache misses return null/empty
    • Only cached data is accessible
    • Use for strict latency requirements

Troubleshooting

Common Issues

IssueCauseSolution
High miss rateCache cold / insufficient TTLRun warmup job, increase TTLs
Latency spikesConnection exhaustionIncrease PoolSize
Memory pressureToo many cached advisoriesReduce HotSetMaxSize, lower TTLs
Index staleInvalidation not triggeredCheck event handlers, verify IndexPurlAsync calls

Debug Commands

# Check cache stats
stella cache stats

# View hot set
stella cache list-hot --limit 10

# Check specific advisory
stella cache get sha256:mergehash...

# Flush cache
stella cache flush --confirm

# Check PURL index
stella cache lookup-purl pkg:npm/express@4.0.0

Valkey CLI

# Connect to Valkey
valkey-cli -h localhost -p 6379

# Check memory usage
INFO memory

# List hot set entries
ZRANGE rank:hot 0 9 WITHSCORES

# Check PURL index
SMEMBERS by:purl:pkg:npm/express@4.0.0

# Get advisory
GET advisory:sha256:a1b2c3...