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#[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 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
92fn status_for(err: &LoraError) -> StatusCode {
106 match err.code() {
107 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 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#[derive(Clone)]
166pub struct SnapshotAdminConfig {
167 pub path: PathBuf,
168 pub admin: Arc<dyn SnapshotAdmin>,
169}
170
171#[derive(Clone, Default)]
188pub struct AdminConfig {
189 pub snapshot: Option<SnapshotAdminConfig>,
192 pub wal: Option<Arc<dyn WalAdmin>>,
195}
196
197impl AdminConfig {
198 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 pub fn wal_only(wal: Arc<dyn WalAdmin>) -> Self {
214 Self {
215 snapshot: None,
216 wal: Some(wal),
217 }
218 }
219
220 pub fn is_empty(&self) -> bool {
223 self.snapshot.is_none() && self.wal.is_none()
224 }
225}
226
227pub 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 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#[derive(Clone)]
287struct WalAdminState {
288 default_checkpoint_path: Option<PathBuf>,
293 wal: Arc<dyn WalAdmin>,
294}
295
296#[derive(Debug, Default, Deserialize)]
307#[serde(default)]
308pub struct SnapshotRequest {
309 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
326fn 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#[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 #[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 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}