use std::convert::Infallible;
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::time::Duration;
use ytsaurus_yson::{YsonNode, YsonValue};
use crate::error::{ClientError, Result};
use crate::http::{Method, Payload};
use crate::retry::{Repeatable, RetryPolicy};
use crate::{Client, yson_build};
pub(crate) const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
const DROP_ABORT_TIMEOUT: Duration = Duration::from_secs(5);
const DETACH_JOIN_TIMEOUT: Duration = Duration::from_secs(5);
pub struct Transaction {
client: Client,
id: String,
done: bool,
keep_alive: Option<KeepAlive>,
origin: Origin,
}
#[derive(Clone, Copy, Debug)]
enum Origin {
Started,
Attached,
}
impl std::fmt::Debug for Transaction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Transaction")
.field("id", &self.id)
.field("done", &self.done)
.finish()
}
}
impl Transaction {
pub(crate) fn start(client: &Client, timeout: Duration) -> Result<Self> {
let millis = i64::try_from(timeout.as_millis()).unwrap_or(i64::MAX);
let params = yson_build::map([("timeout", yson_build::int(millis))]);
let body = client.transport.call(
Method::Post,
"start_transaction",
¶ms,
Payload::None,
Repeatable::WithMutationId,
)?;
let value = client.value_field(&body, "transaction_id")?;
let YsonNode::String(bytes) = &value.node else {
return Err(ClientError::Decode {
command: "start_transaction".to_owned(),
reason: format!("transaction_id is not a string: {:?}", value.node),
});
};
let id = String::from_utf8_lossy(bytes).into_owned();
Ok(Self::held(client, id, timeout, Origin::Started))
}
pub(crate) fn attach(client: &Client, id: String) -> Result<Self> {
let value = client
.get(&format!("#{id}/@timeout"))
.map_err(|error| attach_failed(&id, error))?;
let timeout = attached_timeout(&id, &value)?;
ping(client, &id).map_err(|error| attach_failed(&id, error))?;
Ok(Self::held(client, id, timeout, Origin::Attached))
}
fn held(client: &Client, id: String, timeout: Duration, origin: Origin) -> Self {
let client = client.clone().with_transaction(&id);
let interval = ping_interval(timeout);
let mut ping_client = client.clone();
ping_client.transport.set_retries(RetryPolicy::none());
ping_client
.transport
.set_timeout(ping_request_timeout(interval));
let keep_alive = KeepAlive::spawn(ping_client, id.clone(), interval);
Self {
client,
id,
done: false,
keep_alive,
origin,
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn client(&self) -> &Client {
&self.client
}
pub fn commit(mut self) -> Result<()> {
self.finish("commit_transaction")
}
pub fn abort(mut self) -> Result<()> {
self.finish("abort_transaction")
}
pub fn ping(&self) -> Result<()> {
ping(&self.client, &self.id)
}
#[must_use]
pub fn is_lost(&self) -> bool {
self.keep_alive.as_ref().is_some_and(KeepAlive::lost)
}
#[must_use = "the id is the only way left to reach the transaction"]
pub fn detach(mut self) -> String {
self.done = true;
if let Some(keep_alive) = self.keep_alive.take() {
keep_alive.stop_and_join();
}
self.id.clone()
}
fn finish(&mut self, command: &'static str) -> Result<()> {
if self.done {
self.stop_pinging();
return Ok(());
}
let params = yson_build::map([("transaction_id", yson_build::string(&self.id))]);
let outcome = self.client.transport.call(
Method::Post,
command,
¶ms,
Payload::None,
Repeatable::WithMutationId,
);
self.done = outcome.is_ok() || command == "abort_transaction";
self.stop_pinging();
outcome.map(|_| ())
}
fn stop_pinging(&mut self) {
if let Some(keep_alive) = self.keep_alive.take() {
keep_alive.stop();
}
}
}
impl Deref for Transaction {
type Target = Client;
fn deref(&self) -> &Client {
&self.client
}
}
impl Drop for Transaction {
fn drop(&mut self) {
if self.done {
self.stop_pinging();
return;
}
if matches!(self.origin, Origin::Attached) {
self.stop_pinging();
return;
}
self.client.transport.set_retries(RetryPolicy::none());
self.client.transport.set_timeout(DROP_ABORT_TIMEOUT);
let _ = self.finish("abort_transaction");
}
}
pub(crate) fn ping(client: &Client, id: &str) -> Result<()> {
let params = yson_build::map([("transaction_id", yson_build::string(id))]);
client.transport.call(
Method::Post,
"ping_transaction",
¶ms,
Payload::None,
Repeatable::Freely,
)?;
Ok(())
}
pub(crate) fn commit_by_id(client: &Client, id: &str) -> Result<()> {
let params = yson_build::map([("transaction_id", yson_build::string(id))]);
client.transport.call(
Method::Post,
"commit_transaction",
¶ms,
Payload::None,
Repeatable::WithMutationId,
)?;
Ok(())
}
fn attached_timeout(id: &str, value: &YsonValue) -> Result<Duration> {
let millis = match value.node {
YsonNode::Int64(millis) if millis > 0 => u64::try_from(millis).ok(),
YsonNode::Uint64(millis) if millis > 0 => Some(millis),
_ => None,
};
millis
.map(Duration::from_millis)
.ok_or_else(|| ClientError::Decode {
command: "attach_transaction".to_owned(),
reason: format!(
"#{id}/@timeout is not a positive number of milliseconds: {:?}",
value.node
),
})
}
fn attach_failed(id: &str, error: ClientError) -> ClientError {
match error {
ClientError::Cluster {
code, message, raw, ..
} => ClientError::Cluster {
command: "attach_transaction".to_owned(),
code,
message: format!("cannot attach to transaction {id}: {message}"),
raw,
},
other => other,
}
}
pub(crate) fn abort_by_id(client: &Client, id: &str) -> Result<()> {
let params = yson_build::map([("transaction_id", yson_build::string(id))]);
client.transport.call(
Method::Post,
"abort_transaction",
¶ms,
Payload::None,
Repeatable::Freely,
)?;
Ok(())
}
fn ping_interval(timeout: Duration) -> Duration {
(timeout / 3).max(Duration::from_secs(1))
}
fn ping_request_timeout(interval: Duration) -> Duration {
(interval / 2)
.max(Duration::from_secs(1))
.min(crate::DEFAULT_TIMEOUT)
}
fn transaction_is_gone(error: &ClientError) -> bool {
match error {
ClientError::Cluster { code, raw, .. } => {
*code == 11000
|| raw.contains("No such transaction")
|| raw.contains("has expired or was aborted")
}
_ => false,
}
}
struct KeepAlive {
stop: Arc<(Mutex<bool>, Condvar)>,
lost: Arc<AtomicBool>,
exited: Receiver<Infallible>,
thread: std::thread::JoinHandle<()>,
}
impl KeepAlive {
fn spawn(client: Client, id: String, interval: Duration) -> Option<Self> {
let stop = Arc::new((Mutex::new(false), Condvar::new()));
let signal = Arc::clone(&stop);
let lost = Arc::new(AtomicBool::new(false));
let give_up = Arc::clone(&lost);
let (alive, exited) = std::sync::mpsc::channel::<Infallible>();
std::thread::Builder::new()
.name("yt-transaction-ping".to_owned())
.spawn(move || {
let _alive = alive;
let (lock, wake) = &*signal;
loop {
{
let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
if *guard {
return;
}
let (guard, _) = wake
.wait_timeout(guard, interval)
.unwrap_or_else(PoisonError::into_inner);
if *guard {
return;
}
}
if let Err(error) = ping(&client, &id)
&& transaction_is_gone(&error)
{
give_up.store(true, Ordering::Relaxed);
return;
}
}
})
.ok()
.map(|thread| Self {
stop,
lost,
exited,
thread,
})
}
fn lost(&self) -> bool {
self.lost.load(Ordering::Relaxed)
}
fn stop(self) {
self.raise();
}
fn stop_and_join(self) {
self.raise();
if matches!(
self.exited.recv_timeout(DETACH_JOIN_TIMEOUT),
Err(RecvTimeoutError::Disconnected)
) {
let _ = self.thread.join();
}
}
fn raise(&self) {
let (lock, wake) = &*self.stop;
*lock.lock().unwrap_or_else(PoisonError::into_inner) = true;
wake.notify_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_lost_ping_is_not_a_lost_transaction() {
for seconds in [3, 30, 60, 3600] {
let timeout = Duration::from_secs(seconds);
let interval = ping_interval(timeout);
assert!(
interval * 3 <= timeout,
"{seconds}s timeout pinged every {interval:?}"
);
}
}
#[test]
fn a_timeout_below_the_floor_is_the_callers_business() {
assert_eq!(
ping_interval(Duration::from_millis(50)),
Duration::from_secs(1)
);
}
fn handle_at(proxy: &str, origin: Origin) -> Transaction {
let client = Client::new(proxy).with_retries(crate::RetryPolicy::none());
Transaction {
client: client.with_transaction("1-2-3-4"),
id: "1-2-3-4".to_owned(),
done: false,
keep_alive: None,
origin,
}
}
fn doomed() -> Transaction {
handle_at("http://127.0.0.1:1", Origin::Started)
}
fn watched_proxy() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
let arrived = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = Arc::clone(&arrived);
std::thread::spawn(move || {
for stream in listener.incoming() {
if stream.is_err() {
return;
}
counted.fetch_add(1, Ordering::Relaxed);
}
});
(proxy, arrived)
}
fn connections_reach(
arrived: &Arc<std::sync::atomic::AtomicUsize>,
wanted: usize,
budget: Duration,
) -> bool {
let deadline = std::time::Instant::now() + budget;
while std::time::Instant::now() < deadline {
if arrived.load(Ordering::Relaxed) >= wanted {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
arrived.load(Ordering::Relaxed) >= wanted
}
#[test]
fn a_commit_that_failed_leaves_drop_an_abort_to_send() {
let mut tx = doomed();
assert!(tx.finish("commit_transaction").is_err());
assert!(!tx.done, "a failed commit has not finished the transaction");
assert!(tx.finish("abort_transaction").is_err());
assert!(tx.done);
}
#[test]
fn a_transaction_is_finished_once() {
let mut tx = doomed();
tx.done = true;
assert!(tx.finish("commit_transaction").is_ok());
}
#[test]
fn only_a_definitive_answer_stops_the_pinging() {
let gone_by_code = ClientError::Cluster {
command: "ping_transaction".into(),
code: 11000,
message: "whatever spelling".into(),
raw: "{}".into(),
};
assert!(transaction_is_gone(&gone_by_code));
let gone_by_text = ClientError::Cluster {
command: "ping_transaction".into(),
code: 1,
message: "Error resolving path".into(),
raw: r#"{"inner_errors"=[{"message"="No such transaction 1-2-3-4"}]}"#.into(),
};
assert!(transaction_is_gone(&gone_by_text));
let transient = ClientError::Cluster {
command: "ping_transaction".into(),
code: 1,
message: "master is not ready".into(),
raw: "{}".into(),
};
assert!(!transaction_is_gone(&transient));
assert!(!transaction_is_gone(&ClientError::Config("x".into())));
}
#[test]
fn a_stalled_ping_leaves_room_for_the_next_one() {
for seconds in [3, 30, 3600, 100_000] {
let interval = ping_interval(Duration::from_secs(seconds));
let bound = ping_request_timeout(interval);
assert!(bound * 2 <= interval.max(Duration::from_secs(2)));
assert!(bound <= crate::DEFAULT_TIMEOUT);
}
}
#[test]
fn the_keep_alive_thread_stops_when_asked() {
let client = Client::new("http://127.0.0.1:1").with_retries(crate::RetryPolicy::none());
let keep_alive = KeepAlive::spawn(client, "1-2-3-4".to_owned(), Duration::from_millis(1))
.expect("the thread starts");
let stop = Arc::clone(&keep_alive.stop);
keep_alive.stop();
assert!(
*stop.0.lock().expect("not poisoned"),
"stop() must raise the flag the thread waits on"
);
}
#[test]
fn stop_and_join_waits_for_a_ping_it_caught_in_flight() {
const HELD: Duration = Duration::from_millis(400);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
let (accepted, an_accept) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
accepted.send(()).ok();
std::thread::sleep(HELD);
drop(stream);
}
});
let client = Client::new(&proxy).with_retries(crate::RetryPolicy::none());
let interval = Duration::from_millis(1);
let mut ping_client = client.clone();
ping_client
.transport
.set_timeout(ping_request_timeout(interval));
let keep_alive = KeepAlive::spawn(ping_client, "1-2-3-4".to_owned(), interval)
.expect("the thread starts");
an_accept
.recv_timeout(Duration::from_secs(5))
.expect("a ping reached the proxy");
let waited = std::time::Instant::now();
keep_alive.stop_and_join();
let waited = waited.elapsed();
assert!(
waited >= HELD / 2,
"stop_and_join returned in {waited:?}, so it did not wait out the ping it caught"
);
}
#[test]
fn stop_and_join_gives_up_on_a_ping_that_outlasts_the_bound() {
const PING_BUDGET: Duration = Duration::from_secs(30);
const HEADROOM: Duration = Duration::from_secs(2);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
let (accepted, an_accept) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut stalled = Vec::new();
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
stalled.push(stream);
accepted.send(()).ok();
}
});
let mut ping_client = Client::new(&proxy).with_retries(crate::RetryPolicy::none());
ping_client.transport.set_timeout(PING_BUDGET);
let keep_alive =
KeepAlive::spawn(ping_client, "1-2-3-4".to_owned(), Duration::from_millis(1))
.expect("the thread starts");
an_accept
.recv_timeout(Duration::from_secs(5))
.expect("a ping reached the proxy");
let waited = std::time::Instant::now();
keep_alive.stop_and_join();
let waited = waited.elapsed();
assert!(
waited < DETACH_JOIN_TIMEOUT + HEADROOM,
"stop_and_join waited {waited:?} on a ping with a {PING_BUDGET:?} budget: \
the bound is gone, and detach is back to waiting the ping out"
);
}
#[test]
fn detach_hands_back_the_id_and_disarms_drop() {
let (proxy, arrived) = watched_proxy();
let tx = handle_at(&proxy, Origin::Started);
assert_eq!(tx.detach(), "1-2-3-4");
assert!(
!connections_reach(&arrived, 1, Duration::from_millis(300)),
"detach sent something: a detached transaction must look untouched"
);
}
#[test]
fn a_failed_attach_names_the_id_and_keeps_the_clusters_verdict() {
let from_cluster = ClientError::Cluster {
command: "get".into(),
code: 1,
message: "Unknown cell tag 0".into(),
raw: r#"{"code":1}"#.into(),
};
let rebranded = attach_failed("1-2-3-4", from_cluster);
let ClientError::Cluster {
command,
code,
message,
raw,
} = &rebranded
else {
panic!("the variant must survive: {rebranded:?}");
};
assert_eq!(command, "attach_transaction");
assert_eq!(*code, 1, "the cluster's code is the caller's to branch on");
assert!(message.contains("1-2-3-4"), "{message}");
assert!(message.contains("Unknown cell tag 0"), "{message}");
assert_eq!(raw, r#"{"code":1}"#, "the raw document is evidence");
let transport = attach_failed("1-2-3-4", ClientError::Config("x".into()));
assert!(matches!(transport, ClientError::Config(_)));
}
#[test]
fn only_a_started_handles_drop_reaches_for_the_cluster() {
let (started_proxy, reached_by_started) = watched_proxy();
drop(handle_at(&started_proxy, Origin::Started));
assert!(
connections_reach(&reached_by_started, 1, Duration::from_secs(5)),
"a dropped started handle sent nothing: `?` inside a transaction \
no longer leaves the cluster as it was"
);
let (attached_proxy, reached_by_attached) = watched_proxy();
drop(handle_at(&attached_proxy, Origin::Attached));
assert!(
!connections_reach(&reached_by_attached, 1, Duration::from_millis(300)),
"a dropped attached handle reached for the cluster: an attacher's \
`?` must not destroy the owner's work"
);
}
#[test]
fn a_timeout_attribute_is_read_in_either_integer() {
for node in [YsonNode::Int64(30_000), YsonNode::Uint64(30_000)] {
let value = YsonValue {
attributes: None,
node,
};
assert_eq!(
attached_timeout("1-2-3-4", &value).expect("reads"),
Duration::from_secs(30)
);
}
}
#[test]
fn a_nonsense_timeout_attribute_is_an_error_that_names_it() {
for node in [
YsonNode::Int64(-1),
YsonNode::Int64(0),
YsonNode::Uint64(0),
YsonNode::String(b"30s".to_vec()),
YsonNode::Entity,
] {
let value = YsonValue {
attributes: None,
node: node.clone(),
};
let error = attached_timeout("1-2-3-4", &value)
.expect_err(&format!("{node:?} is not a transaction timeout"));
let ClientError::Decode { command, reason } = &error else {
panic!("wrong variant for {node:?}: {error:?}");
};
assert_eq!(command, "attach_transaction");
assert!(reason.contains("1-2-3-4/@timeout"), "{reason}");
}
}
}