Skip to main content

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}