use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TxnOp {
Put {
bucket: Vec<u8>,
key: Vec<u8>,
value: Vec<u8>,
indexes: Vec<(Vec<u8>, Vec<u8>)>,
},
Delete {
bucket: Vec<u8>,
key: Vec<u8>,
},
}
impl TxnOp {
#[must_use]
pub fn bucket(&self) -> &[u8] {
match self {
Self::Put { bucket, .. } | Self::Delete { bucket, .. } => bucket,
}
}
#[must_use]
pub fn key(&self) -> &[u8] {
match self {
Self::Put { key, .. } | Self::Delete { key, .. } => key,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TxnBatch {
pub ops: Vec<TxnOp>,
pub force_abort: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TxnOutcome {
Committed {
operations: usize,
},
Aborted {
reason: String,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TxnStoreError {
#[error("transaction backend: {0}")]
Backend(String),
#[error("empty transaction batch")]
EmptyBatch,
#[error("transaction conflict: {0}")]
Conflict(String),
}
pub trait TransactionalStore: Send + Sync {
fn execute_batch(&self, batch: &TxnBatch) -> Result<TxnOutcome, TxnStoreError>;
}
#[derive(Clone, Debug, Deserialize)]
pub struct HttpTxnRequest {
#[serde(default)]
pub abort: bool,
pub operations: Vec<HttpTxnOp>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum HttpTxnOp {
Put {
bucket: String,
key: String,
value: String,
#[serde(default)]
indexes: Vec<HttpIndexEntry>,
},
Delete {
bucket: String,
key: String,
},
}
#[derive(Clone, Debug, Deserialize)]
pub struct HttpIndexEntry {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct HttpTxnResponse {
pub result: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub operations: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl HttpTxnRequest {
#[must_use]
pub fn into_batch(self) -> TxnBatch {
let ops = self
.operations
.into_iter()
.map(|op| match op {
HttpTxnOp::Put {
bucket,
key,
value,
indexes,
} => {
let index_pairs: Vec<(Vec<u8>, Vec<u8>)> = indexes
.iter()
.map(|i| (i.name.clone().into_bytes(), i.value.clone().into_bytes()))
.collect();
let obj = crate::proto::http::object::HttpObject {
value: value.into_bytes(),
content_type: None,
indexes: indexes
.into_iter()
.map(|i| crate::proto::http::object::HttpIndex {
name: i.name,
value: i.value,
})
.collect(),
links: Vec::new(),
context: Vec::new(),
written_at_unix: crate::server::now_unix(),
};
TxnOp::Put {
bucket: bucket.into_bytes(),
key: key.into_bytes(),
value: obj.to_storage_bytes(),
indexes: index_pairs,
}
}
HttpTxnOp::Delete { bucket, key } => TxnOp::Delete {
bucket: bucket.into_bytes(),
key: key.into_bytes(),
},
})
.collect();
TxnBatch {
ops,
force_abort: self.abort,
}
}
}
impl HttpTxnResponse {
#[must_use]
pub fn from_outcome(outcome: &TxnOutcome) -> Self {
match outcome {
TxnOutcome::Committed { operations } => Self {
result: "committed".to_string(),
operations: Some(*operations),
reason: None,
},
TxnOutcome::Aborted { reason } => Self {
result: "aborted".to_string(),
operations: None,
reason: Some(reason.clone()),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_request_lowers_to_batch() {
let json = r#"{
"operations": [
{"op": "put", "bucket": "b", "key": "k", "value": "v",
"indexes": [{"name": "age_int", "value": "42"}]},
{"op": "delete", "bucket": "b", "key": "old"}
]
}"#;
let req: HttpTxnRequest = serde_json::from_str(json).expect("decode");
let batch = req.into_batch();
assert!(!batch.force_abort);
assert_eq!(batch.ops.len(), 2);
match &batch.ops[0] {
TxnOp::Put {
bucket,
key,
value,
indexes,
} => {
assert_eq!(bucket, b"b");
assert_eq!(key, b"k");
assert_eq!(indexes, &vec![(b"age_int".to_vec(), b"42".to_vec())]);
let obj = crate::proto::http::object::HttpObject::from_storage_bytes(value)
.expect("value is an HttpObject envelope");
assert_eq!(obj.value, b"v".to_vec());
assert_eq!(obj.indexes.len(), 1);
assert_eq!(obj.indexes[0].name, "age_int");
assert_eq!(obj.indexes[0].value, "42");
}
TxnOp::Delete { .. } => panic!("expected Put, got Delete"),
}
assert_eq!(
batch.ops[1],
TxnOp::Delete {
bucket: b"b".to_vec(),
key: b"old".to_vec(),
}
);
}
#[test]
fn abort_flag_round_trips() {
let json = r#"{"abort": true, "operations": []}"#;
let req: HttpTxnRequest = serde_json::from_str(json).expect("decode");
let batch = req.into_batch();
assert!(batch.force_abort);
assert!(batch.ops.is_empty());
}
#[test]
fn response_from_outcome_committed() {
let r = HttpTxnResponse::from_outcome(&TxnOutcome::Committed { operations: 3 });
let s = serde_json::to_string(&r).expect("encode");
assert!(s.contains("\"result\":\"committed\""));
assert!(s.contains("\"operations\":3"));
assert!(!s.contains("reason"));
}
#[test]
fn response_from_outcome_aborted() {
let r = HttpTxnResponse::from_outcome(&TxnOutcome::Aborted {
reason: "client requested abort".to_string(),
});
let s = serde_json::to_string(&r).expect("encode");
assert!(s.contains("\"result\":\"aborted\""));
assert!(s.contains("client requested abort"));
assert!(!s.contains("operations"));
}
#[test]
fn txn_op_accessors() {
let put = TxnOp::Put {
bucket: b"bk".to_vec(),
key: b"ky".to_vec(),
value: b"v".to_vec(),
indexes: vec![],
};
assert_eq!(put.bucket(), b"bk");
assert_eq!(put.key(), b"ky");
}
}