# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

**aci-cache** is an adaptive cache invalidation library (not a service or proxy) that wraps a developer's existing Redis client. It automatically switches between three strategies based on real-time write rate, coordinating across all app instances via Redis Pub/Sub.

Both implementations are complete:
- **Python** (`src/aci_cache/`) — sync `AdaptiveCache` + async `AsyncAdaptiveCache`
- **Node.js** (`node/src/`) — TypeScript `AdaptiveCache`, compiled to `node/dist/`

Design documentation lives in `package-docs/` (PRD, SRS, ARCHITECTURE).

## Commands

### Python

```bash
# Install in editable mode with dev deps
pip install -e ".[dev]"

# Run all tests
pytest

# Run a single test file
pytest tests/test_cache.py

# Run a single test by name
pytest tests/test_cache.py::TestCoreAPI::test_set_and_get

# Lint
ruff check src/ tests/

# Format
ruff format src/ tests/

# Type check
mypy src/
```

### Node.js

All commands must be run from the `node/` directory:

```bash
cd node

# Install dependencies
npm install

# Build TypeScript → dist/
npm run build      # runs: tsc

# Run all tests
npm test           # runs: vitest run

# Watch mode
npm run test:watch # runs: vitest

# Type check (also serves as lint)
npm run lint       # runs: tsc --noEmit
```

## Architecture

### Strategy Selection

The Controller (background thread/timer) samples write rate every `controller_interval` (3s) over a `sliding_window` (5s):

```
write_rate > high_threshold (50)  →  eager    (publish per write)
write_rate < low_threshold  (10)  →  ttl      (rely on Redis TTL)
otherwise                         →  batched  (buffer + flush every 2.5s)
```

The strategy decision is published to the `aci:strategy` Pub/Sub channel so all instances switch simultaneously. Only one instance runs the Controller (`is_controller=True`).

### Request Path

**`set(key, value)`:**
1. `redis.setex(full_key, ttl, value)` — always writes with TTL as safety net
2. `tracker.record()` — appends timestamp to sliding window
3. `strategy.on_write(full_key)` — TTL: no-op | Eager: `PUBLISH` | Batched: buffer append

**`get(key)`:**
1. `redis.get(full_key)` — raw Redis read, records hit/miss in stats
2. Returns `None` on miss; **does not auto-populate** — cache-fill is the caller's responsibility

### Key Namespacing

Without namespace: keys stored as-is (`user:123`).  
With `namespace="myapp"`: keys stored as `aci:myapp:user:123`, channels become `aci:myapp:invalidation` and `aci:myapp:strategy`.

### Cross-Instance Invalidation

When instance A writes a key (Eager/Batched), it publishes to `aci:invalidation`. All subscribed instances receive the message and call `redis.del(key)` locally. The `source` field in every message prevents self-invalidation loops.

### Python Threading

Three daemon threads run alongside the main thread:

| Thread | Active when | Shared state accessed |
|---|---|---|
| `aci-subscriber` | always | none (read-only callbacks) |
| `aci-controller` | `is_controller=True` | `WriteRateTracker` (reads) |
| `aci-batch-flusher` | strategy == `"batched"` | `BatchedStrategy._buffer` |

All shared mutable state is protected by `threading.Lock`. The `BatchedStrategy.flush()` copies and clears the buffer **under lock**, then calls `PUBLISH` **after releasing the lock** — never hold a lock during network I/O.

### Node.js Concurrency

No locks or threads. The event loop handles everything:
- Controller: `setInterval` every `controllerInterval * 1000` ms
- Flusher: `setInterval` every `batchInterval * 1000` ms
- Subscriber: ioredis `"message"` event listener
- Both timers call `.unref()` so they don't prevent process exit

### `AsyncAdaptiveCache` (Python)

The async variant (`async_cache.py`) uses `redis.asyncio` and `asyncio.Task` instead of threads. The controller and subscriber are `asyncio.Task`s. The batch buffer is a plain list (no lock needed — single event loop). Lifecycle methods are `async def start()` / `async def stop()` and it supports `async with`.

## Python Package Layout

```
src/aci_cache/
├── __init__.py          # Exports: AdaptiveCache, AsyncAdaptiveCache, CacheConfig, CacheStats
├── cache.py             # AdaptiveCache — sync public API + lifecycle
├── async_cache.py       # AsyncAdaptiveCache — asyncio variant
├── config.py            # CacheConfig frozen dataclass; validates at __post_init__
├── controller.py        # Controller — daemon thread, strategy selection logic
├── subscriber.py        # Subscriber — daemon thread, pub/sub listener
├── tracker.py           # WriteRateTracker — sliding window write timestamps
├── stats.py             # StatsCollector (mutable) + CacheStats (snapshot dataclass)
├── types.py             # VALID_STRATEGIES, message type aliases, callback types
└── strategies/
    ├── base.py          # Strategy ABC: on_write(), on_activate(), on_deactivate()
    ├── ttl.py           # TTLStrategy — no-op on_write
    ├── eager.py         # EagerStrategy — PUBLISH on every write
    └── batched.py       # BatchedStrategy — buffer + flush(); lock released before PUBLISH
tests/
├── conftest.py          # fake_redis, fake_redis_pair fixtures (fakeredis, same FakeServer)
├── test_cache.py        # AdaptiveCache unit tests (controller disabled via is_controller=False)
├── test_async_cache.py
├── test_controller.py
├── test_strategies.py
├── test_subscriber.py
├── test_tracker.py
└── test_config.py
```

## Node.js Package Layout

All source is under `node/`:

```
node/src/
├── index.ts             # Re-exports: AdaptiveCache, CacheConfig, CacheStats types
├── cache.ts             # AdaptiveCache class (public API + lifecycle)
├── config.ts            # CacheConfig class; validates in constructor
├── controller.ts        # Controller — setInterval-based, static selectStrategy()
├── subscriber.ts        # Subscriber — ioredis event-driven, no threads
├── tracker.ts           # WriteRateTracker — sliding window
├── stats.ts             # StatsCollector + CacheStatsSnapshot
├── types.ts             # VALID_STRATEGIES Set, message interfaces, callback types
└── strategies/
    ├── base.ts          # Strategy interface: onWrite(), onActivate(), onDeactivate()
    ├── ttl.ts
    ├── eager.ts
    └── batched.ts       # BatchedStrategy.flush() — async, deduplicates with Map
node/tests/
├── cache.test.ts
├── config.test.ts
├── controller.test.ts
├── strategies.test.ts
└── tracker.test.ts
node/vitest.config.ts    # Vitest configuration
```

## Testing Notes

- Python tests use `fakeredis.FakeServer` to create isolated Redis instances without a running Redis.
- Two fixtures in `conftest.py`: `fake_redis` (single client) and `fake_redis_pair` (two clients sharing one `FakeServer` — needed for pub/sub tests).
- In unit tests, create `AdaptiveCache` with `is_controller=False` to prevent the controller daemon from interfering with test timing.
- Node.js tests use `ioredis-mock`.
- `pytest` is configured with `asyncio_mode = "auto"` in `pyproject.toml`, so async test functions work without explicit `@pytest.mark.asyncio`.

## Key Invariants

- `CacheConfig` is frozen (`dataclasses.dataclass(frozen=True)`); validation fires in `__post_init__`. `low_threshold` must be strictly less than `high_threshold`.
- TTL is always set via `SETEX` even in Eager/Batched mode — acts as a staleness safety net if pub/sub is unavailable.
- Pub/Sub messages contain **key names only** — never cached values.
- `BatchedStrategy.flush()` must release its lock before calling `PUBLISH` (FR-3.11 — never hold a lock during network I/O).
- `get()` never auto-populates the cache on miss.
- `pubsub_client` is always a **separate** Redis connection from `redis_client` — the pub/sub `listen()` loop is blocking and must not share the main connection.

## Default Configuration

```
high_threshold: 50     # writes/sec → eager
low_threshold: 10      # writes/sec → 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 write rate window
```

## Dependencies

- **Python runtime:** `redis>=4.0` only. Build: `hatchling`.
  Dev: `pytest>=7.0`, `fakeredis>=2.0`, `ruff>=0.1.0`, `mypy>=1.0`
- **Node.js peer dep:** `ioredis>=5.0.0`.
  Dev: `typescript ^5`, `vitest ^3`, `ioredis-mock ^8`, `@types/node ^22`
