ytsaurus_job/error.rs
1//! Errors a job can fail with.
2
3use thiserror::Error;
4use ytsaurus_skiff::CodecError;
5use ytsaurus_yson::YsonError;
6
7/// Shorthand for a job result.
8pub type Result<T, E = JobError> = std::result::Result<T, E>;
9
10/// Something went wrong reading input or writing output.
11///
12/// Every variant is fatal to the job. YTsaurus judges a job by its exit code,
13/// so the right response is to report the error on stderr — where the operation
14/// UI shows it — and exit non-zero. [`crate::run`] does that for you.
15#[derive(Debug, Error)]
16pub enum JobError {
17 /// Reading the input stream failed.
18 #[error("reading job input: {0}")]
19 Read(#[source] std::io::Error),
20
21 /// Writing to an output table failed.
22 ///
23 /// Treated as fatal: a partial write means the output table would be
24 /// missing rows, and silently producing a truncated table is worse than
25 /// failing the job.
26 #[error("writing to output table {table}: {source}")]
27 Write {
28 /// Index of the output table that failed.
29 table: usize,
30 /// The underlying I/O error.
31 #[source]
32 source: std::io::Error,
33 },
34
35 /// The input was not valid YSON.
36 #[error("invalid YSON at byte {offset} of the input stream: {source}")]
37 Yson {
38 /// Offset of the failing record from the start of the stream.
39 offset: u64,
40 /// The underlying parse error.
41 #[source]
42 source: YsonError,
43 },
44
45 /// The stream ended part-way through a record.
46 #[error(
47 "input stream ended {buffered} bytes into an incomplete record at byte {offset}; \
48 the job was most likely killed or the upstream writer failed"
49 )]
50 TruncatedRecord {
51 /// Offset of the incomplete record from the start of the stream.
52 offset: u64,
53 /// How many bytes of it had arrived.
54 buffered: usize,
55 },
56
57 /// A single record was larger than the reader is willing to buffer.
58 ///
59 /// Because a record must be contiguous in memory to be parsed, an
60 /// implausibly large length prefix in corrupt input would otherwise be an
61 /// out-of-memory abort. See [`crate::JobReader::with_max_record_bytes`].
62 #[error(
63 "record at byte {offset} needs more than the {limit} byte buffer limit; \
64 raise it with JobReader::with_max_record_bytes if the data is genuinely this wide"
65 )]
66 RecordTooLarge {
67 /// Offset of the oversized record from the start of the stream.
68 offset: u64,
69 /// The configured limit, in bytes.
70 limit: usize,
71 },
72
73 /// A control record carried an attribute value of the wrong type.
74 #[error("malformed control record at byte {offset}: {reason}")]
75 BadControlRecord {
76 /// Offset of the control record from the start of the stream.
77 offset: u64,
78 /// What was wrong with it.
79 reason: String,
80 },
81
82 /// The Skiff stream could not be framed or decoded.
83 #[error("invalid Skiff job stream: {0}")]
84 Skiff(#[source] CodecError),
85
86 /// The operation's Skiff schema put system fields in an invalid layout.
87 #[error("invalid Skiff system schema for table {table}: {reason}")]
88 BadSkiffSchema {
89 /// The input-table schema that is malformed.
90 table: usize,
91 /// What violates the job-format rules.
92 reason: String,
93 },
94
95 /// A Skiff system field carried a value that does not have its prescribed shape.
96 #[error("malformed Skiff {column} field for table {table}: {reason}")]
97 BadSkiffControl {
98 /// The input table that supplied the bad row.
99 table: usize,
100 /// The system field name.
101 column: &'static str,
102 /// What is malformed about its value.
103 reason: String,
104 },
105
106 /// The number of Skiff output descriptors did not match the format's table schemas.
107 #[error("Skiff output has {sinks} descriptor(s), but its format has {schemas} table schema(s)")]
108 SkiffOutputSchemaCount {
109 /// Number of output descriptors supplied by the caller.
110 sinks: usize,
111 /// Number of schemas supplied by the output format.
112 schemas: usize,
113 },
114
115 /// Writing or flushing a Skiff output table failed.
116 #[error("writing Skiff output table {table}: {source}")]
117 SkiffWrite {
118 /// The output table that failed.
119 table: usize,
120 /// The framing, validation, or I/O failure.
121 #[source]
122 source: CodecError,
123 },
124
125 /// This version of the worker runtime does not know a future
126 /// [`ytsaurus_format::DataFormat`] variant yet.
127 #[error("this ytsaurus-job version does not support the selected data format")]
128 UnsupportedDataFormat,
129
130 /// A row's representation did not match the writer's selected format.
131 #[error("cannot write a {row} row through a {writer} output")]
132 WorkerRowFormatMismatch {
133 /// Format selected by the writer.
134 writer: &'static str,
135 /// Representation supplied by the caller.
136 row: &'static str,
137 },
138
139 /// A row was written to an output table the job does not have.
140 #[error(
141 "output table {index} does not exist; this job has {count} output table(s){}",
142 known_tables(.names)
143 )]
144 UnknownTable {
145 /// The index that was asked for.
146 index: usize,
147 /// How many output tables the job actually has.
148 count: usize,
149 /// Declared table names, when the writer was built with
150 /// [`crate::JobWriter::named`]. Turns a bare index into something the
151 /// reader of the error can act on.
152 names: Vec<String>,
153 },
154
155 /// Serializing a row failed.
156 #[error("serializing a row for output table {table}: {source}")]
157 Serialize {
158 /// Index of the destination output table.
159 table: usize,
160 /// The underlying serialization error.
161 #[source]
162 source: YsonError,
163 },
164
165 /// More custom statistics than a job is allowed to report.
166 ///
167 /// The limit is on distinct names, not on writes: adding to one already
168 /// recorded is always fine.
169 #[error(
170 "this job already reports {limit} custom statistics, which is the limit; \
171 {name:?} would be one too many"
172 )]
173 TooManyStatistics {
174 /// The cluster's limit.
175 limit: usize,
176 /// The name that did not fit.
177 name: String,
178 },
179
180 /// Sending custom statistics failed.
181 ///
182 /// Separate from [`JobError::Write`] because descriptor 5 is not an output
183 /// table, and reporting it as "output table 5" would send the reader
184 /// looking for a table that does not exist.
185 #[error("sending custom job statistics: {reason}")]
186 Statistics {
187 /// What went wrong.
188 reason: String,
189 },
190
191 /// A row was written after [`crate::JobWriter::finish`].
192 ///
193 /// `finish` is the writer's end: a row accepted after it would sit in the
194 /// buffer and vanish when the job exits — a short table under exit code
195 /// zero, the exact outcome `finish` exists to rule out. Refusing the row
196 /// makes the bug the caller's to see instead of the table's to carry.
197 #[error(
198 "row for output table {table} written after finish(); \
199 finish() must be the last thing a job does with its writer"
200 )]
201 WriteAfterFinish {
202 /// Index of the output table the late row was meant for.
203 table: usize,
204 },
205}
206
207impl JobError {
208 /// A short, stable name for what went wrong.
209 ///
210 /// Formatting a `JobError` allocates and produces a message that may change
211 /// between versions. A job that quarantines bad rows wants neither: it wants
212 /// a cheap, stable value to put in a `reason` column so the rejects table
213 /// can be grouped and counted.
214 ///
215 /// ```
216 /// # use ytsaurus_job::JobError;
217 /// # fn demo(e: &JobError) {
218 /// // Cheap and stable — safe to write into an output table.
219 /// let reason: &'static str = e.kind();
220 /// # }
221 /// ```
222 #[must_use]
223 pub fn kind(&self) -> &'static str {
224 match self {
225 JobError::Read(_) => "read_failed",
226 JobError::Write { .. } => "write_failed",
227 JobError::Yson { .. } => "invalid_yson",
228 JobError::TruncatedRecord { .. } => "truncated_record",
229 JobError::RecordTooLarge { .. } => "record_too_large",
230 JobError::BadControlRecord { .. } => "bad_control_record",
231 JobError::Skiff(_) => "invalid_skiff",
232 JobError::BadSkiffSchema { .. } => "bad_skiff_schema",
233 JobError::BadSkiffControl { .. } => "bad_skiff_control",
234 JobError::SkiffOutputSchemaCount { .. } => "skiff_output_schema_count",
235 JobError::SkiffWrite { .. } => "skiff_write_failed",
236 JobError::UnsupportedDataFormat => "unsupported_data_format",
237 JobError::WorkerRowFormatMismatch { .. } => "worker_row_format_mismatch",
238 JobError::UnknownTable { .. } => "unknown_table",
239 JobError::Serialize { .. } => "serialize_failed",
240 JobError::TooManyStatistics { .. } => "too_many_statistics",
241 JobError::Statistics { .. } => "statistics_failed",
242 JobError::WriteAfterFinish { .. } => "write_after_finish",
243 }
244 }
245
246 /// Whether this error is about one bad row rather than the stream itself.
247 ///
248 /// A job that quarantines bad rows should keep going for these and stop for
249 /// the rest: a truncated stream or a failed write means every subsequent row
250 /// is suspect, and carrying on would quietly produce a short output table.
251 ///
252 /// ```
253 /// # use ytsaurus_job::JobError;
254 /// # fn demo(e: JobError) -> Result<(), JobError> {
255 /// if e.is_row_local() {
256 /// // quarantine the row and continue
257 /// } else {
258 /// return Err(e);
259 /// }
260 /// # Ok(())
261 /// # }
262 /// ```
263 #[must_use]
264 pub fn is_row_local(&self) -> bool {
265 match self {
266 JobError::Yson { .. } | JobError::Serialize { .. } => true,
267 JobError::Read(_)
268 | JobError::Write { .. }
269 | JobError::TruncatedRecord { .. }
270 | JobError::RecordTooLarge { .. }
271 | JobError::BadControlRecord { .. }
272 | JobError::Skiff(_)
273 | JobError::BadSkiffSchema { .. }
274 | JobError::BadSkiffControl { .. }
275 | JobError::SkiffOutputSchemaCount { .. }
276 | JobError::SkiffWrite { .. }
277 | JobError::UnsupportedDataFormat
278 | JobError::WorkerRowFormatMismatch { .. }
279 | JobError::UnknownTable { .. }
280 // Neither is about a row: one says the job asked for more
281 // statistics than it may have, the other that reporting them
282 // failed. Quarantining a row would not help either.
283 | JobError::TooManyStatistics { .. }
284 | JobError::Statistics { .. }
285 // A program-order bug, not a data problem: every later row
286 // would be refused the same way.
287 | JobError::WriteAfterFinish { .. } => false,
288 }
289 }
290}
291
292/// Renders declared table names for [`JobError::UnknownTable`].
293fn known_tables(names: &[String]) -> String {
294 if names.is_empty() {
295 String::new()
296 } else {
297 format!(": {}", names.join(", "))
298 }
299}