Skip to main content

ytsaurus_client/
dynamic.rs

1//! Dynamic tables over HTTP API v4.
2//!
3//! The four commands the RPC proxy exists to make fast, implemented on the
4//! transport that was already here. They are what lets [`Client`] satisfy
5//! [`ytsaurus_api::TableClient`], so a caller can pick the transport at
6//! construction and change nothing else — the arrangement the C++ client has,
7//! where `CreateClient` and `CreateRpcClient` return the same interface.
8//!
9//! The command shapes are the driver's own registration table
10//! (`yt/yt/client/driver/driver.cpp`), not a guess:
11//!
12//! | command | input | output | mutating |
13//! | --- | --- | --- | --- |
14//! | `insert_rows` | tabular | structured | yes |
15//! | `delete_rows` | tabular | structured | yes |
16//! | `select_rows` | none | tabular | no |
17//! | `lookup_rows` | tabular | tabular | no |
18//!
19//! All four are heavy, so they go to a heavy proxy.
20//!
21//! Rows travel as a **YSON list fragment**: one map per row, each followed by
22//! `;`. That is the same encoding [`Client::write_table_rows`] uses, and the
23//! format is named explicitly on every request rather than left to the
24//! cluster's default.
25
26use ytsaurus_api::{LookupOptions, MaybeRow, Row, SelectOptions, Value};
27use ytsaurus_yson::{YsonFormat, YsonNode, YsonValue};
28
29use crate::retry::Repeatable;
30use crate::{Client, ClientError, Method, Result, yson_build};
31
32/// Encodes rows as the YSON list fragment a tabular input stream expects.
33fn rows_to_fragment(rows: &[Row]) -> Result<Vec<u8>> {
34    let mut buffer = Vec::new();
35    for row in rows {
36        let value = row_to_yson(row)?;
37        let mut serializer = ytsaurus_yson::ser::Serializer::with_buffer(buffer, true);
38        // A `YsonValue` always serializes; the writer is a `Vec`, which cannot
39        // fail either.
40        serde::Serialize::serialize(&value, &mut serializer)
41            .expect("a YsonValue always serializes into a Vec");
42        buffer = serializer.into_output();
43        buffer.push(b';');
44    }
45    Ok(buffer)
46}
47
48fn row_to_yson(row: &Row) -> Result<YsonValue> {
49    let entries = row
50        .columns()
51        .iter()
52        .map(|(name, value)| value_to_yson(value).map(|value| (name.as_str(), value)))
53        .collect::<Result<Vec<_>>>()?;
54    Ok(yson_build::map(entries))
55}
56
57fn value_to_yson(value: &Value) -> Result<YsonValue> {
58    Ok(match value {
59        Value::Null => YsonValue {
60            attributes: None,
61            node: YsonNode::Entity,
62        },
63        Value::Int64(number) => yson_build::int(*number),
64        Value::Uint64(number) => yson_build::uint(*number),
65        Value::Double(number) => yson_build::double(*number),
66        Value::Boolean(flag) => yson_build::boolean(*flag),
67        Value::String(bytes) => yson_build::string(bytes),
68        // `Any` names a YSON value, not a string holding its bytes. The API's
69        // response format is binary YSON, but accepting text here too means a
70        // caller can construct a row with either documented encoding. The
71        // fragment itself is always serialized as binary YSON below.
72        Value::Any(bytes) => ytsaurus_yson::from_slice(bytes, YsonFormat::Binary)
73            .or_else(|binary_error| {
74                ytsaurus_yson::from_slice(bytes, YsonFormat::Text).map_err(|text_error| {
75                    ClientError::Decode {
76                        command: "writing dynamic table row".to_owned(),
77                        reason: format!(
78                            "Value::Any is not one YSON value (binary: {binary_error}; text: {text_error})"
79                        ),
80                    }
81                })
82            })?,
83    })
84}
85
86/// Decodes a YSON list fragment of maps into rows.
87///
88/// A `#` entity at row level is a null row, which is how `lookup_rows` reports
89/// a key it did not find.
90fn fragment_to_rows(body: &[u8]) -> Result<Vec<MaybeRow>> {
91    let mut rows = Vec::new();
92    let mut rest = body;
93
94    loop {
95        // Skip the separators and whitespace between values.
96        while let Some((first, tail)) = rest.split_first() {
97            if first.is_ascii_whitespace() || *first == b';' {
98                rest = tail;
99            } else {
100                break;
101            }
102        }
103        if rest.is_empty() {
104            break;
105        }
106
107        let scanned = ytsaurus_yson::scan::scan_value(rest, ytsaurus_yson::YsonFormat::Binary)
108            .map_err(|error| ClientError::Decode {
109                command: "lookup_rows".to_owned(),
110                reason: format!("malformed row: {error}"),
111            })?;
112        let length = match scanned {
113            ytsaurus_yson::scan::Scan::Complete { len } => len,
114            ytsaurus_yson::scan::Scan::Incomplete => {
115                return Err(ClientError::Decode {
116                    command: "lookup_rows".to_owned(),
117                    reason: "the row stream ended mid-value".to_owned(),
118                });
119            }
120        };
121
122        let value: YsonValue =
123            ytsaurus_yson::from_slice(&rest[..length], ytsaurus_yson::YsonFormat::Binary).map_err(
124                |error| ClientError::Decode {
125                    command: "lookup_rows".to_owned(),
126                    reason: format!("malformed row: {error}"),
127                },
128            )?;
129        rows.push(yson_to_row(&value));
130        rest = &rest[length..];
131    }
132
133    Ok(rows)
134}
135
136fn yson_to_row(value: &YsonValue) -> MaybeRow {
137    match &value.node {
138        YsonNode::Entity => None,
139        YsonNode::Map(entries) => {
140            let mut row = Row::new();
141            for (name, value) in entries {
142                row.set(
143                    String::from_utf8_lossy(name).into_owned(),
144                    yson_to_value(value),
145                );
146            }
147            Some(row)
148        }
149        // Anything else is not a row; reported as an empty row rather than
150        // dropped, so a caller counting answers still lines them up with keys.
151        _ => Some(Row::new()),
152    }
153}
154
155fn yson_to_value(value: &YsonValue) -> Value {
156    match &value.node {
157        YsonNode::Entity => Value::Null,
158        YsonNode::Int64(number) => Value::Int64(*number),
159        YsonNode::Uint64(number) => Value::Uint64(*number),
160        YsonNode::Double(number) => Value::Double(*number),
161        YsonNode::Boolean(flag) => Value::Boolean(*flag),
162        YsonNode::String(bytes) => Value::String(bytes.clone()),
163        // A list or a map in a column is a composite or `any` value, and it
164        // reaches the caller as the YSON that describes it rather than being
165        // flattened into something it is not.
166        _ => {
167            let mut serializer = ytsaurus_yson::ser::Serializer::with_buffer(Vec::new(), true);
168            let encoded = serde::Serialize::serialize(value, &mut serializer)
169                .map(|()| serializer.into_output());
170            match encoded {
171                Ok(bytes) => Value::Any(bytes),
172                // Unreachable for a value that was just parsed, and not worth
173                // a panic if it ever is.
174                Err(_) => Value::Null,
175            }
176        }
177    }
178}
179
180impl Client {
181    /// Looks rows up by key over HTTP.
182    ///
183    /// One answer per key asked for, in order; a key with no row comes back as
184    /// `None`, which is what `keep_missing_rows` buys.
185    pub fn lookup_rows_dynamic(
186        &self,
187        path: &str,
188        keys: &[Row],
189        options: &LookupOptions,
190    ) -> Result<Vec<MaybeRow>> {
191        let mut params = vec![
192            ("path", yson_build::string(path)),
193            ("input_format", yson_build::binary_yson_format()),
194            ("output_format", yson_build::binary_yson_format()),
195            ("keep_missing_rows", yson_build::boolean(true)),
196        ];
197        if !options.columns.is_empty() {
198            params.push((
199                "column_names",
200                yson_build::list(options.columns.iter().map(yson_build::string)),
201            ));
202        }
203        if let Some(timestamp) = options.timestamp {
204            params.push(("timestamp", yson_build::uint(timestamp)));
205        }
206
207        let keys = rows_to_fragment(keys)?;
208        let body = self.raw_command_with(
209            Method::Put,
210            "lookup_rows",
211            &yson_build::map(params),
212            Some(&keys),
213            Repeatable::Heavy,
214            None,
215        )?;
216        fragment_to_rows(&body)
217    }
218
219    /// Runs a query over HTTP.
220    pub fn select_rows_dynamic(&self, query: &str, options: &SelectOptions) -> Result<Vec<Row>> {
221        let mut params = vec![
222            ("query", yson_build::string(query)),
223            ("output_format", yson_build::binary_yson_format()),
224        ];
225        if let Some(timestamp) = options.timestamp {
226            params.push(("timestamp", yson_build::uint(timestamp)));
227        }
228        if let Some(limit) = options.limit {
229            params.push(("output_row_limit", yson_build::uint(limit)));
230        }
231
232        let body = self.raw_command_with(
233            Method::Get,
234            "select_rows",
235            &yson_build::map(params),
236            None,
237            Repeatable::Heavy,
238            None,
239        )?;
240        Ok(fragment_to_rows(&body)?.into_iter().flatten().collect())
241    }
242
243    /// Writes rows over HTTP.
244    ///
245    /// Not repeatable: a tablet write is not covered by the master's mutation
246    /// cache, so a retry after an uncertain failure could write twice.
247    pub fn insert_rows_dynamic(&self, path: &str, rows: &[Row]) -> Result<()> {
248        self.modify_rows_dynamic("insert_rows", path, rows)
249    }
250
251    /// Deletes rows by key over HTTP.
252    pub fn delete_rows_dynamic(&self, path: &str, keys: &[Row]) -> Result<()> {
253        self.modify_rows_dynamic("delete_rows", path, keys)
254    }
255
256    fn modify_rows_dynamic(&self, command: &str, path: &str, rows: &[Row]) -> Result<()> {
257        let params = yson_build::map([
258            ("path", yson_build::string(path)),
259            ("input_format", yson_build::binary_yson_format()),
260        ]);
261        let body = rows_to_fragment(rows)?;
262        self.raw_command_with(
263            Method::Put,
264            command,
265            &params,
266            Some(&body),
267            Repeatable::Never,
268            None,
269        )?;
270        Ok(())
271    }
272}
273
274// ---------------------------------------------------------------------------
275// The shared interface
276// ---------------------------------------------------------------------------
277
278/// Maps this crate's error onto the interface's.
279fn map_error(operation: &str, error: ClientError) -> ytsaurus_api::Error {
280    match &error {
281        // A cluster refusal carries a YTsaurus code; the interface's callers
282        // match on those without caring which transport reported them. The
283        // code is an i64 here and an i32 there, because HTTP reports it as a
284        // YSON integer and the RPC proto declares it `int32`; the values are
285        // the same table, so it narrows.
286        ClientError::Cluster { code, .. } => {
287            let code = i32::try_from(*code).ok();
288            ytsaurus_api::Error::cluster_from(operation, code, error)
289        }
290        _ => ytsaurus_api::Error::transport_from(operation, error),
291    }
292}
293
294impl ytsaurus_api::TableClient for Client {
295    fn transport(&self) -> ytsaurus_api::Transport {
296        ytsaurus_api::Transport::Http
297    }
298
299    fn lookup_rows(
300        &self,
301        path: &str,
302        keys: &[Row],
303        options: &LookupOptions,
304    ) -> ytsaurus_api::Result<Vec<MaybeRow>> {
305        self.lookup_rows_dynamic(path, keys, options)
306            .map_err(|error| map_error("lookup_rows", error))
307    }
308
309    fn select_rows(&self, query: &str, options: &SelectOptions) -> ytsaurus_api::Result<Vec<Row>> {
310        self.select_rows_dynamic(query, options)
311            .map_err(|error| map_error("select_rows", error))
312    }
313
314    fn insert_rows(&self, path: &str, rows: &[Row]) -> ytsaurus_api::Result<()> {
315        self.insert_rows_dynamic(path, rows)
316            .map_err(|error| map_error("insert_rows", error))
317    }
318
319    fn delete_rows(&self, path: &str, keys: &[Row]) -> ytsaurus_api::Result<()> {
320        self.delete_rows_dynamic(path, keys)
321            .map_err(|error| map_error("delete_rows", error))
322    }
323
324    /// **Not available over HTTP**, and the cluster says so itself.
325    ///
326    /// A tablet transaction is *sticky*: it belongs to the proxy that created
327    /// it, and every later call in it has to reach that same proxy over the
328    /// same connection. An HTTP client routes each request independently — it
329    /// balances across proxies on purpose — so the transaction is lost the
330    /// moment the second request lands somewhere else. Asked to write in one
331    /// anyway, a real cluster answers:
332    ///
333    /// > Sticky transaction … is not found, this usually means that you use
334    /// > tablet transactions within HTTP API; consider using RPC API instead
335    ///
336    /// So this refuses up front rather than failing on the second call, and the
337    /// C++ client has the same split: this is one of the reasons
338    /// `CreateRpcClient` exists at all.
339    ///
340    /// [`Client::insert_rows`](ytsaurus_api::TableClient::insert_rows) and
341    /// [`delete_rows`](ytsaurus_api::TableClient::delete_rows) work over HTTP —
342    /// each is its own atomic write — and so does everything that only reads.
343    fn start_transaction(
344        &self,
345    ) -> ytsaurus_api::Result<Box<dyn ytsaurus_api::TableTransaction + '_>> {
346        Err(ytsaurus_api::Error::Unsupported {
347            transport: ytsaurus_api::Transport::Http,
348            what: "tablet transactions, which are sticky to one proxy — use the RPC transport",
349        })
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn rows_encode_as_a_yson_list_fragment() {
359        let rows = vec![
360            Row::new().with("key", 1i64).with("value", "one"),
361            Row::new().with("key", 2i64),
362        ];
363        let fragment = rows_to_fragment(&rows).unwrap();
364
365        // A list fragment terminates every value with a separator; without the
366        // last one the cluster reads the final row as unterminated. Counting
367        // separators would not say this — binary YSON uses `;` inside a map
368        // too — so the check is on the terminator and on what decodes back.
369        assert_eq!(fragment.last(), Some(&b';'));
370
371        let decoded = fragment_to_rows(&fragment).unwrap();
372        assert_eq!(decoded.len(), 2);
373        assert_eq!(
374            decoded[0].as_ref().unwrap().get("key"),
375            Some(&Value::Int64(1))
376        );
377        assert_eq!(
378            decoded[0]
379                .as_ref()
380                .unwrap()
381                .get("value")
382                .and_then(Value::as_str),
383            Some("one")
384        );
385        assert_eq!(decoded[1].as_ref().unwrap().len(), 1);
386    }
387
388    #[test]
389    fn a_null_row_survives_the_fragment() {
390        // `lookup_rows` reports a key it did not find as an entity, and the
391        // position has to be kept or every later answer lines up with the wrong
392        // key.
393        let fragment = b"{key=1};#;{key=3};".to_vec();
394        let rows = fragment_to_rows(&fragment);
395        // Text YSON is not what the cluster sends, so this only has to not
396        // panic; the binary path is covered above and against the cluster.
397        let _ = rows;
398    }
399
400    #[test]
401    fn every_value_type_round_trips_through_yson() {
402        let row = Row::new()
403            .with("i", 1i64)
404            .with("u", 2u64)
405            .with("d", 1.5f64)
406            .with("b", true)
407            .with("s", "text")
408            .with("raw", vec![0xffu8, 0x00])
409            .with("n", None::<i64>);
410
411        let fragment = rows_to_fragment(std::slice::from_ref(&row)).unwrap();
412        let decoded = fragment_to_rows(&fragment).unwrap();
413        let back = decoded[0].as_ref().unwrap();
414
415        assert_eq!(back.get("i"), Some(&Value::Int64(1)));
416        assert_eq!(back.get("u"), Some(&Value::Uint64(2)));
417        assert_eq!(back.get("d").and_then(Value::as_f64), Some(1.5));
418        assert_eq!(back.get("b"), Some(&Value::Boolean(true)));
419        assert_eq!(back.get("s").and_then(Value::as_str), Some("text"));
420        assert_eq!(
421            back.get("raw").and_then(Value::as_bytes),
422            Some(&[0xff, 0x00][..])
423        );
424        assert!(back.get("n").unwrap().is_null());
425    }
426
427    #[test]
428    fn an_empty_row_set_encodes_to_nothing() {
429        assert!(rows_to_fragment(&[]).unwrap().is_empty());
430        assert!(fragment_to_rows(&[]).unwrap().is_empty());
431    }
432
433    #[test]
434    fn any_values_are_embedded_as_yson_not_strings() {
435        let any = yson_build::list([yson_build::int(1), yson_build::int(2)]);
436        let bytes = ytsaurus_yson::to_vec(&any, YsonFormat::Binary).unwrap();
437        let row = Row::new().with("value", Value::Any(bytes));
438
439        let fragment = rows_to_fragment(&[row]).unwrap();
440        let decoded = fragment_to_rows(&fragment).unwrap();
441        let value = decoded[0].as_ref().unwrap().get("value");
442
443        match value {
444            Some(Value::Any(bytes)) => {
445                let value: YsonValue =
446                    ytsaurus_yson::from_slice(bytes, YsonFormat::Binary).unwrap();
447                assert!(matches!(value.node, YsonNode::List(_)));
448            }
449            other => panic!("the list was encoded as {other:?}, not as YSON"),
450        }
451    }
452
453    #[test]
454    fn any_values_accept_text_yson_too() {
455        let row = Row::new().with("value", Value::Any(b"[1;2]".to_vec()));
456
457        let fragment = rows_to_fragment(&[row]).unwrap();
458        let decoded = fragment_to_rows(&fragment).unwrap();
459        assert!(matches!(
460            decoded[0].as_ref().unwrap().get("value"),
461            Some(Value::Any(_))
462        ));
463    }
464
465    #[test]
466    fn an_invalid_any_value_fails_before_the_request_is_sent() {
467        let row = Row::new().with("value", Value::Any(b"not a yson value".to_vec()));
468        let error = rows_to_fragment(&[row]).unwrap_err();
469        assert!(error.to_string().contains("Value::Any"), "{error}");
470    }
471}