Skip to main content

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}