1use std::path::PathBuf;
2use std::sync::Arc;
3
4use axum::{
5 extract::{rejection::JsonRejection, State},
6 http::StatusCode,
7 response::IntoResponse,
8 routing::post,
9 Json, Router,
10};
11use lora_database::{LoraErrorCode, SnapshotAdmin, SnapshotMeta, WalAdmin};
12
13use super::errors::{json_rejection_error, lora_error_response, ErrorResponse};
14use super::types::{SnapshotRequest, SnapshotResponse, WalStatusResponse, WalTruncateRequest};
15
16#[derive(Clone)]
23pub struct SnapshotAdminConfig {
24 pub path: PathBuf,
25 pub admin: Arc<dyn SnapshotAdmin>,
26}
27
28#[derive(Clone, Default)]
45pub struct AdminConfig {
46 pub snapshot: Option<SnapshotAdminConfig>,
49 pub wal: Option<Arc<dyn WalAdmin>>,
52}
53
54impl AdminConfig {
55 pub fn snapshot_only(snapshot_path: PathBuf, admin: Arc<dyn SnapshotAdmin>) -> Self {
57 Self {
58 snapshot: Some(SnapshotAdminConfig {
59 path: snapshot_path,
60 admin,
61 }),
62 wal: None,
63 }
64 }
65
66 pub fn wal_only(wal: Arc<dyn WalAdmin>) -> Self {
71 Self {
72 snapshot: None,
73 wal: Some(wal),
74 }
75 }
76
77 pub fn is_empty(&self) -> bool {
80 self.snapshot.is_none() && self.wal.is_none()
81 }
82}
83
84pub(crate) fn build_admin_router(cfg: AdminConfig) -> Router {
85 let mut router = Router::new();
86
87 if let Some(snap) = cfg.snapshot.clone() {
88 let snapshot_router: Router = Router::new()
89 .route("/admin/snapshot/save", post(admin_snapshot_save))
90 .route("/admin/snapshot/load", post(admin_snapshot_load))
91 .with_state(snap);
92 router = router.merge(snapshot_router);
93 }
94
95 if let Some(wal) = cfg.wal.clone() {
96 let wal_state = WalAdminState {
97 default_checkpoint_path: cfg.snapshot.as_ref().map(|s| s.path.clone()),
103 wal,
104 };
105 let wal_router: Router = Router::new()
106 .route("/admin/checkpoint", post(admin_checkpoint))
107 .route("/admin/wal/status", post(admin_wal_status))
108 .route("/admin/wal/truncate", post(admin_wal_truncate))
109 .with_state(wal_state);
110 router = router.merge(wal_router);
111 }
112
113 router
114}
115
116#[derive(Clone)]
118struct WalAdminState {
119 default_checkpoint_path: Option<PathBuf>,
124 wal: Arc<dyn WalAdmin>,
125}
126
127fn resolve_snapshot_path(cfg: &SnapshotAdminConfig, req: Option<&SnapshotRequest>) -> PathBuf {
130 match req.and_then(|r| r.path.as_deref()) {
131 Some(p) if !p.trim().is_empty() => PathBuf::from(p),
132 _ => cfg.path.clone(),
133 }
134}
135
136async fn admin_snapshot_save(
137 State(cfg): State<SnapshotAdminConfig>,
138 body: Result<Json<SnapshotRequest>, JsonRejection>,
139) -> impl IntoResponse {
140 let req = match parse_optional_json(body) {
141 Ok(req) => req,
142 Err(err) => return lora_error_response(json_rejection_error(err)),
143 };
144 let path = resolve_snapshot_path(&cfg, req.as_ref());
145
146 match cfg.admin.save_snapshot(&path) {
147 Ok(meta) => snapshot_response(meta, path),
148 Err(err) => lora_error_response(err),
149 }
150}
151
152async fn admin_snapshot_load(
153 State(cfg): State<SnapshotAdminConfig>,
154 body: Result<Json<SnapshotRequest>, JsonRejection>,
155) -> impl IntoResponse {
156 let req = match parse_optional_json(body) {
157 Ok(req) => req,
158 Err(err) => return lora_error_response(json_rejection_error(err)),
159 };
160 let path = resolve_snapshot_path(&cfg, req.as_ref());
161
162 match cfg.admin.load_snapshot(&path) {
163 Ok(meta) => snapshot_response(meta, path),
164 Err(err) => lora_error_response(err),
165 }
166}
167
168fn resolve_checkpoint_path(
169 state: &WalAdminState,
170 req: Option<&SnapshotRequest>,
171) -> Result<PathBuf, &'static str> {
172 match req.and_then(|r| r.path.as_deref()) {
173 Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
174 _ => state
175 .default_checkpoint_path
176 .clone()
177 .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
178 }
179}
180
181async fn admin_checkpoint(
182 State(state): State<WalAdminState>,
183 body: Result<Json<SnapshotRequest>, JsonRejection>,
184) -> impl IntoResponse {
185 let req = match parse_optional_json(body) {
186 Ok(req) => req,
187 Err(err) => return lora_error_response(json_rejection_error(err)),
188 };
189 let path = match resolve_checkpoint_path(&state, req.as_ref()) {
190 Ok(p) => p,
191 Err(msg) => {
192 return (
193 StatusCode::BAD_REQUEST,
194 Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
195 )
196 .into_response()
197 }
198 };
199
200 match state.wal.checkpoint(&path) {
201 Ok(meta) => snapshot_response(meta, path),
202 Err(err) => lora_error_response(err),
203 }
204}
205
206fn snapshot_response(meta: SnapshotMeta, path: PathBuf) -> axum::response::Response {
207 (
208 StatusCode::OK,
209 Json(SnapshotResponse {
210 format_version: meta.format_version,
211 node_count: meta.node_count as u64,
212 relationship_count: meta.relationship_count as u64,
213 wal_lsn: meta.wal_lsn,
214 path: path.display().to_string(),
215 }),
216 )
217 .into_response()
218}
219
220async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
221 match state.wal.wal_status() {
222 Ok(s) => (
223 StatusCode::OK,
224 Json(WalStatusResponse {
225 durable_lsn: s.durable_lsn,
226 next_lsn: s.next_lsn,
227 active_segment_id: s.active_segment_id,
228 oldest_segment_id: s.oldest_segment_id,
229 bg_failure: s.bg_failure,
230 }),
231 )
232 .into_response(),
233 Err(err) => lora_error_response(err),
234 }
235}
236
237async fn admin_wal_truncate(
238 State(state): State<WalAdminState>,
239 body: Result<Json<WalTruncateRequest>, JsonRejection>,
240) -> impl IntoResponse {
241 let req = match parse_optional_json(body) {
242 Ok(req) => req,
243 Err(err) => return lora_error_response(json_rejection_error(err)),
244 };
245 let fence = match req.and_then(|r| r.fence_lsn) {
248 Some(lsn) => lsn,
249 None => match state.wal.wal_status() {
250 Ok(s) => s.durable_lsn,
251 Err(err) => return lora_error_response(err),
252 },
253 };
254
255 match state.wal.wal_truncate(fence) {
256 Ok(()) => StatusCode::NO_CONTENT.into_response(),
257 Err(err) => lora_error_response(err),
258 }
259}
260
261fn parse_optional_json<T>(
262 body: Result<Json<T>, JsonRejection>,
263) -> Result<Option<T>, JsonRejection> {
264 match body {
265 Ok(Json(value)) => Ok(Some(value)),
266 Err(JsonRejection::MissingJsonContentType(_)) => Ok(None),
267 Err(err) => Err(err),
268 }
269}