Skip to main content

ytsaurus_client/
transaction.rs

1//! Transactions: several commands, one all-or-nothing outcome.
2//!
3//! A launcher that creates a table, uploads a worker and runs an operation has
4//! three ways to fail halfway, and each leaves something behind: an empty
5//! table, a stale binary, an output table holding the previous run's rows. A
6//! transaction makes the whole sequence one event — everything appears when it
7//! commits, and nothing does when it does not.
8//!
9//! # Two things the cluster insists on
10//!
11//! **A transaction expires.** The cluster gives it 30 seconds and then aborts
12//! it, unless something says it is still wanted. Verified on a local cluster: a
13//! transaction with a two-second timeout, left alone for four, answers a ping
14//! with `Transaction … has expired or was aborted`. [`Transaction`] therefore
15//! keeps a thread pinging for as long as the handle lives, which is what makes
16//! it usable around an operation that runs for an hour.
17//!
18//! **Nothing outside the transaction can see its work.** That is the point, and
19//! it is also the trap: a `read_table` from a client that is not in the
20//! transaction reads the table as it was before, and a second writer blocks on
21//! the lock the first one took.
22//!
23//! # Handing one to another process
24//!
25//! [`Transaction::detach`] stops the keep-alive and leaves the transaction
26//! running; what remains is the id. [`Client::attach_transaction`] turns an id
27//! back into a handle — pinging again, able to commit or abort — and
28//! [`Client::ping_transaction`], [`Client::commit_transaction`] and
29//! [`Client::abort_transaction`] finish one from a process that holds nothing
30//! but the id. Between the detach and the next ping the transaction is on the
31//! cluster's clock: it expires its timeout after its last ping, 30 seconds by
32//! default.
33//!
34//! What `Drop` does depends on where the handle came from. A **started**
35//! handle aborts on drop — that is what makes `?` safe inside a transaction. An
36//! **attached** one detaches on drop: the attacher walking away must not
37//! destroy what the process that started the transaction is still counting on.
38//! The C++ client's destructor draws the same line.
39
40use std::convert::Infallible;
41use std::ops::Deref;
42use std::sync::atomic::{AtomicBool, Ordering};
43use std::sync::mpsc::{Receiver, RecvTimeoutError};
44use std::sync::{Arc, Condvar, Mutex, PoisonError};
45use std::time::Duration;
46
47use ytsaurus_yson::{YsonNode, YsonValue};
48
49use crate::error::{ClientError, Result};
50use crate::http::{Method, Payload};
51use crate::retry::{Repeatable, RetryPolicy};
52use crate::{Client, yson_build};
53
54/// What the cluster itself defaults to, and what this crate asks for.
55///
56/// Sent explicitly rather than left out, because the ping interval is derived
57/// from it: a client that assumed the wrong default would ping too slowly and
58/// lose the transaction.
59pub(crate) const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(30);
60
61/// How long the abort sent from `Drop` may take.
62///
63/// A destructor — possibly running during a panic unwind — must not hang for
64/// the full retry budget against an unreachable cluster. If the abort is
65/// lost, the transaction expires on its own once nothing pings it.
66const DROP_ABORT_TIMEOUT: Duration = Duration::from_secs(5);
67
68/// How long [`Transaction::detach`] waits for the keep-alive thread.
69///
70/// An unbounded join would be bounded in practice by the ping's own request
71/// budget — [`ping_request_timeout`] — and that is up to
72/// [`crate::DEFAULT_TIMEOUT`], two minutes, for a transaction whose timeout is
73/// an hour. `detach` reads as instant at every call site, so the wait has its
74/// own bound instead: past this, a ping that is still stalled is left to land
75/// on its own.
76///
77/// **When that can happen, and what it costs.** Five seconds covers a ping's
78/// whole budget while the transaction's timeout is under 30 s, and equals it
79/// at the 30 s default — `clamp(interval / 2, 1 s, 120 s)` on an `interval` of
80/// `max(timeout / 3, 1 s)` — so only above the default can a ping outlast the
81/// wait. When one does, it lands up to `min(interval / 2, 120 s)` after the
82/// detach and the transaction then lives a **full timeout from there**, not
83/// one interval. The thread is not leaked: it re-reads the stop flag the
84/// moment its ping ends, so at most one ping is outstanding and it exits
85/// inside that same budget.
86const DETACH_JOIN_TIMEOUT: Duration = Duration::from_secs(5);
87
88/// A transaction, alive for as long as this handle is.
89///
90/// Obtained from [`Client::start_transaction`]. It derefs to a [`Client`] bound
91/// to it, so every command sent through it happens inside the transaction:
92///
93/// ```no_run
94/// # use ytsaurus_client::Client;
95/// # fn main() -> Result<(), ytsaurus_client::ClientError> {
96/// # let client = Client::from_env()?;
97/// # let rows: Vec<u8> = Vec::new();
98/// let tx = client.start_transaction()?;
99///
100/// tx.create("table", "//tmp/out")?;
101/// tx.write_table("//tmp/out", &rows)?;
102///
103/// tx.commit()?;                     // now //tmp/out exists, with its rows
104/// # Ok(())
105/// # }
106/// ```
107///
108/// **Dropping it aborts it.** That is what makes the `?` on those two lines
109/// safe: a failure anywhere returns from the function, the handle drops on the
110/// way out, and the cluster is left as it was. Only [`Transaction::commit`]
111/// publishes anything.
112pub struct Transaction {
113    /// A client bound to this transaction.
114    client: Client,
115    id: String,
116    /// Set by whichever of commit/abort/detach ran, so `Drop` sends nothing.
117    done: bool,
118    keep_alive: Option<KeepAlive>,
119    origin: Origin,
120}
121
122/// How a handle came to hold its transaction, which is what `Drop` turns on.
123#[derive(Clone, Copy, Debug)]
124enum Origin {
125    /// Started by this handle. Dropping it aborts: a `?` inside a transaction
126    /// must leave the cluster as it was.
127    Started,
128    /// Attached to a transaction something else started. Dropping it detaches
129    /// — stops the pinging, sends nothing — because walking away from a
130    /// borrowed transaction must not destroy what its owner is still counting
131    /// on. The C++ client's destructor makes the same distinction.
132    Attached,
133}
134
135impl std::fmt::Debug for Transaction {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("Transaction")
138            .field("id", &self.id)
139            .field("done", &self.done)
140            .finish()
141    }
142}
143
144impl Transaction {
145    pub(crate) fn start(client: &Client, timeout: Duration) -> Result<Self> {
146        let millis = i64::try_from(timeout.as_millis()).unwrap_or(i64::MAX);
147        let params = yson_build::map([("timeout", yson_build::int(millis))]);
148
149        let body = client.transport.call(
150            Method::Post,
151            "start_transaction",
152            &params,
153            Payload::None,
154            // Under a mutation ID, so a retried start cannot leave a
155            // transaction nobody holds a handle to — it would hold its locks
156            // until it expired.
157            //
158            // What that leaves, noted rather than paid for: a start that *was*
159            // retried hands back the transaction the first attempt created,
160            // whose clock started there — so a retry sequence costing more
161            // than the timeout returns a handle whose own first ping is
162            // already late, the same staleness `attach` below pings to close.
163            // It takes a lost answer *and* retries slower than the timeout, so
164            // this is rarer than the handoff `attach` fixes, where the window
165            // was every handoff past two thirds of the timeout; a ping on
166            // every start would spend a round trip on all of them to close it.
167            Repeatable::WithMutationId,
168        )?;
169
170        let value = client.value_field(&body, "transaction_id")?;
171        let YsonNode::String(bytes) = &value.node else {
172            return Err(ClientError::Decode {
173                command: "start_transaction".to_owned(),
174                reason: format!("transaction_id is not a string: {:?}", value.node),
175            });
176        };
177        let id = String::from_utf8_lossy(bytes).into_owned();
178
179        Ok(Self::held(client, id, timeout, Origin::Started))
180    }
181
182    pub(crate) fn attach(client: &Client, id: String) -> Result<Self> {
183        // The transaction's own timeout, read off the object itself: pinging
184        // needs the interval, and the id alone does not carry it. The read is
185        // also what makes attaching to a transaction that is gone fail *here*
186        // rather than later, on the first command sent through the handle.
187        let value = client
188            .get(&format!("#{id}/@timeout"))
189            .map_err(|error| attach_failed(&id, error))?;
190        let timeout = attached_timeout(&id, &value)?;
191
192        // Then one ping, before the handle exists. `@timeout` is the
193        // *configured* lifetime, not the remaining one: the id says nothing
194        // about how long ago somebody last pinged, and the keep-alive thread's
195        // first ping is a whole interval away. A handoff that took longer than
196        // two thirds of the timeout would hand back a handle whose first ping
197        // lands after the cluster has already expired the transaction — the
198        // ping would then be answered `No such transaction`, the thread would
199        // give up, and the loss would surface later on an unrelated command.
200        // Pinging here restarts the clock at the attach and turns a
201        // transaction that is already gone into this call's error, which has a
202        // caller to report it to. A *started* handle needs none of this: its
203        // clock starts at the reply it was born from.
204        //
205        // On the *caller's* client, so under the caller's retry policy — the
206        // same terms as the `get` above, and unlike the keep-alive's own ping
207        // client, which is one attempt on half an interval. That is the right
208        // way round here: this ping has a caller waiting on its verdict and
209        // should not fail over one dropped packet, where a keep-alive ping is
210        // retried by simply being sent again next interval.
211        ping(client, &id).map_err(|error| attach_failed(&id, error))?;
212
213        Ok(Self::held(client, id, timeout, Origin::Attached))
214    }
215
216    /// A handle around `id`, pinging every third of `timeout`.
217    fn held(client: &Client, id: String, timeout: Duration, origin: Origin) -> Self {
218        let client = client.clone().with_transaction(&id);
219        let interval = ping_interval(timeout);
220
221        // The pings go through their own transport configuration: one attempt,
222        // bounded well under the interval. A ping that rode the full retry
223        // pipeline could stall its thread for minutes on one hung connection —
224        // five attempts, two minutes each, backoff between — while the
225        // transaction it was keeping alive quietly expired. A lost ping costs
226        // nothing (the next one is the retry); a late one costs everything.
227        let mut ping_client = client.clone();
228        ping_client.transport.set_retries(RetryPolicy::none());
229        ping_client
230            .transport
231            .set_timeout(ping_request_timeout(interval));
232        let keep_alive = KeepAlive::spawn(ping_client, id.clone(), interval);
233
234        Self {
235            client,
236            id,
237            done: false,
238            keep_alive,
239            origin,
240        }
241    }
242
243    /// The transaction's ID, as the cluster named it.
244    ///
245    /// Worth logging: it is what identifies the transaction in the web UI, and
246    /// what [`Client::with_transaction`] needs to rejoin it from elsewhere.
247    #[must_use]
248    pub fn id(&self) -> &str {
249        &self.id
250    }
251
252    /// The client bound to this transaction.
253    ///
254    /// Rarely needed — [`Transaction`] derefs to it — but a `&Client` is what a
255    /// function taking one wants to be handed.
256    #[must_use]
257    pub fn client(&self) -> &Client {
258        &self.client
259    }
260
261    /// Publishes everything done in the transaction.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`ClientError`] if the commit fails, which leaves the
266    /// transaction aborted and nothing published: the handle is consumed either
267    /// way, and a commit that did not land drops through `Drop`, which sends the
268    /// abort. A failed commit that was left neither committed nor aborted would
269    /// hold its locks until it expired.
270    pub fn commit(mut self) -> Result<()> {
271        self.finish("commit_transaction")
272    }
273
274    /// Discards everything done in the transaction.
275    ///
276    /// The same thing dropping the handle does, for when it should read as a
277    /// decision rather than as a scope ending.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`ClientError`] if the request fails. The transaction expires on
282    /// its own either way, once nothing is pinging it.
283    pub fn abort(mut self) -> Result<()> {
284        self.finish("abort_transaction")
285    }
286
287    /// Tells the cluster the transaction is still wanted.
288    ///
289    /// The handle does this on its own; this is for a process that wants to
290    /// check the transaction is still there — a ping is how the cluster reports
291    /// that it is not.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`ClientError`] if the transaction has expired or was aborted.
296    pub fn ping(&self) -> Result<()> {
297        ping(&self.client, &self.id)
298    }
299
300    /// Whether the keep-alive has given up on this transaction.
301    ///
302    /// The pinging thread stops on its own for exactly one reason: the cluster
303    /// answered a ping with "no such transaction", which is final — the
304    /// transaction expired, or somebody else aborted or committed it. Without
305    /// this the thread's exit is invisible, and a handle that has quietly
306    /// stopped pinging looks exactly like a healthy one until the next command
307    /// fails.
308    ///
309    /// So this is for a holder that keeps a transaction across something long:
310    /// a false answer means only that no ping has been *answered* that way
311    /// yet, which is the strongest thing a handle can say without asking, and
312    /// [`Transaction::ping`] is how to ask.
313    ///
314    /// **False is not "something is pinging".** Two other states read false
315    /// with nothing keeping the transaction alive:
316    ///
317    /// - the thread never started, because the spawn failed. Nothing has been
318    ///   lost and nothing is pinging either, so the transaction runs on the
319    ///   cluster's clock from whenever it was last pinged.
320    /// - the thread panicked. Nothing on the ping path panics as it stands —
321    ///   a poisoned lock is recovered rather than unwrapped — so this is about
322    ///   a future edit to that path rather than about the code today.
323    ///
324    /// Neither is visible from the handle, and a ping does not expose them
325    /// either: it answers for the *transaction*, not for the thread, so it
326    /// goes on succeeding until the transaction actually expires. What they
327    /// have in common is the remedy — ping, or attach afresh.
328    ///
329    /// This is also `&self` while [`Transaction::detach`] consumes the handle,
330    /// so there is nothing left to ask once a transaction has been detached.
331    /// From there the only probe is [`Client::ping_transaction`] on the id.
332    #[must_use]
333    pub fn is_lost(&self) -> bool {
334        self.keep_alive.as_ref().is_some_and(KeepAlive::lost)
335    }
336
337    /// Stops keeping the transaction alive and leaves it running.
338    ///
339    /// The deliberate exception to what `Drop` promises: the transaction
340    /// survives the handle. Nothing is committed, aborted or otherwise decided
341    /// — to the cluster a detached transaction looks exactly like a held one —
342    /// so from here it lives on the cluster's terms: it expires its timeout
343    /// after its last ping, 30 seconds by default, unless something else keeps
344    /// it alive. That something is the point: hand the returned id to another
345    /// process, which re-holds it with [`Client::attach_transaction`] or
346    /// finishes it outright with [`Client::commit_transaction`] or
347    /// [`Client::abort_transaction`].
348    ///
349    /// **The keep-alive is asked to stop and then waited for, for up to five
350    /// seconds.** Inside that bound nothing is left in flight, and the caller
351    /// can kill the process the moment this returns without a stray request
352    /// behind it. What the wait is for, and where it gives up:
353    ///
354    /// - The keep-alive may get *one last ping* away — it can be past its own
355    ///   stop check and about to send when `detach` raises the flag — so the
356    ///   transaction's clock may restart once more, at up to one ping after
357    ///   this was called. That ping is what the wait is for.
358    /// - **Past five seconds the ping is left in flight and this returns
359    ///   anyway**, rather than hold the caller's thread. A ping has a request
360    ///   budget of its own — `min(interval / 2, 120 s)`, on an `interval` of a
361    ///   third of the transaction's timeout — and five seconds covers that
362    ///   whole budget while the timeout is **under 30 seconds**, equalling it
363    ///   at the 30 s default. So at or below the default the wait genuinely
364    ///   ends in the thread's exit. **Above the default it need not**: an
365    ///   hour-long launcher transaction pings on a two-minute budget, and a
366    ///   ping stalled on a proxy that has stopped answering outlasts the wait,
367    ///   reaches the master *after* `detach` returned, and restarts the expiry
368    ///   clock there — the transaction lives a full timeout from wherever that
369    ///   ping landed rather than from this call. Nothing is leaked: the thread
370    ///   re-reads the stop flag as soon as its ping ends, so at most one ping
371    ///   is outstanding and it exits inside that same budget. But it is alive
372    ///   and unreaped past the detach, and a caller whose timeout is above the
373    ///   default cannot treat this call as the transaction's last ping.
374    ///
375    /// What C++ spells `ITransaction::Detach()`. It is also the honest way to
376    /// let a transaction outlive its handle: `mem::forget` on a [`Transaction`]
377    /// leaks the keep-alive thread, which goes on pinging for the life of the
378    /// process and holds the transaction and its locks open indefinitely.
379    #[must_use = "the id is the only way left to reach the transaction"]
380    pub fn detach(mut self) -> String {
381        // `Drop` still runs when this consumes the handle; `done` is what
382        // makes it send nothing.
383        self.done = true;
384        if let Some(keep_alive) = self.keep_alive.take() {
385            keep_alive.stop_and_join();
386        }
387        self.id.clone()
388    }
389
390    fn finish(&mut self, command: &'static str) -> Result<()> {
391        if self.done {
392            self.stop_pinging();
393            return Ok(());
394        }
395
396        let params = yson_build::map([("transaction_id", yson_build::string(&self.id))]);
397        // Sent while the pings are still running. A commit can take longer than
398        // the transaction's own timeout — the request timeout is two minutes,
399        // the default transaction timeout thirty seconds, and the retry loop
400        // adds fifteen more — and a transaction that expires mid-commit is
401        // answered `No such transaction`, discarding work that would have
402        // survived had something kept saying it was wanted.
403        let outcome = self.client.transport.call(
404            Method::Post,
405            command,
406            &params,
407            Payload::None,
408            // A commit that is retried after its answer was lost must not be a
409            // second commit: the cluster refuses that with `No such
410            // transaction`, which reads like the commit failed when it
411            // succeeded. The mutation ID makes the retry the same commit.
412            Repeatable::WithMutationId,
413        );
414
415        // Only a terminal answer ends the transaction. A commit that failed
416        // published nothing and still holds its locks, so `done` stays unset
417        // and `Drop` aborts it on the way out — otherwise the transaction would
418        // be neither committed nor aborted nor pinged, and would sit on its
419        // locks until it expired, which for an hour-long timeout blocks the
420        // next launcher for an hour. An abort that failed is finished either
421        // way: there is nothing left to undo, and repeating it in `Drop` would
422        // only spend the retry budget twice.
423        self.done = outcome.is_ok() || command == "abort_transaction";
424        self.stop_pinging();
425
426        outcome.map(|_| ())
427    }
428
429    /// Asks the keep-alive to stop, and drops it.
430    ///
431    /// Taking the `Option` is what makes it idempotent. Every caller today is
432    /// terminal — `finish` and `Drop` — so nothing reads the handle again, and
433    /// this is worth knowing before that stops being true: dropping the
434    /// keep-alive drops the flag [`Transaction::is_lost`] reads, so a `&mut
435    /// self` method that called this would silently reset a true verdict to
436    /// false. Such a method would have to carry the verdict out first.
437    fn stop_pinging(&mut self) {
438        if let Some(keep_alive) = self.keep_alive.take() {
439            keep_alive.stop();
440        }
441    }
442}
443
444impl Deref for Transaction {
445    type Target = Client;
446
447    fn deref(&self) -> &Client {
448        &self.client
449    }
450}
451
452impl Drop for Transaction {
453    fn drop(&mut self) {
454        if self.done {
455            self.stop_pinging();
456            return;
457        }
458
459        if matches!(self.origin, Origin::Attached) {
460            // An attached handle borrowed the transaction; it does not own the
461            // fate of it. Dropping one detaches — the pings stop, nothing is
462            // sent — and the transaction is back where `detach` left it: alive,
463            // and expiring on the cluster's schedule unless somebody pings it.
464            // Aborting here would let any attacher's `?` destroy work the
465            // process that started the transaction still holds a handle to.
466            self.stop_pinging();
467            return;
468        }
469
470        // Abandoning it would work too — an unpinged transaction expires — but
471        // it would hold its locks until then, and a failed launcher should not
472        // block the next attempt for half a minute. The error is dropped
473        // because a destructor has nowhere to report one, and because the
474        // cluster accepts an abort of a transaction that is already gone.
475        //
476        // One bounded attempt, not the retry pipeline: a destructor that can
477        // block its thread for the full budget — ten minutes against an
478        // unreachable cluster — is worse than a lost abort, which expiry
479        // cleans up anyway. The explicit `abort()` keeps the full retries; it
480        // has a caller to wait for it.
481        self.client.transport.set_retries(RetryPolicy::none());
482        self.client.transport.set_timeout(DROP_ABORT_TIMEOUT);
483        let _ = self.finish("abort_transaction");
484    }
485}
486
487/// Sends one ping.
488pub(crate) fn ping(client: &Client, id: &str) -> Result<()> {
489    let params = yson_build::map([("transaction_id", yson_build::string(id))]);
490    client.transport.call(
491        Method::Post,
492        "ping_transaction",
493        &params,
494        Payload::None,
495        // A ping says "still here"; sending it twice says it twice.
496        Repeatable::Freely,
497    )?;
498    Ok(())
499}
500
501/// Commits a transaction that is held as nothing but an id.
502///
503/// The handle's own [`Transaction::commit`] goes through `finish` instead,
504/// because it also has pings to stop and a `done` flag to keep honest.
505pub(crate) fn commit_by_id(client: &Client, id: &str) -> Result<()> {
506    let params = yson_build::map([("transaction_id", yson_build::string(id))]);
507    client.transport.call(
508        Method::Post,
509        "commit_transaction",
510        &params,
511        Payload::None,
512        // A commit is not idempotent: the second is refused with `No such
513        // transaction`, which reads like the *first* one failed. The mutation
514        // ID makes a retried commit the same commit.
515        Repeatable::WithMutationId,
516    )?;
517    Ok(())
518}
519
520/// `#<id>/@timeout`, as a duration.
521///
522/// **Both integer spellings.** The local cluster answers `{"value"=30000;}` —
523/// text YSON, no `u`, so `Int64` — but a duration in milliseconds is exactly
524/// the kind of field a master could send as `Uint64`, and a `Decode` error on
525/// an attribute the crate can plainly read would be a poor way to find that
526/// out.
527///
528/// Anything else — a negative, a zero, a string — is the attach failing and
529/// naming the attribute. Silently reading it as zero would floor
530/// [`ping_interval`] to one second and leave a 1 Hz pinger running for the
531/// handle's whole life.
532fn attached_timeout(id: &str, value: &YsonValue) -> Result<Duration> {
533    let millis = match value.node {
534        YsonNode::Int64(millis) if millis > 0 => u64::try_from(millis).ok(),
535        YsonNode::Uint64(millis) if millis > 0 => Some(millis),
536        _ => None,
537    };
538
539    millis
540        .map(Duration::from_millis)
541        .ok_or_else(|| ClientError::Decode {
542            command: "attach_transaction".to_owned(),
543            reason: format!(
544                "#{id}/@timeout is not a positive number of milliseconds: {:?}",
545                value.node
546            ),
547        })
548}
549
550/// The timeout read failing is the attach failing, and the error should say
551/// so.
552///
553/// The caller handed over a transaction id, not a `get`, and the cluster's own
554/// answer does not always name what was asked about: an id that was never a
555/// transaction is refused as `cluster error 1: Unknown cell tag 0` — observed
556/// on a local cluster for `1-2-3-4` — which names neither the id nor a
557/// transaction. Only an id whose cell exists earns the resolve error that
558/// does. So the command is rewritten to name the operation and the message to
559/// name the id, and everything else — the code, the raw document — is kept, so
560/// a caller can still branch on what the cluster actually said.
561fn attach_failed(id: &str, error: ClientError) -> ClientError {
562    match error {
563        ClientError::Cluster {
564            code, message, raw, ..
565        } => ClientError::Cluster {
566            command: "attach_transaction".to_owned(),
567            code,
568            message: format!("cannot attach to transaction {id}: {message}"),
569            raw,
570        },
571        other => other,
572    }
573}
574
575/// Aborts a transaction that is held as nothing but an id.
576pub(crate) fn abort_by_id(client: &Client, id: &str) -> Result<()> {
577    let params = yson_build::map([("transaction_id", yson_build::string(id))]);
578    client.transport.call(
579        Method::Post,
580        "abort_transaction",
581        &params,
582        Payload::None,
583        // An abort is forgiving — aborting a transaction that is already gone
584        // answers `{}`, verified on a local cluster — so a repeat is the same
585        // shrug and needs no mutation ID.
586        Repeatable::Freely,
587    )?;
588    Ok(())
589}
590
591/// How often to ping a transaction with this timeout.
592///
593/// A third of it, so a lost ping is not a lost transaction. The floor is for a
594/// caller who asks for a timeout of milliseconds: below three seconds the
595/// pings stop keeping up, which is the right answer — a transaction that short
596/// is one that is meant to expire.
597fn ping_interval(timeout: Duration) -> Duration {
598    (timeout / 3).max(Duration::from_secs(1))
599}
600
601/// How long one ping request may take: half the interval, so a stalled ping
602/// still leaves the next one room inside the transaction's timeout, and never
603/// more than the transport's ordinary two minutes.
604fn ping_request_timeout(interval: Duration) -> Duration {
605    (interval / 2)
606        .max(Duration::from_secs(1))
607        .min(crate::DEFAULT_TIMEOUT)
608}
609
610/// Whether the cluster's answer says the transaction no longer exists.
611///
612/// 11000 is `NoSuchTransaction`; the substring covers the master's other
613/// spelling — `Transaction … has expired or was aborted` — and both are
614/// looked for in the full document, because the outer error is often a
615/// wrapper. Anything else (a transport failure, a busy master) is
616/// indistinguishable from a transaction that is still there, so the pings
617/// continue.
618fn transaction_is_gone(error: &ClientError) -> bool {
619    match error {
620        ClientError::Cluster { code, raw, .. } => {
621            *code == 11000
622                || raw.contains("No such transaction")
623                || raw.contains("has expired or was aborted")
624        }
625        _ => false,
626    }
627}
628
629/// The thread that keeps one transaction alive.
630struct KeepAlive {
631    /// Raised to ask the thread to stop; the condvar wakes it out of its wait.
632    stop: Arc<(Mutex<bool>, Condvar)>,
633    /// Raised by the thread itself when a ping was answered "no such
634    /// transaction" and it gave up. Read through [`Transaction::is_lost`]:
635    /// otherwise the thread's exit is invisible to the handle's owner.
636    lost: Arc<AtomicBool>,
637    /// Disconnects when the thread's body ends, on every path out of it.
638    ///
639    /// Nothing is ever sent on it. It exists because [`Transaction::detach`]
640    /// needs a join with a bound and `std` has no timed one — a
641    /// `recv_timeout` on this is that join.
642    exited: Receiver<Infallible>,
643    /// The thread itself, kept only to reap it once `exited` says its body has
644    /// ended. Joining it directly is what has no bound.
645    thread: std::thread::JoinHandle<()>,
646}
647
648impl KeepAlive {
649    /// Starts pinging `id` every `interval`.
650    ///
651    /// `None` if the thread could not be spawned. The transaction still works;
652    /// it just has to finish within its timeout, which is a better outcome than
653    /// refusing to start one at all.
654    fn spawn(client: Client, id: String, interval: Duration) -> Option<Self> {
655        let stop = Arc::new((Mutex::new(false), Condvar::new()));
656        let signal = Arc::clone(&stop);
657        let lost = Arc::new(AtomicBool::new(false));
658        let give_up = Arc::clone(&lost);
659        let (alive, exited) = std::sync::mpsc::channel::<Infallible>();
660
661        std::thread::Builder::new()
662            .name("yt-transaction-ping".to_owned())
663            .spawn(move || {
664                // Held for the body's whole life and never sent on: dropping it
665                // — however this thread leaves — is what wakes the waiter in
666                // `stop_and_join`. Bound to a name so it is captured at all.
667                let _alive = alive;
668                let (lock, wake) = &*signal;
669                loop {
670                    {
671                        let guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
672                        if *guard {
673                            return;
674                        }
675                        let (guard, _) = wake
676                            .wait_timeout(guard, interval)
677                            .unwrap_or_else(PoisonError::into_inner);
678                        // Checked again on the way out, not only on the way in:
679                        // a stop raised *during* a ping arrives while nothing is
680                        // waiting on the condvar, so the notification is missed
681                        // and only this test catches it.
682                        if *guard {
683                            return;
684                        }
685                    }
686
687                    // A failed ping is not fatal on its own — the next one is
688                    // its retry, and whatever the ping would have said, the
689                    // next command in the transaction says too, to a caller
690                    // who can report it. But a cluster that answers "no such
691                    // transaction" has said something final: pinging on would
692                    // spend a request every interval, for as long as the
693                    // handle lives, on a transaction that cannot come back.
694                    // The flag is what keeps that exit from being silent.
695                    if let Err(error) = ping(&client, &id)
696                        && transaction_is_gone(&error)
697                    {
698                        give_up.store(true, Ordering::Relaxed);
699                        return;
700                    }
701                }
702            })
703            .ok()
704            .map(|thread| Self {
705                stop,
706                lost,
707                exited,
708                thread,
709            })
710    }
711
712    /// Whether the thread gave up because the transaction is gone.
713    fn lost(&self) -> bool {
714        self.lost.load(Ordering::Relaxed)
715    }
716
717    /// Asks the thread to stop, without waiting for it.
718    ///
719    /// Not joined on purpose: the thread may be inside a ping, and a request
720    /// can take as long as the client's timeout. Blocking a `Drop` for two
721    /// minutes to tidy up a thread that is about to exit on its own would be a
722    /// worse bargain than letting a stray ping land on a committed
723    /// transaction, which the cluster answers with an error nobody reads.
724    fn stop(self) {
725        self.raise();
726    }
727
728    /// Asks the thread to stop and waits until it has.
729    ///
730    /// For [`Transaction::detach`], which has a caller to wait for it — unlike
731    /// the destructor above — and which wants no ping landing after it
732    /// returns: a stray ping is harmless on a committed transaction but not on
733    /// a detached one, where it would quietly extend a lifetime the caller has
734    /// just finished reasoning about.
735    ///
736    /// **Bounded by [`DETACH_JOIN_TIMEOUT`], not by the ping.** A plain
737    /// `join()` would wait out the ping's own request budget, and that is
738    /// `min(interval / 2, 120 s)` — two minutes for an hour-long transaction,
739    /// against a proxy that has stopped answering. So the wait is a
740    /// `recv_timeout` on a channel the thread's own `Sender` closes when its
741    /// body ends, which is the timed join `std` does not have.
742    ///
743    /// The bound is the reason `detach` can only promise that much: past it
744    /// the ping is on its own, which [`DETACH_JOIN_TIMEOUT`] and
745    /// [`Transaction::detach`] both spell out.
746    fn stop_and_join(self) {
747        self.raise();
748        if matches!(
749            self.exited.recv_timeout(DETACH_JOIN_TIMEOUT),
750            Err(RecvTimeoutError::Disconnected)
751        ) {
752            // The body has already ended, so this only reaps the thread and
753            // cannot block. An `Err` from it is the thread having panicked;
754            // the ping loop has nothing in it that panics, and a
755            // destructor-adjacent path must not turn someone else's panic into
756            // its own.
757            let _ = self.thread.join();
758        }
759    }
760
761    fn raise(&self) {
762        let (lock, wake) = &*self.stop;
763        *lock.lock().unwrap_or_else(PoisonError::into_inner) = true;
764        wake.notify_all();
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn a_lost_ping_is_not_a_lost_transaction() {
774        // The invariant, whatever else changes: three pings fit inside one
775        // timeout, so one going missing costs nothing.
776        for seconds in [3, 30, 60, 3600] {
777            let timeout = Duration::from_secs(seconds);
778            let interval = ping_interval(timeout);
779            assert!(
780                interval * 3 <= timeout,
781                "{seconds}s timeout pinged every {interval:?}"
782            );
783        }
784    }
785
786    #[test]
787    fn a_timeout_below_the_floor_is_the_callers_business() {
788        // The floor is what keeps a caller who asks for 50 ms from turning the
789        // ping thread into a load generator. A transaction that short is one
790        // that is meant to expire.
791        assert_eq!(
792            ping_interval(Duration::from_millis(50)),
793            Duration::from_secs(1)
794        );
795    }
796
797    /// A handle around `1-2-3-4` on `proxy`, unfinished and not pinging.
798    fn handle_at(proxy: &str, origin: Origin) -> Transaction {
799        let client = Client::new(proxy).with_retries(crate::RetryPolicy::none());
800        Transaction {
801            client: client.with_transaction("1-2-3-4"),
802            id: "1-2-3-4".to_owned(),
803            done: false,
804            keep_alive: None,
805            origin,
806        }
807    }
808
809    /// A transaction whose commit is going nowhere: nothing listens on port 1.
810    fn doomed() -> Transaction {
811        handle_at("http://127.0.0.1:1", Origin::Started)
812    }
813
814    /// A socket that answers nothing and counts what reaches it.
815    ///
816    /// The point of a *bound* listener rather than a port nothing listens on:
817    /// "nothing was sent" and "something was sent to a closed port" look the
818    /// same to a caller who drops the error, which is every destructor here.
819    /// A connection arriving is the evidence. Nothing is written back, so the
820    /// sender sees the connection close and fails — quickly, which is all
821    /// these tests need of it.
822    fn watched_proxy() -> (String, Arc<std::sync::atomic::AtomicUsize>) {
823        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
824        let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
825        let arrived = Arc::new(std::sync::atomic::AtomicUsize::new(0));
826
827        let counted = Arc::clone(&arrived);
828        std::thread::spawn(move || {
829            for stream in listener.incoming() {
830                if stream.is_err() {
831                    return;
832                }
833                counted.fetch_add(1, Ordering::Relaxed);
834            }
835        });
836
837        (proxy, arrived)
838    }
839
840    /// Whether `arrived` reaches `wanted` within `budget`.
841    fn connections_reach(
842        arrived: &Arc<std::sync::atomic::AtomicUsize>,
843        wanted: usize,
844        budget: Duration,
845    ) -> bool {
846        let deadline = std::time::Instant::now() + budget;
847        while std::time::Instant::now() < deadline {
848            if arrived.load(Ordering::Relaxed) >= wanted {
849                return true;
850            }
851            std::thread::sleep(Duration::from_millis(10));
852        }
853        arrived.load(Ordering::Relaxed) >= wanted
854    }
855
856    #[test]
857    fn a_commit_that_failed_leaves_drop_an_abort_to_send() {
858        // The bug this pins down: marking the transaction finished before the
859        // commit was answered. `Drop` would then send nothing, and a
860        // transaction that is neither committed nor aborted nor pinged sits on
861        // its locks until it expires — an hour, for an hour-long timeout.
862        let mut tx = doomed();
863
864        assert!(tx.finish("commit_transaction").is_err());
865        assert!(!tx.done, "a failed commit has not finished the transaction");
866
867        // And the abort that `Drop` would send does finish it, whether or not
868        // the cluster heard it: there is nothing left to undo.
869        assert!(tx.finish("abort_transaction").is_err());
870        assert!(tx.done);
871    }
872
873    #[test]
874    fn a_transaction_is_finished_once() {
875        let mut tx = doomed();
876        tx.done = true;
877
878        // No request at all — the second call would be a second commit.
879        assert!(tx.finish("commit_transaction").is_ok());
880    }
881
882    #[test]
883    fn only_a_definitive_answer_stops_the_pinging() {
884        let gone_by_code = ClientError::Cluster {
885            command: "ping_transaction".into(),
886            code: 11000,
887            message: "whatever spelling".into(),
888            raw: "{}".into(),
889        };
890        assert!(transaction_is_gone(&gone_by_code));
891
892        let gone_by_text = ClientError::Cluster {
893            command: "ping_transaction".into(),
894            code: 1,
895            message: "Error resolving path".into(),
896            raw: r#"{"inner_errors"=[{"message"="No such transaction 1-2-3-4"}]}"#.into(),
897        };
898        assert!(transaction_is_gone(&gone_by_text));
899
900        // A busy master or an unreachable proxy says nothing about the
901        // transaction; the thread must keep pinging.
902        let transient = ClientError::Cluster {
903            command: "ping_transaction".into(),
904            code: 1,
905            message: "master is not ready".into(),
906            raw: "{}".into(),
907        };
908        assert!(!transaction_is_gone(&transient));
909        assert!(!transaction_is_gone(&ClientError::Config("x".into())));
910    }
911
912    #[test]
913    fn a_stalled_ping_leaves_room_for_the_next_one() {
914        // Half the interval, floored and capped: the request must not be able
915        // to consume the slot of the ping after it.
916        for seconds in [3, 30, 3600, 100_000] {
917            let interval = ping_interval(Duration::from_secs(seconds));
918            let bound = ping_request_timeout(interval);
919            assert!(bound * 2 <= interval.max(Duration::from_secs(2)));
920            assert!(bound <= crate::DEFAULT_TIMEOUT);
921        }
922    }
923
924    #[test]
925    fn the_keep_alive_thread_stops_when_asked() {
926        // The transaction it would ping does not exist, so every ping fails;
927        // the thread must survive that and still exit on request. A thread that
928        // died on the first failed ping would leave real transactions to
929        // expire.
930        let client = Client::new("http://127.0.0.1:1").with_retries(crate::RetryPolicy::none());
931        let keep_alive = KeepAlive::spawn(client, "1-2-3-4".to_owned(), Duration::from_millis(1))
932            .expect("the thread starts");
933
934        let stop = Arc::clone(&keep_alive.stop);
935        keep_alive.stop();
936
937        assert!(
938            *stop.0.lock().expect("not poisoned"),
939            "stop() must raise the flag the thread waits on"
940        );
941    }
942
943    #[test]
944    fn stop_and_join_waits_for_a_ping_it_caught_in_flight() {
945        // What `detach` buys with the join, measured: a ping already on the
946        // wire is finished before this returns. The proxy accepts and holds
947        // the connection, so the ping is reliably in flight when the stop is
948        // raised, and `stop_and_join` must not come back before it is over.
949        // Plain `stop()` returns in ~0 ms here; that difference is the assert.
950        //
951        // (A thread ignoring the stop would hang instead of failing. libtest
952        // has no per-test timeout, so that would stall the whole run — hence
953        // the bound in `stop_and_join` itself, which caps the damage at
954        // `DETACH_JOIN_TIMEOUT` even then.)
955        const HELD: Duration = Duration::from_millis(400);
956
957        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
958        let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
959        let (accepted, an_accept) = std::sync::mpsc::channel();
960        std::thread::spawn(move || {
961            for stream in listener.incoming() {
962                let Ok(stream) = stream else { return };
963                accepted.send(()).ok();
964                // Held, unanswered, then closed: the ping fails at HELD rather
965                // than waiting out its own one-second request budget.
966                std::thread::sleep(HELD);
967                drop(stream);
968            }
969        });
970
971        let client = Client::new(&proxy).with_retries(crate::RetryPolicy::none());
972        let interval = Duration::from_millis(1);
973        let mut ping_client = client.clone();
974        ping_client
975            .transport
976            .set_timeout(ping_request_timeout(interval));
977        let keep_alive = KeepAlive::spawn(ping_client, "1-2-3-4".to_owned(), interval)
978            .expect("the thread starts");
979
980        an_accept
981            .recv_timeout(Duration::from_secs(5))
982            .expect("a ping reached the proxy");
983
984        let waited = std::time::Instant::now();
985        keep_alive.stop_and_join();
986        let waited = waited.elapsed();
987
988        assert!(
989            waited >= HELD / 2,
990            "stop_and_join returned in {waited:?}, so it did not wait out the ping it caught"
991        );
992    }
993
994    #[test]
995    fn stop_and_join_gives_up_on_a_ping_that_outlasts_the_bound() {
996        // The other half of the bound, and the half nothing guarded. The test
997        // above asserts only that `stop_and_join` *waits*; a plain unbounded
998        // `join()` passes it just as well, and then `detach` on an hour-long
999        // transaction against a hung proxy holds its caller for the ping's own
1000        // two-minute budget. This is the upper bound.
1001        //
1002        // It is what makes the mechanism testable rather than the wait: the
1003        // proxy accepts and never answers or closes, and the ping's request
1004        // timeout is set six times [`DETACH_JOIN_TIMEOUT`], so a
1005        // `stop_and_join` that had degraded to a plain join — which is exactly
1006        // what dropping the thread's `_alive` sender produces, since the
1007        // channel then reports `Disconnected` at once — returns at the request
1008        // timeout instead, six times late.
1009        const PING_BUDGET: Duration = Duration::from_secs(30);
1010        // Two seconds of headroom on a five-second bound, and 25 s of distance
1011        // to the failure it looks for. Alone among the timing assertions here
1012        // this one is an *upper* bound, so load pushes it toward its threshold
1013        // rather than away — but all that is between the bound and this
1014        // measurement is a `recv_timeout` waking and one `Instant::elapsed`,
1015        // which scheduler latency moves by a constant, not proportionally.
1016        // Measured over the bound: 0.3–5.1 ms idle, worst 6.0 ms across five
1017        // runs at load average 71 on ten cores. The mutation lands at 30.0 s.
1018        const HEADROOM: Duration = Duration::from_secs(2);
1019
1020        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("binds");
1021        let proxy = format!("http://{}", listener.local_addr().expect("has an address"));
1022        let (accepted, an_accept) = std::sync::mpsc::channel();
1023        std::thread::spawn(move || {
1024            // Held open, never answered and never closed, so the ping can only
1025            // end at its own request timeout.
1026            let mut stalled = Vec::new();
1027            for stream in listener.incoming() {
1028                let Ok(stream) = stream else { return };
1029                stalled.push(stream);
1030                accepted.send(()).ok();
1031            }
1032        });
1033
1034        let mut ping_client = Client::new(&proxy).with_retries(crate::RetryPolicy::none());
1035        ping_client.transport.set_timeout(PING_BUDGET);
1036        let keep_alive =
1037            KeepAlive::spawn(ping_client, "1-2-3-4".to_owned(), Duration::from_millis(1))
1038                .expect("the thread starts");
1039
1040        an_accept
1041            .recv_timeout(Duration::from_secs(5))
1042            .expect("a ping reached the proxy");
1043
1044        let waited = std::time::Instant::now();
1045        keep_alive.stop_and_join();
1046        let waited = waited.elapsed();
1047
1048        assert!(
1049            waited < DETACH_JOIN_TIMEOUT + HEADROOM,
1050            "stop_and_join waited {waited:?} on a ping with a {PING_BUDGET:?} budget: \
1051             the bound is gone, and detach is back to waiting the ping out"
1052        );
1053    }
1054
1055    #[test]
1056    fn detach_hands_back_the_id_and_disarms_drop() {
1057        // `detach` consumes the handle, so `Drop` still runs inside it, and
1058        // `done` is the only thing keeping it from aborting. Asserted against
1059        // a socket that counts connections rather than a dead port, where an
1060        // abort that was sent and one that was not look identical.
1061        let (proxy, arrived) = watched_proxy();
1062        let tx = handle_at(&proxy, Origin::Started);
1063
1064        assert_eq!(tx.detach(), "1-2-3-4");
1065
1066        assert!(
1067            !connections_reach(&arrived, 1, Duration::from_millis(300)),
1068            "detach sent something: a detached transaction must look untouched"
1069        );
1070    }
1071
1072    #[test]
1073    fn a_failed_attach_names_the_id_and_keeps_the_clusters_verdict() {
1074        // What the cluster says about `1-2-3-4` is `cluster error 1: Unknown
1075        // cell tag 0` — no id, no mention of a transaction. The rebranding
1076        // must add both without discarding what a caller can branch on.
1077        let from_cluster = ClientError::Cluster {
1078            command: "get".into(),
1079            code: 1,
1080            message: "Unknown cell tag 0".into(),
1081            raw: r#"{"code":1}"#.into(),
1082        };
1083
1084        let rebranded = attach_failed("1-2-3-4", from_cluster);
1085        let ClientError::Cluster {
1086            command,
1087            code,
1088            message,
1089            raw,
1090        } = &rebranded
1091        else {
1092            panic!("the variant must survive: {rebranded:?}");
1093        };
1094        assert_eq!(command, "attach_transaction");
1095        assert_eq!(*code, 1, "the cluster's code is the caller's to branch on");
1096        assert!(message.contains("1-2-3-4"), "{message}");
1097        assert!(message.contains("Unknown cell tag 0"), "{message}");
1098        assert_eq!(raw, r#"{"code":1}"#, "the raw document is evidence");
1099
1100        // A transport failure says nothing about the id and is left alone.
1101        let transport = attach_failed("1-2-3-4", ClientError::Config("x".into()));
1102        assert!(matches!(transport, ClientError::Config(_)));
1103    }
1104
1105    #[test]
1106    fn only_a_started_handles_drop_reaches_for_the_cluster() {
1107        // The whole of `Drop`'s distinction, in one pair. Both handles are
1108        // unfinished; both drop; the *started* one must abort and the
1109        // *attached* one must send nothing at all. Asserting the second alone
1110        // would pass on a `Drop` that had stopped sending anything, which is
1111        // why the first is here beside it.
1112        let (started_proxy, reached_by_started) = watched_proxy();
1113        drop(handle_at(&started_proxy, Origin::Started));
1114        assert!(
1115            connections_reach(&reached_by_started, 1, Duration::from_secs(5)),
1116            "a dropped started handle sent nothing: `?` inside a transaction \
1117             no longer leaves the cluster as it was"
1118        );
1119
1120        let (attached_proxy, reached_by_attached) = watched_proxy();
1121        drop(handle_at(&attached_proxy, Origin::Attached));
1122        assert!(
1123            !connections_reach(&reached_by_attached, 1, Duration::from_millis(300)),
1124            "a dropped attached handle reached for the cluster: an attacher's \
1125             `?` must not destroy the owner's work"
1126        );
1127    }
1128
1129    #[test]
1130    fn a_timeout_attribute_is_read_in_either_integer() {
1131        // Int64 is what the local cluster answers — `{"value"=30000;}`, no `u`
1132        // — but a millisecond count is exactly the sort of field a master
1133        // could spell unsigned, and failing an attach over that would be a bad
1134        // way to find out.
1135        for node in [YsonNode::Int64(30_000), YsonNode::Uint64(30_000)] {
1136            let value = YsonValue {
1137                attributes: None,
1138                node,
1139            };
1140            assert_eq!(
1141                attached_timeout("1-2-3-4", &value).expect("reads"),
1142                Duration::from_secs(30)
1143            );
1144        }
1145    }
1146
1147    #[test]
1148    fn a_nonsense_timeout_attribute_is_an_error_that_names_it() {
1149        // Read as zero, each of these would floor `ping_interval` to a second
1150        // and leave a 1 Hz pinger running for the handle's whole life, on a
1151        // transaction whose real interval nobody knows.
1152        for node in [
1153            YsonNode::Int64(-1),
1154            YsonNode::Int64(0),
1155            YsonNode::Uint64(0),
1156            YsonNode::String(b"30s".to_vec()),
1157            YsonNode::Entity,
1158        ] {
1159            let value = YsonValue {
1160                attributes: None,
1161                node: node.clone(),
1162            };
1163            let error = attached_timeout("1-2-3-4", &value)
1164                .expect_err(&format!("{node:?} is not a transaction timeout"));
1165
1166            let ClientError::Decode { command, reason } = &error else {
1167                panic!("wrong variant for {node:?}: {error:?}");
1168            };
1169            assert_eq!(command, "attach_transaction");
1170            assert!(reason.contains("1-2-3-4/@timeout"), "{reason}");
1171        }
1172    }
1173}