Skip to main content

ytsaurus_client/
spec.rs

1//! Operation specifications.
2//!
3//! Specs are YSON dicts with a great many optional fields. These builders cover
4//! what launching a `ytsaurus-job` worker needs and expose an escape hatch —
5//! [`MapSpec::with_raw`] — for the rest, rather than pretending to model the
6//! whole surface.
7//!
8//! Reference:
9//! <https://ytsaurus.tech/docs/en/user-guide/data-processing/operations/operations-options>
10
11use ytsaurus_yson::YsonValue;
12
13use crate::yson_build::{binary_yson_format, boolean, insert, int, list, map, string};
14
15/// The kind of operation to start.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OperationType {
18    /// A map operation.
19    Map,
20    /// A map-reduce operation.
21    MapReduce,
22    /// A reduce operation over sorted input.
23    Reduce,
24    /// A sort operation.
25    Sort,
26    /// An operation with no input tables.
27    Vanilla,
28}
29
30impl OperationType {
31    /// The wire name, as `start_operation` expects it.
32    #[must_use]
33    pub fn as_str(self) -> &'static str {
34        match self {
35            OperationType::Map => "map",
36            OperationType::MapReduce => "map_reduce",
37            OperationType::Reduce => "reduce",
38            OperationType::Sort => "sort",
39            OperationType::Vanilla => "vanilla",
40        }
41    }
42}
43
44/// The parts of a user-job spec shared by mappers and reducers.
45#[derive(Debug, Clone)]
46struct UserJob {
47    command: String,
48    files: Vec<String>,
49    memory_limit: Option<i64>,
50    environment: Vec<(String, String)>,
51}
52
53impl UserJob {
54    fn new(command: impl Into<String>) -> Self {
55        Self {
56            command: command.into(),
57            files: Vec::new(),
58            memory_limit: None,
59            environment: Vec::new(),
60        }
61    }
62
63    fn to_yson(&self) -> YsonValue {
64        let mut job = map([
65            ("command", string(&self.command)),
66            // Both directions are binary YSON, which is what `JobReader` and
67            // `JobWriter` expect by default.
68            ("input_format", binary_yson_format()),
69            ("output_format", binary_yson_format()),
70        ]);
71
72        if !self.files.is_empty() {
73            insert(&mut job, "file_paths", list(self.files.iter().map(string)));
74        }
75        if let Some(limit) = self.memory_limit {
76            insert(&mut job, "memory_limit", int(limit));
77        }
78        if !self.environment.is_empty() {
79            insert(
80                &mut job,
81                "environment",
82                map(self
83                    .environment
84                    .iter()
85                    .map(|(k, v)| (k.as_str(), string(v)))),
86            );
87        }
88        job
89    }
90}
91
92/// A map operation.
93///
94/// ```
95/// use ytsaurus_client::MapSpec;
96///
97/// let spec = MapSpec::new("./cat", ["//tmp/in"], ["//tmp/out"])
98///     .with_local_file("//tmp/cat")
99///     .with_memory_limit(512 * 1024 * 1024);
100/// ```
101#[derive(Debug, Clone)]
102pub struct MapSpec {
103    mapper: UserJob,
104    inputs: Vec<String>,
105    outputs: Vec<String>,
106    job_count: Option<i64>,
107    input_table_index: bool,
108    extra: Vec<(String, YsonValue)>,
109}
110
111impl MapSpec {
112    /// A map running `command` over `inputs`, writing `outputs`.
113    #[must_use]
114    pub fn new<I, O>(command: impl Into<String>, inputs: I, outputs: O) -> Self
115    where
116        I: IntoIterator,
117        I::Item: Into<String>,
118        O: IntoIterator,
119        O::Item: Into<String>,
120    {
121        Self {
122            mapper: UserJob::new(command),
123            inputs: inputs.into_iter().map(Into::into).collect(),
124            outputs: outputs.into_iter().map(Into::into).collect(),
125            job_count: None,
126            input_table_index: false,
127            extra: Vec::new(),
128        }
129    }
130
131    /// Adds a Cypress file the job needs — normally the worker binary.
132    #[must_use]
133    pub fn with_local_file(mut self, path: impl Into<String>) -> Self {
134        self.mapper.files.push(path.into());
135        self
136    }
137
138    /// Sets the mapper's memory limit, in bytes.
139    #[must_use]
140    pub fn with_memory_limit(mut self, bytes: i64) -> Self {
141        self.mapper.memory_limit = Some(bytes);
142        self
143    }
144
145    /// Sets an environment variable for the job, e.g. `RUST_BACKTRACE`.
146    #[must_use]
147    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
148        self.mapper.environment.push((key.into(), value.into()));
149        self
150    }
151
152    /// Asks for the input table index to be delivered with each row.
153    ///
154    /// Without this, `Row::table_index` is always 0.
155    #[must_use]
156    pub fn with_input_table_index(mut self) -> Self {
157        self.input_table_index = true;
158        self
159    }
160
161    /// Requests a specific job count.
162    #[must_use]
163    pub fn with_job_count(mut self, count: i64) -> Self {
164        self.job_count = Some(count);
165        self
166    }
167
168    /// Sets any spec field this builder does not model.
169    #[must_use]
170    pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
171        self.extra.push((key.into(), value));
172        self
173    }
174
175    /// Renders the spec.
176    #[must_use]
177    pub fn to_yson(&self) -> YsonValue {
178        let mut mapper = self.mapper.to_yson();
179        if self.input_table_index {
180            insert(&mut mapper, "enable_input_table_index", boolean(true));
181        }
182
183        let mut spec = map([
184            ("mapper", mapper),
185            ("input_table_paths", list(self.inputs.iter().map(string))),
186            ("output_table_paths", list(self.outputs.iter().map(string))),
187        ]);
188
189        if let Some(count) = self.job_count {
190            insert(&mut spec, "job_count", int(count));
191        }
192        for (key, value) in &self.extra {
193            insert(&mut spec, key, value.clone());
194        }
195        spec
196    }
197}
198
199/// A map-reduce operation.
200///
201/// The mapper is optional: without one, the input is fed straight to the
202/// reducer, which is how YTsaurus models a plain shuffle-and-reduce.
203#[derive(Debug, Clone)]
204pub struct MapReduceSpec {
205    mapper: Option<UserJob>,
206    reducer: UserJob,
207    inputs: Vec<String>,
208    outputs: Vec<String>,
209    reduce_by: Vec<String>,
210    sort_by: Vec<String>,
211    key_switch: bool,
212    extra: Vec<(String, YsonValue)>,
213}
214
215impl MapReduceSpec {
216    /// A map-reduce running `reducer` over `inputs`, grouped by `reduce_by`.
217    #[must_use]
218    pub fn new<I, O, K>(reducer: impl Into<String>, inputs: I, outputs: O, reduce_by: K) -> Self
219    where
220        I: IntoIterator,
221        I::Item: Into<String>,
222        O: IntoIterator,
223        O::Item: Into<String>,
224        K: IntoIterator,
225        K::Item: Into<String>,
226    {
227        Self {
228            mapper: None,
229            reducer: UserJob::new(reducer),
230            inputs: inputs.into_iter().map(Into::into).collect(),
231            outputs: outputs.into_iter().map(Into::into).collect(),
232            reduce_by: reduce_by.into_iter().map(Into::into).collect(),
233            sort_by: Vec::new(),
234            // On by default: a reducer built on `JobReader::groups` is wrong
235            // without it, and silently so — every key collapses into one group.
236            key_switch: true,
237            extra: Vec::new(),
238        }
239    }
240
241    /// Adds a mapper phase.
242    #[must_use]
243    pub fn with_mapper(mut self, command: impl Into<String>) -> Self {
244        self.mapper = Some(UserJob::new(command));
245        self
246    }
247
248    /// Adds a Cypress file to both phases.
249    ///
250    /// One binary usually serves both, dispatching on `argv[1]`, so attaching
251    /// it to each phase separately would only be a way to forget one.
252    #[must_use]
253    pub fn with_local_file(mut self, path: impl Into<String>) -> Self {
254        let path = path.into();
255        if let Some(mapper) = &mut self.mapper {
256            mapper.files.push(path.clone());
257        }
258        self.reducer.files.push(path);
259        self
260    }
261
262    /// Sets the memory limit for both phases, in bytes.
263    #[must_use]
264    pub fn with_memory_limit(mut self, bytes: i64) -> Self {
265        if let Some(mapper) = &mut self.mapper {
266            mapper.memory_limit = Some(bytes);
267        }
268        self.reducer.memory_limit = Some(bytes);
269        self
270    }
271
272    /// Sets the sort columns, when they differ from the reduce columns.
273    #[must_use]
274    pub fn with_sort_by<K>(mut self, columns: K) -> Self
275    where
276        K: IntoIterator,
277        K::Item: Into<String>,
278    {
279        self.sort_by = columns.into_iter().map(Into::into).collect();
280        self
281    }
282
283    /// Turns off `key_switch` delivery to the reducer.
284    ///
285    /// Only useful for a reducer that does not group — with it off,
286    /// `JobReader::groups` sees the whole input as one group.
287    #[must_use]
288    pub fn without_key_switch(mut self) -> Self {
289        self.key_switch = false;
290        self
291    }
292
293    /// Sets any spec field this builder does not model.
294    #[must_use]
295    pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
296        self.extra.push((key.into(), value));
297        self
298    }
299
300    /// Renders the spec.
301    #[must_use]
302    pub fn to_yson(&self) -> YsonValue {
303        let mut spec = map([
304            ("reducer", self.reducer.to_yson()),
305            ("input_table_paths", list(self.inputs.iter().map(string))),
306            ("output_table_paths", list(self.outputs.iter().map(string))),
307            ("reduce_by", list(self.reduce_by.iter().map(string))),
308        ]);
309
310        if let Some(mapper) = &self.mapper {
311            insert(&mut spec, "mapper", mapper.to_yson());
312        }
313
314        let sort_by = if self.sort_by.is_empty() {
315            &self.reduce_by
316        } else {
317            &self.sort_by
318        };
319        insert(&mut spec, "sort_by", list(sort_by.iter().map(string)));
320
321        if self.key_switch {
322            // An operation with several job types gives each type its own I/O
323            // section, so this is `reduce_job_io` and NOT `job_io`. Using
324            // `job_io` here is accepted and silently ignored, and the reducer
325            // then sees no key switches at all.
326            insert(
327                &mut spec,
328                "reduce_job_io",
329                map([(
330                    "control_attributes",
331                    map([("enable_key_switch", boolean(true))]),
332                )]),
333            );
334        }
335
336        for (key, value) in &self.extra {
337            insert(&mut spec, key, value.clone());
338        }
339        spec
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use ytsaurus_yson::{YsonFormat, to_string};
347
348    fn render(v: &YsonValue) -> String {
349        to_string(v, YsonFormat::Text).expect("encodes")
350    }
351
352    #[test]
353    fn a_map_spec_carries_what_the_operation_needs() {
354        let spec = MapSpec::new("./cat", ["//tmp/in"], ["//tmp/out"])
355            .with_local_file("//tmp/cat")
356            .with_memory_limit(1024);
357        let out = render(&spec.to_yson());
358
359        assert!(out.contains(r#"command="./cat""#), "{out}");
360        assert!(out.contains(r#"file_paths=["//tmp/cat"]"#), "{out}");
361        assert!(out.contains("memory_limit=1024"), "{out}");
362        assert!(out.contains(r#"input_table_paths=["//tmp/in"]"#), "{out}");
363        assert!(out.contains(r#"output_table_paths=["//tmp/out"]"#), "{out}");
364        assert!(out.contains("input_format=<format=binary>yson"), "{out}");
365    }
366
367    #[test]
368    fn multiple_outputs_are_preserved_in_order() {
369        let spec = MapSpec::new("./cat", ["//tmp/a", "//tmp/b"], ["//tmp/x", "//tmp/y"]);
370        let out = render(&spec.to_yson());
371        assert!(
372            out.contains(r#"input_table_paths=["//tmp/a";"//tmp/b"]"#),
373            "{out}"
374        );
375        assert!(
376            out.contains(r#"output_table_paths=["//tmp/x";"//tmp/y"]"#),
377            "{out}"
378        );
379    }
380
381    #[test]
382    fn table_index_is_off_unless_asked_for() {
383        let plain = render(&MapSpec::new("./c", ["//i"], ["//o"]).to_yson());
384        assert!(!plain.contains("enable_input_table_index"), "{plain}");
385
386        let asked = render(
387            &MapSpec::new("./c", ["//i"], ["//o"])
388                .with_input_table_index()
389                .to_yson(),
390        );
391        assert!(asked.contains("enable_input_table_index=%true"), "{asked}");
392    }
393
394    /// The mistake that cost real debugging time: on a map-reduce the reducer's
395    /// section is `reduce_job_io`, and `job_io` is silently ignored.
396    #[test]
397    fn map_reduce_puts_key_switch_under_reduce_job_io() {
398        let spec = MapReduceSpec::new("./wc reduce", ["//in"], ["//out"], ["word"])
399            .with_mapper("./wc map");
400        let out = render(&spec.to_yson());
401
402        assert!(
403            out.contains("reduce_job_io={control_attributes={enable_key_switch=%true}}"),
404            "{out}"
405        );
406        // `reduce_job_io` ends with `job_io`, so a naive substring check would
407        // always pass. Anchor on the key boundary instead.
408        assert!(
409            !out.contains(";job_io=") && !out.contains("{job_io="),
410            "must not use the plain job_io section: {out}"
411        );
412    }
413
414    #[test]
415    fn key_switch_can_be_turned_off() {
416        let out = render(
417            &MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
418                .without_key_switch()
419                .to_yson(),
420        );
421        assert!(!out.contains("enable_key_switch"), "{out}");
422    }
423
424    #[test]
425    fn sort_by_defaults_to_reduce_by() {
426        let out = render(&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"]).to_yson());
427        assert!(out.contains("sort_by=[k]"), "{out}");
428
429        let out = render(
430            &MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
431                .with_sort_by(["k", "ts"])
432                .to_yson(),
433        );
434        assert!(out.contains("sort_by=[k;ts]"), "{out}");
435    }
436
437    #[test]
438    fn one_file_reaches_both_phases() {
439        let out = render(
440            &MapReduceSpec::new("./w reduce", ["//in"], ["//out"], ["k"])
441                .with_mapper("./w map")
442                .with_local_file("//tmp/w")
443                .to_yson(),
444        );
445        assert_eq!(
446            out.matches(r#"file_paths=["//tmp/w"]"#).count(),
447            2,
448            "the binary must be attached to both phases: {out}"
449        );
450    }
451
452    #[test]
453    fn raw_fields_land_in_the_spec() {
454        let out = render(
455            &MapSpec::new("./c", ["//i"], ["//o"])
456                .with_raw("max_failed_job_count", int(3))
457                .to_yson(),
458        );
459        assert!(out.contains("max_failed_job_count=3"), "{out}");
460    }
461
462    #[test]
463    fn operation_type_wire_names() {
464        assert_eq!(OperationType::Map.as_str(), "map");
465        assert_eq!(OperationType::MapReduce.as_str(), "map_reduce");
466        assert_eq!(OperationType::Reduce.as_str(), "reduce");
467    }
468}