use ytsaurus_format::DataFormat;
use ytsaurus_skiff::Format as SkiffFormat;
use ytsaurus_yson::YsonValue;
use crate::yson_build::{boolean, insert, int, list, map, string, with_attributes};
fn named_file(path: impl Into<String>, name: impl AsRef<str>) -> YsonValue {
with_attributes(string(path.into()), [("file_name", string(name.as_ref()))])
}
fn skiff_table_mismatch(
what: &str,
format: &DataFormat,
tables: usize,
kind: &str,
) -> Option<String> {
let schemas = format.as_skiff()?.table_schemas().len();
if schemas == tables {
return None;
}
Some(format!(
"{what} declares {}, but this operation has {}",
plural(schemas, "Skiff table schema"),
plural(tables, kind)
))
}
fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("{count} {noun}")
} else {
format!("{count} {noun}s")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationType {
Map,
MapReduce,
Reduce,
Sort,
Vanilla,
Merge,
Erase,
RemoteCopy,
JoinReduce,
}
impl OperationType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
OperationType::Map => "map",
OperationType::MapReduce => "map_reduce",
OperationType::Reduce => "reduce",
OperationType::Sort => "sort",
OperationType::Vanilla => "vanilla",
OperationType::Merge => "merge",
OperationType::Erase => "erase",
OperationType::RemoteCopy => "remote_copy",
OperationType::JoinReduce => "join_reduce",
}
}
}
#[derive(Debug, Clone)]
struct UserJob {
command: String,
files: Vec<YsonValue>,
memory_limit: Option<i64>,
environment: Vec<(String, String)>,
input_format: DataFormat,
output_format: DataFormat,
}
impl UserJob {
fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
files: Vec::new(),
memory_limit: None,
environment: Vec::new(),
input_format: DataFormat::binary_yson(),
output_format: DataFormat::binary_yson(),
}
}
fn with_formats(&mut self, input: DataFormat, output: DataFormat) {
self.input_format = input;
self.output_format = output;
}
fn to_yson(&self) -> YsonValue {
let mut job = map([
("command", string(&self.command)),
("input_format", self.input_format.to_yson()),
("output_format", self.output_format.to_yson()),
]);
if !self.files.is_empty() {
insert(&mut job, "file_paths", list(self.files.iter().cloned()));
}
if let Some(limit) = self.memory_limit {
insert(&mut job, "memory_limit", int(limit));
}
if !self.environment.is_empty() {
insert(
&mut job,
"environment",
map(self
.environment
.iter()
.map(|(k, v)| (k.as_str(), string(v)))),
);
}
job
}
}
#[derive(Debug, Clone)]
pub struct MapSpec {
mapper: UserJob,
inputs: Vec<String>,
outputs: Vec<String>,
job_count: Option<i64>,
input_table_index: bool,
extra: Vec<(String, YsonValue)>,
}
impl MapSpec {
#[must_use]
pub fn new<I, O>(command: impl Into<String>, inputs: I, outputs: O) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
O: IntoIterator,
O::Item: Into<String>,
{
Self {
mapper: UserJob::new(command),
inputs: inputs.into_iter().map(Into::into).collect(),
outputs: outputs.into_iter().map(Into::into).collect(),
job_count: None,
input_table_index: false,
extra: Vec::new(),
}
}
#[must_use]
pub fn with_local_file(mut self, path: impl Into<String>) -> Self {
self.mapper.files.push(string(path.into()));
self
}
#[must_use]
pub fn with_local_file_named(mut self, path: impl Into<String>, name: impl AsRef<str>) -> Self {
self.mapper.files.push(named_file(path, name));
self
}
#[must_use]
pub fn with_memory_limit(mut self, bytes: i64) -> Self {
self.mapper.memory_limit = Some(bytes);
self
}
#[must_use]
pub fn with_formats(mut self, input: DataFormat, output: DataFormat) -> Self {
self.mapper.with_formats(input, output);
self
}
#[must_use]
pub fn with_skiff_formats(self, input: SkiffFormat, output: SkiffFormat) -> Self {
self.with_formats(DataFormat::skiff(input), DataFormat::skiff(output))
}
#[must_use]
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.mapper.environment.push((key.into(), value.into()));
self
}
#[must_use]
pub fn with_input_table_index(mut self) -> Self {
self.input_table_index = true;
self
}
#[must_use]
pub fn with_job_count(mut self, count: i64) -> Self {
self.job_count = Some(count);
self
}
#[must_use]
pub fn skiff_table_mismatch(&self) -> Option<String> {
skiff_table_mismatch(
"the mapper's input_format",
&self.mapper.input_format,
self.inputs.len(),
"input table",
)
.or_else(|| {
skiff_table_mismatch(
"the mapper's output_format",
&self.mapper.output_format,
self.outputs.len(),
"output table",
)
})
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut mapper = self.mapper.to_yson();
if self.input_table_index {
insert(&mut mapper, "enable_input_table_index", boolean(true));
}
let mut spec = map([
("mapper", mapper),
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_paths", list(self.outputs.iter().map(string))),
]);
if let Some(count) = self.job_count {
insert(&mut spec, "job_count", int(count));
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct MapReduceSpec {
mapper: Option<UserJob>,
mapper_formats: Option<(DataFormat, DataFormat)>,
reducer: UserJob,
files: Vec<YsonValue>,
memory_limit: Option<i64>,
inputs: Vec<String>,
outputs: Vec<String>,
reduce_by: Vec<String>,
sort_by: Vec<String>,
key_switch: bool,
extra: Vec<(String, YsonValue)>,
}
impl MapReduceSpec {
#[must_use]
pub fn new<I, O, K>(reducer: impl Into<String>, inputs: I, outputs: O, reduce_by: K) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
O: IntoIterator,
O::Item: Into<String>,
K: IntoIterator,
K::Item: Into<String>,
{
Self {
mapper: None,
mapper_formats: None,
reducer: UserJob::new(reducer),
files: Vec::new(),
memory_limit: None,
inputs: inputs.into_iter().map(Into::into).collect(),
outputs: outputs.into_iter().map(Into::into).collect(),
reduce_by: reduce_by.into_iter().map(Into::into).collect(),
sort_by: Vec::new(),
key_switch: true,
extra: Vec::new(),
}
}
#[must_use]
pub fn with_mapper(mut self, command: impl Into<String>) -> Self {
self.mapper = Some(UserJob::new(command));
self
}
#[must_use]
pub fn with_mapper_formats(mut self, input: DataFormat, output: DataFormat) -> Self {
self.mapper_formats = Some((input, output));
self
}
#[must_use]
pub fn with_mapper_skiff_formats(self, input: SkiffFormat, output: SkiffFormat) -> Self {
self.with_mapper_formats(DataFormat::skiff(input), DataFormat::skiff(output))
}
#[must_use]
pub fn with_reducer_formats(mut self, input: DataFormat, output: DataFormat) -> Self {
self.reducer.with_formats(input, output);
self
}
#[must_use]
pub fn with_reducer_skiff_formats(self, input: SkiffFormat, output: SkiffFormat) -> Self {
self.with_reducer_formats(DataFormat::skiff(input), DataFormat::skiff(output))
}
#[must_use]
pub fn with_local_file(self, path: impl Into<String>) -> Self {
self.attach(string(path.into()))
}
#[must_use]
pub fn with_local_file_named(self, path: impl Into<String>, name: impl AsRef<str>) -> Self {
self.attach(named_file(path, name))
}
fn attach(mut self, file: YsonValue) -> Self {
self.files.push(file);
self
}
#[must_use]
pub fn with_memory_limit(mut self, bytes: i64) -> Self {
self.memory_limit = Some(bytes);
self
}
fn phase(&self, job: &UserJob, formats: Option<&(DataFormat, DataFormat)>) -> YsonValue {
let mut job = job.clone();
if let Some((input, output)) = formats {
job.with_formats(input.clone(), output.clone());
}
job.files.extend(self.files.iter().cloned());
if job.memory_limit.is_none() {
job.memory_limit = self.memory_limit;
}
job.to_yson()
}
#[must_use]
pub fn with_sort_by<K>(mut self, columns: K) -> Self
where
K: IntoIterator,
K::Item: Into<String>,
{
self.sort_by = columns.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn without_key_switch(mut self) -> Self {
self.key_switch = false;
self
}
#[must_use]
pub fn skiff_table_mismatch(&self) -> Option<String> {
let split_outputs = self
.extra
.iter()
.any(|(key, _)| key == "mapper_output_table_count");
self.mapper
.as_ref()
.and(self.mapper_formats.as_ref())
.and_then(|(input, _)| {
skiff_table_mismatch(
"the mapper's input_format",
input,
self.inputs.len(),
"input table",
)
})
.or_else(|| {
if split_outputs {
return None;
}
skiff_table_mismatch(
"the reducer's output_format",
&self.reducer.output_format,
self.outputs.len(),
"output table",
)
})
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([
("reducer", self.phase(&self.reducer, None)),
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_paths", list(self.outputs.iter().map(string))),
("reduce_by", list(self.reduce_by.iter().map(string))),
]);
if let Some(mapper) = &self.mapper {
insert(
&mut spec,
"mapper",
self.phase(mapper, self.mapper_formats.as_ref()),
);
}
let sort_by = if self.sort_by.is_empty() {
&self.reduce_by
} else {
&self.sort_by
};
insert(&mut spec, "sort_by", list(sort_by.iter().map(string)));
if self.key_switch {
insert(
&mut spec,
"reduce_job_io",
map([(
"control_attributes",
map([("enable_key_switch", boolean(true))]),
)]),
);
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct ReduceSpec {
reducer: UserJob,
inputs: Vec<String>,
outputs: Vec<String>,
reduce_by: Vec<String>,
sort_by: Vec<String>,
job_count: Option<i64>,
key_switch: bool,
input_table_index: bool,
extra: Vec<(String, YsonValue)>,
}
impl ReduceSpec {
#[must_use]
pub fn new<I, O, K>(command: impl Into<String>, inputs: I, outputs: O, reduce_by: K) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
O: IntoIterator,
O::Item: Into<String>,
K: IntoIterator,
K::Item: Into<String>,
{
Self {
reducer: UserJob::new(command),
inputs: inputs.into_iter().map(Into::into).collect(),
outputs: outputs.into_iter().map(Into::into).collect(),
reduce_by: reduce_by.into_iter().map(Into::into).collect(),
sort_by: Vec::new(),
job_count: None,
key_switch: true,
input_table_index: false,
extra: Vec::new(),
}
}
#[must_use]
pub fn with_local_file(mut self, path: impl Into<String>) -> Self {
self.reducer.files.push(string(path.into()));
self
}
#[must_use]
pub fn with_local_file_named(mut self, path: impl Into<String>, name: impl AsRef<str>) -> Self {
self.reducer.files.push(named_file(path, name));
self
}
#[must_use]
pub fn with_memory_limit(mut self, bytes: i64) -> Self {
self.reducer.memory_limit = Some(bytes);
self
}
#[must_use]
pub fn with_formats(mut self, input: DataFormat, output: DataFormat) -> Self {
self.reducer.with_formats(input, output);
self
}
#[must_use]
pub fn with_skiff_formats(self, input: SkiffFormat, output: SkiffFormat) -> Self {
self.with_formats(DataFormat::skiff(input), DataFormat::skiff(output))
}
#[must_use]
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.reducer.environment.push((key.into(), value.into()));
self
}
#[must_use]
pub fn with_sort_by<K>(mut self, columns: K) -> Self
where
K: IntoIterator,
K::Item: Into<String>,
{
self.sort_by = columns.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_job_count(mut self, count: i64) -> Self {
self.job_count = Some(count);
self
}
#[must_use]
pub fn with_input_table_index(mut self) -> Self {
self.input_table_index = true;
self
}
#[must_use]
pub fn without_key_switch(mut self) -> Self {
self.key_switch = false;
self
}
#[must_use]
pub fn skiff_table_mismatch(&self) -> Option<String> {
skiff_table_mismatch(
"the reducer's input_format",
&self.reducer.input_format,
self.inputs.len(),
"input table",
)
.or_else(|| {
skiff_table_mismatch(
"the reducer's output_format",
&self.reducer.output_format,
self.outputs.len(),
"output table",
)
})
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut reducer = self.reducer.to_yson();
if self.input_table_index {
insert(&mut reducer, "enable_input_table_index", boolean(true));
}
let mut spec = map([
("reducer", reducer),
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_paths", list(self.outputs.iter().map(string))),
("reduce_by", list(self.reduce_by.iter().map(string))),
]);
if !self.sort_by.is_empty() {
insert(&mut spec, "sort_by", list(self.sort_by.iter().map(string)));
}
if let Some(count) = self.job_count {
insert(&mut spec, "job_count", int(count));
}
if self.key_switch {
insert(
&mut spec,
"job_io",
map([(
"control_attributes",
map([("enable_key_switch", boolean(true))]),
)]),
);
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct SortSpec {
inputs: Vec<String>,
output: String,
sort_by: Vec<String>,
extra: Vec<(String, YsonValue)>,
}
impl SortSpec {
#[must_use]
pub fn new<I, K>(inputs: I, output: impl Into<String>, sort_by: K) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
K: IntoIterator,
K::Item: Into<String>,
{
Self {
inputs: inputs.into_iter().map(Into::into).collect(),
output: output.into(),
sort_by: sort_by.into_iter().map(Into::into).collect(),
extra: Vec::new(),
}
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_path", string(&self.output)),
("sort_by", list(self.sort_by.iter().map(string))),
]);
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeMode {
Unordered,
Ordered,
Sorted,
}
impl MergeMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
MergeMode::Unordered => "unordered",
MergeMode::Ordered => "ordered",
MergeMode::Sorted => "sorted",
}
}
}
#[derive(Debug, Clone)]
pub struct MergeSpec {
inputs: Vec<String>,
output: String,
mode: MergeMode,
merge_by: Vec<String>,
combine_chunks: Option<bool>,
force_transform: Option<bool>,
job_count: Option<i64>,
extra: Vec<(String, YsonValue)>,
}
impl MergeSpec {
#[must_use]
pub fn new<I>(inputs: I, output: impl Into<String>) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
{
Self {
inputs: inputs.into_iter().map(Into::into).collect(),
output: output.into(),
mode: MergeMode::Unordered,
merge_by: Vec::new(),
combine_chunks: None,
force_transform: None,
job_count: None,
extra: Vec::new(),
}
}
#[must_use]
pub fn with_mode(mut self, mode: MergeMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_merge_by<K>(mut self, columns: K) -> Self
where
K: IntoIterator,
K::Item: Into<String>,
{
self.merge_by = columns.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_combine_chunks(mut self, combine: bool) -> Self {
self.combine_chunks = Some(combine);
self
}
#[must_use]
pub fn with_force_transform(mut self, force: bool) -> Self {
self.force_transform = Some(force);
self
}
#[must_use]
pub fn with_job_count(mut self, count: i64) -> Self {
self.job_count = Some(count);
self
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_path", string(&self.output)),
("mode", string(self.mode.as_str())),
]);
if !self.merge_by.is_empty() {
insert(
&mut spec,
"merge_by",
list(self.merge_by.iter().map(string)),
);
}
if let Some(combine) = self.combine_chunks {
insert(&mut spec, "combine_chunks", boolean(combine));
}
if let Some(force) = self.force_transform {
insert(&mut spec, "force_transform", boolean(force));
}
if let Some(count) = self.job_count {
insert(&mut spec, "job_count", int(count));
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct EraseSpec {
table: String,
combine_chunks: Option<bool>,
extra: Vec<(String, YsonValue)>,
}
impl EraseSpec {
#[must_use]
pub fn new(table: impl Into<String>) -> Self {
Self {
table: table.into(),
combine_chunks: None,
extra: Vec::new(),
}
}
#[must_use]
pub fn with_combine_chunks(mut self, combine: bool) -> Self {
self.combine_chunks = Some(combine);
self
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([("table_path", string(&self.table))]);
if let Some(combine) = self.combine_chunks {
insert(&mut spec, "combine_chunks", boolean(combine));
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct RemoteCopySpec {
cluster_name: String,
inputs: Vec<String>,
output: String,
network_name: Option<String>,
copy_attributes: Option<bool>,
attribute_keys: Vec<String>,
extra: Vec<(String, YsonValue)>,
}
impl RemoteCopySpec {
#[must_use]
pub fn new<I>(cluster_name: impl Into<String>, inputs: I, output: impl Into<String>) -> Self
where
I: IntoIterator,
I::Item: Into<String>,
{
Self {
cluster_name: cluster_name.into(),
inputs: inputs.into_iter().map(Into::into).collect(),
output: output.into(),
network_name: None,
copy_attributes: None,
attribute_keys: Vec::new(),
extra: Vec::new(),
}
}
#[must_use]
pub fn with_network_name(mut self, network: impl Into<String>) -> Self {
self.network_name = Some(network.into());
self
}
#[must_use]
pub fn with_copy_attributes(mut self, copy: bool) -> Self {
self.copy_attributes = Some(copy);
self
}
#[must_use]
pub fn with_attribute_keys<K>(mut self, keys: K) -> Self
where
K: IntoIterator,
K::Item: Into<String>,
{
self.attribute_keys = keys.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([
("cluster_name", string(&self.cluster_name)),
("input_table_paths", list(self.inputs.iter().map(string))),
("output_table_path", string(&self.output)),
]);
if let Some(network) = &self.network_name {
insert(&mut spec, "network_name", string(network));
}
if let Some(copy) = self.copy_attributes {
insert(&mut spec, "copy_attributes", boolean(copy));
}
if !self.attribute_keys.is_empty() {
insert(
&mut spec,
"attribute_keys",
list(self.attribute_keys.iter().map(string)),
);
}
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[derive(Debug, Clone)]
pub struct VanillaTask {
name: String,
job: UserJob,
job_count: i64,
outputs: Vec<String>,
extra: Vec<(String, YsonValue)>,
}
impl VanillaTask {
#[must_use]
pub fn new(name: impl Into<String>, command: impl Into<String>, job_count: i64) -> Self {
Self {
name: name.into(),
job: UserJob::new(command),
job_count,
outputs: Vec::new(),
extra: Vec::new(),
}
}
#[must_use]
pub fn with_local_file(mut self, path: impl Into<String>) -> Self {
self.job.files.push(string(path.into()));
self
}
#[must_use]
pub fn with_local_file_named(mut self, path: impl Into<String>, name: impl AsRef<str>) -> Self {
self.job.files.push(named_file(path, name));
self
}
#[must_use]
pub fn with_outputs<O>(mut self, paths: O) -> Self
where
O: IntoIterator,
O::Item: Into<String>,
{
self.outputs = paths.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_memory_limit(mut self, bytes: i64) -> Self {
self.job.memory_limit = Some(bytes);
self
}
#[must_use]
pub fn with_output_format(mut self, output: DataFormat) -> Self {
self.job.output_format = output;
self
}
#[must_use]
pub fn with_skiff_output_format(self, output: SkiffFormat) -> Self {
self.with_output_format(DataFormat::skiff(output))
}
#[must_use]
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.job.environment.push((key.into(), value.into()));
self
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
fn to_yson(&self) -> YsonValue {
let mut task = self.job.to_yson();
insert(&mut task, "job_count", int(self.job_count));
insert(
&mut task,
"output_table_paths",
list(self.outputs.iter().map(string)),
);
for (key, value) in &self.extra {
insert(&mut task, key, value.clone());
}
task
}
}
#[derive(Debug, Clone)]
pub struct VanillaSpec {
tasks: Vec<VanillaTask>,
extra: Vec<(String, YsonValue)>,
}
impl VanillaSpec {
#[must_use]
pub fn new(task: VanillaTask) -> Self {
Self {
tasks: vec![task],
extra: Vec::new(),
}
}
#[must_use]
pub fn with_task(mut self, task: VanillaTask) -> Self {
self.tasks.push(task);
self
}
#[must_use]
pub fn duplicate_task(&self) -> Option<&str> {
let mut seen = std::collections::HashSet::new();
self.tasks
.iter()
.find(|task| !seen.insert(task.name.as_str()))
.map(|task| task.name.as_str())
}
#[must_use]
pub fn skiff_table_mismatch(&self) -> Option<String> {
self.tasks.iter().find_map(|task| {
skiff_table_mismatch(
&format!("task {:?}'s output_format", task.name),
&task.job.output_format,
task.outputs.len(),
"output table",
)
})
}
#[must_use]
pub fn with_raw(mut self, key: impl Into<String>, value: YsonValue) -> Self {
self.extra.push((key.into(), value));
self
}
#[must_use]
pub fn to_yson(&self) -> YsonValue {
let mut spec = map([(
"tasks",
map(self
.tasks
.iter()
.map(|task| (task.name.as_str(), task.to_yson()))),
)]);
for (key, value) in &self.extra {
insert(&mut spec, key, value.clone());
}
spec
}
}
#[cfg(test)]
mod tests {
use super::*;
use ytsaurus_skiff::{Schema, SchemaRef, WireType};
use ytsaurus_yson::{YsonFormat, to_string};
fn render(v: &YsonValue) -> String {
to_string(v, YsonFormat::Text).expect("encodes")
}
fn skiff_format(column: &str) -> SkiffFormat {
SkiffFormat::new(vec![SchemaRef::Inline(Schema::tuple([Schema::named(
column,
WireType::Uint64,
)]))])
.expect("a named tuple is a table schema")
}
fn skiff_tables(columns: &[&str]) -> SkiffFormat {
SkiffFormat::new(
columns
.iter()
.map(|column| {
SchemaRef::Inline(Schema::tuple([Schema::named(*column, WireType::Uint64)]))
})
.collect(),
)
.expect("named tuples are table schemas")
}
#[test]
fn a_map_skiff_format_needs_one_schema_per_table() {
let two_in_one_out = MapSpec::new("./w", ["//a", "//b"], ["//out"]);
let short_input = two_in_one_out
.clone()
.with_skiff_formats(skiff_tables(&["source"]), skiff_tables(&["result"]));
let reason = short_input
.skiff_table_mismatch()
.expect("one schema cannot describe two input tables");
assert!(reason.contains("input_format"), "{reason}");
assert!(reason.contains("1 Skiff table schema,"), "{reason}");
assert!(reason.contains("2 input tables"), "{reason}");
let long_output = two_in_one_out
.clone()
.with_skiff_formats(skiff_tables(&["a", "b"]), skiff_tables(&["x", "y"]));
let reason = long_output
.skiff_table_mismatch()
.expect("two schemas cannot describe one output table");
assert!(reason.contains("output_format"), "{reason}");
assert!(
two_in_one_out
.clone()
.with_skiff_formats(skiff_tables(&["a", "b"]), skiff_tables(&["x"]))
.skiff_table_mismatch()
.is_none()
);
assert!(two_in_one_out.skiff_table_mismatch().is_none());
}
#[test]
fn a_reduce_skiff_format_needs_one_schema_per_table() {
let spec = ReduceSpec::new("./w", ["//a", "//b"], ["//out"], ["key"]);
let reason = spec
.clone()
.with_skiff_formats(skiff_tables(&["source"]), skiff_tables(&["result"]))
.skiff_table_mismatch()
.expect("a reduce input format describes every input table");
assert!(reason.contains("input_format"), "{reason}");
assert!(
spec.with_skiff_formats(skiff_tables(&["a", "b"]), skiff_tables(&["x"]))
.skiff_table_mismatch()
.is_none()
);
}
#[test]
fn map_reduce_checks_the_counts_it_knows_and_leaves_the_shuffle_alone() {
let spec = MapReduceSpec::new("./r", ["//a", "//b"], ["//out"], ["key"])
.with_mapper("./m")
.with_mapper_skiff_formats(skiff_tables(&["one"]), skiff_tables(&["shuffle"]));
let reason = spec
.skiff_table_mismatch()
.expect("the mapper still reads the operation's input tables");
assert!(reason.contains("input_format"), "{reason}");
let shuffle = MapReduceSpec::new("./r", ["//a"], ["//out"], ["key"])
.with_mapper("./m")
.with_mapper_skiff_formats(skiff_tables(&["one"]), skiff_tables(&["x", "y"]))
.with_reducer_skiff_formats(skiff_tables(&["x", "y"]), skiff_tables(&["out"]));
assert!(shuffle.skiff_table_mismatch().is_none());
}
#[test]
fn a_vanilla_task_skiff_output_needs_one_schema_per_output() {
let spec = VanillaSpec::new(
VanillaTask::new("worker", "./w", 1)
.with_outputs(["//one"])
.with_skiff_output_format(skiff_tables(&["a", "b"])),
);
let reason = spec
.skiff_table_mismatch()
.expect("two schemas cannot describe one output table");
assert!(reason.contains(r#"task "worker""#), "{reason}");
assert!(
VanillaSpec::new(
VanillaTask::new("worker", "./w", 1)
.with_outputs(["//one"])
.with_skiff_output_format(skiff_tables(&["a"])),
)
.skiff_table_mismatch()
.is_none()
);
}
#[test]
fn a_map_spec_carries_what_the_operation_needs() {
let spec = MapSpec::new("./cat", ["//tmp/in"], ["//tmp/out"])
.with_local_file("//tmp/cat")
.with_memory_limit(1024);
let out = render(&spec.to_yson());
assert!(out.contains(r#"command="./cat""#), "{out}");
assert!(out.contains(r#"file_paths=["//tmp/cat"]"#), "{out}");
assert!(out.contains("memory_limit=1024"), "{out}");
assert!(out.contains(r#"input_table_paths=["//tmp/in"]"#), "{out}");
assert!(out.contains(r#"output_table_paths=["//tmp/out"]"#), "{out}");
assert!(out.contains("input_format=<format=binary>yson"), "{out}");
}
#[test]
fn multiple_outputs_are_preserved_in_order() {
let spec = MapSpec::new("./cat", ["//tmp/a", "//tmp/b"], ["//tmp/x", "//tmp/y"]);
let out = render(&spec.to_yson());
assert!(
out.contains(r#"input_table_paths=["//tmp/a";"//tmp/b"]"#),
"{out}"
);
assert!(
out.contains(r#"output_table_paths=["//tmp/x";"//tmp/y"]"#),
"{out}"
);
}
#[test]
fn map_can_select_schema_checked_skiff_for_both_directions() {
let out = render(
&MapSpec::new("./worker", ["//in"], ["//out"])
.with_skiff_formats(skiff_format("source"), skiff_format("result"))
.to_yson(),
);
assert!(out.contains("input_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("output_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("name=source"), "{out}");
assert!(out.contains("name=result"), "{out}");
assert!(!out.contains("format=binary"), "{out}");
}
#[test]
fn map_can_select_yson_and_skiff_through_the_shared_format_enum() {
let out = render(
&MapSpec::new("./worker", ["//in"], ["//out"])
.with_formats(
DataFormat::text_yson(),
DataFormat::skiff(skiff_format("result")),
)
.to_yson(),
);
assert!(out.contains("input_format=<format=text>yson"), "{out}");
assert!(out.contains("output_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("name=result"), "{out}");
}
#[test]
fn table_index_is_off_unless_asked_for() {
let plain = render(&MapSpec::new("./c", ["//i"], ["//o"]).to_yson());
assert!(!plain.contains("enable_input_table_index"), "{plain}");
let asked = render(
&MapSpec::new("./c", ["//i"], ["//o"])
.with_input_table_index()
.to_yson(),
);
assert!(asked.contains("enable_input_table_index=%true"), "{asked}");
}
#[test]
fn map_reduce_puts_key_switch_under_reduce_job_io() {
let spec = MapReduceSpec::new("./wc reduce", ["//in"], ["//out"], ["word"])
.with_mapper("./wc map");
let out = render(&spec.to_yson());
assert!(
out.contains("reduce_job_io={control_attributes={enable_key_switch=%true}}"),
"{out}"
);
assert!(
!out.contains(";job_io=") && !out.contains("{job_io="),
"must not use the plain job_io section: {out}"
);
}
#[test]
fn map_reduce_can_select_skiff_per_job_phase() {
let out = render(
&MapReduceSpec::new("./worker reduce", ["//in"], ["//out"], ["key"])
.with_mapper("./worker map")
.with_mapper_skiff_formats(skiff_format("map_input"), skiff_format("map_output"))
.with_reducer_skiff_formats(
skiff_format("reduce_input"),
skiff_format("reduce_output"),
)
.to_yson(),
);
for column in ["map_input", "map_output", "reduce_input", "reduce_output"] {
assert!(out.contains(&format!("name={column}")), "{out}");
}
assert_eq!(
out.matches("input_format=<table_skiff_schemas=").count(),
2,
"{out}"
);
assert_eq!(
out.matches("output_format=<table_skiff_schemas=").count(),
2,
"{out}"
);
}
#[test]
fn a_mapper_added_last_still_gets_its_formats() {
let before = render(
&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
.with_mapper_skiff_formats(skiff_format("map_input"), skiff_format("map_output"))
.with_mapper("./m")
.to_yson(),
);
let after = render(
&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
.with_mapper("./m")
.with_mapper_skiff_formats(skiff_format("map_input"), skiff_format("map_output"))
.to_yson(),
);
assert_eq!(before, after);
assert!(before.contains("name=map_input"), "{before}");
assert!(before.contains("name=map_output"), "{before}");
}
#[test]
fn key_switch_can_be_turned_off() {
let out = render(
&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
.without_key_switch()
.to_yson(),
);
assert!(!out.contains("enable_key_switch"), "{out}");
}
#[test]
fn sort_by_defaults_to_reduce_by() {
let out = render(&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"]).to_yson());
assert!(out.contains("sort_by=[k]"), "{out}");
let out = render(
&MapReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
.with_sort_by(["k", "ts"])
.to_yson(),
);
assert!(out.contains("sort_by=[k;ts]"), "{out}");
}
#[test]
fn one_file_reaches_both_phases() {
let out = render(
&MapReduceSpec::new("./w reduce", ["//in"], ["//out"], ["k"])
.with_mapper("./w map")
.with_local_file("//tmp/w")
.to_yson(),
);
assert_eq!(
out.matches(r#"file_paths=["//tmp/w"]"#).count(),
2,
"the binary must be attached to both phases: {out}"
);
}
#[test]
fn a_mapper_added_last_still_gets_the_files_and_the_limit() {
let out = render(
&MapReduceSpec::new("./w reduce", ["//in"], ["//out"], ["k"])
.with_local_file("//tmp/w")
.with_memory_limit(512 * 1024 * 1024)
.with_mapper("./w map")
.to_yson(),
);
assert_eq!(
out.matches(r#"file_paths=["//tmp/w"]"#).count(),
2,
"the binary must reach both phases whatever the call order: {out}"
);
assert_eq!(
out.matches("memory_limit=536870912").count(),
2,
"the limit must reach both phases whatever the call order: {out}"
);
}
#[test]
fn a_named_file_carries_its_sandbox_name() {
let cached = "//tmp/yt_wrapper/file_storage/new_cache/da/2c76e46b90e8b9d5ec25397e14c043da";
let out = render(
&MapSpec::new("./cat", ["//i"], ["//o"])
.with_local_file_named(cached, "cat")
.to_yson(),
);
assert!(out.contains("file_name=cat"), "{out}");
assert!(out.contains(cached), "{out}");
}
#[test]
fn a_named_file_reaches_both_map_reduce_phases() {
let out = render(
&MapReduceSpec::new("./w reduce", ["//in"], ["//out"], ["k"])
.with_mapper("./w map")
.with_local_file_named("//tmp/cache/ab/cd", "w")
.to_yson(),
);
assert_eq!(
out.matches("file_name=w").count(),
2,
"the binary must be attached to both phases: {out}"
);
}
#[test]
fn a_plain_file_gets_no_attributes() {
let out = render(
&MapSpec::new("./cat", ["//i"], ["//o"])
.with_local_file("//tmp/cat")
.to_yson(),
);
assert!(out.contains(r#"file_paths=["//tmp/cat"]"#), "{out}");
}
#[test]
fn raw_fields_land_in_the_spec() {
let out = render(
&MapSpec::new("./c", ["//i"], ["//o"])
.with_raw("max_failed_job_count", int(3))
.to_yson(),
);
assert!(out.contains("max_failed_job_count=3"), "{out}");
}
#[test]
fn operation_type_wire_names() {
assert_eq!(OperationType::Map.as_str(), "map");
assert_eq!(OperationType::MapReduce.as_str(), "map_reduce");
assert_eq!(OperationType::Reduce.as_str(), "reduce");
assert_eq!(OperationType::Sort.as_str(), "sort");
assert_eq!(OperationType::Vanilla.as_str(), "vanilla");
assert_eq!(OperationType::Merge.as_str(), "merge");
assert_eq!(OperationType::Erase.as_str(), "erase");
assert_eq!(OperationType::RemoteCopy.as_str(), "remote_copy");
assert_eq!(OperationType::JoinReduce.as_str(), "join_reduce");
}
#[test]
fn merge_mode_wire_names() {
assert_eq!(MergeMode::Unordered.as_str(), "unordered");
assert_eq!(MergeMode::Ordered.as_str(), "ordered");
assert_eq!(MergeMode::Sorted.as_str(), "sorted");
}
#[test]
fn a_merge_spec_names_one_output() {
let out = render(&MergeSpec::new(["//tmp/a", "//tmp/b"], "//tmp/all").to_yson());
assert!(
out.contains(r#"input_table_paths=["//tmp/a";"//tmp/b"]"#),
"{out}"
);
assert!(out.contains(r#"output_table_path="//tmp/all""#), "{out}");
assert!(
out.contains("mode=unordered"),
"the cheapest mode is the default, and it is sent rather than \
assumed: {out}"
);
assert!(!out.contains("merge_by"), "{out}");
}
#[test]
fn a_sorted_merge_carries_its_key() {
let spec = MergeSpec::new(["//tmp/a"], "//tmp/all")
.with_mode(MergeMode::Sorted)
.with_merge_by(["host", "day"])
.with_combine_chunks(true)
.with_job_count(4);
let out = render(&spec.to_yson());
assert!(out.contains("mode=sorted"), "{out}");
assert!(out.contains("merge_by=[host;day]"), "{out}");
assert!(out.contains("combine_chunks=%true"), "{out}");
assert!(out.contains("job_count=4"), "{out}");
let _ = spec;
}
#[test]
fn a_sorted_merge_may_leave_its_key_to_the_cluster() {
let out = render(
&MergeSpec::new(["//tmp/a"], "//tmp/all")
.with_mode(MergeMode::Sorted)
.to_yson(),
);
assert!(out.contains("mode=sorted"), "{out}");
assert!(
!out.contains("merge_by"),
"an absent key is the request to infer one: {out}"
);
}
#[test]
fn a_key_set_through_the_escape_hatch_is_rendered() {
let out = render(
&MergeSpec::new(["//tmp/a"], "//tmp/all")
.with_mode(MergeMode::Sorted)
.with_raw("merge_by", list([string("host")]))
.to_yson(),
);
assert!(out.contains("merge_by=[host]"), "{out}");
}
#[test]
fn an_erase_spec_names_the_table_once() {
let out = render(&EraseSpec::new("//tmp/log[#0:#10]").to_yson());
assert_eq!(out, r#"{table_path="//tmp/log[#0:#10]"}"#);
}
#[test]
fn an_erase_spec_can_ask_for_compaction() {
let out = render(
&EraseSpec::new("//tmp/log")
.with_combine_chunks(true)
.to_yson(),
);
assert!(out.contains("combine_chunks=%true"), "{out}");
}
#[test]
fn a_remote_copy_spec_names_the_source_cluster() {
let spec = RemoteCopySpec::new("hahn", ["//tmp/theirs"], "//tmp/ours")
.with_network_name("fastbone")
.with_copy_attributes(true)
.with_attribute_keys(["expiration_time"]);
let out = render(&spec.to_yson());
assert!(out.contains("cluster_name=hahn"), "{out}");
assert!(
out.contains(r#"input_table_paths=["//tmp/theirs"]"#),
"{out}"
);
assert!(out.contains(r#"output_table_path="//tmp/ours""#), "{out}");
assert!(out.contains("network_name=fastbone"), "{out}");
assert!(out.contains("copy_attributes=%true"), "{out}");
assert!(out.contains("attribute_keys=[expiration_time]"), "{out}");
}
#[test]
fn the_new_specs_take_raw_fields_too() {
let merge = render(
&MergeSpec::new(["//i"], "//o")
.with_raw("schema_inference_mode", string("from_output"))
.to_yson(),
);
assert!(
merge.contains("schema_inference_mode=from_output"),
"{merge}"
);
let erase = render(
&EraseSpec::new("//t")
.with_raw("schema_inference_mode", string("auto"))
.to_yson(),
);
assert!(erase.contains("schema_inference_mode=auto"), "{erase}");
let copy = render(
&RemoteCopySpec::new("c", ["//i"], "//o")
.with_raw("allow_unfrozen_input_tables", boolean(true))
.to_yson(),
);
assert!(copy.contains("allow_unfrozen_input_tables=%true"), "{copy}");
}
#[test]
fn reduce_puts_key_switch_under_job_io() {
let out =
render(&ReduceSpec::new("./wc reduce", ["//sorted"], ["//out"], ["word"]).to_yson());
assert!(
out.contains("job_io={control_attributes={enable_key_switch=%true}}"),
"{out}"
);
assert!(
!out.contains("reduce_job_io"),
"reduce_job_io belongs to map-reduce, not to reduce: {out}"
);
}
#[test]
fn a_reduce_spec_carries_what_the_operation_needs() {
let spec = ReduceSpec::new("./wc reduce", ["//tmp/sorted"], ["//tmp/counts"], ["word"])
.with_local_file("//tmp/wc")
.with_memory_limit(1024)
.with_job_count(2);
let out = render(&spec.to_yson());
assert!(out.contains(r#"command="./wc reduce""#), "{out}");
assert!(out.contains(r#"file_paths=["//tmp/wc"]"#), "{out}");
assert!(out.contains("memory_limit=1024"), "{out}");
assert!(out.contains("reduce_by=[word]"), "{out}");
assert!(out.contains("job_count=2"), "{out}");
assert!(out.contains("input_format=<format=binary>yson"), "{out}");
}
#[test]
fn reduce_sort_by_is_only_sent_when_set() {
let plain = render(&ReduceSpec::new("./r", ["//in"], ["//out"], ["k"]).to_yson());
assert!(!plain.contains("sort_by"), "{plain}");
let asked = render(
&ReduceSpec::new("./r", ["//in"], ["//out"], ["k"])
.with_sort_by(["k", "ts"])
.to_yson(),
);
assert!(asked.contains("sort_by=[k;ts]"), "{asked}");
}
#[test]
fn reduce_table_index_is_off_unless_asked_for() {
let plain = render(&ReduceSpec::new("./r", ["//a", "//b"], ["//o"], ["k"]).to_yson());
assert!(!plain.contains("enable_input_table_index"), "{plain}");
let asked = render(
&ReduceSpec::new("./r", ["//a", "//b"], ["//o"], ["k"])
.with_input_table_index()
.to_yson(),
);
assert!(asked.contains("enable_input_table_index=%true"), "{asked}");
}
#[test]
fn sort_writes_one_table_through_a_singular_field() {
let out = render(&SortSpec::new(["//a", "//b"], "//sorted", ["key", "sub"]).to_yson());
assert!(out.contains(r#"output_table_path="//sorted""#), "{out}");
assert!(!out.contains("output_table_paths"), "{out}");
assert!(out.contains(r#"input_table_paths=["//a";"//b"]"#), "{out}");
assert!(out.contains("sort_by=[key;sub]"), "{out}");
}
#[test]
fn a_sort_spec_has_no_user_job() {
let out = render(&SortSpec::new(["//a"], "//sorted", ["key"]).to_yson());
assert!(
!out.contains("command"),
"the cluster sorts, not a job: {out}"
);
assert!(!out.contains("input_format"), "{out}");
}
#[test]
fn a_vanilla_spec_describes_its_tasks() {
let out = render(
&VanillaSpec::new(
VanillaTask::new("worker", "./my_job", 4)
.with_local_file("//tmp/my_job")
.with_outputs(["//tmp/results"])
.with_memory_limit(1024),
)
.with_task(VanillaTask::new("master", "./my_job master", 1))
.with_raw("max_failed_job_count", int(1))
.to_yson(),
);
assert!(out.contains("tasks={"), "{out}");
assert!(out.contains("worker={"), "{out}");
assert!(out.contains("master={"), "{out}");
assert!(out.contains("job_count=4"), "{out}");
assert!(out.contains("job_count=1"), "{out}");
assert!(
out.contains(r#"output_table_paths=["//tmp/results"]"#),
"{out}"
);
assert!(out.contains("max_failed_job_count=1"), "{out}");
assert!(!out.contains("input_table_paths"), "{out}");
}
#[test]
fn reduce_can_select_skiff_for_both_directions() {
let out = render(
&ReduceSpec::new("./worker", ["//in"], ["//out"], ["key"])
.with_skiff_formats(skiff_format("reduce_input"), skiff_format("reduce_output"))
.to_yson(),
);
assert!(out.contains("input_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("output_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("name=reduce_input"), "{out}");
assert!(out.contains("name=reduce_output"), "{out}");
assert!(!out.contains("format=binary"), "{out}");
assert!(
out.contains("control_attributes={enable_key_switch=%true}"),
"{out}"
);
}
#[test]
fn a_vanilla_task_can_select_skiff_output_only() {
let out = render(
&VanillaSpec::new(
VanillaTask::new("worker", "./my_job", 1)
.with_outputs(["//tmp/results"])
.with_skiff_output_format(skiff_format("result")),
)
.to_yson(),
);
assert!(out.contains("output_format=<table_skiff_schemas="), "{out}");
assert!(out.contains("name=result"), "{out}");
assert!(out.contains("input_format=<format=binary>yson"), "{out}");
}
#[test]
fn two_tasks_with_one_name_are_caught_before_the_cluster_sees_them() {
let spec = VanillaSpec::new(VanillaTask::new("worker", "./j shard-a", 4))
.with_task(VanillaTask::new("worker", "./j shard-b", 4));
assert_eq!(spec.duplicate_task(), Some("worker"));
let out = render(&spec.to_yson());
assert!(!out.contains("shard-a"), "the first task is gone: {out}");
}
#[test]
fn tasks_with_distinct_names_are_fine() {
let spec = VanillaSpec::new(VanillaTask::new("worker", "./j", 4))
.with_task(VanillaTask::new("master", "./j master", 1));
assert_eq!(spec.duplicate_task(), None);
}
#[test]
fn a_task_without_outputs_says_so() {
let out = render(&VanillaSpec::new(VanillaTask::new("t", "./j", 1)).to_yson());
assert!(out.contains("output_table_paths=[]"), "{out}");
}
#[test]
fn gang_options_go_through_raw() {
let out = render(
&VanillaSpec::new(
VanillaTask::new("worker", "./j", 3).with_raw("gang_options", map::<&str>([])),
)
.to_yson(),
);
assert!(out.contains("gang_options={}"), "{out}");
}
#[test]
fn sort_tuning_goes_through_raw() {
let out = render(
&SortSpec::new(["//a"], "//sorted", ["key"])
.with_raw("partition_count", int(4))
.to_yson(),
);
assert!(out.contains("partition_count=4"), "{out}");
}
}