1use axum::{
2 extract::{Json, Path, State},
3 http::HeaderMap,
4 response::Json as JsonResponse,
5};
6use linkrs_wire::meta::{BeginTransactionRequest, TransactionResponse};
7use serde::{Deserialize, Serialize};
8use tokio::task;
9
10use crate::http::{error::HttpError, state::AppState};
11use crate::storage::{
12 StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
13};
14use linkrs_api::api_core::{SavepointId, TransactionHandle};
15use linkrs_transaction::{DurabilityLevel, IsolationLevel, TransactionOptions};
16
17#[utoipa::path(
18 post,
19 path = "/v1/transactions",
20 tag = "Transaction",
21 request_body = BeginTransactionRequest,
22 responses(
23 (status = 200, body = TransactionResponse, description = "Transaction started"),
24 (status = 500, description = "Internal error")
25 )
26)]
27pub async fn begin<
29 S: StorageClient
30 + StorageSchemaContextOps
31 + StorageSyncContextOps
32 + StorageOperationContextOps
33 + Clone
34 + Send
35 + Sync
36 + 'static,
37>(
38 State(state): State<AppState<S>>,
39 headers: HeaderMap,
40 Json(request): Json<BeginTransactionRequest>,
41) -> Result<JsonResponse<TransactionResponse>, HttpError> {
42 let owner = headers
43 .get("x-transaction-owner")
44 .and_then(|value| value.to_str().ok())
45 .map(str::to_string);
46 let result = task::spawn_blocking(move || {
47 let txn_manager = state.server.get_txn_manager();
48
49 let isolation_level = match request
50 .isolation_level
51 .as_deref()
52 .map(str::to_ascii_lowercase)
53 .as_deref()
54 {
55 None | Some("repeatable_read") | Some("repeatable read") => {
56 IsolationLevel::RepeatableRead
57 }
58 Some("read_committed") | Some("read committed") => IsolationLevel::ReadCommitted,
59 Some("serializable") => IsolationLevel::Serializable,
60 Some(value) => {
61 return Err(HttpError::bad_request(format!(
62 "Unsupported isolation level: {}",
63 value
64 )))
65 }
66 };
67 let options = TransactionOptions {
68 read_only: request.read_only,
69 long_read: false,
70 timeout: request.timeout_seconds.map(std::time::Duration::from_secs),
71 durability: DurabilityLevel::Sync,
72 isolation_level,
73 query_timeout: request
74 .query_timeout_seconds
75 .map(std::time::Duration::from_secs),
76 statement_timeout: request
77 .statement_timeout_seconds
78 .map(std::time::Duration::from_secs),
79 idle_timeout: request
80 .idle_timeout_seconds
81 .map(std::time::Duration::from_secs),
82 };
83
84 let transaction = match owner {
85 Some(owner) => txn_manager.begin_transaction_with_owner(options, owner),
86 None => txn_manager.begin_transaction(options),
87 };
88
89 match transaction {
90 Ok(handle) => Ok::<_, HttpError>(TransactionResponse {
91 transaction_id: handle.as_u64(),
92 status: "Active".to_string(),
93 }),
94 Err(e) => Err(HttpError::transaction_message(format!(
95 "Failed to begin transaction: {}",
96 e
97 ))),
98 }
99 })
100 .await
101 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
102
103 Ok(JsonResponse(result?))
104}
105
106#[utoipa::path(
107 post,
108 path = "/v1/transactions/{id}/commit",
109 tag = "Transaction",
110 params(("id" = u64, Path, description = "Transaction id")),
111 responses(
112 (status = 200, body = serde_json::Value, description = "Transaction committed"),
113 (status = 500, description = "Internal error")
114 )
115)]
116pub async fn commit<
118 S: StorageClient
119 + StorageSchemaContextOps
120 + StorageSyncContextOps
121 + StorageOperationContextOps
122 + Clone
123 + Send
124 + Sync
125 + 'static,
126>(
127 State(state): State<AppState<S>>,
128 Path(txn_id): Path<u64>,
129 headers: HeaderMap,
130) -> Result<JsonResponse<serde_json::Value>, HttpError> {
131 let owner = headers
132 .get("x-transaction-owner")
133 .and_then(|value| value.to_str().ok())
134 .map(str::to_string);
135 let result = task::spawn_blocking(move || {
136 match state
137 .server
138 .get_txn_manager()
139 .commit_transaction_as_owner(txn_id.into(), owner.as_deref())
140 {
141 Ok(()) => Ok::<_, HttpError>(serde_json::json!({
142 "message": "Transaction committed successfully",
143 "transaction_id": txn_id,
144 })),
145 Err(e) => Err(HttpError::transaction_message(format!(
146 "Failed to commit transaction: {}",
147 e
148 ))),
149 }
150 })
151 .await
152 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
153
154 Ok(JsonResponse(result?))
155}
156
157#[utoipa::path(
158 post,
159 path = "/v1/transactions/{id}/rollback",
160 tag = "Transaction",
161 params(("id" = u64, Path, description = "Transaction id")),
162 responses(
163 (status = 200, body = serde_json::Value, description = "Transaction rolled back"),
164 (status = 500, description = "Internal error")
165 )
166)]
167pub async fn rollback<
169 S: StorageClient
170 + StorageSchemaContextOps
171 + StorageSyncContextOps
172 + StorageOperationContextOps
173 + Clone
174 + Send
175 + Sync
176 + 'static,
177>(
178 State(state): State<AppState<S>>,
179 Path(txn_id): Path<u64>,
180 headers: HeaderMap,
181) -> Result<JsonResponse<serde_json::Value>, HttpError> {
182 let owner = headers
183 .get("x-transaction-owner")
184 .and_then(|value| value.to_str().ok())
185 .map(str::to_string);
186 let result = task::spawn_blocking(move || {
187 match state
188 .server
189 .get_txn_manager()
190 .abort_transaction_as_owner(txn_id.into(), owner.as_deref())
191 {
192 Ok(()) => Ok::<_, HttpError>(serde_json::json!({
193 "message": "Transaction rolled back successfully",
194 "transaction_id": txn_id,
195 })),
196 Err(e) => Err(HttpError::transaction_message(format!(
197 "Failed to rollback transaction: {}",
198 e
199 ))),
200 }
201 })
202 .await
203 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
204
205 Ok(JsonResponse(result?))
206}
207
208#[derive(Debug, Deserialize, utoipa::ToSchema)]
213pub struct CreateSavepointRequest {
214 pub name: Option<String>,
215}
216
217#[derive(Debug, Serialize, utoipa::ToSchema)]
218pub struct SavepointResponse {
219 pub savepoint_id: u64,
220 pub transaction_id: u64,
221 pub name: Option<String>,
222}
223
224#[utoipa::path(
225 post,
226 path = "/v1/transactions/{id}/savepoints",
227 tag = "Transaction",
228 params(("id" = u64, Path, description = "Transaction id")),
229 request_body = CreateSavepointRequest,
230 responses(
231 (status = 200, body = SavepointResponse, description = "Savepoint created"),
232 (status = 500, description = "Internal error")
233 )
234)]
235pub async fn create_savepoint<
237 S: StorageClient
238 + StorageSchemaContextOps
239 + StorageSyncContextOps
240 + StorageOperationContextOps
241 + Clone
242 + Send
243 + Sync
244 + 'static,
245>(
246 State(state): State<AppState<S>>,
247 Path(txn_id): Path<u64>,
248 headers: HeaderMap,
249 Json(request): Json<CreateSavepointRequest>,
250) -> Result<JsonResponse<SavepointResponse>, HttpError> {
251 let owner = headers
252 .get("x-transaction-owner")
253 .and_then(|value| value.to_str().ok())
254 .map(str::to_string);
255 let result = task::spawn_blocking(move || {
256 let txn_api = state.server.get_txn_api();
257 let handle = TransactionHandle::from(txn_id);
258
259 state
260 .server
261 .get_txn_manager()
262 .check_transaction_owner(txn_id.into(), owner.as_deref())
263 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
264 let staged_mark = {
267 let storage = state.server.get_storage();
268 let storage_guard = storage.read();
269 linkrs_storage::UndoTarget::staged_write_mark(&*storage_guard, txn_id.into())
270 };
271 match txn_api.create_savepoint(handle, request.name.clone(), staged_mark) {
272 Ok(sp_id) => Ok::<_, HttpError>(SavepointResponse {
273 savepoint_id: sp_id.0,
274 transaction_id: txn_id,
275 name: request.name,
276 }),
277 Err(e) => Err(HttpError::transaction_message(format!(
278 "Failed to create savepoint: {}",
279 e
280 ))),
281 }
282 })
283 .await
284 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
285
286 Ok(JsonResponse(result?))
287}
288
289#[utoipa::path(
290 get,
291 path = "/v1/transactions/{id}/savepoints",
292 tag = "Transaction",
293 params(("id" = u64, Path, description = "Transaction id")),
294 responses(
295 (status = 200, body = Vec<serde_json::Value>, description = "Savepoint list"),
296 (status = 500, description = "Internal error")
297 )
298)]
299pub async fn get_savepoints<
301 S: StorageClient
302 + StorageSchemaContextOps
303 + StorageSyncContextOps
304 + StorageOperationContextOps
305 + Clone
306 + Send
307 + Sync
308 + 'static,
309>(
310 State(state): State<AppState<S>>,
311 Path(txn_id): Path<u64>,
312 headers: HeaderMap,
313) -> Result<JsonResponse<Vec<serde_json::Value>>, HttpError> {
314 let owner = headers
315 .get("x-transaction-owner")
316 .and_then(|value| value.to_str().ok())
317 .map(str::to_string);
318 let result = task::spawn_blocking(move || {
319 let txn_api = state.server.get_txn_api();
320 let handle = TransactionHandle::from(txn_id);
321
322 state
323 .server
324 .get_txn_manager()
325 .check_transaction_owner(txn_id.into(), owner.as_deref())
326 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
327 match txn_api.get_savepoints(handle) {
328 Ok(savepoints) => Ok::<_, HttpError>(
329 savepoints
330 .into_iter()
331 .map(|sp| {
332 serde_json::json!({
333 "id": sp.id,
334 "name": sp.name,
335 "created_at": format!("{:?}", sp.created_at),
336 })
337 })
338 .collect(),
339 ),
340 Err(e) => Err(HttpError::transaction_message(format!(
341 "Failed to list savepoints: {}",
342 e
343 ))),
344 }
345 })
346 .await
347 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
348
349 Ok(JsonResponse(result?))
350}
351
352#[utoipa::path(
353 post,
354 path = "/v1/transactions/{id}/savepoints/{sid}/rollback",
355 tag = "Transaction",
356 params(
357 ("id" = u64, Path, description = "Transaction id"),
358 ("sid" = u64, Path, description = "Savepoint id")
359 ),
360 responses(
361 (status = 200, body = serde_json::Value, description = "Rolled back to savepoint"),
362 (status = 500, description = "Internal error")
363 )
364)]
365pub async fn rollback_to_savepoint<
367 S: StorageClient
368 + StorageSchemaContextOps
369 + StorageSyncContextOps
370 + StorageOperationContextOps
371 + linkrs_storage::UndoTarget
372 + Clone
373 + Send
374 + Sync
375 + 'static,
376>(
377 State(state): State<AppState<S>>,
378 Path((txn_id, savepoint_id)): Path<(u64, u64)>,
379 headers: HeaderMap,
380) -> Result<JsonResponse<serde_json::Value>, HttpError> {
381 let owner = headers
382 .get("x-transaction-owner")
383 .and_then(|value| value.to_str().ok())
384 .map(str::to_string);
385 let result = task::spawn_blocking(move || {
386 let txn_api = state.server.get_txn_api();
387 let handle = TransactionHandle::from(txn_id);
388 let sp_id = SavepointId(savepoint_id);
389 let storage = state.server.get_storage();
390 let storage_guard = storage.read();
391 let storage_ref = &*storage_guard;
392
393 state
394 .server
395 .get_txn_manager()
396 .check_transaction_owner(txn_id.into(), owner.as_deref())
397 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
398 match txn_api.rollback_to_savepoint(handle, sp_id, storage_ref) {
399 Ok(()) => Ok::<_, HttpError>(serde_json::json!({
400 "message": "Rolled back to savepoint successfully",
401 "transaction_id": txn_id,
402 "savepoint_id": savepoint_id,
403 })),
404 Err(e) => Err(HttpError::transaction_message(format!(
405 "Failed to rollback to savepoint: {}",
406 e
407 ))),
408 }
409 })
410 .await
411 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
412
413 Ok(JsonResponse(result?))
414}
415
416#[utoipa::path(
417 delete,
418 path = "/v1/transactions/{id}/savepoints/{sid}",
419 tag = "Transaction",
420 params(
421 ("id" = u64, Path, description = "Transaction id"),
422 ("sid" = u64, Path, description = "Savepoint id")
423 ),
424 responses(
425 (status = 200, body = serde_json::Value, description = "Savepoint released"),
426 (status = 500, description = "Internal error")
427 )
428)]
429pub async fn release_savepoint<
431 S: StorageClient
432 + StorageSchemaContextOps
433 + StorageSyncContextOps
434 + StorageOperationContextOps
435 + Clone
436 + Send
437 + Sync
438 + 'static,
439>(
440 State(state): State<AppState<S>>,
441 Path((txn_id, savepoint_id)): Path<(u64, u64)>,
442 headers: HeaderMap,
443) -> Result<JsonResponse<serde_json::Value>, HttpError> {
444 let owner = headers
445 .get("x-transaction-owner")
446 .and_then(|value| value.to_str().ok())
447 .map(str::to_string);
448 let result = task::spawn_blocking(move || {
449 let txn_api = state.server.get_txn_api();
450 let handle = TransactionHandle::from(txn_id);
451 let sp_id = SavepointId(savepoint_id);
452
453 state
454 .server
455 .get_txn_manager()
456 .check_transaction_owner(txn_id.into(), owner.as_deref())
457 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
458 match txn_api.release_savepoint(handle, sp_id) {
459 Ok(()) => Ok::<_, HttpError>(serde_json::json!({
460 "message": "Savepoint released successfully",
461 "transaction_id": txn_id,
462 "savepoint_id": savepoint_id,
463 })),
464 Err(e) => Err(HttpError::transaction_message(format!(
465 "Failed to release savepoint: {}",
466 e
467 ))),
468 }
469 })
470 .await
471 .map_err(|e| HttpError::InternalError(format!("Task execution failed: {}", e)))?;
472
473 Ok(JsonResponse(result?))
474}
475
476#[utoipa::path(
477 get,
478 path = "/v1/transactions",
479 tag = "Transaction",
480 responses(
481 (status = 200, body = Vec<serde_json::Value>, description = "Active transaction list"),
482 (status = 500, description = "Internal error")
483 )
484)]
485pub async fn list_transactions<
487 S: StorageClient
488 + StorageSchemaContextOps
489 + StorageSyncContextOps
490 + StorageOperationContextOps
491 + Clone
492 + Send
493 + Sync
494 + 'static,
495>(
496 State(state): State<AppState<S>>,
497) -> Result<JsonResponse<Vec<serde_json::Value>>, HttpError> {
498 let manager = state.server.get_txn_manager();
499 let transactions = manager
500 .list_transactions()
501 .into_iter()
502 .map(|info| {
503 serde_json::json!({
504 "transaction_id": info.id.as_u64(),
505 "state": info.state.to_string(),
506 "owner": info.owner,
507 "read_timestamp": info.read_timestamp,
508 "write_timestamp": info.write_timestamp,
509 "elapsed_ms": info.elapsed.as_millis(),
510 "last_activity_ms": info.last_activity.as_millis(),
511 "rollback_only": info.rollback_only,
512 "staged_bytes": info.staged_bytes,
513 "undo_bytes": info.undo_bytes,
514 "blocking_reason": info.blocking_reason,
515 })
516 })
517 .collect();
518 Ok(JsonResponse(transactions))
519}
520
521#[utoipa::path(
522 get,
523 path = "/v1/transactions/metrics",
524 tag = "Transaction",
525 responses(
526 (status = 200, body = serde_json::Value, description = "Transaction outcome and resource gauges"),
527 (status = 500, description = "Internal error")
528 )
529)]
530pub async fn metrics<
532 S: StorageClient
533 + StorageSchemaContextOps
534 + StorageSyncContextOps
535 + StorageOperationContextOps
536 + Clone
537 + Send
538 + Sync
539 + 'static,
540>(
541 State(state): State<AppState<S>>,
542) -> Result<JsonResponse<serde_json::Value>, HttpError> {
543 let manager = state.server.get_txn_manager();
544 let stats = manager.stats();
545 let resources = manager.resource_metrics();
546 Ok(JsonResponse(serde_json::json!({
547 "outcomes": {
548 "begun": stats.total_transactions.load(std::sync::atomic::Ordering::Relaxed),
549 "active": stats.active_transactions.load(std::sync::atomic::Ordering::Relaxed),
550 "committed": stats.committed_transactions.load(std::sync::atomic::Ordering::Relaxed),
551 "aborted": stats.aborted_transactions.load(std::sync::atomic::Ordering::Relaxed),
552 "timeout": stats.timeout_transactions.load(std::sync::atomic::Ordering::Relaxed),
553 "conflict": stats.conflict_transactions.load(std::sync::atomic::Ordering::Relaxed),
554 "disconnect": stats.disconnect_transactions.load(std::sync::atomic::Ordering::Relaxed),
555 "recovery_abort": stats.recovery_abort_transactions.load(std::sync::atomic::Ordering::Relaxed),
556 "cleanup_failure": stats.cleanup_failure_transactions.load(std::sync::atomic::Ordering::Relaxed),
557 },
558 "resources": {
559 "active_statements": stats.active_statements.load(std::sync::atomic::Ordering::Relaxed),
560 "active_snapshots": resources.active_snapshots,
561 "pending_writes": resources.pending_writes,
562 "committed_frontier_lag": resources.committed_frontier_lag,
563 "staged_wal_bytes": resources.staged_wal_bytes,
564 "undo_bytes": resources.undo_bytes,
565 "checkpoint_drain_time_ms": resources.checkpoint_drain_time.as_millis(),
566 },
567 })))
568}
569
570#[utoipa::path(
571 post,
572 path = "/v1/transactions/{id}/kill",
573 tag = "Transaction",
574 params(("id" = u64, Path, description = "Transaction id")),
575 responses(
576 (status = 200, body = serde_json::Value, description = "Transaction killed"),
577 (status = 500, description = "Internal error")
578 )
579)]
580pub async fn kill_transaction<
582 S: StorageClient
583 + StorageSchemaContextOps
584 + StorageSyncContextOps
585 + StorageOperationContextOps
586 + Clone
587 + Send
588 + Sync
589 + 'static,
590>(
591 State(state): State<AppState<S>>,
592 Path(txn_id): Path<u64>,
593 headers: HeaderMap,
594) -> Result<JsonResponse<serde_json::Value>, HttpError> {
595 let owner = headers
596 .get("x-transaction-owner")
597 .and_then(|value| value.to_str().ok());
598 state
599 .server
600 .get_txn_manager()
601 .kill_transaction(txn_id.into(), owner)
602 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
603 Ok(JsonResponse(serde_json::json!({
604 "transaction_id": txn_id,
605 "status": "Aborted",
606 })))
607}
608
609#[utoipa::path(
610 post,
611 path = "/v1/transactions/{id}/outbox/retry",
612 tag = "Transaction",
613 params(("id" = u64, Path, description = "Transaction id")),
614 responses(
615 (status = 200, body = serde_json::Value, description = "Outbox projection retried"),
616 (status = 500, description = "Internal error")
617 )
618)]
619pub async fn retry_transaction_outbox<
621 S: StorageClient
622 + StorageSchemaContextOps
623 + StorageSyncContextOps
624 + StorageOperationContextOps
625 + Clone
626 + Send
627 + Sync
628 + 'static,
629>(
630 State(state): State<AppState<S>>,
631 Path(txn_id): Path<u64>,
632 headers: HeaderMap,
633) -> Result<JsonResponse<serde_json::Value>, HttpError> {
634 let owner = headers
635 .get("x-transaction-owner")
636 .and_then(|value| value.to_str().ok());
637 let manager = state.server.get_txn_manager();
638 manager
639 .check_transaction_owner(txn_id.into(), owner)
640 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
641 let delivered = manager
642 .retry_outbox_projection()
643 .map_err(|error| HttpError::transaction_message(error.to_string()))?;
644 Ok(JsonResponse(serde_json::json!({
645 "transaction_id": txn_id,
646 "delivered": delivered,
647 "status": "completed",
648 })))
649}