1use ytsaurus_yson::YsonValue;
12
13use crate::yson_build::{binary_yson_format, boolean, insert, int, list, map, string};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OperationType {
18 Map,
20 MapReduce,
22 Reduce,
24 Sort,
26 Vanilla,
28}
29
30impl OperationType {
31 #[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#[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 ("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#[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 #[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 #[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 #[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 #[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 #[must_use]
156 pub fn with_input_table_index(mut self) -> Self {
157 self.input_table_index = true;
158 self
159 }
160
161 #[must_use]
163 pub fn with_job_count(mut self, count: i64) -> Self {
164 self.job_count = Some(count);
165 self
166 }
167
168 #[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 #[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#[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 #[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 key_switch: true,
237 extra: Vec::new(),
238 }
239 }
240
241 #[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 #[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 #[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 #[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 #[must_use]
288 pub fn without_key_switch(mut self) -> Self {
289 self.key_switch = false;
290 self
291 }
292
293 #[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 #[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 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 #[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 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}