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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ Thumbs.db
# Logs
*.log

# Note: Cargo.lock is intentionally NOT ignored.
# Because this project contains an executable (server-native),
# Note: Cargo.lock is intentionally NOT ignored.
# Because this project contains an executable (server-native),
# committing Cargo.lock is best practice to ensure reproducible builds!

PLAN.md
NOTES.md

scripts/
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,86 @@ All notable changes to Recached are documented here.

---

## [0.1.7] — 2026-06-12

### Fixed

**Security**
- Replication auth password was compared with `!=` (byte-by-byte), leaking timing information an attacker could use to brute-force the password one character at a time. Replaced with a constant-time XOR-fold comparison. (`server-native/src/main.rs`)
- The client `AUTH` command compared the supplied password with `==` (`String` equality, short-circuiting on the first mismatched byte) — the same timing side-channel the replication path was already hardened against. `process_auth` now uses the constant-time comparison. (`server-native/src/main.rs`)
- Replication frame length prefixes (snapshot and per-command) were read and allocated without an upper bound. Because the replication port may be unauthenticated and plaintext, a peer or MITM could send a 4 GB length prefix and force a matching allocation per frame (memory DoS). Frames are now capped at 512 MB before allocation. (`server-native/src/main.rs`)
- `auth()` in the WASM SDK now emits a `console.warn` when the active connection is an unencrypted `ws://` URL, alerting developers that the password is sent in plaintext. Production deployments should use `wss://`. (`wasm-edge/src/lib.rs`)

**Correctness**
- WebSocket `connect()` followed by `auth()` raced the socket handshake: `createCache` sent `AUTH` (and any early `set`/`del`/`subscribe`/`publish`) while the socket was still `CONNECTING`, so the frames were silently dropped — server sync was completely broken whenever `RECACHED_PASSWORD` was set, and early writes were lost otherwise. Commands issued before the socket opens are now buffered and flushed in FIFO order by an `onopen` handler. (`wasm-edge/src/lib.rs`, `wasm-edge/sdk.ts`)
- `MULTI`/`EXEC` did not honour `WATCH`: a watched key changing before `EXEC` did not abort the transaction, so the standard Redis optimistic-locking (compare-and-swap) pattern silently lost updates. `EXEC` now returns a nil array when any watched key has changed since `WATCH`; `EXEC` and `DISCARD` clear all watches; and `WATCH`/`UNWATCH` inside `MULTI` are rejected. Works over both the TCP and WebSocket ports. (`server-native/src/main.rs`)
- AOF replay restored nothing on the live server. Writes are recorded via `on_write` in RESP3 Push (`>`) form, but `replay_aof` passed parsed frames straight to `Command::from_value`, which only accepts arrays — so every replayed frame was rejected and skipped (the existing test masked this by feeding `*`-array frames). Replay now normalises Push→Array, matching the replica stream path. (`server-native/src/main.rs`)
- `SPOP` and `SRANDMEMBER` returned members in `HashMap` iteration order rather than randomly, and positive `SRANDMEMBER count` was non-random while negative count was fully deterministic (`members[i % len]`). All now sample randomly, matching Redis. (`core-engine/src/store.rs`)
- `allkeys-lru` / `volatile-lru` eviction ranked entries by last *write* time and never updated it on reads, so a hot, frequently-read key could be evicted as if it were cold. Entries now carry an atomic last-access timestamp refreshed on the main read paths (`GET`, `MGET`, `HGET`/`HGETALL`, `LRANGE`, `SMEMBERS`, `SISMEMBER`, `ZSCORE`, and the sorted-set range reads), giving true access-based LRU. (`core-engine/src/store.rs`)
- `SCAN` ignored its `COUNT` argument and returned the entire matching keyspace in one reply at cursor `0`, defeating its purpose as the non-blocking alternative to `KEYS`. It now returns at most `COUNT` keys per call (default 10) with a real next-cursor for incremental iteration. (`core-engine/src/store.rs`)
- A read-only replica applied writes streamed from the primary but never re-broadcast them, so the replica's own WebSocket clients received no live updates and multi-tier (chained) replication was impossible. Replicas now relay each applied write to their local WebSocket clients and run a replication server so they can serve sub-replicas. (`server-native/src/main.rs`)
- Local writes through the SDK fired the mutation callback twice — once from the Rust layer and again in `sdk.ts` — causing redundant `useSyncExternalStore` re-renders. The duplicate notification was removed. (`wasm-edge/sdk.ts`)
- `SRANDMEMBER key -N` panicked with a divide-by-zero when the target key did not exist or the set was empty. An early-return guard now produces an empty array, matching Redis semantics. (`core-engine/src/store.rs`)
- `ZINCRBY` did not validate the resulting score for NaN or Infinity before writing it into the sorted set, corrupting subsequent range queries when called with `+inf`/`-inf` deltas. The result is now pre-computed and rejected with `ERR increment would produce NaN or Infinity` if invalid — consistent with `HINCRBYFLOAT`. (`core-engine/src/store.rs`)
- `DECRBY` used `extract_string` (no size limit) for key parsing while `INCRBY` used `extract_key` (≤ 512 KB). Keys larger than 512 KB sent via `DECRBY` now return an error, consistent with all other key-bearing commands. (`core-engine/src/cmd.rs`)
- `SET … EX <n>` with a TTL value large enough to overflow `u64` when multiplied by 1 000 silently saturated to `u64::MAX`, making the key effectively immortal. Such values now return `ERR TTL overflow`. (`core-engine/src/store.rs`)
- Vue `useKey` and `useKeyJSON` read the initial value before subscribing to mutations, leaving a narrow window where a write between `get()` and `onMutation()` was missed. The subscription is now registered first, then the initial value is read. (`recached-vue/src/useKey.ts`)
- React `usePubSub` captured the `handler` closure at subscribe time and held it for the lifetime of the effect. Inline handlers (redefined each render) would go stale and never receive updated closure state. The hook now stores the latest handler in a `useRef` and calls through it — no re-subscribe needed when the handler changes. (`recached-react/src/usePubSub.ts`)

**Performance**
- Memory-limit eviction (`RECACHED_MAX_MEMORY`) was O(N²): `try_evict_for_memory` re-scanned the entire keyspace to recompute total memory after every single eviction, on the 1-second background sweep — stalling the server under exactly the memory pressure it was meant to relieve. It now measures total memory once and maintains it incrementally by subtracting each evicted entry's measured size, with a periodic re-sync to correct drift. (`core-engine/src/store.rs`)

**DoS / resilience**
- The RESP array parser bounded each bulk string at 64 MB but applied no limit to the total number of elements, making it possible to stream 1 million small strings and force ~64 TB of cumulative allocation before rejection. A 64 MB cumulative-bytes check is now applied across the entire array parse loop. (`core-engine/src/resp.rs`)
- The RESP3 Push (`>`) parser lacked the cumulative-size guard the array parser has, so the replica and AOF parse paths would accept arbitrarily large push frames. The same 64 MB cumulative check is now applied. (`core-engine/src/resp.rs`)
- The glob matcher used by `KEYS` and `SCAN` was a recursive function with no memoization or depth limit. Patterns such as `*.*.*.*x` against a long non-matching string caused exponential backtracking (ReDoS). The implementation is replaced with an iterative two-row DP algorithm that is strictly O(m × n). (`core-engine/src/store.rs`)

**Portability**
- On non-Unix platforms the server bound one listener socket per CPU core to the same port, relying on `SO_REUSEPORT` (Unix-only). Without it the second bind failed and the process exited at startup. Non-Unix builds now fall back to a single accept loop. (`server-native/src/main.rs`)

**Resource management**
- Calling `connect()` a second time on a `RecachedCache` instance replaced the internal `WebSocket` field without closing the previous socket. The old connection remained open, receiving stale messages. `connect()` now calls `.close()` on the existing socket before creating the new one. (`wasm-edge/src/lib.rs`)

### Added

- **`RECACHED_BIND`** — new env var controlling the network interface every listener (TCP, WebSocket, replication, metrics) binds to. Defaults to `0.0.0.0` for backwards compatibility; set `127.0.0.1` (or a specific private interface) to keep the server off public interfaces. A startup warning is logged when bound to all interfaces. (`server-native/src/main.rs`)
- **`WATCH` / `UNWATCH` over TCP** — optimistic-lock (CAS) `WATCH` is now available on the RESP/TCP port (6379), not just WebSocket. TCP clients receive no keychange push (it would break the request/response protocol); they use `WATCH` purely for the `EXEC` abort guarantee. (`server-native/src/main.rs`)

### Changed

- `WATCH`/`UNWATCH` semantics: previously WebSocket-only "observable keys" with no transactional effect, they now provide Redis-compatible optimistic locking on both transports. Over WebSocket, `WATCH` additionally pushes live keychange notifications as before. The store no longer returns `ERR WATCH/UNWATCH only supported over WebSocket`. (`server-native/src/main.rs`, `docs/server/commands.md`)
- The replication server now runs on every node, including replicas, so a replica can in turn serve sub-replicas (multi-tier replication). (`server-native/src/main.rs`)
- The `Entry` struct's `written_at_ms` field was replaced with an atomic `last_access_ms`, refreshed on reads, to back access-based LRU eviction. (`core-engine/src/store.rs`)
- Documented that WebSocket uses text frames, so values must be valid UTF-8 (non-UTF-8 bytes are replaced lossily); raw binary values are fully round-trippable only over the TCP port. The SDK's string-typed `set` API is unaffected. (`server-native/src/main.rs`)

---

## [0.1.6] — 2026-05-11

### Fixed

**Correctness**
- `TTL` / `PTTL`: replaced `exp - now` with `exp.saturating_sub(now)` — a tight race between the expiry check and the subtraction could panic in debug builds or wrap to `u64::MAX` in release, returning a wildly incorrect TTL. (`core-engine/src/store.rs`)
- `DEL` / `UNLINK`: switched from `data.remove(k)` to `data.remove_if(k, |_, e| !e.is_expired(now))` — expired-but-not-yet-swept keys were counted as deleted, violating Redis semantics which returns 0 for missing/expired keys. (`core-engine/src/store.rs`)
- `ZADD GT` / `LT` flags were parsed and silently discarded. They are now fully enforced: `GT` updates an existing member only if the new score is greater; `LT` only if lower; new members are always inserted regardless of the flag. Incompatible combinations (`GT`+`LT`, `GT`/`LT`+`NX`) return errors matching Redis. (`core-engine/src/cmd.rs`, `core-engine/src/store.rs`)

**Security**
- `Command::Auth` reached `store.execute()` and unconditionally returned `+OK`, bypassing authentication during AOF replay and any other path that calls the store directly. `store.execute()` now returns an error for `Auth` — authentication is handled exclusively by the connection-layer `process_auth` function. (`core-engine/src/store.rs`)

**Performance / reliability**
- `PubSubHub::unsubscribe` left an empty `Vec` in `channel_subs` after the last subscriber left a channel. Over time, high-churn subscriber patterns leaked memory proportional to the total number of unique channels ever seen. Empty entries are now removed immediately in `unsubscribe`, `unsubscribe_all`, and `publish`. (`server-native/src/main.rs`)
- `SharedPubSub` and `WatchRegistry` used `std::sync::Mutex` (blocking) in async connection handlers. Holding a blocking lock across `.await` points starves the Tokio thread pool under high pub/sub publish rates. Both types now use `tokio::sync::Mutex`; `notify_watchers` is now `async`. (`server-native/src/main.rs`)

### Added

- Key-length validation in `Command::from_value`: keys larger than 512 KB or empty keys are rejected at parse time with a descriptive `ERR` before reaching the store. Validation is applied to all primary-key positions in `GET`, `SET`, `DEL`, `UNLINK`, `MGET`, `MSET`, `EXISTS`, `APPEND`, `STRLEN`, `GETSET`, `SETNX`, `SETEX`, `PSETEX`, `INCR`, `DECR`, `INCRBY`, and all commands that assign `let key = …`. (`core-engine/src/cmd.rs`)

### Changed

- `format_score` (f64 → Redis score string) is now `pub` and exported from `core-engine::store`. The identical private `format_zset_score` function in `wasm-edge` has been removed in favour of the shared implementation. (`core-engine/src/store.rs`, `wasm-edge/src/lib.rs`)

---

## [0.1.5] — 2026-05-10

### Added
Expand Down
59 changes: 54 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ resolver = "2"
# ── Single source of truth for all crate versions ────────────────────────────
# Members inherit with: version.workspace = true / edition.workspace = true
[workspace.package]
version = "0.1.5"
version = "0.1.7"
edition = "2024"
license = "MIT"
authors = ["ThinkGrid Labs"]
Expand Down Expand Up @@ -44,3 +44,15 @@ rmp-serde = "1"
wasm-bindgen = "0.2.92"
js-sys = "0.3.69"
web-sys = "0.3.69"

# perf
socket2 = { version = "0.5", features = ["all"] }
num_cpus = "1"
tikv-jemallocator = "0.6"

# ── Release profile ───────────────────────────────────────────────────────────
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
Loading
Loading