Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ repository builds the minimal stack — a YSON codec and a job runtime.
## Commands

```sh
cargo test --workspace # 721 tests: 649 unit and integration, 72 doc
cargo test --workspace # 741 tests: 668 unit and integration, 73 doc
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all

Expand Down Expand Up @@ -295,6 +295,47 @@ per command. Cluster facts:
- `ping_ancestor_transactions=%true` is accepted; unnecessary here, since every
handle pings its own transaction.

Handing one to another process (`detach` / `attach_transaction`, #13):

- **`@timeout` is in milliseconds and comes back `Int64`.** `get
#<id>/@timeout` on a 30 s transaction answers `{"value"=30000;}` in text
YSON — no `u`, so not `Uint64`. `Transaction::attach` reads both anyway: a
duration in milliseconds is exactly the field a master could spell unsigned,
and this crate has been surprised by that class of thing before.
- **The attribute says nothing about how much life is left.** It is the
configured timeout, not the remaining one, and the id carries no last-ping
time. That is why `attach` pings before it returns: without it, a handoff
taking longer than `timeout × 2/3` produces a handle whose own first ping —
one interval away — lands after the cluster has already expired the
transaction.
- **Three different absences, three different errors**, all observed on a local
cluster:
- a garbage id (`1-2-3-4`): `cluster error 1: Unknown cell tag 0` — names
neither the id nor a transaction, which is what `attach_failed` rebrands;
- an expired or aborted id, addressed as an object: `Error resolving path
#<id>/@timeout` wrapping `No such object <id>` — **not** `No such
transaction`;
- the same id *pinged*: `No such transaction`, code 11000. Both spellings are
why `transaction_is_gone` looks for each, in the whole document.
- **A detached transaction is indistinguishable from a held one**, so the only
evidence a test can read is which requests stop arriving — which is what
`crates/ytsaurus-client/tests/transaction_lifecycle.rs` does, against a stub
cluster in-process, plus wall-clock timing for the join `detach` does.
- **`detach`'s wait covers the ping only up to a 30 s timeout.** The join is
bounded at five seconds and a ping's request budget is
`clamp(interval / 2, 1 s, 120 s)` on an `interval` of `max(timeout / 3, 1 s)`
— so the budget fits inside the bound while the timeout is under 30 s, equals
it at the 30 s default, and exceeds it above. The master honours the asked-for
timeout verbatim, so that arithmetic is the caller's to do: `#<id>/@timeout`
read back `3600000`, `30000` and `20000` for transactions started at each,
observed on a local cluster — budgets of 120 s, 5 s and 3.3 s. Above the
default a stalled ping outlives the detach and can restart the cluster's
clock afterwards; the docs say so, and this is why they cannot say "no ping
is in flight" flatly. Both directions are pinned in `transaction.rs`'s unit
tests — one asserts the wait happens, one asserts it ends — and the second is
what a `drop(alive)` in the ping thread's body fails; nothing else in the
workspace does.

### Picking a verb, and what is a command at all

- **The proxy documents the rule outright**, so no command's verb is a guess:
Expand Down
89 changes: 89 additions & 0 deletions crates/ytsaurus-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,95 @@

## Unreleased

### A transaction can outlive its handle

- **Added** `Transaction::detach` (#13): stops the keep-alive thread and
leaves the transaction running, returning the id — what C++ spells
`ITransaction::Detach()`. Nothing is committed, aborted or otherwise
decided, so from there the transaction lives on the cluster's terms: it
expires its timeout after its last ping, 30 s by default, unless whoever
received the id keeps it alive. The keep-alive thread is asked to stop and
then waited for, **for up to five seconds** — a detach racing its own ping
neither panics nor, inside that bound, leaves a request behind to restart
the expiry clock after the caller has finished reasoning about it. The
keep-alive may still get one last ping away before it sees the stop, and
that ping is what the wait is for. The bound is deliberate, in place of the
ping's own request budget of `min(interval / 2, 120 s)` — two minutes for an
hour-long transaction against a proxy that has stopped answering — because
`detach` reads as instant at every call site.

**The bound is reachable, and then the promise stops.** Five seconds covers
a ping's whole budget while the transaction's timeout is under 30 s and
equals it at the 30 s default, so at or below the default the wait always
ends in the thread's exit. Above it — every long-running launcher
transaction — a ping stalled on a proxy that has stopped answering outlasts
the wait, is left in flight, and can reach the master *after* `detach`
returned, restarting the clock there: the transaction then lives a full
timeout from wherever that ping landed rather than from the detach. Nothing
leaks — the thread re-reads the stop flag as soon as its ping ends, so at
most one ping is outstanding and it exits inside that same budget — but a
caller above the default cannot treat `detach` as the transaction's last
ping. Both bounds are on `Transaction::detach`.

- **Added** `Client::attach_transaction(id)`: the receiving half. Turns an id
into a real `Transaction` — bound client, ping thread, working
`commit`/`abort`/`ping` — where `with_transaction` only binds commands. The
ping interval is read from `#<id>/@timeout`, because the id alone does not
carry it; that round trip is also what makes attaching to a transaction that
is gone fail immediately, with the cluster's resolve error naming the id.
**Dropping an attached handle detaches rather than aborts**, following the
C++ destructor's line: an attacher's `?` must not destroy work the process
that started the transaction still holds a handle to. The handle always
pings — Go's `AttachTx(id, {AutoPingable: false})` maps onto
`with_transaction` plus the by-id commands below.

It also **pings once before returning**, a second round trip, because
`@timeout` is the *configured* lifetime and says nothing about how much of
it a handoff has already spent: an attach at t=21 s of a 30 s transaction
last pinged at t=0 would otherwise schedule its first ping for t=31 s, one
second after the cluster had expired it — and the reply, `No such
transaction`, would stop the keep-alive thread silently. Any handoff slower
than `timeout × 2/3` lost the transaction that way. The ping restarts the
clock at the attach and doubles as the probe, so a transaction that died in
the handoff is this call's error rather than a later command's.

- **Added** `Transaction::is_lost`: whether the keep-alive has given up. It
stops on its own for exactly one reason — a ping answered "no such
transaction", which is final — and that exit used to be invisible, leaving a
handle that pings nothing looking exactly like a healthy one. Go reports the
same thing by pushing on `Tx.Finished()`; this is polled.

**False is not "something is pinging"**, and the doc now says which other
states read false: a thread that never started because the spawn failed, and
a thread that panicked (nothing on the ping path panics as it stands). A
ping does not expose either — it answers for the transaction, not for the
thread. `is_lost` is also `&self` where `detach` consumes the handle, so
after a detach the only probe left is `Client::ping_transaction` on the id.

- **Added** `Client::ping_transaction`, `Client::commit_transaction` and
`Client::abort_transaction`, taking the bare id, so a process that holds
nothing else can finish someone else's transaction. Commit rides under a
mutation ID (it is not idempotent — the second commit is answered `No such
transaction`, which reads like the first one failed); abort is retried
freely on the cluster's own forgiveness (aborting a transaction that is
gone answers `{}`); a ping doubles as the liveness probe.

- **Unchanged, and deliberately**: dropping a transaction this process
*started* still aborts it. That is what makes `?` safe inside a
transaction, and `examples/transaction.rs` still demonstrates exactly that;
`tests/transaction_lifecycle.rs` pins all four drop/detach shapes at the
wire level, stub-served in-process. The new `detach` example runs the
handoff against a cluster: start, detach, drop, attach from a second
client, hold past the transaction's own timeout, commit.

- **Note for the paranoid**: `mem::forget` on a `Transaction` is not a way to
hand it on. It leaks the keep-alive thread, which goes on pinging for the
life of the process and holds the transaction and its locks open
indefinitely. `detach` is the sanctioned form, and it says so now. Nothing
stops two attaches to the same id either: each gets a handle and a thread of
its own, they ping the same transaction twice as often, and whichever
finishes it first decides it — deliberate, since a second process attaching
is the whole point, and documented on `attach_transaction`.
### A file can be read back

- **Added** `Client::read_file` and `Client::read_file_streaming` — the mirror
Expand Down
147 changes: 147 additions & 0 deletions crates/ytsaurus-client/examples/detach.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//! `detach` — hand a live transaction to another client.
//!
//! One client starts a transaction, creates a table only it can see, and
//! detaches: the handle is gone and the transaction is not. A second client —
//! standing in for another process — attaches by id, keeps it alive past its
//! own timeout, and commits it; only then does the table exist for anyone
//! else. The other half of the contract is checked too: an *attached* handle
//! dropped mid-work leaves the transaction running, where a *started* one
//! dropped mid-work still aborts it.
//!
//! ```sh
//! export YT_PROXY=http://localhost:8000
//! cargo run -p ytsaurus-client --example detach
//! ```

use std::process::ExitCode;
use std::time::{Duration, Instant};

use ytsaurus_client::{Client, ClientError};

/// Where the demo keeps its tables.
const BASE: &str = "//tmp/ytsaurus_rs_detach";

/// A transaction short enough that whoever holds it must really be pinging.
const SHORT: Duration = Duration::from_secs(3);

/// How long the second client holds it — several timeouts' worth, so the
/// commit succeeding proves the attached handle's pings did the keeping alive.
const HELD: Duration = Duration::from_secs(7);

fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("\ndetach failed: {e}");
ExitCode::FAILURE
}
}
}

fn run() -> Result<(), ClientError> {
let first = Client::from_env()?;
let second = Client::from_env()?; // its own connections, like another process

step("Preparing Cypress");
first.remove_tree(BASE)?;
first.create("map_node", BASE)?;
let staging = format!("{BASE}/staging");
done("clean slate");

step(&format!(
"Starting a {}s transaction and detaching",
SHORT.as_secs()
));
let started = Instant::now();
let tx = first.start_transaction_with(SHORT)?;
tx.create("table", &staging)?;
check("the transaction sees its table", tx.exists(&staging)?)?;
let id = tx.detach(); // the handle ends here; the transaction does not
println!(" detached {id}");
check(
"the first client no longer sees the table",
!first.exists(&staging)?,
)?;

step("Attaching from the second client");
let attached = second.attach_transaction(&id)?;
check(
"the attached handle sees the table",
attached.exists(&staging)?,
)?;

step(&format!(
"Holding it for {}s — past its own timeout",
HELD.as_secs()
));
std::thread::sleep(HELD);
// Nothing in this function pinged anything. If the attached handle's
// thread were not doing it, the transaction would have expired seconds
// ago and the commit below would fail with `No such transaction`.
attached.commit()?;
check(
&format!(
"committed by the second client, {:.0}s after the start",
started.elapsed().as_secs_f64()
),
first.exists(&staging)?,
)?;

step("An attached handle dropped mid-work leaves the transaction alive");
let orphan = {
let tx = first.start_transaction()?;
tx.detach()
};
{
let attached = second.attach_transaction(&orphan)?;
attached.ping()?;
} // dropped here — attached, so this detaches rather than aborts
second.ping_transaction(&orphan)?;
done("still answers a ping after the attached handle dropped");

step("A bare id is enough to finish it");
second.abort_transaction(&orphan)?;
match second.ping_transaction(&orphan) {
Ok(()) => {
return Err(ClientError::Config(
"a ping succeeded after the abort, which means the abort did not happen".to_owned(),
));
}
Err(e) => done(&format!("aborted by id; as expected: {e}")),
}

step("A started handle dropped mid-work still aborts — unchanged");
let watched = {
let tx = first.start_transaction()?;
tx.id().to_owned()
}; // dropped here — started, so this aborts
match second.ping_transaction(&watched) {
Ok(()) => {
return Err(ClientError::Config(
"a started handle's drop no longer aborts its transaction".to_owned(),
));
}
Err(e) => done(&format!("as expected: {e}")),
}

println!("\nA transaction outlived its handle and finished in other hands.");
println!("Tables left at {BASE}");
Ok(())
}

fn step(what: &str) {
println!("\n== {what}");
}

fn done(what: &str) {
println!(" ok {what}");
}

fn check(what: &str, passed: bool) -> Result<(), ClientError> {
if passed {
done(what);
return Ok(());
}
eprintln!(" FAIL {what}");
Err(ClientError::Config(format!("check failed: {what}")))
}
Loading
Loading