Skip to content

[S-03] Result Caching — IDebateCache + File/InMemory/Redis implementations, CacheBehavior enum #19

Description

@techbuzzz

Overview

Version: v10.5.0 Scale & Reliability
Effort: S · 2–3 days
Branch: feature/s03-result-caching
Dependencies: S-01 (Redis infrastructure optional)

Add a pluggable caching layer for DebateResult to avoid re-running identical debates. Cache key is derived from the debate's content hash (question + members + strategy + knowledge). Three built-in implementations: in-memory, file system, Redis.


Core Interface

public interface IDebateCache
{
    Task<DebateResult?> GetAsync(string cacheKey, CancellationToken ct = default);
    Task SetAsync(string cacheKey, DebateResult result, TimeSpan? ttl = null, CancellationToken ct = default);
    Task InvalidateAsync(string cacheKey, CancellationToken ct = default);
    Task<bool> ExistsAsync(string cacheKey, CancellationToken ct = default);
}

Cache Key Generation

public static class DebateCacheKeyGenerator
{
    // Deterministic SHA-256 hash of debate-defining inputs
    public static string Generate(ScenarioRequest request)
    {
        var content = JsonSerializer.Serialize(new
        {
            request.Question,
            Members = request.Members.OrderBy(m => m.Role),
            request.Strategy,
            request.MaxRounds,
            request.SystemPrompt,
            KnowledgeHash = request.KnowledgeText is null ? null
                : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.KnowledgeText)))
        });
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)))[..16];
    }
}

Implementations

InMemoryDebateCache

// Uses IMemoryCache with optional TTL
// Suitable for single-instance development and testing
public class InMemoryDebateCache : IDebateCache
{
    public InMemoryDebateCache(IMemoryCache cache, TimeSpan? defaultTtl = null) { ... }
}

FileDebateCache

// Persists DebateResult as JSON files in a configured directory
// File name = cacheKey + ".cache.json"
// Suitable for local development, CLI benchmark caching
public class FileDebateCache : IDebateCache
{
    public FileDebateCache(string cacheDirectory, TimeSpan? defaultTtl = null) { ... }
}

RedisDebateCache

// Uses StackExchange.Redis with optional TTL (SETEX)
// Suitable for distributed/production deployments
// Shares Redis connection with RedisDebateOrchestrator (S-01)
public class RedisDebateCache : IDebateCache
{
    public RedisDebateCache(IConnectionMultiplexer redis, TimeSpan? defaultTtl = null) { ... }
}

CacheBehavior Enum

public enum CacheBehavior
{
    /// Cache disabled entirely (default when no IDebateCache registered)
    Disabled,

    /// Return cached result if hit; execute and cache if miss
    ReadWrite,

    /// Return cached result if hit; do NOT cache new results
    ReadOnly,

    /// Always execute; always overwrite cache with new result
    WriteThrough,

    /// Bypass cache for this run; do not read or write
    Bypass
}

CouncilBuilder Integration

// Register cache globally
services.AddDelibera()
    .UseInMemoryCache(ttl: TimeSpan.FromHours(1))
    // or
    .UseFileCache(directory: "./debate-cache", ttl: TimeSpan.FromDays(7))
    // or
    .UseRedisCache(connectionString: "localhost:6379", ttl: TimeSpan.FromHours(24));

// Per-debate cache behavior
councilBuilder.WithCacheBehavior(CacheBehavior.ReadWrite);
councilBuilder.WithCacheBehavior(CacheBehavior.Bypass); // force fresh run

Execution Flow in CouncilExecutor

1. Generate cacheKey from ScenarioRequest
2. If behavior is ReadWrite or ReadOnly:
   a. Call cache.GetAsync(cacheKey)
   b. If hit → return cached DebateResult (add CacheHit=true flag)
3. Execute debate normally
4. If behavior is ReadWrite or WriteThrough:
   a. Call cache.SetAsync(cacheKey, result, ttl)
5. Return result

DebateResult Cache Metadata

// DebateResult gains:
public bool CacheHit { get; init; }       // true if returned from cache
public string? CacheKey { get; init; }    // key used for this result
public DateTime? CachedAt { get; init; }  // when it was originally cached

appsettings.json

"Delibera": {
  "Cache": {
    "Provider": "InMemory",   // InMemory | File | Redis | None
    "DefaultTtlMinutes": 60,
    "FileDirectory": "./debate-cache",
    "RedisTtlHours": 24
  }
}

Tasks

  • Define IDebateCache in Delibera.Core.Interfaces
  • Implement DebateCacheKeyGenerator (SHA-256, deterministic)
  • Define CacheBehavior enum
  • Implement InMemoryDebateCache using IMemoryCache
  • Implement FileDebateCache (JSON serialization, TTL via file metadata)
  • Implement RedisDebateCache using StackExchange.Redis
  • Add cache check + set logic to CouncilExecutor
  • Add CacheHit, CacheKey, CachedAt to DebateResult
  • Add WithCacheBehavior(...) to ICouncilBuilder + CouncilBuilder
  • Add DI extensions: UseInMemoryCache, UseFileCache, UseRedisCache
  • Wire cache config from appsettings.json in Delibera.Server
  • Add cache_hit field to DebateResponse DTO
  • OpenTelemetry: debate.cache_hit counter metric
  • Unit tests: key generation determinism, all three cache backends
  • Docs: docs/caching.md

Acceptance Criteria

  • Same ScenarioRequest generates identical cacheKey across runs
  • CacheBehavior.ReadWrite: second identical debate returns cached result immediately
  • CacheBehavior.Bypass: always executes, never reads/writes cache
  • FileDebateCache TTL expires correctly (stale files are not returned)
  • RedisDebateCache uses SETEX with correct TTL
  • DebateResult.CacheHit = true when returned from cache
  • No caching overhead when IDebateCache is not registered (default behavior unchanged)
  • debate.cache_hit OpenTelemetry metric emitted

Labels

enhancement, v10.5.0, reliability, caching

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions