Skip to main content

ytsaurus_job/
reader.rs

1//! Incremental reading of a job's input stream.
2//!
3//! YTsaurus hands a job a *list fragment* on fd 0 — `record; record; record;` —
4//! that can be far larger than the job's memory limit. [`JobReader`] consumes it
5//! a buffer at a time, never holding more than one record plus the read buffer,
6//! and hands out rows that borrow directly from that buffer.
7
8use std::io::{ErrorKind, Read};
9
10use serde::Deserialize;
11use ytsaurus_yson::{Scan, YsonFormat, YsonNode, YsonValue, from_slice, scan::scan_value};
12
13use crate::error::{JobError, Result};
14
15/// Default read buffer, and the steady-state memory cost of a reader.
16const DEFAULT_BUFFER_BYTES: usize = 1024 * 1024;
17
18/// Default ceiling on a single record, to bound the damage from corrupt input.
19const DEFAULT_MAX_RECORD_BYTES: usize = 256 * 1024 * 1024;
20
21/// One item from the input stream.
22#[derive(Debug)]
23pub enum Event<'a> {
24    /// A data row.
25    Row(Row<'a>),
26    /// A reduce key boundary: the previous row and the next row belong to
27    /// different keys.
28    ///
29    /// Only produced when the operation sets `control_attributes.enable_key_switch`.
30    /// [`JobReader::groups`] turns these into per-key iterators.
31    KeySwitch,
32}
33
34/// A single input row, borrowed from the reader's buffer.
35///
36/// The row is not decoded until you ask for it, so a job that only forwards
37/// rows never pays to parse them — see [`Row::raw`].
38#[derive(Debug, Clone, Copy)]
39pub struct Row<'a> {
40    /// Index of the input table this row came from.
41    ///
42    /// Stays `0` unless the operation enables `control_attributes.enable_table_index`.
43    pub table_index: i64,
44    /// Index of this row within its input table, if `enable_row_index` is set.
45    pub row_index: Option<i64>,
46    /// Index of the requested range this row came from, if `enable_range_index` is set.
47    pub range_index: Option<i64>,
48
49    bytes: &'a [u8],
50    format: YsonFormat,
51    offset: u64,
52}
53
54impl<'a> Row<'a> {
55    /// The row's raw YSON bytes, exactly as they arrived.
56    ///
57    /// Writing these straight to an output table reproduces the row
58    /// byte-for-byte, which decoding and re-encoding does not guarantee (map
59    /// keys come back sorted). This is what an identity job should use.
60    #[must_use]
61    pub fn raw(&self) -> &'a [u8] {
62        self.bytes
63    }
64
65    /// Offset of this row from the start of the input stream, for diagnostics.
66    #[must_use]
67    pub fn offset(&self) -> u64 {
68        self.offset
69    }
70
71    /// Decodes the row into `T`.
72    ///
73    /// `T` may borrow from the row (`&str`, `&[u8]`), which avoids copying
74    /// string columns; such a `T` cannot outlive this row.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`JobError::Yson`] if the row does not match `T`'s shape.
79    pub fn parse<T: Deserialize<'a>>(&self) -> Result<T> {
80        from_slice(self.bytes, self.format).map_err(|source| JobError::Yson {
81            offset: self.offset,
82            source,
83        })
84    }
85
86    /// Decodes the row into the dynamic [`YsonValue`] representation.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`JobError::Yson`] if the row is not valid YSON.
91    pub fn value(&self) -> Result<YsonValue> {
92        self.parse()
93    }
94}
95
96/// What the reader has lined up but not yet handed out.
97///
98/// Holds only lengths, never a borrow, so it can sit in the reader across calls
99/// without freezing the buffer.
100#[derive(Debug, Clone, Copy)]
101enum Pending {
102    Row { len: usize },
103    KeySwitch { len: usize },
104}
105
106/// Reads a YTsaurus job's input stream incrementally.
107///
108/// # Example
109///
110/// ```no_run
111/// use ytsaurus_job::{Event, JobReader};
112///
113/// let mut reader = JobReader::from_stdin();
114/// while let Some(event) = reader.next_event()? {
115///     match event {
116///         Event::Row(row) => {
117///             let _bytes = row.raw();
118///         }
119///         Event::KeySwitch => {}
120///     }
121/// }
122/// # Ok::<(), ytsaurus_job::JobError>(())
123/// ```
124#[derive(Debug)]
125pub struct JobReader<R> {
126    input: R,
127    format: YsonFormat,
128
129    buf: Vec<u8>,
130    /// Start of the unconsumed region of `buf`.
131    pos: usize,
132    /// End of valid data in `buf`.
133    filled: usize,
134    /// Stream offset corresponding to `buf[0]`.
135    base_offset: u64,
136    /// The input reader has signalled end of file.
137    input_done: bool,
138
139    pending: Option<Pending>,
140    max_record_bytes: usize,
141
142    table_index: i64,
143    row_index: Option<i64>,
144    range_index: Option<i64>,
145}
146
147impl JobReader<std::io::Stdin> {
148    /// Reads binary YSON from fd 0, which is where YTsaurus puts a job's input.
149    ///
150    /// Use [`JobReader::text`] instead if the operation was configured with
151    /// `<format=text>yson`.
152    #[must_use]
153    pub fn from_stdin() -> Self {
154        Self::binary(std::io::stdin())
155    }
156}
157
158impl<R: Read> JobReader<R> {
159    /// Reads binary YSON — the format jobs normally use.
160    #[must_use]
161    pub fn binary(input: R) -> Self {
162        Self::with_format(input, YsonFormat::Binary)
163    }
164
165    /// Reads text YSON, which is useful for fixtures and debugging.
166    #[must_use]
167    pub fn text(input: R) -> Self {
168        Self::with_format(input, YsonFormat::Text)
169    }
170
171    /// Reads YSON in an explicit format.
172    #[must_use]
173    pub fn with_format(input: R, format: YsonFormat) -> Self {
174        Self {
175            input,
176            format,
177            buf: vec![0; DEFAULT_BUFFER_BYTES],
178            pos: 0,
179            filled: 0,
180            base_offset: 0,
181            input_done: false,
182            pending: None,
183            max_record_bytes: DEFAULT_MAX_RECORD_BYTES,
184            table_index: 0,
185            row_index: None,
186            range_index: None,
187        }
188    }
189
190    /// Sets the read buffer size. Records larger than this grow the buffer.
191    #[must_use]
192    pub fn with_buffer_size(mut self, bytes: usize) -> Self {
193        self.buf = vec![0; bytes.max(64)];
194        self
195    }
196
197    /// Sets the ceiling on a single record.
198    ///
199    /// The buffer grows on demand up to this limit; beyond it the job fails with
200    /// [`JobError::RecordTooLarge`] rather than trying to allocate whatever a
201    /// corrupt length prefix asked for.
202    #[must_use]
203    pub fn with_max_record_bytes(mut self, bytes: usize) -> Self {
204        self.max_record_bytes = bytes;
205        self
206    }
207
208    /// Returns the next event, or `None` at end of stream.
209    ///
210    /// The returned [`Event`] borrows the reader's buffer, so it must be dropped
211    /// before the next call — the compiler enforces this.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`JobError`] if the stream cannot be read or does not parse.
216    pub fn next_event(&mut self) -> Result<Option<Event<'_>>> {
217        let Some(pending) = self.ensure_pending()? else {
218            return Ok(None);
219        };
220
221        match pending {
222            Pending::KeySwitch { len } => {
223                self.consume(len);
224                Ok(Some(Event::KeySwitch))
225            }
226            Pending::Row { len } => {
227                let start = self.pos;
228                let offset = self.base_offset + start as u64;
229                let row_index = self.row_index;
230                self.consume(len);
231                Ok(Some(Event::Row(Row {
232                    table_index: self.table_index,
233                    row_index,
234                    range_index: self.range_index,
235                    bytes: &self.buf[start..start + len],
236                    format: self.format,
237                    offset,
238                })))
239            }
240        }
241    }
242
243    /// Splits the stream into reduce groups on `key_switch` boundaries.
244    ///
245    /// Requires `control_attributes.enable_key_switch` on the operation;
246    /// without it the whole input is a single group.
247    ///
248    /// # Example
249    ///
250    /// ```no_run
251    /// # use ytsaurus_job::JobReader;
252    /// let mut reader = JobReader::from_stdin();
253    /// let mut groups = reader.groups();
254    /// while let Some(mut group) = groups.next_group()? {
255    ///     while let Some(row) = group.next_row()? {
256    ///         let _ = row.raw();
257    ///     }
258    /// }
259    /// # Ok::<(), ytsaurus_job::JobError>(())
260    /// ```
261    pub fn groups(&mut self) -> Groups<'_, R> {
262        Groups {
263            reader: self,
264            in_group: false,
265            key_columns: Vec::new(),
266        }
267    }
268
269    /// Like [`JobReader::groups`], but decodes the reduce key for each group.
270    ///
271    /// Pass the same columns the operation was given as `reduce_by`. Each
272    /// [`Group`] then answers [`Group::key`] without the caller having to parse
273    /// its first row and copy the key out.
274    ///
275    /// YTsaurus does not transmit the key: `key_switch` carries no payload, and
276    /// the key lives in the rows. So this reads it from the group's first row —
277    /// the same work a job would do by hand, done once and in one place.
278    ///
279    /// # Example
280    ///
281    /// ```no_run
282    /// # use ytsaurus_job::JobReader;
283    /// let mut reader = JobReader::from_stdin();
284    /// let mut groups = reader.groups_by(["user_id"]);
285    /// while let Some(mut group) = groups.next_group()? {
286    ///     let user = group.key().bytes("user_id").unwrap_or_default().to_vec();
287    ///     while let Some(row) = group.next_row()? {
288    ///         let _ = (&user, row.raw());
289    ///     }
290    /// }
291    /// # Ok::<(), ytsaurus_job::JobError>(())
292    /// ```
293    pub fn groups_by<I>(&mut self, columns: I) -> Groups<'_, R>
294    where
295        I: IntoIterator,
296        I::Item: AsRef<str>,
297    {
298        Groups {
299            reader: self,
300            in_group: false,
301            key_columns: columns
302                .into_iter()
303                .map(|c| c.as_ref().as_bytes().to_vec())
304                .collect(),
305        }
306    }
307
308    /// Decodes the requested key columns out of the pending row.
309    ///
310    /// Called while a row is pending, so `pos..pos + len` is that row and the
311    /// buffer cannot move underneath it.
312    fn decode_key(&mut self, len: usize, columns: &[Vec<u8>]) -> Result<GroupKey> {
313        let start = self.pos;
314        let offset = self.base_offset + start as u64;
315        let record = &self.buf[start..start + len];
316
317        let value: YsonValue =
318            from_slice(record, self.format).map_err(|source| JobError::Yson { offset, source })?;
319
320        let YsonNode::Map(fields) = &value.node else {
321            return Err(JobError::Yson {
322                offset,
323                source: ytsaurus_yson::YsonError::Custom(
324                    "a reduce key can only be read from a row that is a map".to_owned(),
325                ),
326            });
327        };
328
329        // Missing columns are skipped rather than fatal: a reduce key column
330        // may legitimately be absent from a row, and failing the whole job over
331        // it would be a worse default than reporting the key we could read.
332        let mut decoded = Vec::with_capacity(columns.len());
333        for name in columns {
334            if let Some(v) = fields.get(name) {
335                decoded.push((name.clone(), v.clone()));
336            }
337        }
338
339        Ok(GroupKey { columns: decoded })
340    }
341
342    /// Advances `pos` past a record that has been handed out.
343    fn consume(&mut self, len: usize) {
344        self.pos += len;
345        // YTsaurus emits `<row_index=N>#` only at discontinuities — the start
346        // of a range or chunk. Every row after it implicitly advances the
347        // index, so count the row that was just consumed, whether it was
348        // handed out or skipped.
349        if matches!(self.pending, Some(Pending::Row { .. }))
350            && let Some(index) = self.row_index.as_mut()
351        {
352            *index += 1;
353        }
354        self.pending = None;
355    }
356
357    /// Discards the pending record without handing it out.
358    fn discard_pending(&mut self) {
359        if let Some(Pending::Row { len } | Pending::KeySwitch { len }) = self.pending {
360            self.consume(len);
361        }
362    }
363
364    /// Ensures a row or key switch is buffered and ready at `self.pos`.
365    ///
366    /// Control records other than `key_switch` are consumed and applied here, so
367    /// callers only ever see data. Idempotent: while something is pending the
368    /// buffer is never refilled, which is what makes it safe to look before
369    /// deciding to consume.
370    fn ensure_pending(&mut self) -> Result<Option<Pending>> {
371        if let Some(pending) = self.pending {
372            return Ok(Some(pending));
373        }
374
375        loop {
376            let Some(len) = self.next_record_len()? else {
377                return Ok(None);
378            };
379
380            let offset = self.base_offset + self.pos as u64;
381            let record = &self.buf[self.pos..self.pos + len];
382
383            // Control records are attributed entities; data rows are maps. Only
384            // a leading `<` can be a control record, so rows are never decoded
385            // here and cost nothing to route.
386            let classified = if first_significant_byte(record, self.format) == Some(b'<') {
387                classify_attributed(record, self.format, offset)?
388            } else {
389                Classified::Row
390            };
391
392            match classified {
393                Classified::Row => {
394                    let pending = Pending::Row { len };
395                    self.pending = Some(pending);
396                    return Ok(Some(pending));
397                }
398                Classified::KeySwitch => {
399                    let pending = Pending::KeySwitch { len };
400                    self.pending = Some(pending);
401                    return Ok(Some(pending));
402                }
403                Classified::TableIndex(i) => {
404                    self.table_index = i;
405                    // A new table restarts row and range numbering; drop the
406                    // stale values rather than reporting them from the old
407                    // table.
408                    self.row_index = None;
409                    self.range_index = None;
410                    self.pos += len;
411                }
412                Classified::RowIndex(i) => {
413                    self.row_index = Some(i);
414                    self.pos += len;
415                }
416                Classified::RangeIndex(i) => {
417                    self.range_index = Some(i);
418                    self.pos += len;
419                }
420                Classified::Skip => self.pos += len,
421            }
422        }
423    }
424
425    /// Length of the next complete record, refilling the buffer as needed.
426    ///
427    /// Leaves `self.pos` at the first byte of that record.
428    fn next_record_len(&mut self) -> Result<Option<usize>> {
429        loop {
430            self.skip_separators();
431
432            if self.pos < self.filled {
433                match scan_value(&self.buf[self.pos..self.filled], self.format) {
434                    Ok(Scan::Complete { len }) => return Ok(Some(len)),
435                    Ok(Scan::Incomplete) => {}
436                    Err(source) => {
437                        return Err(JobError::Yson {
438                            offset: self.base_offset + self.pos as u64,
439                            source,
440                        });
441                    }
442                }
443            }
444
445            if self.input_done {
446                return if self.pos == self.filled {
447                    Ok(None)
448                } else {
449                    Err(JobError::TruncatedRecord {
450                        offset: self.base_offset + self.pos as u64,
451                        buffered: self.filled - self.pos,
452                    })
453                };
454            }
455
456            self.fill()?;
457        }
458    }
459
460    /// Skips record separators and whitespace between records.
461    fn skip_separators(&mut self) {
462        while self.pos < self.filled {
463            match self.buf[self.pos] {
464                b';' => self.pos += 1,
465                b if b.is_ascii_whitespace() => self.pos += 1,
466                _ => break,
467            }
468        }
469    }
470
471    /// Compacts the buffer, grows it if a single record needs more room, and
472    /// reads once from the input.
473    fn fill(&mut self) -> Result<()> {
474        if self.pos > 0 {
475            self.buf.copy_within(self.pos..self.filled, 0);
476            self.filled -= self.pos;
477            self.base_offset += self.pos as u64;
478            self.pos = 0;
479        }
480
481        if self.filled == self.buf.len() {
482            // The record in flight does not fit. Double the buffer, but refuse
483            // to chase an absurd length prefix into an OOM abort.
484            let new_len = self.buf.len().saturating_mul(2);
485            if self.buf.len() >= self.max_record_bytes {
486                return Err(JobError::RecordTooLarge {
487                    offset: self.base_offset,
488                    limit: self.max_record_bytes,
489                });
490            }
491            self.buf.resize(new_len.min(self.max_record_bytes), 0);
492        }
493
494        loop {
495            match self.input.read(&mut self.buf[self.filled..]) {
496                Ok(0) => {
497                    self.input_done = true;
498                    return Ok(());
499                }
500                Ok(n) => {
501                    self.filled += n;
502                    return Ok(());
503                }
504                // A signal interrupted the read; nothing was consumed, so retry.
505                Err(e) if e.kind() == ErrorKind::Interrupted => {}
506                Err(e) => return Err(JobError::Read(e)),
507            }
508        }
509    }
510}
511
512/// What a record turned out to be.
513enum Classified {
514    /// A data row, to be handed to the job.
515    Row,
516    /// A control record carrying nothing this reader acts on. Consumed silently.
517    Skip,
518    TableIndex(i64),
519    RowIndex(i64),
520    RangeIndex(i64),
521    KeySwitch,
522}
523
524/// First byte that is not insignificant whitespace.
525fn first_significant_byte(record: &[u8], format: YsonFormat) -> Option<u8> {
526    match format {
527        YsonFormat::Binary => record.first().copied(),
528        YsonFormat::Text => record.iter().find(|b| !b.is_ascii_whitespace()).copied(),
529    }
530}
531
532/// Decides what an attributed record (`<...>...`) is.
533///
534/// Per the YTsaurus docs a control record is an *entity* carrying attributes,
535/// while a data record is a map. An attributed entity is therefore always a
536/// control record, even when the attribute is one this version does not know —
537/// such a record must be skipped, never handed to the job as a row.
538fn classify_attributed(record: &[u8], format: YsonFormat, offset: u64) -> Result<Classified> {
539    let value: YsonValue =
540        from_slice(record, format).map_err(|source| JobError::Yson { offset, source })?;
541
542    // Attributes on something that is not an entity: a data row that happens to
543    // carry attributes.
544    if !matches!(value.node, YsonNode::Entity) {
545        return Ok(Classified::Row);
546    }
547    let Some(attributes) = value.attributes.as_ref() else {
548        // A bare `#` with no attributes. Not a row and not a control record;
549        // there is nothing to hand over, so drop it.
550        return Ok(Classified::Skip);
551    };
552
553    let as_i64 = |name: &str, v: &YsonValue| -> Result<i64> {
554        v.as_i64().ok_or_else(|| JobError::BadControlRecord {
555            offset,
556            reason: format!("{name} must be an int64, got {:?}", v.node),
557        })
558    };
559
560    for (key, v) in attributes {
561        match key.as_slice() {
562            b"key_switch" => {
563                return match v.node {
564                    YsonNode::Boolean(true) => Ok(Classified::KeySwitch),
565                    // `<key_switch=%false>#` is a control record, just not a
566                    // group boundary.
567                    YsonNode::Boolean(false) => Ok(Classified::Skip),
568                    ref other => Err(JobError::BadControlRecord {
569                        offset,
570                        reason: format!("key_switch must be a boolean, got {other:?}"),
571                    }),
572                };
573            }
574            b"table_index" => return Ok(Classified::TableIndex(as_i64("table_index", v)?)),
575            b"row_index" => return Ok(Classified::RowIndex(as_i64("row_index", v)?)),
576            b"range_index" => return Ok(Classified::RangeIndex(as_i64("range_index", v)?)),
577            _ => {}
578        }
579    }
580
581    // An attributed entity carrying only attributes we do not recognise. It is
582    // still a control record, so skip it rather than failing: YTsaurus may add
583    // control attributes later, and a job built today should survive meeting
584    // one. Emitting it as a row would silently corrupt the output.
585    Ok(Classified::Skip)
586}
587
588/// The reduce key of a group, decoded from its first row.
589///
590/// Empty unless the group came from [`JobReader::groups_by`].
591#[derive(Debug, Clone, Default, PartialEq)]
592pub struct GroupKey {
593    columns: Vec<(Vec<u8>, YsonValue)>,
594}
595
596impl GroupKey {
597    /// The key column `name`, if the group has one.
598    #[must_use]
599    pub fn get(&self, name: &str) -> Option<&YsonValue> {
600        self.columns
601            .iter()
602            .find(|(k, _)| k == name.as_bytes())
603            .map(|(_, v)| v)
604    }
605
606    /// The key column `name` as raw bytes, if it is a string.
607    ///
608    /// Reduce keys are frequently byte strings rather than text, so this does
609    /// not go through `str`.
610    #[must_use]
611    pub fn bytes(&self, name: &str) -> Option<&[u8]> {
612        match &self.get(name)?.node {
613            YsonNode::String(bytes) => Some(bytes),
614            _ => None,
615        }
616    }
617
618    /// The key column `name` as UTF-8, if it is a string and valid UTF-8.
619    #[must_use]
620    pub fn str(&self, name: &str) -> Option<&str> {
621        std::str::from_utf8(self.bytes(name)?).ok()
622    }
623
624    /// The key column `name` as an integer, if it is one.
625    #[must_use]
626    pub fn i64(&self, name: &str) -> Option<i64> {
627        self.get(name)?.as_i64()
628    }
629
630    /// Every key column, in the order they were requested.
631    #[must_use]
632    pub fn columns(&self) -> &[(Vec<u8>, YsonValue)] {
633        &self.columns
634    }
635
636    /// Whether any key column was decoded.
637    #[must_use]
638    pub fn is_empty(&self) -> bool {
639        self.columns.is_empty()
640    }
641}
642
643/// Iterator over reduce groups. Created by [`JobReader::groups`] or
644/// [`JobReader::groups_by`].
645#[derive(Debug)]
646pub struct Groups<'r, R> {
647    reader: &'r mut JobReader<R>,
648    in_group: bool,
649    /// Columns forming the reduce key; empty for [`JobReader::groups`].
650    key_columns: Vec<Vec<u8>>,
651}
652
653impl<R: Read> Groups<'_, R> {
654    /// Advances to the next group.
655    ///
656    /// Rows left unread in the current group are skipped, so a caller that only
657    /// needs the first row of each group does not have to drain the rest.
658    ///
659    /// # Errors
660    ///
661    /// Returns [`JobError`] if the stream cannot be read or does not parse.
662    pub fn next_group(&mut self) -> Result<Option<Group<'_, R>>> {
663        if self.in_group {
664            // Drain what is left of the current group, stopping on its boundary.
665            loop {
666                match self.reader.ensure_pending()? {
667                    None => {
668                        self.in_group = false;
669                        return Ok(None);
670                    }
671                    Some(Pending::KeySwitch { .. }) => {
672                        self.reader.discard_pending();
673                        break;
674                    }
675                    Some(Pending::Row { .. }) => self.reader.discard_pending(),
676                }
677            }
678        }
679
680        // A group exists only if at least one more record is coming.
681        match self.reader.ensure_pending()? {
682            None => {
683                self.in_group = false;
684                Ok(None)
685            }
686            Some(Pending::KeySwitch { .. }) => {
687                // Back-to-back switches: an empty group. YTsaurus does not emit
688                // these, but reporting one is more honest than dropping it.
689                //
690                // The group is born `done`, and `in_group` stays false: its
691                // boundary was the switch just consumed, so there is nothing to
692                // drain before the next group. A live group here would hand out
693                // the *next* group's rows under this group's (empty) key, and
694                // that group would never be seen.
695                self.reader.discard_pending();
696                self.in_group = false;
697                Ok(Some(Group {
698                    reader: self.reader,
699                    done: true,
700                    key: GroupKey::default(),
701                }))
702            }
703            Some(Pending::Row { len }) => {
704                // Decode the key now, while the first row is pending and the
705                // buffer is guaranteed not to move. Doing it here rather than
706                // in `Group::key` keeps that accessor a plain `&self`.
707                let key = if self.key_columns.is_empty() {
708                    GroupKey::default()
709                } else {
710                    self.reader.decode_key(len, &self.key_columns)?
711                };
712
713                self.in_group = true;
714                Ok(Some(Group {
715                    reader: self.reader,
716                    done: false,
717                    key,
718                }))
719            }
720        }
721    }
722}
723
724/// The rows of a single reduce group. Created by [`Groups::next_group`].
725#[derive(Debug)]
726pub struct Group<'g, R> {
727    reader: &'g mut JobReader<R>,
728    done: bool,
729    key: GroupKey,
730}
731
732impl<R: Read> Group<'_, R> {
733    /// The group's reduce key.
734    ///
735    /// Populated only for groups from [`JobReader::groups_by`]; otherwise
736    /// [`GroupKey::is_empty`] is true.
737    #[must_use]
738    pub fn key(&self) -> &GroupKey {
739        &self.key
740    }
741
742    /// Returns the next row of this group, or `None` at the group boundary.
743    ///
744    /// # Errors
745    ///
746    /// Returns [`JobError`] if the stream cannot be read or does not parse.
747    pub fn next_row(&mut self) -> Result<Option<Row<'_>>> {
748        if self.done {
749            return Ok(None);
750        }
751
752        match self.reader.ensure_pending()? {
753            None | Some(Pending::KeySwitch { .. }) => {
754                // Leave the switch pending; `next_group` consumes it.
755                self.done = true;
756                Ok(None)
757            }
758            Some(Pending::Row { len }) => {
759                let start = self.reader.pos;
760                let offset = self.reader.base_offset + start as u64;
761                let row_index = self.reader.row_index;
762                self.reader.consume(len);
763                Ok(Some(Row {
764                    table_index: self.reader.table_index,
765                    row_index,
766                    range_index: self.reader.range_index,
767                    bytes: &self.reader.buf[start..start + len],
768                    format: self.reader.format,
769                    offset,
770                }))
771            }
772        }
773    }
774}