1use std::path::PathBuf;
2use std::sync::Arc;
3
4use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router};
5use lora_database::{LoraErrorCode, SnapshotAdmin, SnapshotMeta, WalAdmin};
6
7use super::errors::{lora_error_response, ErrorResponse};
8use super::types::{SnapshotRequest, SnapshotResponse, WalStatusResponse, WalTruncateRequest};
9
10#[derive(Clone)]
17pub struct SnapshotAdminConfig {
18 pub path: PathBuf,
19 pub admin: Arc<dyn SnapshotAdmin>,
20}
21
22#[derive(Clone, Default)]
39pub struct AdminConfig {
40 pub snapshot: Option<SnapshotAdminConfig>,
43 pub wal: Option<Arc<dyn WalAdmin>>,
46}
47
48impl AdminConfig {
49 pub fn snapshot_only(snapshot_path: PathBuf, admin: Arc<dyn SnapshotAdmin>) -> Self {
51 Self {
52 snapshot: Some(SnapshotAdminConfig {
53 path: snapshot_path,
54 admin,
55 }),
56 wal: None,
57 }
58 }
59
60 pub fn wal_only(wal: Arc<dyn WalAdmin>) -> Self {
65 Self {
66 snapshot: None,
67 wal: Some(wal),
68 }
69 }
70
71 pub fn is_empty(&self) -> bool {
74 self.snapshot.is_none() && self.wal.is_none()
75 }
76}
77
78pub(crate) fn build_admin_router(cfg: AdminConfig) -> Router {
79 let mut router = Router::new();
80
81 if let Some(snap) = cfg.snapshot.clone() {
82 let snapshot_router: Router = Router::new()
83 .route("/admin/snapshot/save", post(admin_snapshot_save))
84 .route("/admin/snapshot/load", post(admin_snapshot_load))
85 .with_state(snap);
86 router = router.merge(snapshot_router);
87 }
88
89 if let Some(wal) = cfg.wal.clone() {
90 let wal_state = WalAdminState {
91 default_checkpoint_path: cfg.snapshot.as_ref().map(|s| s.path.clone()),
97 wal,
98 };
99 let wal_router: Router = Router::new()
100 .route("/admin/checkpoint", post(admin_checkpoint))
101 .route("/admin/wal/status", post(admin_wal_status))
102 .route("/admin/wal/truncate", post(admin_wal_truncate))
103 .with_state(wal_state);
104 router = router.merge(wal_router);
105 }
106
107 router
108}
109
110#[derive(Clone)]
112struct WalAdminState {
113 default_checkpoint_path: Option<PathBuf>,
118 wal: Arc<dyn WalAdmin>,
119}
120
121fn resolve_snapshot_path(cfg: &SnapshotAdminConfig, req: Option<&SnapshotRequest>) -> PathBuf {
124 match req.and_then(|r| r.path.as_deref()) {
125 Some(p) if !p.trim().is_empty() => PathBuf::from(p),
126 _ => cfg.path.clone(),
127 }
128}
129
130async fn admin_snapshot_save(
131 State(cfg): State<SnapshotAdminConfig>,
132 body: Option<Json<SnapshotRequest>>,
133) -> impl IntoResponse {
134 let req = body.map(|Json(r)| r);
135 let path = resolve_snapshot_path(&cfg, req.as_ref());
136
137 match cfg.admin.save_snapshot(&path) {
138 Ok(meta) => snapshot_response(meta, path),
139 Err(err) => lora_error_response(err),
140 }
141}
142
143async fn admin_snapshot_load(
144 State(cfg): State<SnapshotAdminConfig>,
145 body: Option<Json<SnapshotRequest>>,
146) -> impl IntoResponse {
147 let req = body.map(|Json(r)| r);
148 let path = resolve_snapshot_path(&cfg, req.as_ref());
149
150 match cfg.admin.load_snapshot(&path) {
151 Ok(meta) => snapshot_response(meta, path),
152 Err(err) => lora_error_response(err),
153 }
154}
155
156fn resolve_checkpoint_path(
157 state: &WalAdminState,
158 req: Option<&SnapshotRequest>,
159) -> Result<PathBuf, &'static str> {
160 match req.and_then(|r| r.path.as_deref()) {
161 Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
162 _ => state
163 .default_checkpoint_path
164 .clone()
165 .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
166 }
167}
168
169async fn admin_checkpoint(
170 State(state): State<WalAdminState>,
171 body: Option<Json<SnapshotRequest>>,
172) -> impl IntoResponse {
173 let req = body.map(|Json(r)| r);
174 let path = match resolve_checkpoint_path(&state, req.as_ref()) {
175 Ok(p) => p,
176 Err(msg) => {
177 return (
178 StatusCode::BAD_REQUEST,
179 Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
180 )
181 .into_response()
182 }
183 };
184
185 match state.wal.checkpoint(&path) {
186 Ok(meta) => snapshot_response(meta, path),
187 Err(err) => lora_error_response(err),
188 }
189}
190
191fn snapshot_response(meta: SnapshotMeta, path: PathBuf) -> axum::response::Response {
192 (
193 StatusCode::OK,
194 Json(SnapshotResponse {
195 format_version: meta.format_version,
196 node_count: meta.node_count as u64,
197 relationship_count: meta.relationship_count as u64,
198 wal_lsn: meta.wal_lsn,
199 path: path.display().to_string(),
200 }),
201 )
202 .into_response()
203}
204
205async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
206 match state.wal.wal_status() {
207 Ok(s) => (
208 StatusCode::OK,
209 Json(WalStatusResponse {
210 durable_lsn: s.durable_lsn,
211 next_lsn: s.next_lsn,
212 active_segment_id: s.active_segment_id,
213 oldest_segment_id: s.oldest_segment_id,
214 bg_failure: s.bg_failure,
215 }),
216 )
217 .into_response(),
218 Err(err) => lora_error_response(err),
219 }
220}
221
222async fn admin_wal_truncate(
223 State(state): State<WalAdminState>,
224 body: Option<Json<WalTruncateRequest>>,
225) -> impl IntoResponse {
226 let fence = match body.and_then(|Json(r)| r.fence_lsn) {
229 Some(lsn) => lsn,
230 None => match state.wal.wal_status() {
231 Ok(s) => s.durable_lsn,
232 Err(err) => return lora_error_response(err),
233 },
234 };
235
236 match state.wal.wal_truncate(fence) {
237 Ok(()) => StatusCode::NO_CONTENT.into_response(),
238 Err(err) => lora_error_response(err),
239 }
240}