Skip to main content

linkrs_server/http/handlers/
transaction.rs

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)]
27/// Start a transaction
28pub 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)]
116/// Submit the transaction
117pub 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)]
167/// Roll back a transaction
168pub 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/// ---------------------------------------------------------------------------
209/// Savepoint endpoints
210/// ---------------------------------------------------------------------------
211
212#[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)]
235/// Create a savepoint within a transaction
236pub 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        // Capture the storage-staged write boundary before the savepoint so
265        // ROLLBACK TO can rewind rows staged after it.
266        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)]
299/// List all savepoints for a transaction
300pub 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)]
365/// Roll back to savepoint
366pub 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)]
429/// Release (delete) a savepoint
430pub 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)]
485/// List transactions, including transactions waiting for recovery cleanup.
486pub 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)]
530/// Return transaction outcome and resource gauges.
531pub 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)]
580/// Kill a transaction after validating its owner header.
581pub 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)]
619/// Retry outbox projection for a specific transaction's pending intents.
620pub 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}