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}