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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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 ¶ms,
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}