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:
@@ -8,18 +8,20 @@ use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::rpc::{Rpc, RpcError};
|
||||
use crate::shard::ShardHandle;
|
||||
use crate::store::Store;
|
||||
|
||||
/// Shared state handed to each request handler.
|
||||
#[derive(Clone)]
|
||||
@@ -27,6 +29,7 @@ pub struct AppState {
|
||||
pub events: broadcast::Sender<String>,
|
||||
pub shard: ShardHandle,
|
||||
pub rpc: Rpc,
|
||||
pub store: Store,
|
||||
}
|
||||
|
||||
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
@@ -40,8 +43,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/vendors/:account", get(vendors))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.route("/link/confirm", post(link_confirm))
|
||||
.route("/link/:account", get(link_lookup))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||
// History, read from SQLite rather than the shard.
|
||||
.route("/history", get(history))
|
||||
.route("/economy", get(economy))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
@@ -97,7 +104,7 @@ async fn char_by_slot(
|
||||
) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"char.request","reqId":req_id,"account":account,"slot":slot});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
char_reply(&st, st.rpc.call(&st.shard, cmd, &req_id).await).await
|
||||
}
|
||||
|
||||
async fn char_by_serial(
|
||||
@@ -105,8 +112,38 @@ async fn char_by_serial(
|
||||
Path(serial): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"char.request","reqId":req_id,"serial":serial});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
let cmd = json!({"kind":"char.request","reqId":req_id,"serial":serial.clone()});
|
||||
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||
|
||||
// Resilience: if the shard is unreachable, serve the last cached profile if we have one.
|
||||
if matches!(result, Err(RpcError::NoShard) | Err(RpcError::Timeout)) {
|
||||
if let Ok(Some(cached)) = st.store.get_cached_profile(&serial).await {
|
||||
return (StatusCode::OK, Json(cached));
|
||||
}
|
||||
}
|
||||
char_reply(&st, result).await
|
||||
}
|
||||
|
||||
/// Caches a successful profile before responding, so a later shard outage can still serve it.
|
||||
async fn char_reply(st: &AppState, result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("char.profile") {
|
||||
if let Some(serial) = value.get("serial").and_then(|s| s.as_str()) {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st
|
||||
.store
|
||||
.cache_profile(
|
||||
serial,
|
||||
value.get("acct").and_then(|a| a.as_str()),
|
||||
value.get("name").and_then(|n| n.as_str()),
|
||||
&value.to_string(),
|
||||
t,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(result)
|
||||
}
|
||||
|
||||
async fn roster(State(st): State<AppState>, Path(account): Path<String>) -> impl IntoResponse {
|
||||
@@ -140,7 +177,35 @@ async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> im
|
||||
|
||||
let cmd = json!({"kind":"link.confirm","code":code,"websiteUserId":web_id});
|
||||
let code = code.to_string();
|
||||
respond(st.rpc.call(&st.shard, cmd, &code).await)
|
||||
let result = st.rpc.call(&st.shard, cmd, &code).await;
|
||||
|
||||
// Mirror a successful link into the store so events can be attributed without the shard.
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("link.ok") {
|
||||
if let Some(account) = value.get("account").and_then(|a| a.as_str()) {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st.store.record_link(account, web_id, t).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(result)
|
||||
}
|
||||
|
||||
async fn link_lookup(State(st): State<AppState>, Path(account): Path<String>) -> impl IntoResponse {
|
||||
match st.store.get_link(&account).await {
|
||||
Ok(Some(web_id)) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({"account": account, "websiteUserId": web_id})),
|
||||
),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"account": account, "linked": false})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body: {"id":"n123","lines":["..."],"durationSec":3600}. Correlated on `id`.
|
||||
@@ -167,6 +232,38 @@ async fn towncrier_remove(
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
// ---- history (from SQLite) ----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HistoryQuery {
|
||||
kind: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// Recent events from the store, newest first. `?kind=vendor.sale&limit=50` to filter.
|
||||
async fn history(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> impl IntoResponse {
|
||||
let limit = q.limit.unwrap_or(100);
|
||||
match st.store.recent(q.kind.as_deref(), limit).await {
|
||||
Ok(events) => (StatusCode::OK, Json(json!({"events": events}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The money-supply series (economy.supply snapshots), newest first.
|
||||
async fn economy(State(st): State<AppState>, Query(q): Query<HistoryQuery>) -> impl IntoResponse {
|
||||
let limit = q.limit.unwrap_or(100);
|
||||
match st.store.economy(limit).await {
|
||||
Ok(series) => (StatusCode::OK, Json(json!({"series": series}))),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- websocket ----
|
||||
|
||||
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
Reference in New Issue
Block a user