Sidecar: SQLite persistence
store.rs (sqlx/sqlite) makes the data durable and queryable over time. Three
tables: events (the full live stream, append-only), links (account <-> website
user, mirrored from link.ok), profiles (last-known character sheet, cached from
char.profile). The event loop persists every live event before broadcasting it;
pong is dropped as ephemeral chatter.
New read endpoints served from the DB rather than the shard: GET /history
(optionally ?kind=), GET /economy (the money-supply series), GET /link/{account}.
GET /char/serial/{serial} now falls back to the cached profile when the shard is
unreachable, so an already-viewed character still renders during an outage;
link.confirm mirrors a successful link into the store.
Verified end to end: 10 economy.supply snapshots and the rest of the live stream
persisted and served via /history and /economy; the data survived a sidecar
restart (14 events still present, and the shard reconnected to the new sidecar);
and with the shard killed, a cached profile returned at HTTP 200 while an uncached
query failed cleanly at 503.
The sidecar is feature-complete: shard link, WebSocket feed, REST queries, and
persistence all work end-to-end against the live shard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
mod rpc;
|
||||
mod shard;
|
||||
mod store;
|
||||
mod web;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
@@ -13,6 +14,7 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
const SHARD_ADDR: &str = "127.0.0.1:7788";
|
||||
const WEB_ADDR: &str = "127.0.0.1:8080";
|
||||
const DB_PATH: &str = "uo-link.db";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -29,11 +31,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Request/reply correlation for REST queries.
|
||||
let rpc = rpc::Rpc::new();
|
||||
|
||||
// Durable store: event history, economy series, cached profiles, link map.
|
||||
let store = store::Store::open(DB_PATH).await?;
|
||||
|
||||
// Website-facing HTTP server.
|
||||
let web_state = web::AppState {
|
||||
events: bcast_tx.clone(),
|
||||
shard: handle.clone(),
|
||||
rpc: rpc.clone(),
|
||||
store: store.clone(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = web::serve(WEB_ADDR, web_state).await {
|
||||
@@ -42,9 +48,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
|
||||
// Event loop: a line that correlates to a pending REST call is a reply — route it to the
|
||||
// waiting caller and stop. Everything else is a live event: log it and broadcast it.
|
||||
// waiting caller and stop. Everything else is a live event: log it, persist it, broadcast it.
|
||||
let feed_tx = bcast_tx.clone();
|
||||
let route_rpc = rpc.clone();
|
||||
let event_store = store.clone();
|
||||
let mut total: u64 = 0;
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = event_rx.recv().await {
|
||||
@@ -61,6 +68,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
_ => tracing::debug!(kind = %ev.kind, n = total, "{}", ev.value),
|
||||
}
|
||||
|
||||
// Persist, then broadcast. `pong` and `ws.hello` are ephemeral chatter, not history.
|
||||
if ev.kind != "pong" {
|
||||
let t = ev.value.get("t").and_then(|v| v.as_i64()).unwrap_or_else(now_ms);
|
||||
let text = ev.value.to_string();
|
||||
if let Err(e) = event_store.insert_event(t, &ev.kind, &text).await {
|
||||
tracing::warn!(error = %e, "failed to persist event");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = feed_tx.send(ev.value.to_string());
|
||||
}
|
||||
});
|
||||
@@ -83,6 +99,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
|
||||
Reference in New Issue
Block a user