Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

---

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)** — so you can swap out Redis for SIEVE-powered caching with zero code changes.
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
Expand Down Expand Up @@ -223,10 +223,10 @@ redis-cli -p 6379
|---------|--------|-------------|
| **PING** | `PING` | Health check. Returns `PONG`. |
| **GET** | `GET key` | Retrieve a cached value. Returns bulk string or `(nil)`. |
| **SET** | `SET key value [EX seconds]` | Store a value with optional TTL. Returns `OK`. |
| **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. |
| **EXPIRE** | `EXPIRE key seconds` | Not supported (TTL is set-at-insert). Returns `0`. |
| **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
Expand All @@ -245,7 +245,7 @@ OK
(nil)
```

> **Shared cache**: The RESP interface shares the same in-memory cache as the HTTP proxy. A `SET` via Redis is visible to HTTP `GET` responses, and vice versa.
> **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.

---

Expand Down
44 changes: 32 additions & 12 deletions crates/proxy-server/src/cache_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

fn raw_key(key: &[u8]) -> String {
use std::fmt::Write;
let mut encoded = String::from("\0resp:");
for byte in key {
write!(encoded, "{byte:02x}").expect("writing to String cannot fail");
}
encoded
}

/// Runtime mode for the dual-cache system.
/// - Demo: updates both caches, serves from primary (fair hit-rate comparison)
/// - Bench: updates only primary cache (true latency/throughput)
Expand Down Expand Up @@ -156,28 +165,39 @@ impl CacheLayer {
self.primary.insert(key, value);
}

/// Remove a key from the primary cache. Returns true if the key existed.
pub fn remove(&self, key: &str) -> bool {
self.primary.remove(key)
/// RESP keys are binary-safe and isolated from HTTP cache keys.
pub fn get_raw(&self, key: &[u8]) -> Option<Arc<CachedResponse>> {
self.primary.get(&raw_key(key))
}

/// Insert raw bytes (for RESP SET — bypasses HTTP response wrapping).
/// Only inserts into primary (RESP ops don't participate in demo comparison).
pub fn insert_raw(&self, key: String, value: Bytes, ttl: Option<Duration>) {
pub fn remove_raw(&self, key: &[u8]) -> bool {
let key = raw_key(key);
// Expired entries count as absent, as they do for GET.
self.primary.get(&key).is_some() && self.primary.remove(&key)
}

/// SET without an expiration is persistent until eviction or deletion.
pub fn insert_raw(&self, key: &[u8], value: Bytes, ttl: Option<Duration>) {
let response = CachedResponse {
status: 0,
headers: vec![],
body: value,
inserted_at: Instant::now(),
ttl: ttl.unwrap_or(self.default_ttl()),
ttl: ttl.unwrap_or(Duration::MAX),
};
self.primary.insert(key, response);
self.primary.insert(raw_key(key), response);
}

/// Get TTL remaining for a key. Returns None if key missing/expired.
pub fn ttl_remaining(&self, key: &str) -> Option<Duration> {
let entry = self.primary.get(key)?;
entry.ttl.checked_sub(entry.inserted_at.elapsed())
pub fn raw_ttl(&self, key: &[u8]) -> i64 {
match self.get_raw(key) {
None => -2,
Some(entry) if entry.ttl == Duration::MAX => -1,
Some(entry) => entry
.ttl
.saturating_sub(entry.inserted_at.elapsed())
.as_secs()
.min(i64::MAX as u64) as i64,
}
}

/// Build a CachedResponse from raw HTTP response parts.
Expand Down
Loading
Loading