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}