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, 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) => (
139 StatusCode::OK,
140 Json(SnapshotResponse {
141 format_version: meta.format_version,
142 node_count: meta.node_count as u64,
143 relationship_count: meta.relationship_count as u64,
144 wal_lsn: meta.wal_lsn,
145 path: path.display().to_string(),
146 }),
147 )
148 .into_response(),
149 Err(err) => lora_error_response(err),
150 }
151}
152
153async fn admin_snapshot_load(
154 State(cfg): State<SnapshotAdminConfig>,
155 body: Option<Json<SnapshotRequest>>,
156) -> impl IntoResponse {
157 let req = body.map(|Json(r)| r);
158 let path = resolve_snapshot_path(&cfg, req.as_ref());
159
160 match cfg.admin.load_snapshot(&path) {
161 Ok(meta) => (
162 StatusCode::OK,
163 Json(SnapshotResponse {
164 format_version: meta.format_version,
165 node_count: meta.node_count as u64,
166 relationship_count: meta.relationship_count as u64,
167 wal_lsn: meta.wal_lsn,
168 path: path.display().to_string(),
169 }),
170 )
171 .into_response(),
172 Err(err) => lora_error_response(err),
173 }
174}
175
176fn resolve_checkpoint_path(
177 state: &WalAdminState,
178 req: Option<&SnapshotRequest>,
179) -> Result<PathBuf, &'static str> {
180 match req.and_then(|r| r.path.as_deref()) {
181 Some(p) if !p.trim().is_empty() => Ok(PathBuf::from(p)),
182 _ => state
183 .default_checkpoint_path
184 .clone()
185 .ok_or("no checkpoint path: pass `path` in the request body or start the server with --snapshot-path"),
186 }
187}
188
189async fn admin_checkpoint(
190 State(state): State<WalAdminState>,
191 body: Option<Json<SnapshotRequest>>,
192) -> impl IntoResponse {
193 let req = body.map(|Json(r)| r);
194 let path = match resolve_checkpoint_path(&state, req.as_ref()) {
195 Ok(p) => p,
196 Err(msg) => {
197 return (
198 StatusCode::BAD_REQUEST,
199 Json(ErrorResponse::from_parts(LoraErrorCode::Config, msg)),
200 )
201 .into_response()
202 }
203 };
204
205 match state.wal.checkpoint(&path) {
206 Ok(meta) => (
207 StatusCode::OK,
208 Json(SnapshotResponse {
209 format_version: meta.format_version,
210 node_count: meta.node_count as u64,
211 relationship_count: meta.relationship_count as u64,
212 wal_lsn: meta.wal_lsn,
213 path: path.display().to_string(),
214 }),
215 )
216 .into_response(),
217 Err(err) => lora_error_response(err),
218 }
219}
220
221async fn admin_wal_status(State(state): State<WalAdminState>) -> impl IntoResponse {
222 match state.wal.wal_status() {
223 Ok(s) => (
224 StatusCode::OK,
225 Json(WalStatusResponse {
226 durable_lsn: s.durable_lsn,
227 next_lsn: s.next_lsn,
228 active_segment_id: s.active_segment_id,
229 oldest_segment_id: s.oldest_segment_id,
230 bg_failure: s.bg_failure,
231 }),
232 )
233 .into_response(),
234 Err(err) => lora_error_response(err),
235 }
236}
237
238async fn admin_wal_truncate(
239 State(state): State<WalAdminState>,
240 body: Option<Json<WalTruncateRequest>>,
241) -> impl IntoResponse {
242 let fence = match body.and_then(|Json(r)| r.fence_lsn) {
245 Some(lsn) => lsn,
246 None => match state.wal.wal_status() {
247 Ok(s) => s.durable_lsn,
248 Err(err) => return lora_error_response(err),
249 },
250 };
251
252 match state.wal.wal_truncate(fence) {
253 Ok(()) => StatusCode::NO_CONTENT.into_response(),
254 Err(err) => lora_error_response(err),
255 }
256}