# Software Requirements Specification (SRS)
## aci-cache — Adaptive Cache Invalidation Library

**Version:** 1.0 | **Date:** May 9, 2026 | **Status:** Draft

---

## 1. Introduction

### 1.1 Purpose
This document specifies the complete software requirements for **aci-cache**, a library available as both a Python package (PyPI) and Node.js package (npm) that provides adaptive cache invalidation for Redis-backed applications.

### 1.2 Scope
aci-cache wraps a developer's existing Redis client and adds an intelligent invalidation layer that automatically switches between three strategies (TTL, Batched, Eager) based on real-time write rate. It coordinates cache invalidation across multiple application instances via Redis Pub/Sub.

### 1.3 Definitions

| Term | Definition |
|------|-----------|
| **SRR** | Stale Read Ratio — percentage of reads returning outdated data |
| **TTL** | Time To Live — Redis key expiration mechanism |
| **Eager** | Invalidation strategy that publishes per-write invalidation messages |
| **Batched** | Strategy that buffers writes and flushes invalidation periodically |
| **Controller** | Background process that monitors write rate and switches strategies |
| **Pub/Sub** | Redis publish/subscribe messaging for cross-instance coordination |
| **Sliding Window** | Time-based window used to calculate current write rate |

---

## 2. System Overview

```
┌─────────────────────────────────────────────────────────────────┐
│                    Developer's Application                       │
│                                                                  │
│  app code ──▶ AdaptiveCache.set(key, val) ──▶ Redis (local)     │
│  app code ──▶ AdaptiveCache.get(key)       ──▶ Redis (local)    │
│                       │                                          │
│              ┌────────▼────────┐                                 │
│              │  ACI Core       │                                 │
│              │  ┌────────────┐ │                                 │
│              │  │ Controller │ │  ← Monitors write rate          │
│              │  └─────┬──────┘ │                                 │
│              │        │        │                                 │
│              │  ┌─────▼──────┐ │                                 │
│              │  │ Strategy   │ │  ← TTL / Batched / Eager        │
│              │  │ Engine     │ │                                  │
│              │  └─────┬──────┘ │                                 │
│              │        │        │                                 │
│              │  ┌─────▼──────┐ │                                 │
│              │  │ Pub/Sub    │ │  ← Cross-instance invalidation  │
│              │  │ Coordinator│ │                                  │
│              │  └────────────┘ │                                 │
│              └─────────────────┘                                 │
└─────────────────────────────────────────────────────────────────┘
         │                              │
         ▼                              ▼
    Redis Instance              Redis Pub/Sub Channel
    (dev's existing)            (aci:invalidation)
```

---

## 3. Functional Requirements

### 3.1 Client Wrapper

| ID | Requirement | Priority |
|----|------------|----------|
| FR-1.1 | The library SHALL wrap an existing Redis client instance without modifying it | P0 |
| FR-1.2 | The wrapper SHALL expose `get(key)` that reads from Redis and returns the value or null | P0 |
| FR-1.3 | The wrapper SHALL expose `set(key, value, ttl?)` that writes to Redis with the current strategy's TTL | P0 |
| FR-1.4 | The wrapper SHALL expose `delete(key)` that removes a key from local Redis AND publishes invalidation | P0 |
| FR-1.5 | The wrapper SHALL expose `flush()` that clears all keys managed by this instance | P1 |
| FR-1.6 | The wrapper SHALL pass through all other Redis commands to the underlying client unchanged | P1 |
| FR-1.7 | The wrapper SHALL be thread-safe (Python) and event-loop-safe (Node.js) | P0 |

### 3.2 Adaptive Controller

| ID | Requirement | Priority |
|----|------------|----------|
| FR-2.1 | The controller SHALL run as a background thread (Python) or timer (Node.js) | P0 |
| FR-2.2 | The controller SHALL calculate write rate as `writes_in_window / window_seconds` | P0 |
| FR-2.3 | The controller SHALL use a sliding window (default: 5 seconds) for rate calculation | P0 |
| FR-2.4 | The controller SHALL evaluate the strategy every `controller_interval` seconds (default: 3) | P0 |
| FR-2.5 | The controller SHALL switch to EAGER when `write_rate > high_threshold` (default: 50) | P0 |
| FR-2.6 | The controller SHALL switch to TTL when `write_rate < low_threshold` (default: 10) | P0 |
| FR-2.7 | The controller SHALL switch to BATCHED when write rate is between thresholds | P0 |
| FR-2.8 | The controller SHALL publish strategy changes to Pub/Sub so all instances synchronize | P0 |
| FR-2.9 | The controller SHALL support optional hysteresis (dead band ±5) to prevent oscillation | P2 |
| FR-2.10 | Only ONE instance SHALL run the controller (leader election or config-based designation) | P0 |

### 3.3 Invalidation Strategies

#### 3.3.1 TTL Strategy

| ID | Requirement | Priority |
|----|------------|----------|
| FR-3.1 | TTL strategy SHALL set keys with Redis `SETEX` using the configured TTL value | P0 |
| FR-3.2 | TTL strategy SHALL NOT publish any Pub/Sub messages on write | P0 |
| FR-3.3 | TTL strategy SHALL have zero Pub/Sub overhead | P0 |

#### 3.3.2 Eager Strategy

| ID | Requirement | Priority |
|----|------------|----------|
| FR-3.4 | Eager strategy SHALL publish an invalidation message on EVERY `set()` call | P0 |
| FR-3.5 | The invalidation message SHALL contain: `{action: "invalidate", keys: [key], timestamp, source, strategy}` | P0 |
| FR-3.6 | Eager strategy SHALL still set TTL on keys as a safety net | P0 |

#### 3.3.3 Batched Strategy

| ID | Requirement | Priority |
|----|------------|----------|
| FR-3.7 | Batched strategy SHALL buffer written keys in an in-memory list | P0 |
| FR-3.8 | A background flusher SHALL publish buffered keys every `batch_interval` seconds (default: 2.5) | P0 |
| FR-3.9 | The flusher SHALL deduplicate keys within each batch | P0 |
| FR-3.10 | The buffer SHALL be protected by a lock (Python) or equivalent concurrency primitive | P0 |
| FR-3.11 | The flusher SHALL release the lock BEFORE performing network I/O (publish) | P0 |
| FR-3.12 | On strategy leave, the flusher SHALL flush remaining buffer immediately | P1 |

### 3.4 Pub/Sub Coordinator

| ID | Requirement | Priority |
|----|------------|----------|
| FR-4.1 | The coordinator SHALL subscribe to the invalidation channel on initialization | P0 |
| FR-4.2 | On receiving an invalidation message, the coordinator SHALL delete the specified keys from local Redis | P0 |
| FR-4.3 | The coordinator SHALL ignore messages published by itself (prevent self-invalidation loops) | P0 |
| FR-4.4 | The coordinator SHALL subscribe to the strategy update channel | P0 |
| FR-4.5 | On receiving a strategy update, the coordinator SHALL switch the local strategy accordingly | P0 |
| FR-4.6 | On Pub/Sub disconnection, the coordinator SHALL auto-reconnect with exponential backoff (max 30s) | P0 |
| FR-4.7 | The coordinator SHALL use configurable channel names with namespace prefix | P1 |

### 3.5 Configuration

| ID | Requirement | Priority |
|----|------------|----------|
| FR-5.1 | All settings SHALL have sensible defaults requiring zero configuration | P0 |
| FR-5.2 | Settings SHALL be overridable via constructor parameters | P0 |
| FR-5.3 | Settings SHALL fall back to environment variables if not provided in constructor | P1 |
| FR-5.4 | Invalid configuration SHALL raise a clear error at initialization, not at runtime | P0 |

**Default Configuration Values:**

```yaml
high_threshold: 50        # writes/sec to trigger eager
low_threshold: 10         # writes/sec to trigger TTL
default_ttl: 10           # seconds
batch_interval: 2.5       # seconds between batch flushes
controller_interval: 3    # seconds between rate checks
sliding_window: 5         # seconds for rate calculation
channel_prefix: "aci"     # pub/sub channel prefix
namespace: null            # optional key namespace
is_controller: true       # whether this instance runs the controller
```

### 3.6 Statistics & Observability

| ID | Requirement | Priority |
|----|------------|----------|
| FR-6.1 | `cache.strategy` SHALL return the current active strategy name as a string | P0 |
| FR-6.2 | `cache.stats` SHALL return an object with: `write_rate`, `total_reads`, `total_writes`, `cache_hits`, `cache_misses`, `current_strategy`, `strategy_switches` | P1 |
| FR-6.3 | `cache.on_switch(callback)` SHALL allow registering a listener for strategy changes | P1 |
| FR-6.4 | Stats collection SHALL have negligible performance impact (< 0.1ms per operation) | P1 |

### 3.7 Lifecycle

| ID | Requirement | Priority |
|----|------------|----------|
| FR-7.1 | `cache.start()` or constructor SHALL initialize controller, subscriber, and flusher threads | P0 |
| FR-7.2 | `cache.stop()` / `cache.close()` SHALL gracefully shut down all background threads/timers | P0 |
| FR-7.3 | The library SHALL support Python context manager (`with AdaptiveCache(...) as cache:`) | P1 |
| FR-7.4 | The library SHALL flush pending batched keys on shutdown | P1 |

---

## 4. Non-Functional Requirements

### 4.1 Performance

| ID | Requirement | Target |
|----|------------|--------|
| NFR-1.1 | Latency overhead per `get()` call (beyond raw Redis) | < 0.5ms |
| NFR-1.2 | Latency overhead per `set()` call (beyond raw Redis) | < 1ms (eager), < 0.1ms (TTL/batched) |
| NFR-1.3 | Memory overhead per library instance | < 5MB |
| NFR-1.4 | CPU overhead of controller thread | < 1% of one core |
| NFR-1.5 | Strategy switch propagation time (pub/sub to all instances) | < 500ms |
| NFR-1.6 | Pub/Sub reconnection time after disconnection | < 5 seconds |

### 4.2 Reliability

| ID | Requirement |
|----|------------|
| NFR-2.1 | If Redis Pub/Sub is unavailable, the library SHALL continue operating with TTL-only mode (degraded but functional) |
| NFR-2.2 | If the controller thread crashes, the library SHALL continue serving reads/writes with the last known strategy |
| NFR-2.3 | Pub/Sub message loss SHALL NOT cause data corruption — TTL expiry acts as a safety net |
| NFR-2.4 | The library SHALL NOT block the main application thread for more than 10ms under any condition |

### 4.3 Compatibility

| ID | Requirement |
|----|------------|
| NFR-3.1 | **Python:** Compatible with Python 3.9+ |
| NFR-3.2 | **Python:** Compatible with `redis-py` 4.x and 5.x |
| NFR-3.3 | **Python:** Compatible with `redis.asyncio` for async support |
| NFR-3.4 | **Node.js:** Compatible with Node.js 18+ |
| NFR-3.5 | **Node.js:** Compatible with `ioredis` 5.x and `redis` (node-redis) 4.x |
| NFR-3.6 | **Node.js:** Written in TypeScript with full type definitions |
| NFR-3.7 | Both packages SHALL work with Redis 6.x and 7.x |
| NFR-3.8 | Both packages SHALL work with Redis Sentinel (P2) |

### 4.4 Security

| ID | Requirement |
|----|------------|
| NFR-4.1 | The library SHALL support Redis AUTH (password authentication) |
| NFR-4.2 | The library SHALL support Redis TLS connections |
| NFR-4.3 | Pub/Sub messages SHALL NOT contain cached values — only key names |
| NFR-4.4 | The library SHALL NOT log cached values at INFO level or below |

### 4.5 Packaging

| ID | Requirement |
|----|------------|
| NFR-5.1 | **Python:** Published on PyPI as `aci-cache` |
| NFR-5.2 | **Python:** Installable via `pip install aci-cache` |
| NFR-5.3 | **Python:** Uses `pyproject.toml` (PEP 621), no `setup.py` |
| NFR-5.4 | **Python:** Only runtime dependency: `redis>=4.0` |
| NFR-5.5 | **Node.js:** Published on npm as `aci-cache` |
| NFR-5.6 | **Node.js:** Installable via `npm install aci-cache` |
| NFR-5.7 | **Node.js:** Ships with TypeScript declarations (`.d.ts`) |
| NFR-5.8 | **Node.js:** Zero runtime dependencies beyond peer dependency on `ioredis` or `redis` |
| NFR-5.9 | Both packages SHALL have < 50KB install size (excluding dependencies) |

---

## 5. Interface Specifications

### 5.1 Python Public API

```python
from aci_cache import AdaptiveCache

# Constructor
cache = AdaptiveCache(
    redis_client: redis.Redis,           # Required: existing Redis client
    pubsub_client: redis.Redis = None,   # Optional: separate Redis for pub/sub (defaults to same)
    high_threshold: float = 50,
    low_threshold: float = 10,
    default_ttl: int = 10,
    batch_interval: float = 2.5,
    controller_interval: float = 3,
    sliding_window: float = 5,
    namespace: str = None,
    is_controller: bool = True,          # Set False on non-primary instances
    instance_id: str = auto-generated,
)

# Core methods
cache.get(key: str) -> Optional[str]
cache.set(key: str, value: str, ttl: int = None) -> None
cache.delete(key: str) -> None
cache.flush() -> None

# Observability
cache.strategy -> str                     # "ttl" | "eager" | "batched"
cache.stats -> CacheStats                 # dataclass with metrics
cache.on_switch(callback: Callable) -> None

# Lifecycle
cache.start() -> None                     # Called automatically by constructor
cache.stop() -> None                      # Graceful shutdown
cache.__enter__ / cache.__exit__          # Context manager support

# Async variant
from aci_cache import AsyncAdaptiveCache  # Same API, but awaitable
```

### 5.2 Node.js Public API

```typescript
import { AdaptiveCache } from 'aci-cache';

// Constructor
const cache = new AdaptiveCache({
    redis: redisClient,               // Required: ioredis or node-redis instance
    pubsubRedis?: redisClient,        // Optional: separate Redis for pub/sub
    highThreshold?: number,           // default: 50
    lowThreshold?: number,            // default: 10
    defaultTtl?: number,              // default: 10
    batchInterval?: number,           // default: 2.5
    controllerInterval?: number,      // default: 3
    slidingWindow?: number,           // default: 5
    namespace?: string,
    isController?: boolean,           // default: true
    instanceId?: string,              // default: auto-generated
});

// Core methods
await cache.get(key: string): Promise<string | null>
await cache.set(key: string, value: string, ttl?: number): Promise<void>
await cache.delete(key: string): Promise<void>
await cache.flush(): Promise<void>

// Observability
cache.strategy: string
cache.stats: CacheStats
cache.onSwitch(callback: (from: string, to: string) => void): void

// Lifecycle
await cache.start(): Promise<void>
await cache.stop(): Promise<void>

// Events
cache.on('switch', (event) => {})
cache.on('invalidation', (event) => {})
cache.on('error', (error) => {})
```

### 5.3 Pub/Sub Message Formats

**Invalidation Message:**
```json
{
    "action": "invalidate",
    "keys": ["user:123", "user:456"],
    "strategy": "eager",
    "timestamp": 1717500000.123,
    "source": "instance_abc123"
}
```

**Strategy Update Message:**
```json
{
    "action": "strategy_update",
    "strategy": "eager",
    "write_rate": 65.4,
    "timestamp": 1717500000.456,
    "source": "instance_abc123"
}
```

---

## 6. Data Model

### 6.1 Internal State (Per Instance)

```
AdaptiveCache instance
├── redis_client          : RedisClient      — developer's Redis connection
├── pubsub_client         : RedisClient      — pub/sub connection (may be same as above)
├── current_strategy      : string           — "ttl" | "eager" | "batched"
├── write_timestamps      : float[]          — sliding window of write times
├── batch_buffer          : string[]         — keys waiting to be flushed (batched only)
├── instance_id           : string           — unique identifier for this instance
├── config                : Config           — all thresholds and intervals
├── stats                 : CacheStats       — counters and metrics
├── controller_thread     : Thread/Timer     — background strategy evaluator
├── subscriber_thread     : Thread/Timer     — pub/sub listener
└── flusher_thread        : Thread/Timer     — batch flusher (active only in batched mode)
```

### 6.2 Redis Key Format

```
Without namespace:  key as-is          → "user:123"
With namespace:     aci:{ns}:{key}     → "aci:user-svc:user:123"

Pub/Sub channels:
  Invalidation:     aci:invalidation       (or aci:{ns}:invalidation)
  Strategy update:  aci:strategy           (or aci:{ns}:strategy)
```

---

## 7. Error Handling

| Error Condition | Behavior |
|----------------|----------|
| Redis connection lost during `get` | Return `null`, log warning |
| Redis connection lost during `set` | Raise/throw error to caller |
| Pub/Sub connection lost | Auto-reconnect with backoff, log warning, continue with current strategy |
| Controller thread crash | Log error, strategy freezes at last value, reads/writes continue |
| Batch flusher crash | Log error, buffered keys lost, TTL safety net handles staleness |
| Invalid config at startup | Raise/throw `ConfigurationError` immediately |
| Pub/Sub message parse failure | Log warning, skip message, continue |

---

## 8. Testing Requirements

### 8.1 Unit Tests

| Area | Test Cases |
|------|-----------|
| Controller | Selects correct strategy at boundary values (0, 9, 10, 11, 49, 50, 51, 100 writes/s) |
| TTL Strategy | Sets key with SETEX, no pub/sub publish |
| Eager Strategy | Publishes invalidation on every set() |
| Batched Strategy | Buffers keys, flushes at interval, deduplicates |
| Pub/Sub | Ignores self-messages, processes invalidation, processes strategy updates |
| Config | Validates thresholds (low < high), rejects invalid values |
| Stats | Accurately counts reads, writes, hits, misses |

### 8.2 Integration Tests

| Test | Description |
|------|-----------|
| Multi-instance sync | 3 instances: write on instance A, verify invalidation on B and C |
| Strategy switch | Ramp write rate from 5 → 60 → 5, verify strategy transitions |
| Pub/Sub recovery | Kill pub/sub connection, verify auto-reconnect and resumed operation |
| Graceful shutdown | Call stop(), verify all threads terminated and buffer flushed |

### 8.3 Benchmark Tests

| Benchmark | Measurement |
|-----------|------------|
| Overhead benchmark | Compare raw Redis `get`/`set` latency vs aci-cache wrapped latency |
| SRR benchmark | Measure stale read ratio under mixed workload (replicate 71% improvement claim) |
| Throughput benchmark | Measure max operations/sec under each strategy |

### 8.4 Coverage Target
- Line coverage: ≥ 90%
- Branch coverage: ≥ 85%

---

**END OF SRS**
