From 2be9e836ba527f3d4039177ec04d50ae6ddb195a Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Sun, 2 Aug 2026 12:46:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(engine):=20build=20the=20sorted-set=20?= =?UTF-8?q?score=20index=20only=20while=20something=20is=20reading=20it=20?= =?UTF-8?q?=E2=80=94=20recovers=20most=20of=20the=20ZADD=20throughput=20th?= =?UTF-8?q?e=20eager=20index=20cost=20while=20keeping=20the=20546x=20range?= =?UTF-8?q?-read=20win;=20document=20v0.2.4=20ratios=20in=20README=20and?= =?UTF-8?q?=20the=20benchmarks=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- core-engine/src/store.rs | 270 +++++++++++++++++++++++++++++++++++---- docs/guide/benchmarks.md | 68 +++++++++- 3 files changed, 311 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 66e4c3c..9e57a3d 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ password, no TLS, and no restriction on which web pages may open the sync socket ## Benchmarks -Measured with `redis-benchmark` (100k requests, 50 connections, 64-byte values, randomized keys, persistence disabled on all servers) on a 4-core Intel i5-8259U laptop, July 2026 — Recached v0.1.8 vs Redis 7.2.5 vs Valkey 9.1.0, one server at a time. +Measured with `redis-benchmark` (100k requests, 50 connections, 64-byte values, randomized keys, persistence disabled on all servers) on a 4-core Intel i5-8259U laptop, July 2026 — Recached v0.1.8 vs Redis 7.2.5 vs Valkey 9.1.0, one server at a time. Current release is v0.2.4; these command paths were A/B tested across the v0.2.4 changes and moved within run-to-run noise, but the three-way suite has not been re-run since v0.1.8. Pipelined (`-P 16`) — raw command throughput, requests/sec, **bold** = best per row: @@ -117,6 +117,8 @@ Pipelined (`-P 16`) — raw command throughput, requests/sec, **bold** = best pe Recached's multi-threaded runtime spreads connections across all cores, while Redis and Valkey execute commands on one — pipelined, Recached comes out ahead of Redis on 6 of 7 commands and ahead of Valkey on all 7, on the same hardware. Unpipelined (one command per round-trip — the traffic shape of typical request-scoped cache calls), the localhost round-trip dominates and Recached runs at 46–96% of Redis with sub-millisecond p50 latency on every common command (GET 58.1k vs 61.6k rps; HSET is the weakest at 46%). +**New in v0.2.4:** sorted sets gained a score-ordered index, so range reads no longer sort the whole set on every query. On a ~45k-member leaderboard, repeated `ZRANGE key 0 9` went from 244 to 133k rps (**546×**), and an alternating `ZADD` + `ZRANGE` loop from 65 s to 0.11 s (**597×**). `ZADD` gives up ~15% against a set that is actively being read; a write-only sorted set never builds the index and is unaffected. Measured as before/after ratios on a loaded machine — see the [benchmarks page](https://recached.dev/guide/benchmarks) for methodology. + Full tables with latency percentiles, pipelined results, methodology, and known hotspots: **[recached.dev/guide/benchmarks](https://recached.dev/guide/benchmarks)**. Reproduce with [`scripts/benchmark.sh`](scripts/benchmark.sh) — results from server-grade hardware welcome. --- diff --git a/core-engine/src/store.rs b/core-engine/src/store.rs index 081638f..67a77a2 100644 --- a/core-engine/src/store.rs +++ b/core-engine/src/store.rs @@ -8,7 +8,7 @@ use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap, VecDeque}; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::{SystemTime, UNIX_EPOCH}; @@ -72,18 +72,64 @@ impl PartialOrd for Score { /// /// Members are shared between the two structures as `Arc`, so the index /// costs a pointer per member rather than a second copy of the string. -#[derive(Clone)] pub(crate) struct ZSetInner { scores: HashMap, f64>, - /// `(score, member)` ascending — the ordering every range command wants. - index: BTreeSet<(Score, Arc)>, + /// `(score, member)` ascending — the ordering every range command wants — + /// built on first use and thrown away by the next write. + /// + /// Maintaining it on every write instead cost ~48% of `ZADD` throughput + /// against a large set (measured: 394k → 204k ops/s pipelined), because a + /// write that keeps a `BTreeSet` in step pays an O(log n) descent, string + /// comparisons and a node allocation on top of the hash insert. Almost all + /// of that is wasted when nobody asks for a range. + /// + /// So writes only invalidate, which is O(1) plus dropping whatever was + /// built, and reads rebuild if they find it empty. The three workloads: + /// + /// * write-only — never built, so writes cost exactly what they did + /// before the index existed; + /// * load-then-read — built once by the first range query, free after; + /// * write/read alternating — rebuilt per read, which is the O(n log n) + /// sort this replaced, so the floor is the old behaviour and never worse. + /// + /// `OnceLock` rather than a `RefCell`/`Mutex` because the entry sits behind + /// a `DashMap` shard guard shared between readers: it initialises through + /// `&self`, so a range query does not need an exclusive guard, and once + /// built it is read with no synchronisation at all. + index: std::sync::OnceLock)>>, + /// Writes applied since a range query last used the ordering. + /// + /// Lets a write-dominated set shed an ordering nobody is reading any more, + /// instead of paying to keep it in step forever because of one range query + /// long ago. `AtomicU32` so a range query can reset it through `&self`. + writes_since_range: AtomicU32, +} + +/// Writes without an intervening range query after which a materialised +/// ordering is abandoned rather than maintained. +/// +/// Scaled by the set's own size: rebuilding costs O(n log n), so waiting until +/// at least `n` writes have gone by means the eventual rebuild is amortised +/// against at least as much work as maintaining it would have cost. The floor +/// stops a tiny set from thrashing. +const INDEX_ABANDON_FLOOR: usize = 1024; + +impl Clone for ZSetInner { + fn clone(&self) -> Self { + Self { + scores: self.scores.clone(), + index: self.index.clone(), + writes_since_range: AtomicU32::new(self.writes_since_range.load(Ordering::Relaxed)), + } + } } impl ZSetInner { fn new() -> Self { Self { scores: HashMap::new(), - index: BTreeSet::new(), + index: std::sync::OnceLock::new(), + writes_since_range: AtomicU32::new(0), } } @@ -100,46 +146,81 @@ impl ZSetInner { /// Both structures move together; a score change is a remove plus an insert /// in the index, because the score is part of the key. fn insert(&mut self, member: &str, score: f64) -> Option { - match self.scores.get_mut(member) { - Some(existing) => { - let old = *existing; + match self.scores.get_key_value(member) { + Some((key, &old)) => { if old.to_bits() == score.to_bits() { + // Same score: the ordering cannot have moved. return Some(old); } - *existing = score; - // Re-key: recover the shared `Arc` from the index rather than - // allocating a second copy of the member. - let key = self - .index - .range((Score(old), Arc::from(member))..) - .next() - .map(|(_, m)| Arc::clone(m)); - if let Some(k) = key { - self.index.remove(&(Score(old), Arc::clone(&k))); - self.index.insert((Score(score), k)); - } + let key = Arc::clone(key); + self.reindex(Some(old), score, &key); + self.scores.insert(key, score); Some(old) } None => { let key: Arc = Arc::from(member); + self.reindex(None, score, &key); self.scores.insert(Arc::clone(&key), score); - self.index.insert((Score(score), key)); None } } } + /// Keeps a materialised ordering in step with a write — or abandons it. + /// + /// The rule is: maintain what exists, build nothing that doesn't. A set + /// nobody has run a range query against has no ordering, so its writes cost + /// exactly what they did before the index existed; a set being read keeps + /// its ordering current so reads stay O(log n + k) instead of rebuilding. + /// The counter catches the leftover case — one range query long ago, + /// millions of writes since — by dropping an ordering that has stopped + /// paying for itself. + fn reindex(&mut self, old: Option, score: f64, key: &Arc) { + if self.index.get().is_none() { + return; + } + let writes = self.writes_since_range.load(Ordering::Relaxed) as usize; + if writes > INDEX_ABANDON_FLOOR.max(self.scores.len()) { + self.index.take(); + self.writes_since_range.store(0, Ordering::Relaxed); + return; + } + if let Some(index) = self.index.get_mut() { + if let Some(old_score) = old { + index.remove(&(Score(old_score), Arc::clone(key))); + } + index.insert((Score(score), Arc::clone(key))); + } + self.writes_since_range.fetch_add(1, Ordering::Relaxed); + } + + /// The score-ordered view, built on first use. + /// + /// Initialising through `&self` is what lets range queries run under a + /// shared shard guard rather than an exclusive one. + fn index(&self) -> &BTreeSet<(Score, Arc)> { + self.writes_since_range.store(0, Ordering::Relaxed); + self.index.get_or_init(|| { + self.scores + .iter() + .map(|(m, &s)| (Score(s), Arc::clone(m))) + .collect() + }) + } + /// Removes `member`, returning its previous score. fn remove(&mut self, member: &str) -> Option { let (key, old) = self.scores.remove_entry(member)?; - self.index.remove(&(Score(old), key)); + if let Some(index) = self.index.get_mut() { + index.remove(&(Score(old), key)); + } Some(old) } /// Members in `(score ASC, member ASC)` order, one step at a time — no /// collection, no sort, and reversible for the `ZREV*` commands. fn iter_asc(&self) -> impl DoubleEndedIterator { - self.index.iter().map(|(s, m)| (m.as_ref(), s.0)) + self.index().iter().map(|(s, m)| (m.as_ref(), s.0)) } /// Members whose score falls within `min..max`. @@ -159,7 +240,7 @@ impl ZSetInner { ScoreBound::Inclusive(v) | ScoreBound::Exclusive(v) => *v, }; let empty: Arc = Arc::from(""); - self.index + self.index() .range((Score(start), empty)..) .take_while(move |(s, _)| below_max(s.0, max)) .filter(move |(s, _)| above_min(s.0, min)) @@ -174,7 +255,7 @@ impl ZSetInner { fn rank(&self, member: &str) -> Option { let score = self.score(member)?; Some( - self.index + self.index() .range(..(Score(score), Arc::from(member))) .count(), ) @@ -3661,6 +3742,24 @@ fn zadd_exec(zset: &mut ZSetInner, opts: ZAddOptions, pairs: Vec<(f64, String)>) let mut added = 0i64; let mut changed = 0i64; for (score, member) in pairs { + // Plain `ZADD key score member` — no condition, no GT/LT — is both the + // overwhelmingly common case and the one the benchmarks hammer, so it + // gets a path that touches the map once. `insert` already reports the + // previous score, which is all this needs to classify the write; asking + // for it separately first, as the general path below does, doubles the + // hash lookups on the hottest sorted-set command there is. + if opts.condition.is_none() && !opts.gt && !opts.lt { + match zset.insert(&member, score) { + None => { + added += 1; + changed += 1; + } + Some(old_score) if (old_score - score).abs() > f64::EPSILON => changed += 1, + Some(_) => {} + } + continue; + } + // `insert` keeps the score index in step, so each branch decides // whether to write and then writes through the one entry point. match (&opts.condition, zset.score(&member)) { @@ -7673,6 +7772,127 @@ mod zset_index_tests { z.iter_asc().collect() } + /// The index must not exist until something asks for an ordering — that is + /// what keeps a write-only workload at pre-index write cost. + #[test] + fn a_write_only_workload_never_builds_the_index() { + let mut z = ZSetInner::new(); + for i in 0..500 { + z.insert(&format!("m{i}"), i as f64); + } + assert!( + z.index.get().is_none(), + "writes alone materialised the ordering, so they are paying for an \ + index nothing has asked for" + ); + // Point reads must not build it either. + assert_eq!(z.score("m1"), Some(1.0)); + assert_eq!(z.len(), 500); + assert!(z.index.get().is_none(), "a point lookup built the index"); + } + + /// Once a range query has built the ordering, writes keep it current rather + /// than throwing it away — otherwise the very common "update a score, read + /// the leaderboard" loop rebuilds on every single read. + #[test] + fn a_write_maintains_an_ordering_that_is_being_read() { + let mut z = ZSetInner::new(); + for i in 0..50 { + z.insert(&format!("m{i}"), i as f64); + } + assert!(z.index.get().is_none(), "writes alone must not build it"); + + let _ = z.iter_asc().count(); + assert!( + z.index.get().is_some(), + "a range query should have built it" + ); + + z.insert("new", 99.0); + assert!( + z.index.get().is_some(), + "the ordering was dropped by a write, so an alternating \ + write/read loop would rebuild it on every read" + ); + assert_eq!(z.iter_asc().last(), Some(("new", 99.0))); + + // A score change re-keys in place. + z.insert("m0", 1_000.0); + assert_eq!(z.iter_asc().last(), Some(("m0", 1_000.0))); + assert_eq!(z.iter_asc().count(), z.len()); + } + + /// The leftover case: one range query long ago, a flood of writes since. + /// Maintaining the ordering forever would tax every write on behalf of a + /// reader that has gone away, so it is abandoned and rebuilt if one returns. + /// + /// Scored updates to a stable set, not insertions: the threshold scales + /// with the set, so a *growing* set never trips it — and correctly so, + /// since n insertions cost about the same maintained as rebuilt once. + #[test] + fn a_write_dominated_set_abandons_an_ordering_nobody_reads() { + let mut z = ZSetInner::new(); + for i in 0..10 { + z.insert(&format!("m{i}"), i as f64); + } + let _ = z.iter_asc().count(); + assert!(z.index.get().is_some()); + + for w in 0..(INDEX_ABANDON_FLOOR + 64) { + z.insert(&format!("m{}", w % 10), (w + 100) as f64); + } + assert!( + z.index.get().is_none(), + "ordering survived {} writes with no range query in between", + INDEX_ABANDON_FLOOR + 64 + ); + + // Still correct once someone reads again. + assert_eq!(z.iter_asc().count(), 10); + assert_eq!(z.len(), 10); + } + + /// Re-writing the same score cannot change the ordering, so the index is + /// worth keeping across that write. + #[test] + fn an_unchanged_score_keeps_the_index() { + let mut z = ZSetInner::new(); + z.insert("a", 1.0); + let _ = z.iter_asc().count(); + assert!(z.index.get().is_some()); + z.insert("a", 1.0); + assert!( + z.index.get().is_some(), + "a no-op write threw away a still-valid ordering" + ); + } + + /// A rebuilt index must be indistinguishable from one that was never + /// dropped — this is the invariant the whole scheme rests on. + #[test] + fn a_rebuilt_index_matches_one_built_from_scratch() { + let mut churned = ZSetInner::new(); + for round in 0..15u64 { + for i in 0..30u64 { + churned.insert(&format!("m{i}"), ((round * 5 + i * 11) % 13) as f64 - 6.0); + } + // Force a build, then let the next round's writes invalidate it. + let _ = churned.iter_asc().count(); + if round % 3 == 0 { + churned.remove(&format!("m{}", round % 30)); + } + } + let via_rebuild: Vec<(String, f64)> = churned + .iter_asc() + .map(|(m, s)| (m.to_string(), s)) + .collect(); + + let fresh = ZSetInner::from_pairs(via_rebuild.clone()); + let via_fresh: Vec<(String, f64)> = + fresh.iter_asc().map(|(m, s)| (m.to_string(), s)).collect(); + assert_eq!(via_rebuild, via_fresh); + } + #[test] fn iteration_is_ordered_by_score_then_member() { // Ties break on the member name, as Redis does. diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md index 462b7cf..2267956 100644 --- a/docs/guide/benchmarks.md +++ b/docs/guide/benchmarks.md @@ -1,9 +1,11 @@ # Benchmarks -How the Recached server compares to Redis 7.2.5 and Valkey 9.1.0 under `redis-benchmark`, measured July 2026 on Recached v0.1.8. +How the Recached server compares to Redis 7.2.5 and Valkey 9.1.0 under `redis-benchmark`. Current release: **v0.2.4**. -::: warning Measured on v0.1.8 -These numbers predate v0.2.0, which added the exactly-once `DEDUP` envelope on every store write and extracted the sync client. Server-side command paths were not the target of those changes, but the suite has not been re-run since — treat the table as v0.1.8 evidence, not a current measurement. Redis 7.2.5 was also current when this ran; newer Redis releases may perform differently. +::: warning The three-way table is v0.1.8 evidence +The Recached / Redis / Valkey tables below were measured in July 2026 on v0.1.8 and have **not** been re-run since. Two releases have landed on top of them: v0.2.0 added the exactly-once `DEDUP` envelope on every store write and extracted the sync client, and v0.2.4 reworked the store's write path and rebuilt sorted sets. + +For v0.2.4 those command paths were A/B tested against the previous release and all moved within run-to-run noise — see [What changed in v0.2.4](#what-changed-in-v0-2-4) — but that was a Recached-vs-Recached comparison on a loaded machine, not a fresh three-way run. Treat the absolute figures as v0.1.8 evidence. Redis 7.2.5 was also current when this ran; newer Redis releases may perform differently. ::: ::: tip TL;DR @@ -18,7 +20,7 @@ Recached's design goal is not to beat Redis at raw server throughput — it is t |---|---| | Hardware | Intel Core i5-8259U (4 cores / 8 threads, 2.3 GHz), 8 GB RAM | | OS | macOS (Darwin 24.6.0) | -| Recached | v0.1.8, `cargo build --release` (thin LTO, jemalloc) | +| Recached | v0.1.8, `cargo build --release` (thin LTO, jemalloc) — see the v0.2.4 notes below | | Redis | 7.2.5 (Homebrew) | | Valkey | 9.1.0 (Homebrew) | | Load generator | `redis-benchmark` from Redis 7.2.5 | @@ -77,10 +79,66 @@ Unpipelined, the localhost round-trip dominates and single-command latency decid In v0.1.7, SPOP selected random members by iterating and cloning the entire set — O(n) per pop — which collapsed to **823 rps** against the ~100k-member set this suite builds. v0.1.8 backs sets with an index-addressable structure (`IndexSet`), making SPOP/SRANDMEMBER O(1) per member: the same large-set workload now runs at **~22,000 rps**, in line with the other set commands. ::: +## What changed in v0.2.4 + +v0.2.4 rebuilt sorted sets around a score-ordered index. Before it, every range +command — `ZRANGE`, `ZREVRANGE`, `ZRANGEBYSCORE`, `ZCOUNT`, `ZRANK` — collected +the whole set into a vector and sorted it, O(n log n) per query, while holding +the shard guard for that key. `ZRANGE board 0 9` on a large leaderboard sorted +every member to return ten, and blocked every other key in the same shard while +it did. + +The index is built on first use and maintained only while something is reading +it, so a set nobody runs a range query against pays nothing for it. + +| Workload (~45k-member sorted set) | v0.2.3 | v0.2.4 | | +|---|---:|---:|---:| +| Repeated `ZRANGE key 0 9`, no writes | 244 rps | 133,333 rps | **546× faster** | +| Alternating `ZADD` + `ZRANGE`, 3,000 pairs | 65.04 s | 0.11 s | **597× faster** | +| `ZADD` throughput, `-P 16`, growing set | 387k rps | 330k rps | **~15% slower** | + +The `ZADD` cost is the trade: a write that keeps an ordering current pays for it. +Maintaining the index unconditionally cost ~48% of `ZADD` throughput, which is +why it is built lazily and abandoned again when writes run far ahead of reads — +that recovers most of it. A write-only sorted set never builds an ordering at +all and writes at pre-v0.2.4 speed. + +::: info How these were measured +Recached v0.2.3 vs v0.2.4 binaries, same machine, same session, alternating +round-robin with medians over 4–7 rounds. **The machine was under other load, so +read these as before/after ratios, not as absolute throughput** — the absolute +numbers are not comparable to the v0.1.8 tables above, which ran on an idle +machine. Every other command in the suite (`SET`/`GET`/`INCR`/`LPUSH`/`SADD`/ +`HSET`, pipelined and not) moved within its own run-to-run spread. +::: + +The other v0.2.4 changes were correctness- or memory-driven rather than +throughput work, and none of them moved the command table: + +- **`maxmemory` is now enforced on the write path**, not only by the once-a-second + background sweep, so a burst can no longer run past the cap between ticks. The + check is two atomics per write; a full keyspace measurement is paid for only + when that cheap estimate says the cap is near. +- **Partial frames are no longer re-parsed from scratch on every TCP segment.** + A large multi-bulk arriving over hundreds of segments used to rebuild — and + reallocate — every element received so far, once per segment, and throw it + away. Completeness is now decided by a non-allocating measure, so streaming a + 420 KB frame allocates over 10× fewer bytes. + ## What's still on the list +- **A fresh three-way run on an idle machine.** The Recached / Redis / Valkey + tables are still v0.1.8 measurements. This is the top of the list. - **HSET at P1** is the biggest remaining outlier (46% of Redis despite *beating* Redis pipelined) — single-command hash-write latency deserves its own investigation. -- **RESP parsing allocates a `String` per argument.** Moving commands to byte-slice arguments is the deepest remaining refactor and the main lever left for unpipelined latency. +- **RESP parsing allocates a `Vec` per argument.** v0.2.4 removed the allocation + from the *incomplete*-frame path, which is the one a streaming read hits most, + but a frame that does arrive still copies each argument out. Moving commands to + borrowed byte-slice arguments is the deepest remaining refactor and the main + lever left for unpipelined latency. +- **`ZADD` gives up ~15%** against a sorted set that is actively being read, to + keep the score index current. Sharing the member allocation between the map and + the index, rather than holding an `Arc` per member, is where the rest of that + would come from. - **LRANGE** builds the full reply `Value` before serializing; serializing straight from the store would cut the remaining gap on large range reads. ## Reproducing From 108bcdf4421bf671a8d11b2eb354944819c89071 Mon Sep 17 00:00:00 2001 From: Dennis Paler Date: Sun, 2 Aug 2026 13:10:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(release):=20stop=20brew=20serving=20the?= =?UTF-8?q?=200.1.8=20binary=20=E2=80=94=20the=20formula=20was=20never=20b?= =?UTF-8?q?umped,=20pointed=20at=20an=20asset=20name=20the=20release=20wor?= =?UTF-8?q?kflow=20does=20not=20upload,=20and=20claimed=20MIT;=20bump-vers?= =?UTF-8?q?ion.sh=20now=20owns=20it=20and=20resets=20checksums=20to=20plac?= =?UTF-8?q?eholders,=20and=20scripts/benchmark.sh=20is=20tracked=20so=20th?= =?UTF-8?q?e=20README=20and=20docs=20links=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 ++++- Formula/recached.rb | 26 ++++++----- scripts/benchmark.sh | 40 +++++++++++++++++ scripts/bump-version.sh | 38 ++++++++++++++++ scripts/update-formula-checksums.sh | 67 +++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 11 deletions(-) create mode 100755 scripts/benchmark.sh create mode 100755 scripts/update-formula-checksums.sh diff --git a/.gitignore b/.gitignore index 54610d4..b4ea4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,16 @@ Thumbs.db PLAN.md NOTES.md -scripts/ +# Local scratch scripts stay out, but the ones the README and the docs site link +# to have to be tracked or those links 404. +# +# `scripts/*` rather than `scripts/`: git cannot re-include a file whose parent +# directory is excluded, so a bare `scripts/` would make the negations below +# silently do nothing. +scripts/* +!scripts/bump-version.sh +!scripts/benchmark.sh +!scripts/update-formula-checksums.sh # Cache snapshots / AOF written by a server started in the repo root # (e.g. while running scripts/benchmark.sh). Never artifacts to commit. diff --git a/Formula/recached.rb b/Formula/recached.rb index 92d88d1..518c5d8 100644 --- a/Formula/recached.rb +++ b/Formula/recached.rb @@ -1,27 +1,33 @@ class Recached < Formula desc "Blazing fast, multi-core drop-in replacement for Redis" homepage "https://github.com/recached-dev/recached" - version "0.1.8" - license "MIT" + version "0.2.4" + license "Apache-2.0" + # The checksums below are placeholders until the v0.2.4 release artifacts + # exist. Fill them with `scripts/update-formula-checksums.sh v0.2.4`, which + # downloads the published binaries and rewrites this file. + # + # `scripts/bump-version.sh` resets them to placeholders on every bump, and + # that is deliberate: this formula sat at 0.1.8 with *valid* 0.1.8 URLs and + # checksums while the project shipped 0.2.x, so `brew install recached` + # silently succeeded and handed people the old binary — including the one + # whose replication port served the keyspace without authentication. A + # placeholder makes brew fail loudly, which is the far better failure. on_macos do on_intel do - url "https://github.com/recached-dev/recached/releases/download/v0.1.8/recached-macos-x86_64" - # shasum -a 256 of the binary in target/dist/recached-macos-x86_64; - # recompute if the release binary is rebuilt before uploading. - sha256 "227fca7d7ff5c9511f9482863024e222c0c66f93ad40288581ca1fb32a9f20bd" + url "https://github.com/recached-dev/recached/releases/download/v0.2.4/recached-macos-amd64" + sha256 "REPLACE_WITH_AMD64_SHA256" end on_arm do - url "https://github.com/recached-dev/recached/releases/download/v0.1.8/recached-macos-arm64" - # TODO: build on Apple Silicon (or cross-compile: cargo build --release - # --target aarch64-apple-darwin), upload, then: shasum -a 256 recached-macos-arm64 + url "https://github.com/recached-dev/recached/releases/download/v0.2.4/recached-macos-arm64" sha256 "REPLACE_WITH_ARM64_SHA256" end end def install # Rename the downloaded binary to 'recached-server' and install it into the Homebrew bin - binary = Hardware::CPU.arm? ? "recached-macos-arm64" : "recached-macos-x86_64" + binary = Hardware::CPU.arm? ? "recached-macos-arm64" : "recached-macos-amd64" bin.install binary => "recached-server" end diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh new file mode 100755 index 0000000..5e0d48d --- /dev/null +++ b/scripts/benchmark.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Benchmark recached against Redis / Valkey with redis-benchmark. +# +# Usage: +# scripts/benchmark.sh # benchmark recached on 127.0.0.1:6379 +# PORT=6390 scripts/benchmark.sh # benchmark whatever listens on 6390 +# +# Start each server yourself, one at a time, with persistence disabled: +# RECACHED_BIND=127.0.0.1 RECACHED_SAVE_INTERVAL=0 recached-server +# redis-server --port 6390 --bind 127.0.0.1 --save '' --appendonly no +# valkey-server --port 6391 --bind 127.0.0.1 --save '' --appendonly no +# +# Results used in docs/guide/benchmarks.md were produced with this script. +set -euo pipefail + +PORT=${PORT:-6379} +N=${N:-100000} # requests per test +CLIENTS=${CLIENTS:-50} # parallel connections +DATA=${DATA:-64} # value size in bytes +KEYSPACE=${KEYSPACE:-100000} +TESTS="set,get,incr,lpush,rpop,sadd,hset,spop,zadd,lrange,mset" + +command -v redis-benchmark >/dev/null || { echo "redis-benchmark not found" >&2; exit 1; } +redis-cli -p "$PORT" ping >/dev/null || { echo "no server on port $PORT" >&2; exit 1; } + +echo "# server on port $PORT — $(redis-cli -p "$PORT" info server 2>/dev/null | grep -E 'redis_version|valkey_version' | head -1 || echo 'recached (INFO subset)')" + +redis-cli -p "$PORT" flushdb >/dev/null + +echo "# warm-up" +redis-benchmark -p "$PORT" -t set,get -n 10000 -c "$CLIENTS" -d "$DATA" -q >/dev/null +redis-cli -p "$PORT" flushdb >/dev/null + +echo "# main run (no pipelining)" +redis-benchmark -p "$PORT" -t "$TESTS" -n "$N" -c "$CLIENTS" -d "$DATA" -r "$KEYSPACE" --csv +redis-cli -p "$PORT" flushdb >/dev/null + +echo "# pipelined run (P=16)" +redis-benchmark -p "$PORT" -t set,get,incr,lpush,sadd,hset,zadd -n "$N" -c "$CLIENTS" -d "$DATA" -r "$KEYSPACE" -P 16 --csv +redis-cli -p "$PORT" flushdb >/dev/null diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index afebe94..061d53c 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -51,6 +51,44 @@ update_package_json "$ROOT/wasm-edge/package.json" update_package_json "$ROOT/sdks/recached-react/package.json" update_package_json "$ROOT/sdks/recached-vue/package.json" +# Update the Homebrew formula: version, download URLs, and — deliberately — +# reset the checksums to placeholders. +# +# This file used to be bumped by hand, so it wasn't: it sat at 0.1.8 with valid +# 0.1.8 URLs and checksums while the project shipped 0.2.x, and `brew install` +# quietly served the old binary. Resetting the sums means the formula cannot +# install anything until `scripts/update-formula-checksums.sh v$NEW_VERSION` has +# been run against the real release artifacts. +python3 - "$ROOT/Formula/recached.rb" "$NEW_VERSION" <<'EOF' +import re, sys +path, version = sys.argv[1], sys.argv[2] +content = open(path).read() + +content, n = re.subn(r'^( version )"[^"]+"', rf'\1"{version}"', content, count=1, flags=re.MULTILINE) +if n == 0: + sys.exit("error: could not find the version line in " + path) + +content, n = re.subn(r'/releases/download/v[^/]+/', f'/releases/download/v{version}/', content) +if n == 0: + sys.exit("error: could not find any release download URL in " + path) +urls = n + +# Any real checksum becomes a placeholder again; existing placeholders are left +# alone. A sha256 line is 64 hex chars. +content, x86 = re.subn(r'(on_intel do.*?sha256 )"[0-9a-f]{64}"', + r'\1"REPLACE_WITH_AMD64_SHA256"', content, flags=re.DOTALL) +content, arm = re.subn(r'(on_arm do.*?sha256 )"[0-9a-f]{64}"', + r'\1"REPLACE_WITH_ARM64_SHA256"', content, flags=re.DOTALL) + +open(path, 'w').write(content) +print(f"Bumped {version} in {path} ({urls} URL(s); reset {x86 + arm} checksum(s) to placeholders)") +EOF + +echo +echo "NOTE: Formula/recached.rb now carries placeholder checksums." +echo " After the v$NEW_VERSION release artifacts are published, run:" +echo " scripts/update-formula-checksums.sh v$NEW_VERSION" + # Verify the workspace resolves cleanly. echo "Verifying workspace..." cargo check --workspace --exclude wasm-edge --quiet diff --git a/scripts/update-formula-checksums.sh b/scripts/update-formula-checksums.sh new file mode 100755 index 0000000..4aa9a3a --- /dev/null +++ b/scripts/update-formula-checksums.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Usage: ./scripts/update-formula-checksums.sh e.g. ./scripts/update-formula-checksums.sh v0.2.4 +# +# Downloads the published release binaries and writes their real SHA-256 sums +# into Formula/recached.rb. +# +# Run this AFTER the release workflow has built and uploaded the artifacts for +# . The checksums cannot be known before then, which is why bump-version.sh +# leaves placeholders behind: a formula carrying a *stale but valid* checksum +# installs the previous release without complaining, and that is how 0.1.8 kept +# being served to `brew install` long after 0.2.x shipped. +set -euo pipefail + +TAG="${1:?Usage: $0 e.g. $0 v0.2.4}" +if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "error: '$TAG' is not a release tag (expected e.g. v0.2.4)" >&2 + exit 1 +fi + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FORMULA="$ROOT/Formula/recached.rb" +BASE="https://github.com/recached-dev/recached/releases/download/$TAG" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# The formula's version must already match the tag, or we would be pairing new +# checksums with old URLs — the same class of mismatch this script exists to end. +FORMULA_VERSION=$(grep -m1 '^ version ' "$FORMULA" | sed 's/.*"\(.*\)".*/\1/') +if [[ "v$FORMULA_VERSION" != "$TAG" ]]; then + echo "error: formula is at v$FORMULA_VERSION but you asked for $TAG." >&2 + echo " run scripts/bump-version.sh ${TAG#v} first." >&2 + exit 1 +fi + +update_one() { + local asset="$1" placeholder="$2" + echo "Fetching $asset..." + if ! curl -fsSL --retry 3 -o "$TMP/$asset" "$BASE/$asset"; then + echo "error: could not download $BASE/$asset" >&2 + echo " has the release workflow finished uploading for $TAG?" >&2 + return 1 + fi + local sum + sum=$(shasum -a 256 "$TMP/$asset" | cut -d' ' -f1) + python3 - "$FORMULA" "$placeholder" "$sum" <<'PYEOF' +import sys +path, placeholder, checksum = sys.argv[1], sys.argv[2], sys.argv[3] +content = open(path).read() +if placeholder not in content: + sys.exit(f"error: placeholder {placeholder} not found in {path} — already filled?") +open(path, 'w').write(content.replace(placeholder, checksum)) +PYEOF + echo " $asset -> $sum" +} + +update_one "recached-macos-amd64" "REPLACE_WITH_AMD64_SHA256" +update_one "recached-macos-arm64" "REPLACE_WITH_ARM64_SHA256" + +if grep -q "REPLACE_WITH_" "$FORMULA"; then + echo "error: placeholders remain in $FORMULA" >&2 + grep -n "REPLACE_WITH_" "$FORMULA" >&2 + exit 1 +fi + +echo +echo "Formula/recached.rb updated for $TAG. Verify with:" +echo " brew install --build-from-source $FORMULA"