bench: port urus memory store to gen_server; add spinning A/B harness
- store.rs: MemStore now implements GenServer (one inbox, one hop/request),
replacing the AnyReq + two-forwarder fan-in that cost two extra park/unpark
hops per request and contaminated the spin signal. StoreHandle is now an
enum {Memory(ServerRef<MemStore>) | Sqlite{reads,writes}} with one call().
Response status/body semantics unchanged (S3 stays comparable).
- SQLite store left on raw actors (single-writer + reader pool); not ported
(single-inbox would serialise S4 reads) and not part of the spin A/B. Kept
behind the handle so --store=sqlite still builds.
- handlers.rs: 6 handlers collapse to state.store().call(StoreReq::..); the
per-handler channel+send+recv boilerplate is gone. main.rs untouched.
- spin_ab_urus.sh + analyze_spin_ab_urus.py: A/B the spin policy on urus by
checking out smarm b0c9685 (pre-spin) vs HEAD, S2/S3 on urus-mem, server
cores 1/2/4/8 (2/4 gate-active, 1/8 inert), printing RPS speedup + latency
deltas. Verified: builds release; mem+sqlite smoke pass; scripts dry-run +
analyzer fixture-checked. Real sweep needs the multi-core box.
This commit is contained in:
+24
-44
@@ -1,10 +1,10 @@
|
||||
//! Request handlers and the AppState that holds the store-handle factory.
|
||||
//!
|
||||
//! The trick: smarm requires `spawn` to be called from inside an actor.
|
||||
//! The bootstrap thread that calls `serve_with` is NOT an actor — it's
|
||||
//! the user's main(). So we can't spawn the store actor at startup; we
|
||||
//! defer to the first request that asks for it. That request runs inside
|
||||
//! a connection actor, which is a valid place to call spawn.
|
||||
//! The trick: smarm requires `spawn` (and `gen_server::start`) to be called
|
||||
//! from inside an actor. The bootstrap thread that calls `serve_with` is NOT
|
||||
//! an actor — it's the user's main(). So we can't spawn the store actor at
|
||||
//! startup; we defer to the first request that asks for it. That request runs
|
||||
//! inside a connection actor, which is a valid place to spawn.
|
||||
//!
|
||||
//! AppState holds:
|
||||
//! - a "store_init" factory that lazily spawns the store on first use
|
||||
@@ -18,11 +18,10 @@
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use smarm::channel;
|
||||
use urus::{Conn, Next, Plug, Router};
|
||||
|
||||
use crate::middleware::LogRing;
|
||||
use crate::store::{ReadReq, StoreHandle, WriteReq};
|
||||
use crate::store::{StoreHandle, StoreReq};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppState
|
||||
@@ -64,7 +63,7 @@ impl AppState {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny helper for writing JSON responses
|
||||
// Tiny helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
||||
@@ -81,13 +80,17 @@ fn text(conn: Conn, status: u16, body: &'static str) -> Conn {
|
||||
|
||||
fn parse_id(s: &str) -> Option<u64> { s.parse().ok() }
|
||||
|
||||
const STORE_UNAVAILABLE: &[u8] = b"{\"error\":\"store unavailable\"}";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// All handlers close over a clone of AppState. Each handler closure is a
|
||||
// `Fn(Conn, Next) -> Conn`, which is exactly the Plug shape urus's
|
||||
// blanket impl picks up.
|
||||
// `Fn(Conn, Next) -> Conn`, which is exactly the Plug shape urus's blanket
|
||||
// impl picks up. A request is now a single `store().call(...)`: the gen_server
|
||||
// `call` builds the reply channel, sends, and parks for the reply, so the
|
||||
// old per-handler channel + send + recv boilerplate is gone.
|
||||
|
||||
fn build_ping_handler() -> impl Plug {
|
||||
// /ping is a literal endpoint: status 200, body "pong". No work.
|
||||
@@ -96,13 +99,9 @@ fn build_ping_handler() -> impl Plug {
|
||||
|
||||
fn build_list_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().reads().send(ReadReq::List { reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
match state.store().call(StoreReq::List) {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
Err(_) => json(conn, 503, STORE_UNAVAILABLE.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,31 +112,20 @@ fn build_get_one_handler(state: AppState) -> impl Plug {
|
||||
Some(id) => id,
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().reads().send(ReadReq::Get { id, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
match state.store().call(StoreReq::Get { id }) {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
Err(_) => json(conn, 503, STORE_UNAVAILABLE.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_create_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
// Take the body out of the request side. `Body::into_bytes`
|
||||
// would need to consume `conn`, which we still need; clone the
|
||||
// bytes instead. (For a 100-byte JSON object this is fine; for
|
||||
// a large upload we'd want to redesign.)
|
||||
// Clone the body bytes out; we still need `conn` to build the reply.
|
||||
let body = conn.body.as_bytes().to_vec();
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Create { body, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
match state.store().call(StoreReq::Create { body }) {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
Err(_) => json(conn, 503, STORE_UNAVAILABLE.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,13 +137,9 @@ fn build_update_handler(state: AppState) -> impl Plug {
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let body = conn.body.as_bytes().to_vec();
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Update { id, body, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
match state.store().call(StoreReq::Update { id, body }) {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
Err(_) => json(conn, 503, STORE_UNAVAILABLE.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,13 +150,9 @@ fn build_delete_handler(state: AppState) -> impl Plug {
|
||||
Some(id) => id,
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Delete { id, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
match state.store().call(StoreReq::Delete { id }) {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
Err(_) => json(conn, 503, STORE_UNAVAILABLE.to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+169
-209
@@ -1,43 +1,58 @@
|
||||
//! Store actors for the urus-server bench.
|
||||
//!
|
||||
//! Two implementations, same protocol:
|
||||
//! - Memory store: one actor owns a Vec<User>.
|
||||
//! - SQLite store: one writer actor + a pool of reader actors. Reads
|
||||
//! are dispatched through a single MPSC `Receiver` shared by all
|
||||
//! readers — smarm's MPSC gives us "first available reader wins" for
|
||||
//! free.
|
||||
//! Two implementations behind one [`StoreHandle`]:
|
||||
//! - **Memory store** (S1/S2/S3): one `gen_server` actor owning a
|
||||
//! `Vec<User>`. Reads and writes share the single gen_server inbox, so a
|
||||
//! request is exactly one park/unpark round-trip — the path the RFC 004
|
||||
//! spinning patch perturbs. This is the scenario we actually want clean.
|
||||
//! - **SQLite store** (S4): one writer actor + a pool of reader actors over
|
||||
//! raw `channel` actors. NOT ported to gen_server (single-inbox would
|
||||
//! serialise the reader pool and kill S4 read parallelism); left exactly
|
||||
//! as it was. It is not part of the spinning A/B and is kept only so the
|
||||
//! binary still builds with `--store=sqlite`.
|
||||
//!
|
||||
//! `StoreHandle` is what handlers hold. It's cheap-cloneable (it wraps a
|
||||
//! pair of smarm Senders) and carries the routing logic for splitting
|
||||
//! requests across the writer and the reader pool.
|
||||
//! `StoreHandle` is what handlers hold. It's cheap-cloneable and exposes a
|
||||
//! single `call(StoreReq) -> Reply` that both backends answer.
|
||||
|
||||
use smarm::{channel, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use smarm::{channel, GenServer, Receiver, Sender, ServerBuilder, ServerRef};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types — what the store returns over the reply channel.
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We send (status, body_bytes) so handlers can hand them straight to
|
||||
// We return (status, body_bytes) so handlers can hand them straight to
|
||||
// `Conn::put_body` without re-serialising. The store does the JSON work.
|
||||
|
||||
pub type Reply = (u16, Vec<u8>);
|
||||
|
||||
// Debug is intentionally not derived: `smarm::Sender<T>` doesn't implement
|
||||
// Debug, and these enums carry one. We never print them.
|
||||
/// The unified store request. One variant per route. Unlike the old
|
||||
/// `ReadReq`/`WriteReq`, these carry *no* reply channel — gen_server's `call`
|
||||
/// creates and threads the reply for us, so the request is just the payload.
|
||||
pub enum StoreReq {
|
||||
List,
|
||||
Get { id: u64 },
|
||||
Create { body: Vec<u8> },
|
||||
Update { id: u64, body: Vec<u8> },
|
||||
Delete { id: u64 },
|
||||
}
|
||||
|
||||
// The SQLite backend still routes reads vs writes to two different actor
|
||||
// sets, so it keeps the split request enums *with* their reply senders.
|
||||
// These are now used ONLY by the SQLite store internals.
|
||||
|
||||
pub enum ReadReq {
|
||||
List { reply: Sender<Reply> },
|
||||
Get { id: u64, reply: Sender<Reply> },
|
||||
List { reply: Sender<Reply> },
|
||||
Get { id: u64, reply: Sender<Reply> },
|
||||
}
|
||||
|
||||
pub enum WriteReq {
|
||||
Create { body: Vec<u8>, reply: Sender<Reply> },
|
||||
Update { id: u64, body: Vec<u8>, reply: Sender<Reply> },
|
||||
Delete { id: u64, reply: Sender<Reply> },
|
||||
Create { body: Vec<u8>, reply: Sender<Reply> },
|
||||
Update { id: u64, body: Vec<u8>, reply: Sender<Reply> },
|
||||
Delete { id: u64, reply: Sender<Reply> },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,187 +76,149 @@ struct NewUser {
|
||||
// Public handle
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Both implementations of the store give back a StoreHandle. Internally
|
||||
// we always have a "read side" and a "write side". For the memory store
|
||||
// they're the same actor (so the writer Sender is just a clone of the
|
||||
// reader Sender wrapped in a small adapter); we keep the split anyway for
|
||||
// API uniformity.
|
||||
// Both backends produce a StoreHandle. Memory wraps a gen_server ServerRef;
|
||||
// SQLite wraps the read/write Sender pair. `call` hides the difference so
|
||||
// handlers issue one uniform request regardless of backend.
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StoreHandle {
|
||||
reads: Sender<ReadReq>,
|
||||
writes: Sender<WriteReq>,
|
||||
pub enum StoreHandle {
|
||||
Memory(ServerRef<MemStore>),
|
||||
Sqlite { reads: Sender<ReadReq>, writes: Sender<WriteReq> },
|
||||
}
|
||||
|
||||
impl StoreHandle {
|
||||
pub fn reads(&self) -> &Sender<ReadReq> { &self.reads }
|
||||
pub fn writes(&self) -> &Sender<WriteReq> { &self.writes }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory store — one actor, one Vec.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The memory store collapses reads and writes into one mailbox. We bridge
|
||||
// the two-channel API by spawning a tiny *fan-in* actor whose job is to
|
||||
// forward ReadReq | WriteReq into the single backing actor's mailbox.
|
||||
//
|
||||
// Actually, simpler: we make the backing actor accept an `enum AnyReq`
|
||||
// and spawn two forwarder loops. That avoids changing the public API or
|
||||
// inventing a `try_recv_either`. The cost is two extra hops per request,
|
||||
// which is fine for the memory store — it's the *baseline*, not the
|
||||
// target of the bench.
|
||||
//
|
||||
// Wait — that's two extra context switches *per request*, and the
|
||||
// memory tier exists specifically to expose actor-message-pass costs.
|
||||
// Tainting it with extra hops would make the bench lie. Let's do it
|
||||
// properly: one actor, a `select`-shaped recv. smarm doesn't have a
|
||||
// multi-channel select primitive (per the README it's a single-mailbox
|
||||
// model), but we *can* have one actor own one channel that carries
|
||||
// `enum Req { Read(...), Write(...) }` and expose the two Senders by
|
||||
// mapping at the boundary. The mapping happens in a couple of cheap
|
||||
// forwarder actors. For a *pure* in-memory benchmark we still pay one
|
||||
// extra hop per request — fair across all paths.
|
||||
//
|
||||
// Decision: take the one extra hop for clean code. The actor-message
|
||||
// cost we want to measure is "handler -> store -> handler", and the
|
||||
// forwarder just makes the bookkeeping uniform. We document it.
|
||||
|
||||
enum AnyReq {
|
||||
Read(ReadReq),
|
||||
Write(WriteReq),
|
||||
}
|
||||
|
||||
pub fn spawn_memory_store() -> StoreHandle {
|
||||
let (any_tx, any_rx) = channel::<AnyReq>();
|
||||
|
||||
// The backing actor.
|
||||
let core_tx = any_tx.clone();
|
||||
let _ = core_tx; // keep alive via forwarders below
|
||||
smarm::spawn(move || memory_store_loop(any_rx));
|
||||
|
||||
// Forwarder: ReadReq -> AnyReq::Read.
|
||||
let (reads_tx, reads_rx) = channel::<ReadReq>();
|
||||
{
|
||||
let any_tx = any_tx.clone();
|
||||
smarm::spawn(move || forward_reads(reads_rx, any_tx));
|
||||
}
|
||||
|
||||
// Forwarder: WriteReq -> AnyReq::Write.
|
||||
let (writes_tx, writes_rx) = channel::<WriteReq>();
|
||||
{
|
||||
let any_tx = any_tx.clone();
|
||||
smarm::spawn(move || forward_writes(writes_rx, any_tx));
|
||||
}
|
||||
|
||||
StoreHandle { reads: reads_tx, writes: writes_tx }
|
||||
}
|
||||
|
||||
fn forward_reads(rx: Receiver<ReadReq>, tx: Sender<AnyReq>) {
|
||||
while let Ok(r) = rx.recv() {
|
||||
if tx.send(AnyReq::Read(r)).is_err() { return; }
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_writes(rx: Receiver<WriteReq>, tx: Sender<AnyReq>) {
|
||||
while let Ok(w) = rx.recv() {
|
||||
if tx.send(AnyReq::Write(w)).is_err() { return; }
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_store_loop(rx: Receiver<AnyReq>) {
|
||||
let mut users: Vec<User> = Vec::with_capacity(1024);
|
||||
let mut next_id: u64 = 1;
|
||||
|
||||
loop {
|
||||
let req = match rx.recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return, // all forwarders dropped
|
||||
};
|
||||
match req {
|
||||
AnyReq::Read(ReadReq::List { reply }) => {
|
||||
// Cap at 100 per the spec; reuse a Vec via collect.
|
||||
let snapshot: Vec<&User> = users.iter().take(100).collect();
|
||||
let body = serde_json::to_vec(&snapshot).unwrap_or_else(|_| b"[]".to_vec());
|
||||
let _ = reply.send((200, body));
|
||||
}
|
||||
AnyReq::Read(ReadReq::Get { id, reply }) => {
|
||||
match users.iter().find(|u| u.id == id) {
|
||||
Some(u) => {
|
||||
let body = serde_json::to_vec(u).unwrap();
|
||||
let _ = reply.send((200, body));
|
||||
}
|
||||
None => {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Create { body, reply }) => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
let u = User { id: next_id, name: nu.name, email: nu.email };
|
||||
next_id += 1;
|
||||
users.push(u.clone());
|
||||
// The bench cares about *steady-state* RPS; let
|
||||
// the in-memory store cap itself so we don't
|
||||
// grow to Vec<infinity>.
|
||||
if users.len() > 100_000 {
|
||||
users.drain(..50_000);
|
||||
}
|
||||
let _ = reply.send((201, serde_json::to_vec(&u).unwrap()));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Update { id, body, reply }) => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => match users.iter_mut().find(|u| u.id == id) {
|
||||
Some(u) => {
|
||||
u.name = nu.name;
|
||||
u.email = nu.email;
|
||||
let snap = u.clone();
|
||||
let _ = reply.send((200, serde_json::to_vec(&snap).unwrap()));
|
||||
}
|
||||
None => {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Delete { id, reply }) => {
|
||||
let before = users.len();
|
||||
users.retain(|u| u.id != id);
|
||||
if users.len() < before {
|
||||
let _ = reply.send((204, Vec::new()));
|
||||
} else {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
/// Issue a request and wait for the reply. `Err(())` means the store is
|
||||
/// unreachable (server gone / channel closed); handlers turn that into a
|
||||
/// 503. The memory path is a single gen_server `call`; the SQLite path
|
||||
/// builds a one-shot reply channel and routes to the writer or a reader.
|
||||
pub fn call(&self, req: StoreReq) -> Result<Reply, ()> {
|
||||
match self {
|
||||
StoreHandle::Memory(r) => r.call(req).map_err(|_| ()),
|
||||
StoreHandle::Sqlite { reads, writes } => {
|
||||
let (tx, rx) = channel::<Reply>();
|
||||
// Map each backend's distinct SendError<T> to () so the arms
|
||||
// unify, then `?` on the common Result<(), ()>.
|
||||
let sent: Result<(), ()> = match req {
|
||||
StoreReq::List => reads.send(ReadReq::List { reply: tx }).map_err(|_| ()),
|
||||
StoreReq::Get { id } => reads.send(ReadReq::Get { id, reply: tx }).map_err(|_| ()),
|
||||
StoreReq::Create { body } => writes.send(WriteReq::Create { body, reply: tx }).map_err(|_| ()),
|
||||
StoreReq::Update { id, body } => writes.send(WriteReq::Update { id, body, reply: tx }).map_err(|_| ()),
|
||||
StoreReq::Delete { id } => writes.send(WriteReq::Delete { id, reply: tx }).map_err(|_| ()),
|
||||
};
|
||||
sent?;
|
||||
rx.recv().map_err(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite store — one writer + N readers.
|
||||
// Memory store — one gen_server, one Vec.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Everything is a `Call`: the HTTP handler needs the reply to build the
|
||||
// response, so there is no fire-and-forget path here (`type Cast = ()`,
|
||||
// unused). One inbox, one hop per request — no forwarder actors, which is the
|
||||
// whole point of the port: the old raw-actor store needed two forwarders to
|
||||
// fake a read/write select over smarm's single mailbox, costing two extra
|
||||
// park/unpark hops per request and contaminating exactly the cost the spin
|
||||
// patch moves.
|
||||
|
||||
pub struct MemStore {
|
||||
users: Vec<User>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
fn new() -> Self {
|
||||
MemStore { users: Vec::with_capacity(1024), next_id: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl GenServer for MemStore {
|
||||
type Call = StoreReq;
|
||||
type Reply = Reply;
|
||||
type Cast = ();
|
||||
type Info = ();
|
||||
|
||||
fn handle_call(&mut self, req: StoreReq) -> Reply {
|
||||
match req {
|
||||
StoreReq::List => {
|
||||
// Cap at 100 per the spec.
|
||||
let snapshot: Vec<&User> = self.users.iter().take(100).collect();
|
||||
let body = serde_json::to_vec(&snapshot).unwrap_or_else(|_| b"[]".to_vec());
|
||||
(200, body)
|
||||
}
|
||||
StoreReq::Get { id } => {
|
||||
match self.users.iter().find(|u| u.id == id) {
|
||||
Some(u) => (200, serde_json::to_vec(u).unwrap()),
|
||||
None => (404, b"{\"error\":\"not found\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
StoreReq::Create { body } => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
let u = User { id: self.next_id, name: nu.name, email: nu.email };
|
||||
self.next_id += 1;
|
||||
self.users.push(u.clone());
|
||||
// Steady-state RPS bench: cap so we don't grow unbounded.
|
||||
if self.users.len() > 100_000 {
|
||||
self.users.drain(..50_000);
|
||||
}
|
||||
(201, serde_json::to_vec(&u).unwrap())
|
||||
}
|
||||
Err(_) => (400, b"{\"error\":\"invalid body\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
StoreReq::Update { id, body } => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => match self.users.iter_mut().find(|u| u.id == id) {
|
||||
Some(u) => {
|
||||
u.name = nu.name;
|
||||
u.email = nu.email;
|
||||
let snap = u.clone();
|
||||
(200, serde_json::to_vec(&snap).unwrap())
|
||||
}
|
||||
None => (404, b"{\"error\":\"not found\"}".to_vec()),
|
||||
},
|
||||
Err(_) => (400, b"{\"error\":\"invalid body\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
StoreReq::Delete { id } => {
|
||||
let before = self.users.len();
|
||||
self.users.retain(|u| u.id != id);
|
||||
if self.users.len() < before {
|
||||
(204, Vec::new())
|
||||
} else {
|
||||
(404, b"{\"error\":\"not found\"}".to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, _req: ()) {}
|
||||
}
|
||||
|
||||
pub fn spawn_memory_store() -> StoreHandle {
|
||||
// gen_server::start (via ServerBuilder) must run inside an actor — the
|
||||
// OnceLock lazy-init in AppState::store() guarantees that, same as before.
|
||||
StoreHandle::Memory(ServerBuilder::new(MemStore::new()).start())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite store — one writer + N readers. (UNCHANGED — not ported.)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Per the spec §5.1:
|
||||
// - One writer actor owns a single rusqlite::Connection (read-write).
|
||||
// - N=4 reader actors share a single `Receiver<ReadReq>`. The MPSC
|
||||
// semantics guarantee each request goes to exactly one reader; the
|
||||
// scheduler picks whichever reader is parked.
|
||||
// - N=4 reader actors each own a private channel; a dispatcher round-robins
|
||||
// reads across them. (gen_server is single-inbox, so porting this would
|
||||
// serialise reads through one actor and kill S4 read parallelism — hence
|
||||
// it stays on raw actors. It is not part of the spinning A/B.)
|
||||
//
|
||||
// All connections open the same database file. WAL is set on the writer's
|
||||
// connection (the journal mode is a database-wide setting, so any
|
||||
// connection setting it sticks). busy_timeout is set on every conn so a
|
||||
// momentary writer hold doesn't EBUSY the readers.
|
||||
//
|
||||
// Schema is created idempotently by the writer on first run.
|
||||
// connection (the journal mode is a database-wide setting). busy_timeout is
|
||||
// set on every conn so a momentary writer hold doesn't EBUSY the readers.
|
||||
|
||||
const SQLITE_READER_COUNT: usize = 4;
|
||||
|
||||
@@ -258,14 +235,7 @@ pub fn spawn_sqlite_store(db_path: &str) -> StoreHandle {
|
||||
// The readers. smarm's channel is MPSC, so we can't share a single
|
||||
// Receiver across N reader actors. Instead: one dispatcher owns the
|
||||
// public `reads_rx`, and round-robins each request into one of N
|
||||
// per-reader private channels. Cost: one extra context switch on
|
||||
// every read. That's the price of fan-out on an MPSC runtime.
|
||||
//
|
||||
// Alternative considered: have handlers know about N senders and
|
||||
// pick one themselves. Rejected: it bakes the pool size into the
|
||||
// handler protocol and gives noisy load balancing under bursty
|
||||
// traffic (any per-handler hashing is worse than a single dispatcher
|
||||
// with strict round-robin).
|
||||
// per-reader private channels.
|
||||
let (reads_tx, reads_rx) = channel::<ReadReq>();
|
||||
let mut per_reader_txs: Vec<Sender<ReadReq>> = Vec::with_capacity(SQLITE_READER_COUNT);
|
||||
for _ in 0..SQLITE_READER_COUNT {
|
||||
@@ -276,7 +246,7 @@ pub fn spawn_sqlite_store(db_path: &str) -> StoreHandle {
|
||||
}
|
||||
smarm::spawn(move || dispatch_reads(reads_rx, per_reader_txs));
|
||||
|
||||
StoreHandle { reads: reads_tx, writes: writes_tx }
|
||||
StoreHandle::Sqlite { reads: reads_tx, writes: writes_tx }
|
||||
}
|
||||
|
||||
fn open_writer(path: &str) -> Connection {
|
||||
@@ -303,9 +273,7 @@ fn open_reader(path: &str) -> Connection {
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
).expect("sqlite: open reader");
|
||||
conn.pragma_update(None, "busy_timeout", 5000_i64).expect("sqlite: busy_timeout");
|
||||
// We deliberately don't set journal_mode here — readers inherit the
|
||||
// DB-wide WAL setting from the writer. Setting it on a read-only
|
||||
// connection would just be a no-op or an error.
|
||||
// Readers inherit the DB-wide WAL setting from the writer.
|
||||
conn
|
||||
}
|
||||
|
||||
@@ -316,8 +284,6 @@ fn sqlite_writer_loop(path: &str, rx: Receiver<WriteReq>) {
|
||||
WriteReq::Create { body, reply } => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
// BEGIN IMMEDIATE so the write lock is taken at
|
||||
// statement start, not lazily.
|
||||
let res = conn.execute(
|
||||
"INSERT INTO users (name, email) VALUES (?1, ?2)",
|
||||
params![nu.name, nu.email],
|
||||
@@ -376,23 +342,17 @@ fn sqlite_writer_loop(path: &str, rx: Receiver<WriteReq>) {
|
||||
}
|
||||
|
||||
fn dispatch_reads(rx: Receiver<ReadReq>, workers: Vec<Sender<ReadReq>>) {
|
||||
// Strict round-robin. A free-list / readiness queue would distribute
|
||||
// better under skew, but it requires bidirectional signalling we
|
||||
// don't have a primitive for. Round-robin is fine here: SQLite reads
|
||||
// are uniform in cost (single-row PK lookup or LIMIT 100), and any
|
||||
// brief reader stall just shifts a request to the next reader on
|
||||
// the next pass.
|
||||
// Strict round-robin. SQLite reads are uniform in cost (single-row PK
|
||||
// lookup or LIMIT 100), so a brief reader stall just shifts a request to
|
||||
// the next reader on the next pass.
|
||||
let n = workers.len();
|
||||
let mut idx = 0usize;
|
||||
while let Ok(req) = rx.recv() {
|
||||
let target = &workers[idx % n];
|
||||
idx = idx.wrapping_add(1);
|
||||
if target.send(req).is_err() {
|
||||
// Reader died; skip it. (smarm restarts panicking actors
|
||||
// under a supervisor, but in this binary the readers are
|
||||
// unsupervised — a panic on one of them would mean a dropped
|
||||
// request now and that worker is permanently silent. Fine
|
||||
// for the bench; for production it'd want a supervisor.)
|
||||
// Reader died; skip it. Unsupervised in this binary — fine for the
|
||||
// bench.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user