Skip to main content

ytsaurus_client/
stream.rs

1//! Table and file I/O that does not go through memory.
2//!
3//! [`Client::read_table`](crate::Client::read_table),
4//! [`Client::write_table`](crate::Client::write_table) and
5//! [`Client::read_file`](crate::Client::read_file) hold the whole thing at
6//! once, which is right for a launcher inspecting a result and wrong for
7//! anything the size of the data. The streaming forms move the same bytes
8//! without ever holding more than a buffer of them.
9//!
10//! Both are the raw byte stream — a YSON list fragment — because that is what
11//! the other end of this project already speaks: `ytsaurus_job::JobReader`
12//! reads exactly this, so a table read on a laptop and a table read inside a
13//! job go through the same decoder.
14
15use std::io::Read;
16
17/// A table's rows, arriving as they are read.
18///
19/// A YSON list fragment, in whatever format the read asked for — binary by
20/// default, which is what `ytsaurus_job::JobReader::binary` expects.
21///
22/// This is [`ResponseReader`] under the name the table paths use; the type is
23/// the same, because nothing about reading a response body as it arrives is
24/// specific to tables.
25///
26/// # The check this gives up
27///
28/// [`Client::read_table`](crate::Client::read_table) verifies that what came
29/// back is a *complete* fragment, which is the client's only defence against a
30/// mid-stream failure it cannot see (the proxy reports one in a trailer, and
31/// `ureq` 3.3 exposes no trailers — rechecked against its source, not assumed).
32/// Streaming cannot do that up front: the point is not to have the whole thing.
33///
34/// The defence moves to the decoder. A fragment cut short leaves a record that
35/// does not parse, and `JobReader` fails on it rather than stopping quietly —
36/// which is the same protection, applied at the point where it can still be
37/// applied.
38pub type TableReader = ResponseReader;
39
40/// A file's bytes, arriving as they are read.
41///
42/// This is [`ResponseReader`] under the name the file path uses, exactly as
43/// [`TableReader`] is for tables: what
44/// [`Client::read_file_streaming`](crate::Client::read_file_streaming) hands
45/// back is the response body, and nothing about reading one as it arrives is
46/// specific to files.
47///
48/// The check described on [`TableReader`] is given up here too, and with less
49/// underneath it: a table cut short leaves a record that does not parse, but a
50/// file's bytes carry no framing at all, so a body ended early by a mid-stream
51/// failure is indistinguishable from a complete one. [`bytes_read`] against
52/// the size the caller expects — which is what the buffered
53/// [`Client::read_file`](crate::Client::read_file) checks against the node's
54/// own `@uncompressed_data_size` — is the compensation available.
55///
56/// [`bytes_read`]: ResponseReader::bytes_read
57pub type FileReader = ResponseReader;
58
59/// A response body, arriving as it is read.
60///
61/// What [`Client::read_table_streaming`](crate::Client::read_table_streaming)
62/// and [`Client::read_file_streaming`](crate::Client::read_file_streaming)
63/// hand back under the names [`TableReader`] and [`FileReader`], and what
64/// [`Client::raw_command_streaming`](crate::Client::raw_command_streaming)
65/// hands back for a command this crate does not model — `read_blob_table`, or
66/// anything else whose answer is the data rather than a report about it.
67///
68/// Uncapped, unlike the buffered path: a stream has no size a client should
69/// presume. The trailer gap described on [`TableReader`] applies to every use
70/// of this, not only to tables — a body cut short by a mid-stream failure ends
71/// early and says nothing, so whatever consumes it has to be the thing that
72/// notices.
73pub struct ResponseReader {
74    inner: ureq::BodyReader<'static>,
75    read: u64,
76}
77
78impl ResponseReader {
79    pub(crate) fn new(body: ureq::Body) -> Self {
80        Self {
81            // A reader has no size cap, where the buffered path enforces one
82            // of its own (`http::CapReader`) and `ureq`'s `read_to_vec` stops
83            // at 10 MB unless told otherwise. That asymmetry is the right way
84            // round: a stream has no size a client should presume, and a table
85            // read into memory very much does.
86            inner: body.into_reader(),
87            read: 0,
88        }
89    }
90
91    /// How many bytes have come out of it so far.
92    #[must_use]
93    pub fn bytes_read(&self) -> u64 {
94        self.read
95    }
96}
97
98impl Read for ResponseReader {
99    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
100        let n = self.inner.read(buf)?;
101        self.read += n as u64;
102        Ok(n)
103    }
104}
105
106impl std::fmt::Debug for ResponseReader {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("ResponseReader")
109            .field("bytes_read", &self.read)
110            .finish()
111    }
112}
113
114/// Turns rows into the byte stream a table write sends.
115///
116/// The encoder sits inside the request body rather than in front of it: rows
117/// are serialised a bufferful at a time, as the transport asks for bytes, so
118/// writing a million rows costs one buffer and not a million rows' worth of
119/// memory. That is the difference between
120/// [`Client::write_table_rows`](crate::Client::write_table_rows) and encoding a
121/// `Vec<u8>` first, and it is why the encoder lives here.
122pub(crate) struct RowStream<I> {
123    rows: I,
124    buffer: Vec<u8>,
125    position: usize,
126    /// How many rows have been encoded, so a failure can name which one.
127    written: u64,
128    /// The first row that would not serialise.
129    ///
130    /// `Read` can only report an `io::Error`, which the transport wraps in
131    /// whatever it makes of a failed body. Keeping the real reason here lets
132    /// the caller be told what actually happened: which is that row 40 000 has
133    /// a map key that is not a string, not that the connection broke.
134    pub(crate) failed: Option<String>,
135    /// Latched once the rows have run out.
136    ///
137    /// `Read::read` may be called again after it has answered `Ok(0)`, and
138    /// without this the next call would poll the iterator past its first
139    /// `None`. `write_table_rows` accepts any `IntoIterator`, and what an
140    /// iterator that is not `Fuse` does after `None` is unspecified — a
141    /// generator that resumed yielding would append rows to a body the
142    /// transport had already finished sending.
143    exhausted: bool,
144}
145
146/// How much to encode before handing bytes over.
147const ROW_CHUNK: usize = 64 * 1024;
148
149impl<T, I> RowStream<I>
150where
151    T: serde::Serialize,
152    I: Iterator<Item = T>,
153{
154    pub(crate) fn new(rows: I) -> Self {
155        Self {
156            rows,
157            buffer: Vec::with_capacity(ROW_CHUNK + 4096),
158            position: 0,
159            written: 0,
160            failed: None,
161            exhausted: false,
162        }
163    }
164
165    /// Encodes rows until the buffer is full or the rows run out.
166    fn fill(&mut self) {
167        self.buffer.clear();
168        self.position = 0;
169
170        if self.exhausted {
171            return;
172        }
173
174        while self.buffer.len() < ROW_CHUNK {
175            let Some(row) = self.rows.next() else {
176                self.exhausted = true;
177                break;
178            };
179
180            // Serialised straight into the buffer that is about to be sent:
181            // `to_vec` would allocate a `Vec` per row, which for a table write
182            // is one allocation per row of the table.
183            let mut serializer =
184                ytsaurus_yson::ser::Serializer::with_buffer(std::mem::take(&mut self.buffer), true);
185            let outcome = serde::Serialize::serialize(&row, &mut serializer);
186            self.buffer = serializer.into_output();
187
188            if let Err(e) = outcome {
189                self.failed = Some(format!("row {}: {e}", self.written));
190                return;
191            }
192            self.buffer.push(b';');
193            self.written += 1;
194        }
195    }
196}
197
198impl<T, I> Read for RowStream<I>
199where
200    T: serde::Serialize,
201    I: Iterator<Item = T>,
202{
203    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
204        if self.position == self.buffer.len() {
205            if self.failed.is_some() {
206                // Ending the body early would upload a truncated table and
207                // call it success. Failing the request is the only honest
208                // answer, and `failed` carries the reason out.
209                return Err(std::io::Error::other("a row could not be encoded"));
210            }
211            self.fill();
212            if let Some(reason) = &self.failed {
213                return Err(std::io::Error::other(reason.clone()));
214            }
215            if self.buffer.is_empty() {
216                return Ok(0);
217            }
218        }
219
220        let n = out.len().min(self.buffer.len() - self.position);
221        out[..n].copy_from_slice(&self.buffer[self.position..self.position + n]);
222        self.position += n;
223        Ok(n)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn reader_of(bytes: &[u8]) -> TableReader {
232        TableReader::new(ureq::Body::builder().data(bytes.to_vec()))
233    }
234
235    fn encoded<T: serde::Serialize>(rows: Vec<T>) -> Vec<u8> {
236        let mut stream = RowStream::new(rows.into_iter());
237        let mut out = Vec::new();
238        stream.read_to_end(&mut out).expect("encodes");
239        out
240    }
241
242    #[test]
243    fn rows_become_a_yson_list_fragment() {
244        #[derive(serde::Serialize)]
245        struct Row {
246            n: i64,
247        }
248
249        let bytes = encoded(vec![Row { n: 1 }, Row { n: 2 }]);
250
251        // Two records, each terminated: exactly what `write_table` and a job's
252        // output both expect.
253        assert_eq!(bytes.iter().filter(|b| **b == b';').count(), 2);
254        assert!(bytes.ends_with(b";"));
255
256        let decoded: Vec<std::collections::BTreeMap<String, i64>> =
257            crate::decode_rows(&bytes, "test").expect("round-trips");
258        assert_eq!(decoded.len(), 2);
259        assert_eq!(decoded[0]["n"], 1);
260        assert_eq!(decoded[1]["n"], 2);
261    }
262
263    #[test]
264    fn an_exhausted_iterator_is_not_polled_again() {
265        /// Yields a row, then `None`, then rows again — which is unspecified
266        /// behaviour for an iterator, and exactly what `write_table_rows`
267        /// cannot rule out: it accepts any `IntoIterator`.
268        struct Resumes(u32);
269
270        impl Iterator for Resumes {
271            type Item = i64;
272
273            fn next(&mut self) -> Option<i64> {
274                self.0 += 1;
275                match self.0 {
276                    1 => Some(1),
277                    2 => None,
278                    _ => Some(2),
279                }
280            }
281        }
282
283        let mut stream = RowStream::new(Resumes(0));
284        let mut out = Vec::new();
285        stream.read_to_end(&mut out).expect("encodes");
286        assert_eq!(out.iter().filter(|b| **b == b';').count(), 1);
287
288        // `Read::read` after `Ok(0)` is allowed, and must stay `Ok(0)`. Without
289        // the latch this hands back a second row, appended to a body the
290        // transport has already finished sending.
291        let mut more = [0_u8; 64];
292        assert_eq!(stream.read(&mut more).expect("reads"), 0);
293    }
294
295    #[test]
296    fn no_rows_is_an_empty_body_rather_than_an_error() {
297        // An empty table is a legitimate result, and a write that refused to
298        // send one would make callers special-case it.
299        let bytes = encoded(Vec::<i64>::new());
300        assert!(bytes.is_empty());
301    }
302
303    #[test]
304    fn a_row_that_cannot_be_encoded_fails_the_write() {
305        // The codec itself refuses almost nothing — it writes whatever the
306        // visitor hands it, byte-string map keys included, which is the point
307        // of the fork. So the failure that actually happens is a caller's own
308        // `Serialize` refusing a value, and that is what this stands in for.
309        struct Unwritable(u32);
310
311        impl serde::Serialize for Unwritable {
312            fn serialize<S: serde::Serializer>(
313                &self,
314                s: S,
315            ) -> std::result::Result<S::Ok, S::Error> {
316                if self.0 == 2 {
317                    return Err(serde::ser::Error::custom("this row refuses to be written"));
318                }
319                s.serialize_u32(self.0)
320            }
321        }
322
323        let mut stream = RowStream::new((0..5).map(Unwritable));
324        let mut out = Vec::new();
325
326        // Sending the rows encoded so far would leave a short table reported
327        // as a successful write, which is the failure worth preventing.
328        assert!(stream.read_to_end(&mut out).is_err());
329        let reason = stream.failed.expect("the reason must survive the error");
330        assert!(reason.contains("row 2"), "{reason}");
331        assert!(reason.contains("refuses to be written"), "{reason}");
332    }
333
334    #[test]
335    fn a_million_rows_do_not_become_a_million_rows_of_memory() {
336        #[derive(serde::Serialize)]
337        struct Row {
338            n: i64,
339            payload: &'static str,
340        }
341
342        let rows = (0..1_000_000).map(|n| Row {
343            n,
344            payload: "0123456789abcdef0123456789abcdef",
345        });
346        let mut stream = RowStream::new(rows);
347
348        let mut total = 0_u64;
349        let mut scratch = [0_u8; 8192];
350        loop {
351            let n = stream.read(&mut scratch).expect("encodes");
352            if n == 0 {
353                break;
354            }
355            total += n as u64;
356        }
357
358        // Far more than the buffer, which is the point of the exercise.
359        assert!(total > 40_000_000, "{total} bytes");
360        assert!(
361            stream.buffer.capacity() < 4 * ROW_CHUNK,
362            "the buffer grew with the table: {} bytes",
363            stream.buffer.capacity()
364        );
365    }
366
367    #[test]
368    fn it_hands_back_what_came_in_and_counts_it() {
369        let mut reader = reader_of(b"{\x01\x02a=\x02\x02};");
370        let mut out = Vec::new();
371        reader.read_to_end(&mut out).expect("reads");
372
373        assert_eq!(out, b"{\x01\x02a=\x02\x02};");
374        assert_eq!(reader.bytes_read(), out.len() as u64);
375    }
376
377    #[test]
378    fn the_count_follows_the_reading_rather_than_the_response() {
379        // A caller that stops early has read what it read; the count is not the
380        // table's size, and saying so is the difference between a progress
381        // number and a wrong one.
382        let mut reader = reader_of(&[b'x'; 100]);
383        let mut first = [0_u8; 10];
384        reader.read_exact(&mut first).expect("reads");
385
386        assert_eq!(reader.bytes_read(), 10);
387    }
388
389    #[test]
390    fn a_table_past_the_buffered_paths_cap_streams_whole() {
391        // Twice what `read_to_vec` would take without being told otherwise. A
392        // cap on this path would show up as a short table rather than an error,
393        // which is the worst way for a limit to be discovered.
394        let big = vec![b'y'; 20 * 1024 * 1024];
395        let mut reader = reader_of(&big);
396
397        let mut out = Vec::new();
398        reader.read_to_end(&mut out).expect("reads");
399        assert_eq!(out.len(), big.len());
400    }
401}