diff --git a/README.md b/README.md index 853dfd7..4145b92 100644 --- a/README.md +++ b/README.md @@ -1,484 +1,412 @@ -
- Colander
- High-performance HTTP caching reverse proxy powered by SIEVE
-
Explore cache eviction with a Rust HTTP proxy, a Zipfian load generator, and a live dashboard.
---- - -Colander is a drop-in caching reverse proxy that replaces LRU with the [SIEVE](https://cachemon.github.io/SIEVE-website/) eviction algorithm (published at [NSDI '24](https://www.usenix.org/conference/nsdi24/presentation/zhang-yazhuo)). It sits between your clients and your backend, caches HTTP responses, and speaks both **HTTP** and the **Redis wire protocol (RESP2)** — with a small Redis-compatible command subset for cache workloads. - -```bash -docker compose up # proxy + backend + load generator + dashboard -open http://localhost:3001 # watch SIEVE outperform LRU in real time -``` +Colander implements **SIEVE**, the cache eviction algorithm introduced at +[NSDI ’24](https://www.usenix.org/conference/nsdi24/presentation/zhang-yazhuo), +alongside LRU and FIFO. Run the local demo to see how cache capacity, request +skew, and eviction policy affect hit rates. ---- - -## Table of Contents - -- [Why SIEVE](#why-sieve) -- [Architecture](#architecture) -- [Features](#features) -- [Quick Start](#quick-start) - - [Docker (recommended)](#docker-recommended) - - [From source](#from-source) -- [Configuration](#configuration) - - [Server](#server) - - [Upstream](#upstream) - - [Cache](#cache) - - [RESP](#resp) - - [Hot-Reload](#hot-reload) -- [Redis Interface (RESP2)](#redis-interface-resp2) -- [Prometheus Metrics](#prometheus-metrics) -- [Live Dashboard](#live-dashboard) -- [HTTP Response Headers](#http-response-headers) -- [Admin API](#admin-api) -- [Cache Design](#cache-design) - - [Eviction Policies](#eviction-policies) - - [Arena Allocation](#arena-allocation) - - [64-Shard Concurrency](#64-shard-concurrency) - - [Lazy TTL Expiration](#lazy-ttl-expiration) -- [Project Structure](#project-structure) -- [Development](#development) -- [References](#references) -- [License](#license) - ---- - -## Why SIEVE - -Traditional caches use **LRU**, which moves every accessed item to the front of a linked list. That means a **write lock on every cache hit** — a scalability wall on multi-core systems. - -**SIEVE** replaces move-to-front with a single atomic bit flip on hit. A roving "hand" pointer handles eviction by scanning from tail to head, keeping visited items in place and evicting cold ones. No list mutation. No write contention. - -| Property | LRU | SIEVE | -|----------|-----|-------| -| Hit operation | Move-to-front (write lock) | Flip atomic visited bit (shard write lock) | -| Eviction | Always evict tail | Hand scans for unvisited | -| Miss ratio | Baseline | [Up to 63% lower](https://www.usenix.org/conference/nsdi24/presentation/zhang-yazhuo) than ARC | -| Multi-thread scaling | Limited by write contention | Near-linear to 16+ threads | -| Parameters | None | None | -| Per-object metadata | Pointer × 2 | 1 bit (visited) + pointer × 2 | - -> **Key insight from the paper**: SIEVE achieves "lazy promotion" and "quick demotion" — popular objects stay in place without being moved, while one-hit wonders are quickly evicted. This makes it especially effective for web cache workloads with power-law (Zipfian) access patterns. - -Read the full paper: [*SIEVE is Simpler than LRU: an Efficient Turn-Key Eviction Algorithm for Web Caches*](https://www.usenix.org/conference/nsdi24/presentation/zhang-yazhuo) (NSDI '24). - ---- +- **Cache library:** arena-backed storage, lazy TTL expiration, and up to 64 shards. +- **HTTP demo:** a caching proxy in front of a synthetic origin with 5–20 ms latency. +- **Adjustable traffic:** control dataset size, concurrency, request rate, and Zipfian alpha. +- **Live inspection:** React charts, JSON statistics, and WebSocket snapshots. -## Architecture +The project is an experimental cache playground. See [current scope](#current-scope) +for the HTTP, Redis, and observability features that are still incomplete. -``` - ┌──────────────────────────────────────────────┐ - │ Colander Proxy │ - │ │ -┌──────────┐ HTTP │ ┌──────────────────────────────────────┐ │ ┌──────────┐ -│ Clients │───:8080──▶│ Cache Layer │ │──:3000─▶│ Backend │ -└──────────┘ │ │ ┌──────────┐ ┌──────────┐ │ │ └──────────┘ - │ │ │ SIEVE │ │ LRU │ │ │ -┌──────────┐ RESP │ │ │ (primary)│ │ (shadow) │ │ │ -│ Redis │───:6379──▶│ └──────────┘ └──────────┘ │ │ -│ clients │ │ │ 64 shards × RwLock │ │ -└──────────┘ │ └──────────────────────────────────────┘ │ - │ │ -┌──────────┐ WS │ Metrics Engine │ -│Dashboard │◀──:9090──│ WebSocket broadcast @ 500ms │ -│ (React) │ │ │ Prometheus GET /metrics │ -└──────────┘ │ │ Admin API (mode toggle, stats) │ - └──────────────────────────────────────────────┘ - -┌──────────┐ -│ Load Gen │ Zipfian(α) traffic → proxy:8080 -│ (tunable)│ POST /control to adjust α at runtime -└──────────┘ -``` +[Quick start](#quick-start) · [Run from source](#run-from-source) · +[Architecture](#architecture) · [Experiments](#experiments) · +[Configuration](#configuration) · [API](#api) · +[Development](#development) · [Troubleshooting](#troubleshooting) -**Dual-cache mode**: every request hits both SIEVE (primary) and LRU (comparison). Responses are served from SIEVE; LRU runs in shadow mode for a fair, same-traffic comparison. Toggle to **bench mode** via the [Admin API](#admin-api) for single-policy throughput numbers. +## Quick start ---- +Install Docker with the Compose plugin, then run: -## Features +```bash +git clone https://github.com/kclaka/colander.git +cd colander +docker compose up --build -d +docker compose ps +``` -| Category | Feature | -|----------|---------| -| **Caching** | SIEVE, LRU, and FIFO eviction policies behind a common trait | -| **Protocols** | HTTP reverse proxy (`:8080`) + [RESP2 Redis interface](#redis-interface-resp2) (`:6379`) | -| **Observability** | [Prometheus metrics](#prometheus-metrics) (`:9090/metrics`), WebSocket live stream, [React dashboard](#live-dashboard) | -| **Operability** | [Graceful shutdown](#graceful-shutdown) (SIGINT/SIGTERM), [config hot-reload](#hot-reload), per-policy stats | -| **Performance** | Up to 64 shards, arena-allocated linked lists, atomic visited bits (SIEVE), `ahash` for DoS-resistant sharding | -| **DevOps** | Docker Compose one-click demo, [GitHub Actions CI](#development) (fmt + clippy + test) | +Open **[localhost:3001](http://localhost:3001)** for the dashboard. Compose starts +the origin, proxy, dashboard, and a continuous load generator. The first build +compiles the Rust workspace and installs the dashboard dependencies. ---- +| Service | Address | Purpose | +| --- | --- | --- | +| Dashboard | [localhost:3001](http://localhost:3001) | Charts and workload controls | +| HTTP proxy | [localhost:8080](http://localhost:8080) | Routes requests to the origin | +| Metrics/admin | [localhost:9090/api/stats](http://localhost:9090/api/stats) | JSON statistics and mode control | +| Demo origin | [localhost:3000/health](http://localhost:3000/health) | Health check; `/api/items/{id}` serves items | +| Load generator | [localhost:9091/status](http://localhost:9091/status) | Traffic status and control | +| Experimental RESP2 | `localhost:6379` | Redis protocol development; see [current scope](#current-scope) | -## Quick Start +### Check a cache miss and hit -### Docker (recommended) +Use an item outside the load generator's default 1–100,000 range: ```bash -docker compose up --build +curl -si http://localhost:8080/api/items/100001 +curl -si http://localhost:8080/api/items/100001 ``` -| Service | Port | Description | -|---------|------|-------------| -| Proxy | [`localhost:8080`](http://localhost:8080) | HTTP caching reverse proxy | -| Metrics | [`localhost:9090`](http://localhost:9090) | Prometheus + WebSocket + Admin API | -| RESP | `localhost:6379` | Redis wire protocol interface | -| Dashboard | [`localhost:3001`](http://localhost:3001) | Live SIEVE vs LRU charts | -| Backend | `localhost:3000` | Demo origin with 5–20ms latency | -| Load Gen | `localhost:9091` | Zipfian traffic control | - -### From source - -Prerequisites: [Rust](https://www.rust-lang.org/tools/install) (1.70+), [Node.js](https://nodejs.org/) 18+ (for dashboard only). +With a fresh cache, the first response includes `X-Cache: MISS` and the second +includes `X-Cache: HIT`. Header names may appear lowercase. Repeating the example +can return two hits; expiration or eviction can produce another miss. ```bash -# Terminal 1 — demo backend -cargo run -p demo-backend - -# Terminal 2 — proxy -cargo run -p proxy-server - -# Terminal 3 — test it -curl -v http://localhost:8080/api/items/42 # X-Cache: MISS -curl -v http://localhost:8080/api/items/42 # X-Cache: HIT - -# Redis protocol -redis-cli -p 6379 PING # PONG -redis-cli -p 6379 SET foo bar # OK -redis-cli -p 6379 GET foo # "bar" +curl -fsS http://localhost:9090/api/stats +docker compose logs --tail=50 proxy ``` ---- - -## Configuration - -Colander reads from `config.toml` in the working directory. All fields have defaults — the file is optional. An existing invalid file is rejected at startup rather than silently replaced with defaults. - -### Server +Stop the demo when finished: -```toml -[server] -listen_addr = "0.0.0.0:8080" # HTTP proxy bind address -metrics_addr = "0.0.0.0:9090" # Metrics/admin bind address +```bash +docker compose down ``` -### Upstream - -```toml -[upstream] -url = "http://localhost:3000" # Backend origin URL -timeout_ms = 5000 # Upstream request timeout -``` +Cache contents are held in memory and are lost when the proxy restarts. -### Cache +## Run from source -```toml -[cache] -capacity = 10000 # Max entries across all shards -default_ttl_seconds = 60 # Default TTL when Cache-Control is absent -max_body_size_bytes = 1048576 # 1 MB — responses larger than this are not cached -eviction_policy = "sieve" # Primary policy: "sieve", "lru", or "fifo" -comparison_policy = "lru" # Shadow policy for dual-cache comparison (optional) -``` +Use a current stable [Rust toolchain](https://www.rust-lang.org/tools/install). +The repository does not declare a minimum supported Rust version. For the +dashboard, use Node.js **22.12+** or a newer supported release; the locked Vite 7 +dependencies also accept Node 20.19+ ([Vite requirements](https://vite.dev/guide/)). -### RESP +From the cloned repository, build the binaries once: -```toml -[resp] -enabled = true # Enable/disable Redis protocol interface -listen_addr = "0.0.0.0:6379" # RESP bind address +```bash +cargo build --workspace ``` -### Hot-Reload - -Colander watches `config.toml` for changes at runtime. When a change is detected: - -| Field | Behavior | Downtime | -|-------|----------|----------| -| `default_ttl_seconds` | Applied immediately via atomic swap | **None** — cache data preserved | -| `eviction_policy` / `comparison_policy` | Cache rebuilt with new policy | Cache cleared (cold start) | -| `capacity`, `max_body_size_bytes`, upstream and listen settings | **Ignored** — active values retained and logged as WARN | Restart required | - -Policy changes preserve the active demo/bench mode. Invalid reloads leave the current -configuration and cache intact. The parent directory is watched so editor atomic -replacements and a configuration file created after startup are detected. +Start each service in a **separate terminal**. Run Rust commands from the +repository root so the proxy loads the checked-in `config.toml`. -> **Why capacity changes are rejected**: If a running cache is full (e.g., 1M items) and capacity drops to 500K, the next request would synchronously evict 500K items in a tight loop, stalling the event loop and spiking P99 latency. Colander prioritizes stability over flexibility — restart to resize safely. - ---- - -## Redis Interface (RESP2) - -Colander speaks the [Redis Serialization Protocol](https://redis.io/docs/latest/develop/reference/protocol-spec/) on port `6379`. Point any Redis client at Colander and get SIEVE-powered caching — no code changes needed. +**Terminal 1 — origin** ```bash -redis-cli -p 6379 +cargo run -p demo-backend ``` -### Supported Commands - -| Command | Syntax | Description | -|---------|--------|-------------| -| **PING** | `PING` | Health check. Returns `PONG`. | -| **GET** | `GET key` | Retrieve a cached value. Returns bulk string or `(nil)`. | -| **SET** | `SET key value [EX seconds | PX milliseconds]` | Store a value with optional positive TTL. Without EX/PX, it persists until eviction or deletion. Unsupported options return an error. | -| **DEL** | `DEL key [key ...]` | Delete one or more keys. Returns count of deleted keys. | -| **TTL** | `TTL key` | Seconds remaining before expiry. Returns `-2` if key missing and `-1` if it has no expiration. | -| **EXPIRE** | `EXPIRE key seconds` | Not supported (TTL is set-at-insert). Returns an explicit error. | -| **COMMAND** | `COMMAND` | Client compatibility (redis-cli sends this on connect). Returns `OK`. | - -### Example +**Terminal 2 — proxy** ```bash -$ redis-cli -p 6379 -127.0.0.1:6379> SET session:abc '{"user":"kenny"}' EX 300 -OK -127.0.0.1:6379> GET session:abc -"{\"user\":\"kenny\"}" -127.0.0.1:6379> TTL session:abc -(integer) 299 -127.0.0.1:6379> DEL session:abc -(integer) 1 -127.0.0.1:6379> GET session:abc -(nil) +cargo run -p proxy-server ``` -> **Isolated keys**: RESP and HTTP use separate key namespaces within the same bounded primary cache. Binary Redis keys cannot overwrite HTTP responses. RESP operations do not update the comparison cache. Values are limited by `max_body_size_bytes`; incoming frames are limited to 8 MiB. - ---- - -## Prometheus Metrics - -Colander exposes [Prometheus](https://prometheus.io/)-compatible metrics at `GET :9090/metrics`. +**Terminal 3 — dashboard** ```bash -curl http://localhost:9090/metrics +cd dashboard +npm ci +npm run dev ``` -### Available Metrics +**Terminal 4 — optional traffic generator** -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `colander_cache_hits_total` | counter | `policy` | Total cache hits | -| `colander_cache_misses_total` | counter | `policy` | Total cache misses | -| `colander_cache_keys` | gauge | `policy` | Current number of cached entries | -| `colander_cache_evictions_total` | gauge | `policy` | Total evictions | -| `colander_request_duration_seconds` | histogram | — | End-to-end request latency | -| `colander_upstream_duration_seconds` | histogram | — | Upstream (origin) latency on cache misses | - -### Grafana - -Add `http://proxy:9090` as a Prometheus data source in [Grafana](https://grafana.com/) and import/build dashboards using the metrics above. - ---- +```bash +cargo run -p loadgen -- \ + --proxy-url http://127.0.0.1:8080 \ + --num-items 100000 \ + --concurrency 16 \ + --rps 500 \ + --alpha 0.8 +``` -## Live Dashboard +Open [localhost:3001](http://localhost:3001). The Vite development server forwards +metrics and control requests to ports 9090 and 9091. Without the load generator, +you can still send manual HTTP requests and inspect the cache; the alpha control +will show the generator as offline. Use Ctrl+C in each terminal to stop it. -The React dashboard at [`localhost:3001`](http://localhost:3001) connects via WebSocket to the proxy's metrics engine and renders: +## Architecture -- **Hit rate chart** — SIEVE vs LRU hit rate over time -- **Throughput chart** — requests/second over time -- **Stats cards** — live counters for hits, misses, evictions, cache size, uptime -- **Alpha slider** — adjust the Zipfian skewness parameter (α) of the load generator in real time -- **Mode toggle** — switch between Demo (dual-cache) and Bench (single-cache) mode +```mermaid +flowchart LR + Clients["HTTP clients"] --> Proxy["HTTP proxy :8080"] + Loadgen["Zipfian load generator :9091"] --> Proxy + Proxy --> Primary["Primary cache: SIEVE by default"] + Proxy -. "demo lookups" .-> Shadow["Comparison cache: LRU by default"] + Proxy -- "cache miss" --> Origin["Demo origin :3000"] + Primary --> Metrics["Statistics and WebSocket API :9090"] + Shadow --> Metrics + Metrics -- "snapshots" --> Dashboard["Dashboard :3001"] + Dashboard -- "mode" --> Metrics + Dashboard -- "alpha" --> Loadgen +``` -Built with [Vite](https://vitejs.dev/), [React](https://react.dev/), and [Recharts](https://recharts.org/). +The proxy serves cached responses from the primary policy. In demo mode, it also +looks up keys in a comparison cache. The synthetic origin makes cache misses +visible by adding latency. Both caches have separate storage and statistics. ---- +### What SIEVE changes -## HTTP Response Headers +SIEVE marks an entry as visited on a hit. During eviction, a hand scans from the +tail toward the head, clearing visited bits and removing an unvisited entry. +Visited entries stay in place rather than moving to the front of the list. -Colander adds the following headers to every proxied response: +| Policy | On a live hit | When full | +| --- | --- | --- | +| SIEVE | Mark the visited bit | Scan for an unvisited entry | +| LRU | Move the entry to the head | Evict the least recently used entry | +| FIFO | Leave insertion order unchanged | Evict the oldest entry | -| Header | Values | Description | -|--------|--------|-------------| -| `X-Cache` | `HIT` / `MISS` | Whether the response was served from cache | -| `X-Cache-Policy` | `SIEVE` / `LRU` / `FIFO` | Which eviction policy served the response | -| `X-Mode` | `demo` / `bench` | Current cache mode | +In **this implementation**, every policy lookup takes a shard write lock to +update statistics and remove expired entries. SIEVE avoids list promotion, but +Colander's hit path is not lock-free. The original paper's performance results +are research context, not benchmark results for this repository. -### Caching Behavior +[`colander-cache`](crates/colander-cache/) stores list nodes in an arena using +`u32` indices and a reusable free list. Shards share a process-random hash mapping +across policies. Capacity is distributed exactly across up to 64 shards, with +fewer shards for small caches. Eviction is local to each shard, so uneven key +distribution can cause eviction before every slot in the overall cache is full. -- Only **GET** requests with **200 OK** responses are cached -- Responses larger than `max_body_size_bytes` are not cached -- `Cache-Control: no-store`, `no-cache`, and `private` are respected -- `s-maxage` takes precedence over `max-age` (as per [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111)) +Expired entries are removed on lookup; SIEVE can also remove them during its +eviction scan. There is no background expiration sweep. ---- +## Experiments -## Admin API +### Change the workload -The metrics port (`:9090`) exposes administrative endpoints: +Higher alpha concentrates requests on fewer items. The load-generator API accepts +values from `0.01` to `3.0`; the dashboard slider exposes a narrower range. -### `GET /api/stats` +```bash +# Change skew while traffic is running. +curl -fsS http://localhost:9091/control \ + -H 'Content-Type: application/json' \ + -d '{"alpha":1.2}' -Returns a JSON snapshot of current cache statistics. +# Pause traffic to inspect the cache. +curl -fsS http://localhost:9091/control \ + -H 'Content-Type: application/json' \ + -d '{"running":false}' -```bash -curl http://localhost:9090/api/stats +# Resume traffic. +curl -fsS http://localhost:9091/control \ + -H 'Content-Type: application/json' \ + -d '{"running":true}' ``` -```json -{ - "primary": { "name": "SIEVE", "hit_rate": 0.72, "hits": 14400, "misses": 5600, "evictions": 3200, "size": 9800, "capacity": 10000 }, - "comparison": { "name": "LRU", "hit_rate": 0.65, "hits": 13000, "misses": 7000, "evictions": 4100, "size": 9800, "capacity": 10000 }, - "mode": "demo" -} -``` +`--rps` limits the aggregate request rate across all workers; `0` means unlimited. +Compose uses unlimited traffic by default. Dataset size and concurrency must be +positive. Use `cargo run -p loadgen -- --help` for all startup options. -### `POST /api/mode` +### Switch modes -Toggle between demo (dual-cache) and bench (single-cache) mode. +| Mode | Primary cache | Comparison cache | +| --- | --- | --- | +| `demo` (initial mode) | Serves requests | Participates in cache lookups and insertions | +| `bench` | Serves requests | Skipped by the HTTP lookup/insertion path | ```bash -curl -X POST http://localhost:9090/api/mode \ +curl -fsS http://localhost:9090/api/mode \ -H 'Content-Type: application/json' \ - -d '{"mode": "bench"}' + -d '{"mode":"bench"}' ``` -### `GET /ws/metrics` - -WebSocket endpoint streaming [`MetricsSnapshot`](crates/proxy-server/src/metrics.rs) JSON every 500ms. Used by the [dashboard](#live-dashboard). - -### `GET /metrics` - -[Prometheus text format](#prometheus-metrics) metrics endpoint. - ---- - -## Cache Design - -### Eviction Policies - -The [`colander-cache`](crates/colander-cache/) crate implements three eviction policies behind a common [`CachePolicy`](crates/colander-cache/src/traits.rs) trait: +Use `{"mode":"demo"}` to switch back. Switching mode does not reset cache contents +or counters. To compare separate runs, restart the proxy and record the policy, +capacity, TTL, dataset size, alpha, concurrency, and RPS limit for each run. -| Policy | Hit Behavior | Eviction | Best For | -|--------|-------------|----------|----------| -| **SIEVE** | Flip visited bit (`AtomicBool`) — no list mutation | Hand scans tail→head, evicts unvisited | Web caches, Zipfian workloads | -| **LRU** | Move-to-front (requires write lock) | Evict tail (least recently used) | General purpose, baseline comparison | -| **FIFO** | No-op (no promotion) | Evict tail (oldest) | Scan-heavy workloads | +Hit rates are cumulative since cache creation. Dashboard throughput is derived +from primary cache lookups, rather than counting every HTTP request. A higher +alpha does not guarantee that SIEVE wins. The [comparison limitations](#current-scope) +also matter when interpreting the charts. -### Arena Allocation - -All policies use an **arena-allocated doubly-linked list** ([`arena.rs`](crates/colander-cache/src/arena.rs)): - -- Nodes stored in a `Vec