1use axum::{
2 extract::{Extension, Query, State},
3 response::Json as JsonResponse,
4 Json,
5};
6use serde::{Deserialize, Serialize};
7
8use crate::http::{error::HttpError, state::AppState};
9use crate::storage::{
10 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
11};
12
13#[derive(Debug, Serialize, utoipa::ToSchema)]
15pub struct SyncStatusResponse {
16 pub is_running: bool,
17 pub dlq_size: usize,
18 pub unrecovered_dlq_size: usize,
19 pub outbox_pending: usize,
20 pub outbox_retries: u64,
21 pub outbox_dead_lettered: usize,
22 pub outbox_leased: usize,
23 pub outbox_oldest_event_age_ms: u64,
24 pub outbox_write_amplification_bytes: u64,
25 pub outbox_lock_wait_nanos: u64,
26 pub outbox_persist_operations: u64,
27}
28
29#[utoipa::path(
30 get,
31 operation_id = "get_v1_sync_status",
32 path = "/v1/sync/status",
33 tag = "Sync",
34 responses(
35 (status = 200, body = SyncStatusResponse, description = "Sync status"),
36 (status = 500, description = "Internal error")
37 )
38)]
39pub async fn status<
41 S: StorageClient
42 + StorageSchemaContextOps
43 + StorageSyncContextOps
44 + StorageOperationContextOps
45 + Clone
46 + Send
47 + Sync
48 + 'static,
49>(
50 State(state): State<AppState<S>>,
51 Extension(session_id): Extension<i64>,
52) -> Result<JsonResponse<SyncStatusResponse>, HttpError> {
53 super::authz::require_admin_session(&state, session_id)?;
54 let graph_service = state.server.get_graph_service();
55 let sync_api = graph_service.sync_api();
56
57 if let Some(sync_api) = sync_api {
58 let outbox = sync_api.outbox_stats();
59 Ok(JsonResponse(SyncStatusResponse {
60 is_running: sync_api.is_running(),
61 dlq_size: sync_api.get_dlq_size(),
62 unrecovered_dlq_size: sync_api.get_unrecovered_dlq_size(),
63 outbox_pending: outbox.pending,
64 outbox_retries: outbox.retries,
65 outbox_dead_lettered: outbox.dead_lettered,
66 outbox_leased: outbox.leased,
67 outbox_oldest_event_age_ms: outbox.oldest_event_age_ms,
68 outbox_write_amplification_bytes: outbox.write_amplification_bytes,
69 outbox_lock_wait_nanos: outbox.lock_wait_nanos,
70 outbox_persist_operations: outbox.persist_operations,
71 }))
72 } else {
73 Ok(JsonResponse(SyncStatusResponse {
75 is_running: false,
76 dlq_size: 0,
77 unrecovered_dlq_size: 0,
78 outbox_pending: 0,
79 outbox_retries: 0,
80 outbox_dead_lettered: 0,
81 outbox_leased: 0,
82 outbox_oldest_event_age_ms: 0,
83 outbox_write_amplification_bytes: 0,
84 outbox_lock_wait_nanos: 0,
85 outbox_persist_operations: 0,
86 }))
87 }
88}
89
90#[utoipa::path(
91 post,
92 path = "/v1/sync/outbox/retry",
93 tag = "Sync",
94 responses(
95 (status = 200, body = serde_json::Value, description = "Outbox delivery retried"),
96 (status = 500, description = "Internal error")
97 )
98)]
99pub async fn retry_outbox<
101 S: StorageClient
102 + StorageSchemaContextOps
103 + StorageSyncContextOps
104 + StorageOperationContextOps
105 + Clone
106 + Send
107 + Sync
108 + 'static,
109>(
110 State(state): State<AppState<S>>,
111 Extension(session_id): Extension<i64>,
112) -> Result<JsonResponse<serde_json::Value>, HttpError> {
113 super::authz::require_admin_session(&state, session_id)?;
114 let graph_service = state.server.get_graph_service();
115 let sync_api = graph_service
116 .sync_api()
117 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
118 let delivered = sync_api
119 .retry_outbox_projection()
120 .map_err(HttpError::from)?;
121 Ok(JsonResponse(serde_json::json!({
122 "delivered": delivered,
123 "status": "completed",
124 })))
125}
126
127#[utoipa::path(
128 get,
129 path = "/v1/sync/outbox/diagnostics",
130 tag = "Sync",
131 responses(
132 (status = 200, body = serde_json::Value, description = "Outbox diagnostics"),
133 (status = 500, description = "Internal error")
134 )
135)]
136pub async fn diagnostics<
138 S: StorageClient
139 + StorageSchemaContextOps
140 + StorageSyncContextOps
141 + StorageOperationContextOps
142 + Clone
143 + Send
144 + Sync
145 + 'static,
146>(
147 State(state): State<AppState<S>>,
148 Extension(session_id): Extension<i64>,
149) -> Result<JsonResponse<serde_json::Value>, HttpError> {
150 super::authz::require_admin_session(&state, session_id)?;
151 let graph_service = state.server.get_graph_service();
152 let sync_api = graph_service
153 .sync_api()
154 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
155 let diag = sync_api.sync_diagnostics().map_err(HttpError::from)?;
156 Ok(JsonResponse(serde_json::json!(diag)))
157}
158
159#[derive(Debug, Deserialize, utoipa::ToSchema)]
160pub struct DeadLetterQuery {
161 pub target: Option<String>,
162 pub index_id: Option<u64>,
163 pub generation: Option<u64>,
164 pub limit: Option<usize>,
165 pub offset: Option<usize>,
166}
167
168#[utoipa::path(
169 get,
170 path = "/v1/sync/outbox/dead_letters",
171 tag = "Sync",
172 params(
173 ("target" = Option<String>, Query, description = "Sync target filter"),
174 ("index_id" = Option<u64>, Query, description = "Index id filter"),
175 ("generation" = Option<u64>, Query, description = "Generation filter"),
176 ("limit" = Option<usize>, Query, description = "Maximum rows returned"),
177 ("offset" = Option<usize>, Query, description = "Rows to skip")
178 ),
179 responses(
180 (status = 200, body = serde_json::Value, description = "Dead letter entries"),
181 (status = 500, description = "Internal error")
182 )
183)]
184pub async fn dead_letters<
185 S: StorageClient
186 + StorageSchemaContextOps
187 + StorageSyncContextOps
188 + StorageOperationContextOps
189 + Clone
190 + Send
191 + Sync
192 + 'static,
193>(
194 State(state): State<AppState<S>>,
195 Extension(session_id): Extension<i64>,
196 Query(params): Query<DeadLetterQuery>,
197) -> Result<JsonResponse<serde_json::Value>, HttpError> {
198 super::authz::require_admin_session(&state, session_id)?;
199 let graph_service = state.server.get_graph_service();
200 let sync_api = graph_service
201 .sync_api()
202 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
203 let target = params
204 .target
205 .as_deref()
206 .map(|s| linkrs_core::types::TargetId::new(s.to_string()).map_err(HttpError::bad_request))
207 .transpose()?;
208 let rows = sync_api
209 .list_dead_letters(
210 target.as_ref(),
211 params.index_id,
212 params.generation,
213 params.limit.unwrap_or(100),
214 params.offset.unwrap_or(0),
215 )
216 .map_err(HttpError::from)?;
217 Ok(JsonResponse(serde_json::json!({ "dead_letters": rows })))
218}
219
220#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
221pub struct RequeueRequest {
222 pub target: Option<String>,
223 pub index_id: Option<u64>,
224 pub generation: Option<u64>,
225 pub limit: Option<usize>,
226 pub event_ids: Option<Vec<i64>>,
227}
228
229#[utoipa::path(
230 post,
231 path = "/v1/sync/outbox/requeue",
232 tag = "Sync",
233 request_body = RequeueRequest,
234 responses(
235 (status = 200, body = serde_json::Value, description = "Dead letters requeued"),
236 (status = 500, description = "Internal error")
237 )
238)]
239pub async fn requeue<
240 S: StorageClient
241 + StorageSchemaContextOps
242 + StorageSyncContextOps
243 + StorageOperationContextOps
244 + Clone
245 + Send
246 + Sync
247 + 'static,
248>(
249 State(state): State<AppState<S>>,
250 Extension(session_id): Extension<i64>,
251 Json(payload): Json<RequeueRequest>,
252) -> Result<JsonResponse<serde_json::Value>, HttpError> {
253 super::authz::require_admin_session(&state, session_id)?;
254 let graph_service = state.server.get_graph_service();
255 let sync_api = graph_service
256 .sync_api()
257 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
258 if let Some(ids) = payload.event_ids {
259 let mut requeued = 0usize;
260 for id in ids {
261 if sync_api.requeue_dead_letter(id).map_err(HttpError::from)? {
262 requeued += 1;
263 }
264 }
265 return Ok(JsonResponse(serde_json::json!({ "requeued": requeued })));
266 }
267 let target = payload
268 .target
269 .as_deref()
270 .map(|s| linkrs_core::types::TargetId::new(s.to_string()).map_err(HttpError::bad_request))
271 .transpose()?;
272 let requeued = sync_api
273 .requeue_dead_letters_batch(
274 target.as_ref(),
275 payload.index_id,
276 payload.generation,
277 payload.limit.unwrap_or(100),
278 )
279 .map_err(HttpError::from)?;
280 Ok(JsonResponse(serde_json::json!({ "requeued": requeued })))
281}
282
283#[derive(Debug, Deserialize, utoipa::ToSchema)]
284pub struct DegradedQuery {
285 pub target: Option<String>,
286 pub index_id: Option<u64>,
287 pub generation: Option<u64>,
288}
289
290#[utoipa::path(
291 get,
292 path = "/v1/sync/outbox/degraded_ranges",
293 tag = "Sync",
294 params(
295 ("target" = Option<String>, Query, description = "Sync target filter"),
296 ("index_id" = Option<u64>, Query, description = "Index id filter"),
297 ("generation" = Option<u64>, Query, description = "Generation filter")
298 ),
299 responses(
300 (status = 200, body = serde_json::Value, description = "Degraded ranges"),
301 (status = 500, description = "Internal error")
302 )
303)]
304pub async fn degraded_ranges<
305 S: StorageClient
306 + StorageSchemaContextOps
307 + StorageSyncContextOps
308 + StorageOperationContextOps
309 + Clone
310 + Send
311 + Sync
312 + 'static,
313>(
314 State(state): State<AppState<S>>,
315 Extension(session_id): Extension<i64>,
316 Query(params): Query<DegradedQuery>,
317) -> Result<JsonResponse<serde_json::Value>, HttpError> {
318 super::authz::require_admin_session(&state, session_id)?;
319 let graph_service = state.server.get_graph_service();
320 let sync_api = graph_service
321 .sync_api()
322 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
323 let target = params
324 .target
325 .as_deref()
326 .map(|s| linkrs_core::types::TargetId::new(s.to_string()).map_err(HttpError::bad_request))
327 .transpose()?;
328 let rows = sync_api
329 .list_degraded_ranges(target.as_ref(), params.index_id, params.generation)
330 .map_err(HttpError::from)?;
331 Ok(JsonResponse(serde_json::json!({ "degraded_ranges": rows })))
332}
333
334#[derive(Debug, Deserialize, utoipa::ToSchema)]
335pub struct ClearDegradedRequest {
336 pub target: String,
337 pub index_id: u64,
338 pub generation: u64,
339 pub start_lsn: u64,
340 pub end_lsn: u64,
341}
342
343#[utoipa::path(
344 post,
345 path = "/v1/sync/outbox/degraded/clear",
346 tag = "Sync",
347 request_body = ClearDegradedRequest,
348 responses(
349 (status = 200, body = serde_json::Value, description = "Degraded range cleared"),
350 (status = 500, description = "Internal error")
351 )
352)]
353pub async fn degraded_clear<
354 S: StorageClient
355 + StorageSchemaContextOps
356 + StorageSyncContextOps
357 + StorageOperationContextOps
358 + Clone
359 + Send
360 + Sync
361 + 'static,
362>(
363 State(state): State<AppState<S>>,
364 Extension(session_id): Extension<i64>,
365 Json(payload): Json<ClearDegradedRequest>,
366) -> Result<JsonResponse<serde_json::Value>, HttpError> {
367 super::authz::require_admin_session(&state, session_id)?;
368 let graph_service = state.server.get_graph_service();
369 let sync_api = graph_service
370 .sync_api()
371 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
372 let target =
373 linkrs_core::types::TargetId::new(payload.target).map_err(HttpError::bad_request)?;
374 let cleared = sync_api
375 .clear_degraded_range(
376 &target,
377 payload.index_id,
378 payload.generation,
379 linkrs_core::types::CommitLsn::new(payload.start_lsn),
380 linkrs_core::types::CommitLsn::new(payload.end_lsn),
381 )
382 .map_err(HttpError::from)?;
383 Ok(JsonResponse(serde_json::json!({ "cleared": cleared })))
384}
385
386#[derive(Debug, Deserialize, utoipa::ToSchema)]
387pub struct RetentionRunRequest {
388 pub grace_lsn_distance: Option<u64>,
389 pub max_age_ms: Option<u64>,
390}
391
392#[utoipa::path(
393 post,
394 path = "/v1/sync/outbox/retention/run",
395 tag = "Sync",
396 request_body = RetentionRunRequest,
397 responses(
398 (status = 200, body = serde_json::Value, description = "Retention pass completed"),
399 (status = 500, description = "Internal error")
400 )
401)]
402pub async fn retention_run<
403 S: StorageClient
404 + StorageSchemaContextOps
405 + StorageSyncContextOps
406 + StorageOperationContextOps
407 + Clone
408 + Send
409 + Sync
410 + 'static,
411>(
412 State(state): State<AppState<S>>,
413 Extension(session_id): Extension<i64>,
414 Json(payload): Json<RetentionRunRequest>,
415) -> Result<JsonResponse<serde_json::Value>, HttpError> {
416 super::authz::require_admin_session(&state, session_id)?;
417 let graph_service = state.server.get_graph_service();
418 let sync_api = graph_service
419 .sync_api()
420 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
421 let grace = payload.grace_lsn_distance.unwrap_or(10_000);
422 let max_age = payload.max_age_ms.unwrap_or(86_400_000 * 30);
423 let (pruned, archived, retention_lsn) = sync_api
424 .run_retention_once(grace, max_age)
425 .map_err(HttpError::from)?;
426 Ok(JsonResponse(serde_json::json!({
427 "pruned": pruned,
428 "archived": archived,
429 "retention_lsn": retention_lsn,
430 })))
431}
432
433#[utoipa::path(
434 get,
435 path = "/v1/sync/outbox/retention/status",
436 tag = "Sync",
437 responses(
438 (status = 200, body = serde_json::Value, description = "Retention watermark"),
439 (status = 500, description = "Internal error")
440 )
441)]
442pub async fn retention_status<
443 S: StorageClient
444 + StorageSchemaContextOps
445 + StorageSyncContextOps
446 + StorageOperationContextOps
447 + Clone
448 + Send
449 + Sync
450 + 'static,
451>(
452 State(state): State<AppState<S>>,
453 Extension(session_id): Extension<i64>,
454) -> Result<JsonResponse<serde_json::Value>, HttpError> {
455 super::authz::require_admin_session(&state, session_id)?;
456 let graph_service = state.server.get_graph_service();
457 let sync_api = graph_service
458 .sync_api()
459 .ok_or_else(|| HttpError::bad_request("Synchronization is not configured"))?;
460 let retention = sync_api.retention_lsn().map_err(HttpError::from)?;
461 Ok(JsonResponse(
462 serde_json::json!({ "retention_lsn": retention.get() }),
463 ))
464}