ytsaurus_client/operation.rs
1//! The operation object, and the filters and parameters its commands take.
2//!
3//! An operation used to be a `String` here, and every command took one. That is
4//! still true — [`Client`] carries the whole lifecycle over an id — but a string
5//! is a poor thing to hand to a function, and it is nothing to *reattach* to.
6//! [`Operation`] is the handle: a client and an id, with the same commands on
7//! it, obtained either from an id you just started or from one you persisted
8//! before the process died.
9//!
10//! ```no_run
11//! # use ytsaurus_client::{Client, VanillaSpec, VanillaTask};
12//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
13//! # let client = Client::from_env()?;
14//! # let spec = VanillaSpec::new(VanillaTask::new("t", "sleep 60", 1));
15//! let id = client.start_vanilla(&spec)?;
16//! std::fs::write("run.id", &id)?; // survive a restart
17//!
18//! // …later, in a process that did not start it:
19//! let op = client.attach_operation(std::fs::read_to_string("run.id")?);
20//! op.suspend(false)?;
21//! op.resume()?;
22//! op.wait()?;
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! # What the cluster says about the lifecycle
28//!
29//! Measured on a local cluster, because none of it is obvious:
30//!
31//! - **Suspension is not a state.** A suspended operation still reports
32//! `running`; `suspended` is a separate attribute, which is what
33//! [`Operation::suspended`] reads — and [`Operation::status`] reads beside
34//! the state, in one request, because a poll loop needs both. Polling the
35//! state alone will never tell you an operation is paused.
36//! - **Suspend is idempotent, resume is not.** Suspending a suspended operation
37//! answers `{}`; resuming one that is not suspended fails with code 201,
38//! `Operation is in "running" state`.
39//! - **Complete is not idempotent**, and behaves like
40//! [`Client::abort_operation`]: the second one is answered `No such
41//! operation`.
42//! - Once the scheduler has let the operation go, *every* one of these answers
43//! `No such operation` — the rule is "the scheduler still has it", not "it has
44//! not finished".
45
46use ytsaurus_yson::{YsonNode, YsonValue};
47
48use crate::error::{ClientError, Result};
49use crate::jobs::{JobInfo, field, text};
50use crate::stream::ResponseReader;
51use crate::{Client, yson_build};
52
53/// A running — or finished — operation, and the client that can ask about it.
54///
55/// Obtained from [`Client::attach_operation`]. Every method is the [`Client`]
56/// method of the same name with the id filled in, so nothing here can be done
57/// only through the handle; the handle exists so that an operation can be
58/// *passed around* as one thing, and so that reattaching to one has an obvious
59/// spelling.
60///
61/// **Dropping it does nothing**, which is the opposite of
62/// [`Transaction`](crate::Transaction). A transaction that loses its handle is
63/// aborted, because a transaction is a scope. An operation is meant to outlive
64/// the process that started it — that is what makes reattaching worth having —
65/// so this handle is a name and not a lease.
66#[derive(Debug, Clone)]
67pub struct Operation {
68 client: Client,
69 id: String,
70}
71
72impl Operation {
73 pub(crate) fn new(client: Client, id: String) -> Self {
74 Self { client, id }
75 }
76
77 /// The operation's ID, as the cluster named it.
78 ///
79 /// The thing to persist: it is what the web interface shows, and what
80 /// [`Client::attach_operation`] needs to build this handle again.
81 #[must_use]
82 pub fn id(&self) -> &str {
83 &self.id
84 }
85
86 /// The client this handle sends its commands through.
87 #[must_use]
88 pub fn client(&self) -> &Client {
89 &self.client
90 }
91
92 /// The whole operation document. See [`Client::get_operation`].
93 ///
94 /// # Errors
95 ///
96 /// Returns [`ClientError`](crate::ClientError) if the request fails.
97 pub fn get(&self, attributes: &[&str]) -> Result<YsonValue> {
98 self.client.get_operation(&self.id, attributes)
99 }
100
101 /// The current state, e.g. `running` or `completed`.
102 ///
103 /// Note that a **suspended operation still reports `running`**; ask
104 /// [`Operation::suspended`] about that, or [`Operation::status`] about
105 /// both at once.
106 ///
107 /// # Errors
108 ///
109 /// Returns [`ClientError`](crate::ClientError) if the request fails.
110 pub fn state(&self) -> Result<String> {
111 self.client.operation_state(&self.id)
112 }
113
114 /// Whether the operation is suspended. See [`Client::operation_suspended`].
115 ///
116 /// # Errors
117 ///
118 /// Returns [`ClientError`](crate::ClientError) if the request fails.
119 pub fn suspended(&self) -> Result<bool> {
120 self.client.operation_suspended(&self.id)
121 }
122
123 /// The state and the suspension together. See [`Client::operation_status`].
124 ///
125 /// What a poll loop wants: the two are useless apart, and this asks for
126 /// them in one request.
127 ///
128 /// # Errors
129 ///
130 /// Returns [`ClientError`](crate::ClientError) if the request fails.
131 pub fn status(&self) -> Result<OperationStatus> {
132 self.client.operation_status(&self.id)
133 }
134
135 /// Polls until the operation finishes. See [`Client::wait_for_operation`].
136 ///
137 /// # Errors
138 ///
139 /// Returns [`ClientError::OperationFailed`](crate::ClientError::OperationFailed)
140 /// if it ends as anything other than `completed`.
141 pub fn wait(&self) -> Result<()> {
142 self.client.wait_for_operation(&self.id)
143 }
144
145 /// Stops the operation. See [`Client::abort_operation`].
146 ///
147 /// # Errors
148 ///
149 /// Returns [`ClientError`](crate::ClientError) if the request fails.
150 pub fn abort(&self, reason: Option<&str>) -> Result<()> {
151 self.client.abort_operation(&self.id, reason)
152 }
153
154 /// Pauses the operation. See [`Client::suspend_operation`].
155 ///
156 /// # Errors
157 ///
158 /// Returns [`ClientError`](crate::ClientError) if the request fails.
159 pub fn suspend(&self, abort_running_jobs: bool) -> Result<()> {
160 self.client.suspend_operation(&self.id, abort_running_jobs)
161 }
162
163 /// Lets a suspended operation run again. See [`Client::resume_operation`].
164 ///
165 /// # Errors
166 ///
167 /// Returns [`ClientError`](crate::ClientError) if the request fails,
168 /// including when the operation was not suspended.
169 pub fn resume(&self) -> Result<()> {
170 self.client.resume_operation(&self.id)
171 }
172
173 /// Finishes the operation with what it has. See
174 /// [`Client::complete_operation`].
175 ///
176 /// # Errors
177 ///
178 /// Returns [`ClientError`](crate::ClientError) if the request fails.
179 pub fn complete(&self) -> Result<()> {
180 self.client.complete_operation(&self.id)
181 }
182
183 /// Changes the operation's scheduling parameters while it runs. See
184 /// [`Client::update_operation_parameters`].
185 ///
186 /// # Errors
187 ///
188 /// Returns [`ClientError`](crate::ClientError) if the request fails, or
189 /// [`ClientError::Config`](crate::ClientError::Config) if `parameters` is
190 /// empty.
191 pub fn update_parameters(&self, parameters: &OperationParameters) -> Result<()> {
192 self.client
193 .update_operation_parameters(&self.id, parameters)
194 }
195
196 /// Why the operation ended as it did. See
197 /// [`Client::operation_result_error`].
198 ///
199 /// # Errors
200 ///
201 /// Returns [`ClientError`](crate::ClientError) if the request fails.
202 pub fn error(&self) -> Result<Option<String>> {
203 self.client.operation_result_error(&self.id)
204 }
205
206 /// The operation's jobs. See [`Client::list_jobs`].
207 ///
208 /// # Errors
209 ///
210 /// Returns [`ClientError`](crate::ClientError) if the request fails.
211 pub fn jobs(&self, state: Option<&str>, limit: u32) -> Result<Vec<JobInfo>> {
212 self.client.list_jobs(&self.id, state, limit)
213 }
214
215 /// One job of the operation. See [`Client::get_job`].
216 ///
217 /// # Errors
218 ///
219 /// Returns [`ClientError`](crate::ClientError) if the request fails.
220 pub fn job(&self, job_id: &str) -> Result<JobInfo> {
221 self.client.get_job(&self.id, job_id)
222 }
223
224 /// What a job read. See [`Client::get_job_input`].
225 ///
226 /// # Errors
227 ///
228 /// Returns [`ClientError`](crate::ClientError) if the request fails.
229 pub fn job_input(&self, job_id: &str) -> Result<ResponseReader> {
230 self.client.get_job_input(&self.id, job_id)
231 }
232
233 /// What a job wrote to stderr. See [`Client::get_job_stderr`].
234 ///
235 /// # Errors
236 ///
237 /// Returns [`ClientError`](crate::ClientError) if the request fails.
238 pub fn job_stderr(&self, job_id: &str) -> Result<Vec<u8>> {
239 self.client.get_job_stderr(&self.id, job_id)
240 }
241
242 /// The operation's event log. See [`Client::list_operation_events`].
243 ///
244 /// # Errors
245 ///
246 /// Returns [`ClientError`](crate::ClientError) if the request fails.
247 pub fn events(&self) -> Result<Vec<OperationEvent>> {
248 self.client.list_operation_events(&self.id)
249 }
250
251 /// Everything the scheduler recorded about the jobs. See
252 /// [`Client::job_statistics`].
253 ///
254 /// # Errors
255 ///
256 /// Returns [`ClientError`](crate::ClientError) if the request fails.
257 pub fn statistics(&self) -> Result<YsonValue> {
258 self.client.job_statistics(&self.id)
259 }
260
261 /// The statistics the jobs reported themselves. See
262 /// [`Client::custom_statistics`].
263 ///
264 /// # Errors
265 ///
266 /// Returns [`ClientError`](crate::ClientError) if the request fails.
267 pub fn custom_statistics(&self) -> Result<YsonValue> {
268 self.client.custom_statistics(&self.id)
269 }
270
271 /// The total of one custom statistic. See [`Client::statistic_sum`].
272 ///
273 /// # Errors
274 ///
275 /// Returns [`ClientError`](crate::ClientError) if the request fails.
276 pub fn statistic_sum(&self, name: &str) -> Result<Option<i64>> {
277 self.client.statistic_sum(&self.id, name)
278 }
279
280 /// The total of one built-in statistic. See
281 /// [`Client::job_statistic_sum`].
282 ///
283 /// # Errors
284 ///
285 /// Returns [`ClientError`](crate::ClientError) if the request fails.
286 pub fn job_statistic_sum(&self, path: &str) -> Result<Option<i64>> {
287 self.client.job_statistic_sum(&self.id, path)
288 }
289}
290
291/// One operation, as [`Client::list_operations`] reports it.
292///
293/// A subset of the cluster's `TOperation`, in the spirit of [`JobInfo`]: enough
294/// to recognise an operation and decide what to do about it. The document has a
295/// great deal more in it — `brief_spec`, `runtime_parameters`, the whole
296/// progress tree — and [`Client::get_operation`] is how to read that.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct OperationInfo {
299 /// Operation ID, in the form every other command here expects.
300 pub id: String,
301 /// The operation type — `map`, `vanilla`, `sort`, … — under the cluster's
302 /// own key `type`, which is a keyword in Rust.
303 pub kind: String,
304 /// `running`, `completed`, `failed`, `aborted`, `pending`, …
305 pub state: String,
306 /// Who started it.
307 pub user: Option<String>,
308 /// When it started, in the cluster's ISO 8601 spelling.
309 pub start_time: Option<String>,
310 /// When it finished; `None` while it has not.
311 pub finish_time: Option<String>,
312 /// Whether it is paused.
313 ///
314 /// Worth having beside `state`, because a suspended operation still reports
315 /// `running` there.
316 pub suspended: bool,
317}
318
319/// What an operation is doing, as [`Client::operation_status`] reports it.
320///
321/// The pair rather than either alone: suspension is not a state, so `state`
322/// says `running` for an operation that is paused and `suspended` is the only
323/// thing that says otherwise. Reading them together also costs one request
324/// instead of two, which a poll loop notices.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct OperationStatus {
327 /// `running`, `completed`, `failed`, `aborted`, `pending`, …
328 pub state: String,
329 /// Whether it is paused. Never visible in `state`.
330 pub suspended: bool,
331}
332
333/// The answer to [`Client::list_operations`].
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct OperationList {
336 /// The operations the filter matched.
337 pub operations: Vec<OperationInfo>,
338 /// Whether the cluster had more to say than the limit allowed.
339 ///
340 /// The cluster's own `incomplete`, and not an error: the way to page
341 /// through a long list is to move the filter's time window, so a caller
342 /// that ignores this silently sees only the first page.
343 pub incomplete: bool,
344}
345
346/// One entry of an operation's event log, as `list_operation_events` reports it.
347///
348/// **A cluster with no operations archive has none of these.** The command is
349/// registered and answers with an empty list, which is what a local cluster
350/// does; the archive is what actually keeps the events.
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct OperationEvent {
353 /// What happened, e.g. `started_running` or `incarnation_started`.
354 pub event_type: String,
355 /// When, in the cluster's ISO 8601 spelling.
356 pub timestamp: Option<String>,
357 /// The incarnation this event belongs to, for an operation that has been
358 /// restarted by the controller agent.
359 pub incarnation: Option<String>,
360}
361
362/// Which operations [`Client::list_operations`] should return.
363///
364/// Every filter is optional and they combine; the default asks for everything
365/// the cluster is willing to answer with, which is the most recent operations up
366/// to its own limit.
367///
368/// ```
369/// use ytsaurus_client::OperationFilter;
370///
371/// let mine = OperationFilter::new()
372/// .with_user("robot-loader")
373/// .with_state("running")
374/// .with_limit(20);
375/// ```
376#[derive(Debug, Clone)]
377pub struct OperationFilter {
378 params: YsonValue,
379}
380
381impl Default for OperationFilter {
382 fn default() -> Self {
383 Self::new()
384 }
385}
386
387impl OperationFilter {
388 /// No filter at all.
389 #[must_use]
390 pub fn new() -> Self {
391 Self {
392 params: yson_build::empty_map(),
393 }
394 }
395
396 fn set(mut self, key: &str, value: YsonValue) -> Self {
397 yson_build::insert(&mut self.params, key, value);
398 self
399 }
400
401 /// Only operations started by this user.
402 #[must_use]
403 pub fn with_user(self, user: impl AsRef<str>) -> Self {
404 self.set("user", yson_build::string(user.as_ref()))
405 }
406
407 /// Only operations in this state — `running`, `completed`, `failed`, …
408 #[must_use]
409 pub fn with_state(self, state: impl AsRef<str>) -> Self {
410 self.set("state", yson_build::string(state.as_ref()))
411 }
412
413 /// Only operations of this type.
414 #[must_use]
415 pub fn with_kind(self, kind: crate::OperationType) -> Self {
416 self.set("type", yson_build::string(kind.as_str()))
417 }
418
419 /// Only operations in this pool.
420 #[must_use]
421 pub fn with_pool(self, pool: impl AsRef<str>) -> Self {
422 self.set("pool", yson_build::string(pool.as_ref()))
423 }
424
425 /// Only operations in this pool tree.
426 #[must_use]
427 pub fn with_pool_tree(self, tree: impl AsRef<str>) -> Self {
428 self.set("pool_tree", yson_build::string(tree.as_ref()))
429 }
430
431 /// Only operations whose id, alias, user or spec contains this text.
432 ///
433 /// The cluster calls it `filter`; this is the free-text search the web
434 /// interface's search box sends.
435 #[must_use]
436 pub fn with_substring(self, text: impl AsRef<str>) -> Self {
437 self.set("filter", yson_build::string(text.as_ref()))
438 }
439
440 /// Only operations that started at or after this time.
441 ///
442 /// An ISO 8601 timestamp as the cluster writes them —
443 /// `2026-08-06T09:21:23.534387Z`. This crate has no date type and does not
444 /// want a dependency on one, so the timestamps go across as text, exactly as
445 /// they come back in [`OperationInfo::start_time`].
446 #[must_use]
447 pub fn with_from_time(self, time: impl AsRef<str>) -> Self {
448 self.set("from_time", yson_build::string(time.as_ref()))
449 }
450
451 /// Only operations that started at or before this time.
452 ///
453 /// See [`OperationFilter::with_from_time`] for the spelling.
454 #[must_use]
455 pub fn with_to_time(self, time: impl AsRef<str>) -> Self {
456 self.set("to_time", yson_build::string(time.as_ref()))
457 }
458
459 /// Only operations that have failed jobs.
460 #[must_use]
461 pub fn with_failed_jobs(self, with_failed_jobs: bool) -> Self {
462 self.set("with_failed_jobs", yson_build::boolean(with_failed_jobs))
463 }
464
465 /// Also look in the operations archive, not only at what the scheduler
466 /// still holds.
467 ///
468 /// This is how an operation that finished a while ago is found at all — and
469 /// it needs an archive, which a local cluster does not have.
470 #[must_use]
471 pub fn with_archive(self, include: bool) -> Self {
472 self.set("include_archive", yson_build::boolean(include))
473 }
474
475 /// At most this many operations.
476 #[must_use]
477 pub fn with_limit(self, limit: u32) -> Self {
478 self.set("limit", yson_build::int(i64::from(limit)))
479 }
480
481 /// Sets any filter this builder does not model — `cursor_time`,
482 /// `cursor_direction`, `include_counters`.
483 #[must_use]
484 pub fn with_raw(self, key: impl AsRef<str>, value: YsonValue) -> Self {
485 self.set(key.as_ref(), value)
486 }
487
488 /// The filter as `list_operations` wants it.
489 #[must_use]
490 pub fn to_yson(&self) -> YsonValue {
491 self.params.clone()
492 }
493}
494
495/// What [`Client::update_operation_parameters`] should change.
496///
497/// The parameters a **running** operation will accept: which pool it competes
498/// in, and how much of that pool it gets. Everything else about an operation is
499/// fixed when it starts.
500///
501/// ```
502/// use ytsaurus_client::OperationParameters;
503///
504/// // Move a job that turned out to matter into the pool that gets served
505/// // first, and give it twice the share.
506/// let urgent = OperationParameters::new().with_pool("interactive").with_weight(2.0);
507/// ```
508#[derive(Debug, Clone)]
509pub struct OperationParameters {
510 params: YsonValue,
511}
512
513impl Default for OperationParameters {
514 fn default() -> Self {
515 Self::new()
516 }
517}
518
519impl OperationParameters {
520 /// Changes nothing yet.
521 #[must_use]
522 pub fn new() -> Self {
523 Self {
524 params: yson_build::empty_map(),
525 }
526 }
527
528 fn set(mut self, key: &str, value: YsonValue) -> Self {
529 yson_build::insert(&mut self.params, key, value);
530 self
531 }
532
533 /// Moves the operation into another pool.
534 ///
535 /// Applies to every pool tree the operation runs in. Verified on a local
536 /// cluster: a top-level key here lands under
537 /// `runtime_parameters/scheduling_options_per_pool_tree/<tree>`, once per
538 /// tree. [`OperationParameters::with_pool_in_tree`] names one instead.
539 #[must_use]
540 pub fn with_pool(self, pool: impl AsRef<str>) -> Self {
541 self.set("pool", yson_build::string(pool.as_ref()))
542 }
543
544 /// Changes the operation's share of its pool.
545 ///
546 /// A double, and the cluster means it: `1.0` is the default share, `2.0` is
547 /// twice as much of whatever the pool gets.
548 #[must_use]
549 pub fn with_weight(self, weight: f64) -> Self {
550 self.set("weight", yson_build::double(weight))
551 }
552
553 /// Moves the operation into another pool **of one tree**.
554 ///
555 /// For an installation with more than one pool tree, where the operation
556 /// should move in one of them and stay where it is in the others.
557 ///
558 /// Adds to what the tree's entry already holds rather than replacing it:
559 /// `update_operation_parameters` assigns, so an entry that arrived here
560 /// with a `weight` in it and left with only a `pool` would reset that
561 /// weight on the cluster and report success.
562 #[must_use]
563 pub fn with_pool_in_tree(mut self, tree: impl AsRef<str>, pool: impl AsRef<str>) -> Self {
564 let mut trees = map_or_empty(tree_options(&self.params));
565 let mut options = map_or_empty(field(&trees, tree.as_ref()));
566
567 yson_build::insert(&mut options, "pool", yson_build::string(pool.as_ref()));
568 yson_build::insert(&mut trees, tree.as_ref(), options);
569 yson_build::insert(&mut self.params, "scheduling_options_per_pool_tree", trees);
570 self
571 }
572
573 /// Sets any parameter this builder does not model — `acl`, `annotations`,
574 /// `scheduling_tag_filter`.
575 #[must_use]
576 pub fn with_raw(self, key: impl AsRef<str>, value: YsonValue) -> Self {
577 self.set(key.as_ref(), value)
578 }
579
580 /// Whether this would ask the cluster to change nothing.
581 ///
582 /// [`Client::update_operation_parameters`] refuses an empty update: the
583 /// cluster answers 200 and does nothing, so the mistake is invisible where
584 /// it is made.
585 #[must_use]
586 pub fn is_empty(&self) -> bool {
587 match &self.params.node {
588 YsonNode::Map(m) => m.is_empty(),
589 _ => true,
590 }
591 }
592
593 /// The parameters as `update_operation_parameters` wants them.
594 #[must_use]
595 pub fn to_yson(&self) -> YsonValue {
596 self.params.clone()
597 }
598}
599
600/// The `scheduling_options_per_pool_tree` already in a parameters document.
601fn tree_options(params: &YsonValue) -> Option<&YsonValue> {
602 field(params, "scheduling_options_per_pool_tree")
603}
604
605/// A value if it is a dict, and a fresh empty dict otherwise.
606///
607/// What lets a builder read back and add to what is already under a key without
608/// trusting its shape: [`yson_build::insert`] panics on anything that is not a
609/// dict, and `with_raw` puts whatever the caller passes wherever they name.
610fn map_or_empty(value: Option<&YsonValue>) -> YsonValue {
611 match value {
612 Some(existing) if matches!(existing.node, YsonNode::Map(_)) => existing.clone(),
613 _ => yson_build::empty_map(),
614 }
615}
616
617/// Reads the `operations` list of a `list_operations` response.
618///
619/// An operation with no id is dropped, as a job with no id is: there is nothing
620/// to ask the cluster about it afterwards. A response with no `operations` list
621/// at all is an error rather than an empty list — "the cluster has no operations
622/// running" is a conclusion a supervisor acts on, and a shape it does not
623/// recognise must not be able to say that.
624pub(crate) fn parse_operations(response: &YsonValue) -> Result<OperationList> {
625 let Some(YsonNode::List(items)) = field(response, "operations").map(|ops| &ops.node) else {
626 return Err(ClientError::Decode {
627 command: "list_operations".to_owned(),
628 reason: format!(
629 "the answer carries no `operations` list: {}",
630 // Truncated: a listing is large, and a wrong-shaped one is no
631 // smaller for being wrong.
632 crate::error::truncate(&format!("{:?}", response.node), 300)
633 ),
634 });
635 };
636
637 Ok(OperationList {
638 operations: items.iter().filter_map(parse_operation).collect(),
639 incomplete: flag(field(response, "incomplete")).unwrap_or(false),
640 })
641}
642
643fn parse_operation(operation: &YsonValue) -> Option<OperationInfo> {
644 let id = text(field(operation, "id")?)?;
645
646 Some(OperationInfo {
647 id,
648 // `type` is the documented key; `operation_type` is the same value
649 // under the name API v4 also answers with.
650 kind: field(operation, "type")
651 .and_then(text)
652 // Decoded before falling back, not merely present: a `type` that is
653 // there but unreadable must still let `operation_type` answer.
654 .or_else(|| field(operation, "operation_type").and_then(text))
655 .unwrap_or_default(),
656 state: field(operation, "state").and_then(text).unwrap_or_default(),
657 user: field(operation, "authenticated_user").and_then(text),
658 start_time: field(operation, "start_time").and_then(text),
659 finish_time: field(operation, "finish_time").and_then(text),
660 suspended: flag(field(operation, "suspended")).unwrap_or(false),
661 })
662}
663
664/// Reads a `list_operation_events` response.
665///
666/// The answer is a **bare list**, with none of the one-key envelope the rest of
667/// API v4 wraps a structured response in — verified against a cluster, which is
668/// the only way anyone would know. That cluster has no operations archive and
669/// so answers `[]` every time, which means only the *empty* bare list was ever
670/// seen; the `{events=[…]}` envelope is accepted too, rather than betting the
671/// whole command on which of the two an installation with an archive sends.
672///
673/// Anything else is an error. An empty list is a legitimate answer here — it is
674/// what a cluster with no archive gives — so a shape this does not recognise
675/// must not be able to spell itself "no events".
676pub(crate) fn parse_events(response: &YsonValue) -> Result<Vec<OperationEvent>> {
677 let items = match &response.node {
678 YsonNode::List(items) => items,
679 _ => match field(response, "events").map(|events| &events.node) {
680 Some(YsonNode::List(items)) => items,
681 _ => {
682 return Err(ClientError::Decode {
683 command: "list_operation_events".to_owned(),
684 reason: format!(
685 "expected a list of events, or a dict holding one under \
686 `events`: {}",
687 crate::error::truncate(&format!("{:?}", response.node), 300)
688 ),
689 });
690 }
691 },
692 };
693 Ok(items.iter().filter_map(parse_event).collect())
694}
695
696fn parse_event(event: &YsonValue) -> Option<OperationEvent> {
697 Some(OperationEvent {
698 event_type: text(field(event, "event_type")?)?,
699 timestamp: field(event, "timestamp").and_then(text),
700 incarnation: field(event, "incarnation").and_then(text),
701 })
702}
703
704/// A boolean field, absent-or-not-a-boolean being `None`.
705pub(crate) fn flag(value: Option<&YsonValue>) -> Option<bool> {
706 match value?.node {
707 YsonNode::Boolean(b) => Some(b),
708 _ => None,
709 }
710}
711
712// The narrow readers of a `get_operation` document, as functions of the
713// document rather than methods on the client. Each one is a guess about where
714// an attribute sits, and a guess a test cannot reach is a guess nobody checks:
715// these are what `Client::operation_state` and its kin are, and what the
716// fixture test in `lib.rs` runs against a document a cluster actually sent.
717
718/// The `state` of a `get_operation` answer.
719pub(crate) fn state_of(document: &YsonValue) -> Result<String> {
720 match field(document, "state").map(|state| &state.node) {
721 Some(YsonNode::String(bytes)) => Ok(String::from_utf8_lossy(bytes).into_owned()),
722 other => Err(ClientError::Decode {
723 command: "get_operation".to_owned(),
724 reason: format!("state is missing or not a string: {other:?}"),
725 }),
726 }
727}
728
729/// Whether a `get_operation` answer says the operation is paused.
730///
731/// **Absent is `false`**, which is the honest answer rather than a decode
732/// failure: the scheduler reports `suspended` for an operation it still holds,
733/// and one resolved out of the operations archive may simply not carry it.
734/// [`parse_operation`] reads the same attribute the same way, and the two must
735/// not disagree about what absence means. A `suspended` that is *there* and not
736/// a boolean is a shape that moved, and does fail.
737pub(crate) fn suspended_of(document: &YsonValue) -> Result<bool> {
738 match field(document, "suspended") {
739 None => Ok(false),
740 Some(value) => flag(Some(value)).ok_or_else(|| ClientError::Decode {
741 command: "get_operation".to_owned(),
742 reason: format!("suspended is not a boolean: {:?}", value.node),
743 }),
744 }
745}
746
747/// Why an operation ended as it did, out of a document holding its `result`.
748///
749/// `None` for one that succeeded, and for one that has not finished.
750pub(crate) fn result_error_of(document: &YsonValue) -> Option<String> {
751 let error = field(document, "result").and_then(|result| field(result, "error"))?;
752
753 // An operation that succeeded still has an error document — code 0 with an
754 // empty message. Reporting that as `Some("")` would make `if let Some(why)`
755 // fire on every success and print nothing, which is the shape of bug that
756 // survives review because it looks like it works.
757 if field(error, "code").and_then(YsonValue::as_i64) == Some(0) {
758 return None;
759 }
760
761 crate::jobs::error_summary(error)
762}
763
764/// The `job_statistics` subtree of a document holding an operation's `progress`.
765///
766/// An empty dict when the cluster reports none, which is what an operation that
767/// has not run a job yet looks like.
768pub(crate) fn statistics_of(document: &YsonValue) -> YsonValue {
769 field(document, "progress")
770 .and_then(|progress| field(progress, "job_statistics"))
771 .cloned()
772 .unwrap_or_else(yson_build::empty_map)
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use ytsaurus_yson::{YsonFormat, from_slice, to_string};
779
780 fn parse(text: &str) -> YsonValue {
781 from_slice(text.as_bytes(), YsonFormat::Text).expect("valid YSON")
782 }
783
784 fn rendered(value: &YsonValue) -> String {
785 to_string(value, YsonFormat::Text).expect("encodes")
786 }
787
788 /// A real `list_operations` response, captured from the local cluster with
789 /// one operation running and one already completed.
790 const LIST_OPERATIONS: &str = include_str!("../tests/fixtures/list_operations.yson");
791
792 fn operations(text: &str) -> OperationList {
793 parse_operations(&parse(text)).expect("a well-formed listing")
794 }
795
796 #[test]
797 fn reads_a_list_captured_from_a_cluster() {
798 let list = operations(LIST_OPERATIONS);
799
800 assert_eq!(list.operations.len(), 2);
801 assert!(!list.incomplete);
802
803 let running = &list.operations[0];
804 assert_eq!(running.id, "4f5a087b-aac92287-103e8-a74d2331");
805 assert_eq!(running.kind, "vanilla");
806 assert_eq!(running.state, "running");
807 assert_eq!(running.user.as_deref(), Some("root"));
808 assert!(running.start_time.is_some());
809 assert_eq!(
810 running.finish_time, None,
811 "an operation that has not finished has no finish time, and that \
812 must stay distinguishable from a time of zero"
813 );
814
815 let finished = &list.operations[1];
816 assert_eq!(finished.state, "completed");
817 assert!(finished.finish_time.is_some());
818 }
819
820 /// The cluster reports suspension beside the state, not in it — an
821 /// operation that is paused still says `running`.
822 #[test]
823 fn suspension_is_read_from_its_own_field() {
824 let list =
825 operations(r#"{"operations"=[{"id"="a-b-c-d";"state"="running";"suspended"=%true}]}"#);
826 assert_eq!(list.operations[0].state, "running");
827 assert!(list.operations[0].suspended);
828 }
829
830 #[test]
831 fn an_operation_without_an_id_is_dropped() {
832 let list = operations(
833 r#"{"operations"=[{"state"="running"};{"id"="a-b-c-d"}];"incomplete"=%true}"#,
834 );
835 assert_eq!(list.operations.len(), 1);
836 assert_eq!(list.operations[0].id, "a-b-c-d");
837 assert!(list.incomplete, "a truncated listing must say so");
838 }
839
840 /// The listing a supervisor reads to decide whether its own operation is
841 /// still alive. An answer this cannot read must say so, rather than come
842 /// back as "the cluster is idle" and have a duplicate started on the
843 /// strength of it.
844 #[test]
845 fn a_response_without_an_operation_list_is_an_error() {
846 assert!(parse_operations(&parse(r#"{"operations"=#}"#)).is_err());
847 assert!(parse_operations(&parse(r#""not a dict""#)).is_err());
848 assert!(
849 parse_operations(&parse(r#"{"operations"=[]}"#)).is_ok(),
850 "an empty list is a cluster with nothing running, and stays Ok"
851 );
852 }
853
854 #[test]
855 fn the_type_falls_back_when_it_is_present_but_unreadable() {
856 // `or_else` on the field would short-circuit on presence and never
857 // reach the fallback, leaving the kind empty for an operation the
858 // cluster named perfectly well under its other key.
859 let list =
860 operations(r#"{"operations"=[{"id"="a-b-c-d";"type"=#;"operation_type"="map"}]}"#);
861 assert_eq!(list.operations[0].kind, "map");
862 }
863
864 /// The documented `TOperationEvent`: a timestamp, an event type, and the
865 /// incarnation fields an operation restarted by its controller agent gets.
866 #[test]
867 fn reads_the_documented_event_list() {
868 let events = parse_events(&parse(
869 r#"[
870 {"timestamp"="2026-08-06T09:21:23.534387Z";"event_type"="started_running"};
871 {"timestamp"="2026-08-06T09:22:00.000000Z";"event_type"="incarnation_started";
872 "incarnation"="8fd0b4a1-…"};
873 ]"#,
874 ))
875 .expect("a bare list is the shape the cluster sent");
876
877 assert_eq!(events.len(), 2);
878 assert_eq!(events[0].event_type, "started_running");
879 assert_eq!(events[0].incarnation, None);
880 assert_eq!(events[1].incarnation.as_deref(), Some("8fd0b4a1-…"));
881 }
882
883 /// The bare list is the shape the local cluster answers with, and the only
884 /// one anyone here has seen — it has no operations archive, so it is always
885 /// empty. An installation that has one may well wrap it, so both are read.
886 #[test]
887 fn an_enveloped_event_list_is_read_rather_than_dropped() {
888 let events = parse_events(&parse(r#"{"events"=[{"event_type"="started_running"}]}"#))
889 .expect("the enveloped shape is accepted too");
890 assert_eq!(events.len(), 1, "an envelope must not read as no events");
891 }
892
893 /// A cluster with no operations archive answers with an empty list rather
894 /// than an error, which is what the local one does.
895 #[test]
896 fn an_empty_event_list_is_not_a_failure() {
897 assert!(
898 parse_events(&parse("[]"))
899 .expect("empty is fine")
900 .is_empty()
901 );
902 }
903
904 /// The one command here whose non-empty shape could not be checked against
905 /// a cluster. An answer that is neither shape must be an error: "no events"
906 /// is the normal answer, so a wrong shape that reads as one would be
907 /// indistinguishable from it forever.
908 #[test]
909 fn an_event_answer_of_neither_shape_is_an_error() {
910 assert!(parse_events(&parse(r#"{"event_list"=[]}"#)).is_err());
911 assert!(parse_events(&parse(r#""not a list""#)).is_err());
912 }
913
914 /// Compared whole rather than by `contains`: these values are fixed
915 /// literals, so their rendering is stable — and the text writer drops the
916 /// quotes around a string that looks like an identifier, which is exactly
917 /// the sort of thing a `contains` check would let past.
918 #[test]
919 fn a_filter_renders_the_keys_the_command_expects() {
920 let filter = OperationFilter::new()
921 .with_user("robot")
922 .with_state("running")
923 .with_kind(crate::OperationType::Merge)
924 .with_limit(7);
925
926 assert_eq!(
927 rendered(&filter.to_yson()),
928 "{limit=7;state=running;type=merge;user=robot}"
929 );
930 }
931
932 #[test]
933 fn setting_a_filter_twice_replaces_it() {
934 let out = rendered(&OperationFilter::new().with_limit(1).with_limit(2).to_yson());
935 assert_eq!(out, "{limit=2}");
936 }
937
938 #[test]
939 fn parameters_render_pool_and_weight() {
940 let out = rendered(
941 &OperationParameters::new()
942 .with_pool("fast")
943 .with_weight(2.5)
944 .to_yson(),
945 );
946 assert_eq!(
947 out, "{pool=fast;weight=2.5}",
948 "a weight is a double, and 2.5 must not arrive as an int: {out}"
949 );
950 }
951
952 #[test]
953 fn a_pool_can_be_set_for_one_tree_at_a_time() {
954 let out = rendered(
955 &OperationParameters::new()
956 .with_pool_in_tree("default", "fast")
957 .with_pool_in_tree("gpu", "research")
958 .to_yson(),
959 );
960 assert_eq!(
961 out, "{scheduling_options_per_pool_tree={default={pool=fast};gpu={pool=research}}}",
962 "the second tree must not replace the first: {out}"
963 );
964 }
965
966 /// `update_operation_parameters` assigns rather than merges, so an entry
967 /// that loses a field here loses it on the cluster too — and is answered
968 /// 200. The builder must add to the tree's options rather than replace
969 /// them, however they got there.
970 #[test]
971 fn a_pool_is_added_to_what_the_tree_already_carries() {
972 let out = rendered(
973 &OperationParameters::new()
974 .with_raw(
975 "scheduling_options_per_pool_tree",
976 yson_build::map([(
977 "default",
978 yson_build::map([("weight", yson_build::double(3.0))]),
979 )]),
980 )
981 .with_pool_in_tree("default", "fast")
982 .to_yson(),
983 );
984 assert_eq!(
985 out, "{scheduling_options_per_pool_tree={default={pool=fast;weight=3.0}}}",
986 "the weight the caller set must survive: {out}"
987 );
988 }
989
990 /// `with_raw` is the documented escape hatch and takes any value, so the
991 /// builder cannot assume the shape of what it finds under a key. This used
992 /// to abort the caller's process inside `yson_build::insert`.
993 #[test]
994 fn a_tree_option_that_is_not_a_dict_is_replaced_rather_than_panicked_on() {
995 let out = rendered(
996 &OperationParameters::new()
997 .with_raw(
998 "scheduling_options_per_pool_tree",
999 yson_build::string("oops"),
1000 )
1001 .with_pool_in_tree("default", "fast")
1002 .to_yson(),
1003 );
1004 assert_eq!(
1005 out,
1006 "{scheduling_options_per_pool_tree={default={pool=fast}}}"
1007 );
1008 }
1009
1010 #[test]
1011 fn an_empty_update_is_recognisable() {
1012 assert!(OperationParameters::new().is_empty());
1013 assert!(!OperationParameters::new().with_weight(1.0).is_empty());
1014 }
1015}