Skip to main content

lora_server/
app.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::Result;
5use axum::{
6    extract::State,
7    http::StatusCode,
8    response::{IntoResponse, Response},
9    routing::{get, post},
10    Json, Router,
11};
12use lora_database::{
13    ExecuteOptions, LoraError, LoraErrorCode, QueryRunner, ResultFormat, SnapshotAdmin, WalAdmin,
14};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Deserialize)]
18pub struct QueryRequest {
19    pub query: String,
20    #[serde(default)]
21    pub format: Option<QueryFormat>,
22}
23
24#[derive(Debug, Clone, Copy, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub enum QueryFormat {
27    Rows,
28    RowArrays,
29    Graph,
30    Combined,
31}
32
33impl From<QueryFormat> for ResultFormat {
34    fn from(value: QueryFormat) -> Self {
35        match value {
36            QueryFormat::Rows => ResultFormat::Rows,
37            QueryFormat::RowArrays => ResultFormat::RowArrays,
38            QueryFormat::Graph => ResultFormat::Graph,
39            QueryFormat::Combined => ResultFormat::Combined,
40        }
41    }
42}
43
44/// Structured error body returned by every fallible HTTP endpoint.
45///
46/// Wire shape:
47/// ```json
48/// { "error": { "code": "LORA_PARSE", "message": "...", "category": "client" } }
49/// ```
50///
51/// `code` is a stable wire string from the [`LoraErrorCode`] catalog and
52/// is the field bindings / dashboards / tests should match on. `message`
53/// is human-friendly and may be reworded between releases. `category` is
54/// `"client"` for caller mistakes (4xx) and `"server"` for engine
55/// failures (5xx).
56#[derive(Debug, Serialize)]
57pub struct ErrorResponse {
58    pub error: ErrorBody,
59}
60
61#[derive(Debug, Serialize)]
62pub struct ErrorBody {
63    pub code: &'static str,
64    pub message: String,
65    pub category: &'static str,
66}
67
68impl ErrorResponse {
69    fn from_lora(err: &LoraError) -> Self {
70        Self {
71            error: ErrorBody {
72                code: err.code().as_str(),
73                message: err.message().to_string(),
74                category: err.category().as_str(),
75            },
76        }
77    }
78
79    /// Build an ad-hoc error response for cases that never reach the
80    /// engine (e.g. config-level argument validation in a handler).
81    fn from_parts(code: LoraErrorCode, message: impl Into<String>) -> Self {
82        Self {
83            error: ErrorBody {
84                code: code.as_str(),
85                message: message.into(),
86                category: code.category().as_str(),
87            },
88        }
89    }
90}
91
92/// Map a [`LoraError`] to its HTTP status code.
93///
94/// Server-category errors collapse to 500, with one refinement:
95/// `WalPoisoned` → 503 because the engine cannot accept further writes
96/// until an operator restarts from snapshot + WAL.
97///
98/// Client-category errors collapse to 400, with refinements that match
99/// standard HTTP semantics:
100/// * `Timeout` → 408 (cooperative-deadline expired)
101/// * `NotFound` → 404 (named entity does not exist)
102/// * `InvalidParams` / `InvalidVector` → 422 (well-formed request,
103///   semantically invalid value)
104/// * `ConstraintViolation` → 409 (action conflicts with current state)
105fn status_for(err: &LoraError) -> StatusCode {
106    match err.code() {
107        // Server-category
108        LoraErrorCode::WalPoisoned => StatusCode::SERVICE_UNAVAILABLE,
109        LoraErrorCode::Io
110        | LoraErrorCode::WalCorruption
111        | LoraErrorCode::SnapshotCodec
112        | LoraErrorCode::SnapshotCrypto
113        | LoraErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
114        // Client-category
115        LoraErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
116        LoraErrorCode::NotFound => StatusCode::NOT_FOUND,
117        LoraErrorCode::InvalidParams | LoraErrorCode::InvalidVector => {
118            StatusCode::UNPROCESSABLE_ENTITY
119        }
120        LoraErrorCode::ConstraintViolation => StatusCode::CONFLICT,
121        LoraErrorCode::Parse
122        | LoraErrorCode::Semantic
123        | LoraErrorCode::ReadOnlyViolation
124        | LoraErrorCode::DatabaseName
125        | LoraErrorCode::Config => StatusCode::BAD_REQUEST,
126    }
127}
128
129fn lora_error_response(err: impl Into<LoraError>) -> Response {
130    let lora = err.into();
131    let status = status_for(&lora);
132    (status, Json(ErrorResponse::from_lora(&lora))).into_response()
133}
134
135#[derive(Debug, Serialize)]
136pub struct HealthResponse {
137    pub status: &'static str,
138}
139
140pub fn build_app<R>(db: Arc<R>) -> Router
141where
142    R: QueryRunner,
143{
144    Router::new()
145        .route("/health", get(health))
146        .route("/query", post(query::<R>))
147        .with_state(db)
148}
149
150pub async fn serve<R>(listener: tokio::net::TcpListener, db: Arc<R>) -> Result<()>
151where
152    R: QueryRunner,
153{
154    let app = build_app(db);
155    axum::serve(listener, app).await?;
156    Ok(())
157}
158
159/// Snapshot admin surface. Mounted as a unit so that
160/// `/admin/snapshot/{save,load}` always have a configured default
161/// path: an operator who set `--snapshot-path` is the one paying the
162/// cost of the route's existence, and they reasonably expect the
163/// path to be resolved automatically when no `path` field is sent in
164/// the request body.
165#[derive(Clone)]
166pub struct SnapshotAdminConfig {
167    pub path: PathBuf,
168    pub admin: Arc<dyn SnapshotAdmin>,
169}
170
171/// Configuration for the admin surface. Snapshot and WAL admin are
172/// independent: each set of routes mounts only when its corresponding
173/// field is `Some`.
174///
175/// - `snapshot.is_some()` mounts `POST /admin/snapshot/save` and
176///   `POST /admin/snapshot/load` against the configured path
177///   (the body's optional `path` field overrides per request).
178/// - `wal.is_some()` mounts `POST /admin/wal/status` and
179///   `POST /admin/wal/truncate` unconditionally, plus
180///   `POST /admin/checkpoint` (which uses `snapshot.path` as a default
181///   when present, and otherwise requires `path` in the request body).
182///
183/// The endpoints are intentionally opt-in: exposing them without
184/// authentication on a network-reachable interface is a footgun, so
185/// the caller must explicitly construct an `AdminConfig` and pass it
186/// to the server — there is no implicit default path.
187#[derive(Clone, Default)]
188pub struct AdminConfig {
189    /// Snapshot save/load admin. `None` to disable
190    /// `/admin/snapshot/{save,load}`.
191    pub snapshot: Option<SnapshotAdminConfig>,
192    /// WAL admin. `None` to disable `/admin/wal/*` and
193    /// `/admin/checkpoint`.
194    pub wal: Option<Arc<dyn WalAdmin>>,
195}
196
197impl AdminConfig {
198    /// Construct a snapshot-only admin config (no WAL endpoints).
199    pub fn snapshot_only(snapshot_path: PathBuf, admin: Arc<dyn SnapshotAdmin>) -> Self {
200        Self {
201            snapshot: Some(SnapshotAdminConfig {
202                path: snapshot_path,
203                admin,
204            }),
205            wal: None,
206        }
207    }
208
209    /// Construct a WAL-only admin config (no snapshot endpoints). The
210    /// `/admin/checkpoint` route still mounts but every call needs a
211    /// `path` in the request body since there is no configured
212    /// default.
213    pub fn wal_only(wal: Arc<dyn WalAdmin>) -> Self {
214        Self {
215            snapshot: None,
216            wal: Some(wal),
217        }
218    }
219
220    /// True when neither admin surface is configured. The router
221    /// merge then becomes a no-op and the admin routes don't exist.
222    pub fn is_empty(&self) -> bool {
223        self.snapshot.is_none() && self.wal.is_none()
224    }
225}
226
227/// Same as [`build_app`] but additionally mounts the admin routes when
228/// `admin_config` is `Some`.
229pub fn build_app_with_admin<R>(db: Arc<R>, admin_config: Option<AdminConfig>) -> Router
230where
231    R: QueryRunner,
232{
233    let router = build_app(db);
234    match admin_config {
235        Some(cfg) => router.merge(build_admin_router(cfg)),
236        None => router,
237    }
238}
239
240pub async fn serve_with_admin<R>(
241    listener: tokio::net::TcpListener,
242    db: Arc<R>,
243    admin_config: Option<AdminConfig>,
244) -> Result<()>
245where
246    R: QueryRunner,
247{
248    let app = build_app_with_admin(db, admin_config);
249    axum::serve(listener, app).await?;
250    Ok(())
251}
252
253fn build_admin_router(cfg: AdminConfig) -> Router {
254    let mut router = Router::new();
255
256    if let Some(snap) = cfg.snapshot.clone() {
257        let snapshot_router: Router = Router::new()
258            .route("/admin/snapshot/save", post(admin_snapshot_save))
259            .route("/admin/snapshot/load", post(admin_snapshot_load))
260            .with_state(snap);
261        router = router.merge(snapshot_router);
262    }
263
264    if let Some(wal) = cfg.wal.clone() {
265        let wal_state = WalAdminState {
266            // Reuse the snapshot path as the default checkpoint
267            // target when present so a body-less
268            // `POST /admin/checkpoint` writes to the same file the
269            // snapshot endpoints use. When no snapshot path is
270            // configured, the handler requires `path` in the body.
271            default_checkpoint_path: cfg.snapshot.as_ref().map(|s| s.path.clone()),
272            wal,
273        };
274        let wal_router: Router = Router::new()
275            .route("/admin/checkpoint", post(admin_checkpoint))
276            .route("/admin/wal/status", post(admin_wal_status))
277            .route("/admin/wal/truncate", post(admin_wal_truncate))
278            .with_state(wal_state);
279        router = router.merge(wal_router);
280    }
281
282    router
283}
284
285/// State plumbed into the WAL admin handlers.
286#[derive(Clone)]
287struct WalAdminState {
288    /// Default target for `POST /admin/checkpoint` when the body
289    /// omits `path`. `None` when the operator did not pass
290    /// `--snapshot-path`; in that case the handler returns 400 with
291    /// a hint.
292    default_checkpoint_path: Option<PathBuf>,
293    wal: Arc<dyn WalAdmin>,
294}
295
296/// Request body for `POST /admin/snapshot/{save,load}`. The body is
297/// optional; when it is absent (or an empty JSON object) the server uses
298/// the path configured in `AdminConfig`.
299///
300/// Supplying a `path` override lets an operator snapshot to / restore from
301/// an arbitrary filesystem location in a single request. **Any client that
302/// can reach the admin surface can write to any path the server process
303/// can write to — deploy the admin surface behind authenticated transport
304/// only.** We deliberately do not sandbox the path here; a well-meaning
305/// whitelist would give a false sense of safety without auth.
306#[derive(Debug, Default, Deserialize)]
307#[serde(default)]
308pub struct SnapshotRequest {
309    /// Override the configured snapshot path for this request only.
310    pub path: Option<String>,
311}
312
313#[derive(Debug, Serialize)]
314pub struct SnapshotResponse {
315    #[serde(rename = "formatVersion")]
316    pub format_version: u32,
317    #[serde(rename = "nodeCount")]
318    pub node_count: u64,
319    #[serde(rename = "relationshipCount")]
320    pub relationship_count: u64,
321    #[serde(rename = "walLsn")]
322    pub wal_lsn: Option<u64>,
323    pub path: String,
324}
325
326/// Extract the target path for a snapshot operation: the request-body
327/// override if present, else the configured default.
328fn resolve_snapshot_path(cfg: &SnapshotAdminConfig, req: Option<&SnapshotRequest>) -> PathBuf {
329    match req.and_then(|r| r.path.as_deref()) {
330        Some(p) if !p.trim().is_empty() => PathBuf::from(p),
331        _ => cfg.path.clone(),
332    }
333}
334
335async fn admin_snapshot_save(
336    State(cfg): State<SnapshotAdminConfig>,
337    body: Option<Json<SnapshotRequest>>,
338) -> impl IntoResponse {
339    let req = body.map(|Json(r)| r);
340    let path = resolve_snapshot_path(&cfg, req.as_ref());
341
342    match cfg.admin.save_snapshot(&path) {
343        Ok(meta) => (
344            StatusCode::OK,
345            Json(SnapshotResponse {
346                format_version: meta.format_version,
347                node_count: meta.node_count as u64,
348                relationship_count: meta.relationship_count as u64,
349                wal_lsn: meta.wal_lsn,
350                path: path.display().to_string(),
351            }),
352        )
353            .into_response(),
354        Err(err) => lora_error_response(err),
355    }
356}
357
358async fn admin_snapshot_load(
359    State(cfg): State<SnapshotAdminConfig>,
360    body: Option<Json<SnapshotRequest>>,
361) -> impl IntoResponse {
362    let req = body.map(|Json(r)| r);
363    let path = resolve_snapshot_path(&cfg, req.as_ref());
364
365    match cfg.admin.load_snapshot(&path) {
366        Ok(meta) => (
367            StatusCode::OK,
368            Json(SnapshotResponse {
369                format_version: meta.format_version,
370                node_count: meta.node_count as u64,
371                relationship_count: meta.relationship_count as u64,
372                wal_lsn: meta.wal_lsn,
373                path: path.display().to_string(),
374            }),
375        )
376            .into_response(),
377        Err(err) => lora_error_response(err),
378    }
379}
380
381// ---------------------------------------------------------------------------
382// WAL admin endpoints (mounted only when `AdminConfig.wal` is `Some`).
383// ---------------------------------------------------------------------------
384
385/// Body for `POST /admin/wal/truncate`. Operators supply the LSN past
386/// which sealed segments may be deleted; the WAL truncates everything
387/// at or below that point. Active and tombstone segments are always
388/// retained.
389#[derive(Debug, Default, Deserialize)]
390#[serde(default)]
391pub struct WalTruncateRequest {
392    #[serde(rename = "fenceLsn")]
393    pub fence_lsn: Option<u64>,
394}
395
396#[derive(Debug, Serialize)]
397pub struct WalStatusResponse {
398    #[serde(rename = "durableLsn")]
399    pub durable_lsn: u64,
400    #[serde(rename = "nextLsn")]
401    pub next_lsn: u64,
402    #[serde(rename = "activeSegmentId")]
403    pub active_segment_id: u64,
404    #[serde(rename = "oldestSegmentId")]
405    pub oldest_segment_id: u64,
406    /// Latched fsync error from the bg flusher (only populated under
407    /// `SyncMode::Group`). `None` when healthy.
408    #[serde(rename = "bgFailure")]
409    pub bg_failure: Option<String>,
410}
411
412fn resolve_checkpoint_path(
413    state: &WalAdminState,
414    req: Option<&SnapshotRequest>,
415) -> Result<PathBuf, &'static str> {
416    match req.and_then(|r| r.path.as_deref()) {
417        Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
418        _ => state
419            .default_checkpoint_path
420            .clone()
421            .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
422    }
423}
424
425async fn admin_checkpoint(
426    State(state): State<WalAdminState>,
427    body: Option<Json<SnapshotRequest>>,
428) -> impl IntoResponse {
429    let req = body.map(|Json(r)| r);
430    let path = match resolve_checkpoint_path(&state, req.as_ref()) {
431        Ok(p) => p,
432        Err(msg) => {
433            return (
434                StatusCode::BAD_REQUEST,
435                Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
436            )
437                .into_response()
438        }
439    };
440
441    match state.wal.checkpoint(&path) {
442        Ok(meta) => (
443            StatusCode::OK,
444            Json(SnapshotResponse {
445                format_version: meta.format_version,
446                node_count: meta.node_count as u64,
447                relationship_count: meta.relationship_count as u64,
448                wal_lsn: meta.wal_lsn,
449                path: path.display().to_string(),
450            }),
451        )
452            .into_response(),
453        Err(err) => lora_error_response(err),
454    }
455}
456
457async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
458    match state.wal.wal_status() {
459        Ok(s) => (
460            StatusCode::OK,
461            Json(WalStatusResponse {
462                durable_lsn: s.durable_lsn,
463                next_lsn: s.next_lsn,
464                active_segment_id: s.active_segment_id,
465                oldest_segment_id: s.oldest_segment_id,
466                bg_failure: s.bg_failure,
467            }),
468        )
469            .into_response(),
470        Err(err) => lora_error_response(err),
471    }
472}
473
474async fn admin_wal_truncate(
475    State(state): State<WalAdminState>,
476    body: Option<Json<WalTruncateRequest>>,
477) -> impl IntoResponse {
478    // No body / no fence => truncate up to the WAL's current durable
479    // LSN. That's the natural "drop everything safe to drop" default.
480    let fence = match body.and_then(|Json(r)| r.fence_lsn) {
481        Some(lsn) => lsn,
482        None => match state.wal.wal_status() {
483            Ok(s) => s.durable_lsn,
484            Err(err) => return lora_error_response(err),
485        },
486    };
487
488    match state.wal.wal_truncate(fence) {
489        Ok(()) => StatusCode::NO_CONTENT.into_response(),
490        Err(err) => lora_error_response(err),
491    }
492}
493
494async fn health() -> Json<HealthResponse> {
495    Json(HealthResponse { status: "ok" })
496}
497
498async fn query<R>(State(db): State<Arc<R>>, Json(req): Json<QueryRequest>) -> impl IntoResponse
499where
500    R: QueryRunner,
501{
502    let options = req.format.map(|format| ExecuteOptions {
503        format: format.into(),
504    });
505
506    match db.execute(&req.query, options) {
507        Ok(result) => (StatusCode::OK, Json(result)).into_response(),
508        Err(err) => lora_error_response(err),
509    }
510}