- crud is now the demonstrator: app owns smarm runtime + root supervisor,
store actor and urus::endpoint as ordered siblings (store first, so
reverse-order shutdown drains HTTP before stopping the store),
Shutdown::Infinity on the endpoint, shutdown via
rt.handle().request_shutdown(root_sup) from the stdin thread.
Deletes two kludges the old shape forced:
* static OnceLock<Sender> spawn-on-first-use -> a supervised child
that self-registers a typed Name; handlers use smarm::send per
request and turn 'between incarnations' into a 503 instead of
panicking on a dropped store.
* static SHUTTING_DOWN AtomicBool + 250ms recv_timeout poll in the
store loop and in the SSE ticker -> a plain park; the tree stops
both. Smoke-tested live: CRUD round-trips, SSE stream, clean drain
with the stream open, port closed after.
- Other examples stay short and on serve*, updated for the split config
(serve_with(cfg, smarm::Config, pipe) / serve_with_shutdown(..., signal)).
plain_serve's URUS_SCHED_THREADS now builds a smarm::Config.
ws_chat's doc block explains the OnceLock is a serve*-only workaround
and points at crud for the clean shape.
- README: new 'Your Own Supervision Tree' section (endpoint as the real
API), graceful shutdown reframed as the serve*-only path, Config table
loses scheduler_threads and gains name, PubSub rule 1 notes the
supervised-sibling alternative.
111 lib + 50 integration + 2 doc tests green; clippy clean.
345 lines
13 KiB
Rust
345 lines
13 KiB
Rust
//! CRUD example: a tiny user database with JSON persistence.
|
|
//!
|
|
//! Demonstrates urus and the actor model together, in the shape a real
|
|
//! application should use (v0.3):
|
|
//! - The APP owns the smarm runtime and the root supervisor. urus is one
|
|
//! child in that tree — `urus::endpoint(...)` — and the store actor is
|
|
//! an ordered sibling started BEFORE it, so the supervisor's
|
|
//! reverse-order shutdown drains HTTP first and only then stops the
|
|
//! store. No handler can be mid-request against a store that is gone.
|
|
//! - Handlers do NOT take a lock or share mutable state directly. A
|
|
//! single "store" actor owns the data; handlers address it by
|
|
//! registered name and block on a reply channel. Serialization is
|
|
//! structural — one request at a time, no Mutex.
|
|
//! - Both actors are supervised: kill the store (or let it panic) and it
|
|
//! restarts from the JSON file, with the endpoint left alone.
|
|
//! - On every mutating request the store writes the JSON file. Read
|
|
//! requests don't touch disk.
|
|
//!
|
|
//! Endpoints:
|
|
//! GET /users — list
|
|
//! POST /users — create
|
|
//! GET /users/:id — fetch
|
|
//! PUT /users/:id — replace
|
|
//! DELETE /users/:id — delete
|
|
//!
|
|
//! Try it:
|
|
//! cargo run --example crud
|
|
//! curl -s http://localhost:8080/users
|
|
//! curl -s -X POST -d '{"name":"alice","email":"a@x"}' http://localhost:8080/users
|
|
//! curl -s http://localhost:8080/users/1
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use smarm::{channel, ChildSpec, Name, OneForOne, Restart, Sender};
|
|
use urus::{Config, Conn, Next, Pipeline, Router};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Domain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct User {
|
|
id: u64,
|
|
name: String,
|
|
email: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NewUser {
|
|
name: String,
|
|
email: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Store actor protocol
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// One enum per request kind. Each carries a `reply` Sender for the response.
|
|
// Reply types are kept simple: most are JSON byte vectors + HTTP status. The
|
|
// store does the serialisation; the handler just writes the bytes.
|
|
|
|
enum Request {
|
|
List { reply: Sender<(u16, Vec<u8>)> },
|
|
Get { id: u64, reply: Sender<(u16, Vec<u8>)> },
|
|
Create { body: Vec<u8>, reply: Sender<(u16, Vec<u8>)> },
|
|
Update { id: u64, body: Vec<u8>, reply: Sender<(u16, Vec<u8>)> },
|
|
Delete { id: u64, reply: Sender<(u16, Vec<u8>)> },
|
|
}
|
|
|
|
const DB_PATH: &str = "/tmp/urus-crud.json";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Store actor body
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn store_loop() {
|
|
let (tx, rx) = channel::<Request>();
|
|
// Self-registration: the name is bound before the first recv, and it
|
|
// is re-bound automatically on every restart.
|
|
smarm::register(STORE, tx).expect("crud.store name already taken");
|
|
|
|
// Load on start. Missing file = empty store. Corrupt file = panic; we
|
|
// don't auto-rebuild because silently losing data is worse than failing
|
|
// loud.
|
|
let mut users: Vec<User> = match std::fs::read(DB_PATH) {
|
|
Ok(bytes) if !bytes.is_empty() =>
|
|
serde_json::from_slice(&bytes).expect("urus-crud: db file is not valid JSON"),
|
|
_ => Vec::new(),
|
|
};
|
|
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
|
|
|
loop {
|
|
// A plain park. The supervisor stops this actor at shutdown (after
|
|
// the endpoint has drained), so there is nothing to poll for.
|
|
let req = match rx.recv() {
|
|
Ok(r) => r,
|
|
Err(_) => return, // all senders dropped
|
|
};
|
|
match req {
|
|
Request::List { reply } => {
|
|
let body = serde_json::to_vec(&users).unwrap();
|
|
let _ = reply.send((200, body));
|
|
}
|
|
Request::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()));
|
|
}
|
|
}
|
|
}
|
|
Request::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());
|
|
persist(&users);
|
|
let _ = reply.send((201, serde_json::to_vec(&u).unwrap()));
|
|
}
|
|
Err(_) => {
|
|
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
|
}
|
|
}
|
|
}
|
|
Request::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 snapshot = u.clone();
|
|
persist(&users);
|
|
let _ = reply.send((200, serde_json::to_vec(&snapshot).unwrap()));
|
|
}
|
|
None => {
|
|
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
|
}
|
|
},
|
|
Err(_) => {
|
|
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
|
}
|
|
}
|
|
}
|
|
Request::Delete { id, reply } => {
|
|
let before = users.len();
|
|
users.retain(|u| u.id != id);
|
|
if users.len() < before {
|
|
persist(&users);
|
|
let _ = reply.send((204, Vec::new()));
|
|
} else {
|
|
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn persist(users: &[User]) {
|
|
let json = serde_json::to_vec_pretty(users).unwrap();
|
|
// Atomic-ish: write to temp then rename. Avoids half-written files on
|
|
// crash. /tmp is on the same filesystem so rename is atomic.
|
|
let tmp = format!("{DB_PATH}.tmp");
|
|
std::fs::write(&tmp, &json).expect("urus-crud: write tmp failed");
|
|
std::fs::rename(&tmp, DB_PATH).expect("urus-crud: rename failed");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler helpers
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The store is a supervised child that registers its own inbox under a
|
|
// typed name; handlers resolve it per send. That replaces the old
|
|
// `OnceLock<Sender>` spawn-on-first-use trick — which had no supervisor,
|
|
// no restart, and no defined shutdown point — with a plain actor whose
|
|
// lifecycle the tree owns. A restart re-registers the same name, so
|
|
// in-flight handlers heal on their next send.
|
|
|
|
const STORE: Name<Request> = Name::new("crud.store");
|
|
|
|
/// Send to the store and wait for its reply. `Err` only if the store is
|
|
/// between incarnations (restarting); the handler turns that into a 503
|
|
/// rather than pretending.
|
|
fn ask(make: impl FnOnce(Sender<(u16, Vec<u8>)>) -> Request) -> Option<(u16, Vec<u8>)> {
|
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
|
smarm::send(STORE, make(tx)).ok()?;
|
|
rx.recv().ok()
|
|
}
|
|
|
|
fn reply(conn: Conn, r: Option<(u16, Vec<u8>)>) -> Conn {
|
|
match r {
|
|
Some((status, body)) => json(conn, status, body),
|
|
None => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
|
}
|
|
}
|
|
|
|
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
|
conn.put_status(status)
|
|
.put_header("content-type", "application/json")
|
|
.put_body(body)
|
|
}
|
|
|
|
fn parse_id(s: &str) -> Option<u64> {
|
|
s.parse().ok()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// SSE demo: `curl -N localhost:8080/ticker` streams a tick every second
|
|
/// (with `: keep-alive` comments if it ever goes quiet). The producer
|
|
/// exits on SseClosed — client gone, write timeout, or the drain stopping
|
|
/// its connection actor. Nothing to flag: the tree's shutdown reaches it.
|
|
fn ticker(conn: Conn, _next: Next) -> Conn {
|
|
let (conn, events) = conn.sse();
|
|
smarm::spawn(move || {
|
|
let mut n: u64 = 0;
|
|
loop {
|
|
if events.send("tick", &n.to_string()).is_err() {
|
|
return; // SseClosed
|
|
}
|
|
n += 1;
|
|
smarm::sleep(std::time::Duration::from_secs(1));
|
|
}
|
|
});
|
|
conn
|
|
}
|
|
|
|
fn list(conn: Conn, _next: Next) -> Conn {
|
|
let r = ask(|reply| Request::List { reply });
|
|
reply(conn, r)
|
|
}
|
|
|
|
fn create(conn: Conn, _next: Next) -> Conn {
|
|
let body = conn.body.as_bytes().to_vec();
|
|
let r = ask(|reply| Request::Create { body, reply });
|
|
reply(conn, r)
|
|
}
|
|
|
|
fn get_one(conn: Conn, _next: Next) -> Conn {
|
|
let id = match conn.params.get("id").and_then(parse_id) {
|
|
Some(id) => id,
|
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
|
};
|
|
let r = ask(|reply| Request::Get { id, reply });
|
|
reply(conn, r)
|
|
}
|
|
|
|
fn update(conn: Conn, _next: Next) -> Conn {
|
|
let id = match conn.params.get("id").and_then(parse_id) {
|
|
Some(id) => id,
|
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
|
};
|
|
let body = conn.body.as_bytes().to_vec();
|
|
let r = ask(|reply| Request::Update { id, body, reply });
|
|
reply(conn, r)
|
|
}
|
|
|
|
fn delete(conn: Conn, _next: Next) -> Conn {
|
|
let id = match conn.params.get("id").and_then(parse_id) {
|
|
Some(id) => id,
|
|
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
|
};
|
|
let r = ask(|reply| Request::Delete { id, reply });
|
|
reply(conn, r)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal request logger
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn logger(conn: Conn, next: Next) -> Conn {
|
|
let method = conn.method.as_str().to_string();
|
|
let path = conn.path.clone();
|
|
let conn = next.run(conn);
|
|
println!("{method} {path} -> {}", conn.status.unwrap_or(0));
|
|
conn
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn main() {
|
|
let pipeline = Pipeline::new()
|
|
.plug(logger)
|
|
.plug(
|
|
Router::new()
|
|
.get( "/users", list)
|
|
.post( "/users", create)
|
|
.get( "/users/:id", get_one)
|
|
.put( "/users/:id", update)
|
|
.delete("/users/:id", delete)
|
|
.get( "/ticker", ticker),
|
|
);
|
|
|
|
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
|
// Bind here, on this thread: an address-in-use error is a startup
|
|
// failure, not an actor crash. The fds outlive any restart of the
|
|
// endpoint child.
|
|
let endpoint = urus::endpoint(cfg, pipeline).expect("bind 127.0.0.1:8080");
|
|
|
|
println!("urus-crud: DB at {DB_PATH}");
|
|
println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down");
|
|
|
|
let rt = smarm::init(smarm::Config::default());
|
|
let handle = rt.handle();
|
|
let (sup_tx, sup_rx) = std::sync::mpsc::channel();
|
|
|
|
// Graceful shutdown on stdin-Enter: a plain OS thread blocks on
|
|
// read_line and shuts the ROOT SUPERVISOR down. No signal-handling
|
|
// crate needed, and no urus-specific shutdown plumbing — this is
|
|
// exactly what a SIGTERM handler would do.
|
|
std::thread::spawn(move || {
|
|
let sup: smarm::Pid = sup_rx.recv().expect("supervisor pid");
|
|
let mut line = String::new();
|
|
let _ = std::io::stdin().read_line(&mut line);
|
|
println!("urus-crud: shutting down (draining in-flight requests)…");
|
|
handle.request_shutdown(sup);
|
|
});
|
|
|
|
rt.run(move || {
|
|
let sup = smarm::spawn(move || {
|
|
OneForOne::new()
|
|
// Store FIRST: reverse-order shutdown therefore stops it
|
|
// LAST, after the endpoint has finished draining.
|
|
.child(ChildSpec::new(Restart::Permanent, store_loop))
|
|
.child(
|
|
ChildSpec::new(Restart::Permanent, endpoint)
|
|
// The endpoint bounds its own drain with
|
|
// Config::drain_timeout; a shorter supervisor
|
|
// deadline would cut that drain in half.
|
|
.shutdown(smarm::supervisor::Shutdown::Infinity),
|
|
)
|
|
.run()
|
|
});
|
|
let _ = sup_tx.send(sup.pid());
|
|
let _ = sup.join();
|
|
});
|
|
println!("urus-crud: bye");
|
|
}
|