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:
sandbox-agent
2026-06-14 21:07:52 +00:00
parent 44ee8dcf4e
commit 56e490c205
4 changed files with 558 additions and 253 deletions
+24 -44
View File
@@ -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
View File
@@ -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.
}
}
}