Skip to main content

ytsaurus_rpc/
client.rs

1//! The public API: a deliberately small subset of the RPC proxy's surface.
2//!
3//! Transactions, `lookup_rows`, `select_rows` and `modify_rows` — the calls
4//! that justify speaking this protocol at all. Everything else the proxy can do
5//! is reachable over HTTP through `ytsaurus-client`, and is not reimplemented
6//! here.
7
8use std::time::Duration;
9
10use bytes::Bytes;
11
12use crate::connection::Connection;
13use crate::error::{Error, Result};
14use crate::guid::Guid;
15use crate::proto;
16use crate::wire::{self, MaybeRow, Row};
17
18/// The default per-request deadline.
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
20
21/// The default transaction timeout: the server aborts a transaction that is not
22/// pinged within it.
23pub const DEFAULT_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(15);
24
25/// `ETransactionType` — `api_service.proto`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[repr(i32)]
28pub enum TransactionType {
29    Master = 0,
30    Tablet = 1,
31}
32
33/// `EAtomicity` — `api_service.proto`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(i32)]
36pub enum Atomicity {
37    Full = 0,
38    None = 1,
39}
40
41/// `ERowModificationType` — `api_service.proto`.
42///
43/// The numbering has a gap: there is no 2.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[repr(i32)]
46pub enum RowModificationType {
47    Write = 0,
48    Delete = 1,
49    /// "Write and lock" — the wire name is `RMT_MODIFY`.
50    WriteAndLock = 3,
51}
52
53/// A timestamp, as the tablet API counts them.
54pub type Timestamp = u64;
55
56/// The read timestamp meaning "the latest committed data".
57///
58/// It is the declared proto2 default of every `timestamp` field in the read
59/// methods. **Zero is not this**: zero is `NullTimestamp`, and sending it asks
60/// for something else entirely.
61pub const LATEST_TIMESTAMP: Timestamp = 0x3fff_ffff_ffff_ff01;
62
63/// A client bound to one RPC proxy.
64///
65/// One connection multiplexes every call, so a `Client` is cheap to share:
66/// wrap it in an `Arc` and call it concurrently rather than opening more.
67#[derive(Debug)]
68pub struct Client {
69    connection: Connection,
70    timeout: Duration,
71}
72
73impl Client {
74    /// Connects to an RPC proxy at `host:port`.
75    ///
76    /// The address is the one `discover_proxies` returns, not the HTTP proxy's.
77    pub async fn connect(address: &str) -> Result<Self> {
78        Self::builder(address).connect().await
79    }
80
81    /// Starts configuring a client.
82    pub fn builder(address: &str) -> ClientBuilder {
83        ClientBuilder {
84            address: address.to_owned(),
85            token: None,
86            timeout: DEFAULT_TIMEOUT,
87        }
88    }
89
90    /// The underlying connection, for callers that need to invoke a method this
91    /// crate does not wrap.
92    pub fn connection(&self) -> &Connection {
93        &self.connection
94    }
95
96    /// The addresses of the cluster's RPC proxies, asked of this one.
97    pub async fn discover_proxies(&self, role: Option<&str>) -> Result<Vec<String>> {
98        crate::connection::discover_proxies(&self.connection, role, Some(self.timeout)).await
99    }
100
101    /// Starts a transaction.
102    ///
103    /// A tablet transaction is **sticky**: it belongs to the proxy that created
104    /// it, and every later call in it must go to that same proxy. This client
105    /// holds one connection, so that happens naturally — but a transaction
106    /// started here must not be used through another `Client`.
107    pub async fn start_transaction(
108        &self,
109        transaction_type: TransactionType,
110        options: StartTransactionOptions,
111    ) -> Result<Transaction<'_>> {
112        let request = start_transaction_request(transaction_type, &options);
113
114        let (response, _) = self
115            .connection
116            .invoke::<proto::api::TRspStartTransaction>(
117                "StartTransaction",
118                &request,
119                Vec::new(),
120                Some(self.timeout),
121                "TRspStartTransaction",
122            )
123            .await?;
124
125        Ok(Transaction {
126            client: self,
127            id: Guid::from_proto(&response.id),
128            start_timestamp: response.start_timestamp,
129            finished: false,
130        })
131    }
132
133    /// Looks rows up by key.
134    ///
135    /// `keys` holds one row per key, carrying only the key columns. The result
136    /// has one entry per key **in the order asked**, and a key with no row
137    /// comes back as `None` — which is why the rows are `Option`s rather than a
138    /// shorter list.
139    pub async fn lookup_rows(
140        &self,
141        path: &str,
142        columns: &[&str],
143        keys: &[Row],
144        options: LookupOptions<'_>,
145    ) -> Result<Vec<MaybeRow>> {
146        let keys: Vec<MaybeRow> = keys.iter().cloned().map(Some).collect();
147        let request = lookup_request(path, columns, &options);
148
149        let (response, attachments) = self
150            .connection
151            .invoke::<proto::api::TRspLookupRows>(
152                "LookupRows",
153                &request,
154                vec![wire::encode_rowset(&keys)?],
155                Some(self.timeout),
156                "TRspLookupRows",
157            )
158            .await?;
159
160        decode_rowset_attachments(&attachments, Some(&response.rowset_descriptor))
161    }
162
163    /// Looks rows up, returning the names of the columns as well as the rows.
164    ///
165    /// A value carries a numeric id, not a name, and the reply's descriptor is
166    /// what resolves them. Callers that map rows onto named columns — the
167    /// blocking facade, and anything building a `serde` row — need both.
168    pub async fn lookup_rows_with_columns(
169        &self,
170        path: &str,
171        columns: &[&str],
172        keys: &[Row],
173        options: LookupOptions<'_>,
174    ) -> Result<(Vec<MaybeRow>, Vec<String>)> {
175        let keys: Vec<MaybeRow> = keys.iter().cloned().map(Some).collect();
176        let request = lookup_request(path, columns, &options);
177
178        let (response, attachments) = self
179            .connection
180            .invoke::<proto::api::TRspLookupRows>(
181                "LookupRows",
182                &request,
183                vec![wire::encode_rowset(&keys)?],
184                Some(self.timeout),
185                "TRspLookupRows",
186            )
187            .await?;
188
189        let descriptor = &response.rowset_descriptor;
190        let rows = decode_rowset_attachments(&attachments, Some(descriptor))?;
191        Ok((rows, descriptor_column_names(descriptor)))
192    }
193
194    /// Runs a query and returns its rows.
195    ///
196    /// The returned descriptor names the columns, in the order the values are
197    /// numbered; [`select_rows_with_columns`](Self::select_rows_with_columns)
198    /// hands both back when the caller needs the names.
199    pub async fn select_rows(&self, query: &str, options: SelectOptions) -> Result<Vec<MaybeRow>> {
200        Ok(self.select_rows_with_columns(query, options).await?.0)
201    }
202
203    /// Runs a query, returning its rows and the names of their columns.
204    pub async fn select_rows_with_columns(
205        &self,
206        query: &str,
207        options: SelectOptions,
208    ) -> Result<(Vec<MaybeRow>, Vec<String>)> {
209        let request = select_request(query, &options);
210
211        let (response, attachments) = self
212            .connection
213            .invoke::<proto::api::TRspSelectRows>(
214                "SelectRows",
215                &request,
216                Vec::new(),
217                Some(self.timeout),
218                "TRspSelectRows",
219            )
220            .await?;
221
222        let descriptor = &response.rowset_descriptor;
223        let rows = decode_rowset_attachments(&attachments, Some(descriptor))?;
224        Ok((rows, descriptor_column_names(descriptor)))
225    }
226}
227
228/// Configuration for [`Client::connect`].
229#[derive(Debug, Clone)]
230pub struct ClientBuilder {
231    address: String,
232    token: Option<String>,
233    timeout: Duration,
234}
235
236impl ClientBuilder {
237    /// The token sent in every request's credentials extension.
238    ///
239    /// A local cluster with authentication disabled needs none.
240    pub fn token(mut self, token: impl Into<String>) -> Self {
241        self.token = Some(token.into());
242        self
243    }
244
245    /// The per-request deadline, sent to the server as well as applied locally.
246    pub fn timeout(mut self, timeout: Duration) -> Self {
247        self.timeout = timeout;
248        self
249    }
250
251    pub async fn connect(self) -> Result<Client> {
252        let connection = Connection::connect(&self.address, self.token).await?;
253        Ok(Client {
254            connection,
255            timeout: self.timeout,
256        })
257    }
258}
259
260/// Options for [`Client::start_transaction`].
261#[derive(Debug, Clone)]
262pub struct StartTransactionOptions {
263    /// How long the server waits between pings before aborting.
264    pub timeout: Duration,
265    pub atomicity: Atomicity,
266    pub parent_id: Option<Guid>,
267}
268
269impl Default for StartTransactionOptions {
270    fn default() -> Self {
271        Self {
272            timeout: DEFAULT_TRANSACTION_TIMEOUT,
273            atomicity: Atomicity::Full,
274            parent_id: None,
275        }
276    }
277}
278
279/// Options for [`Client::lookup_rows`].
280#[derive(Debug, Clone, Default)]
281pub struct LookupOptions<'a> {
282    /// The read timestamp. `None` reads the latest committed data; inside a
283    /// tablet transaction it must be the transaction's start timestamp, which
284    /// [`Transaction::lookup_rows`] fills in.
285    pub timestamp: Option<Timestamp>,
286    /// The columns to return. Empty means all of them.
287    pub column_filter: Vec<&'a str>,
288}
289
290/// Options for [`Client::select_rows`].
291#[derive(Debug, Clone, Default)]
292pub struct SelectOptions {
293    /// The read timestamp; see [`LookupOptions::timestamp`].
294    pub timestamp: Option<Timestamp>,
295    /// Stop after this many rows, if set.
296    ///
297    /// This is a request option, rather than text appended to `query`: a query
298    /// may already have a `LIMIT`, end in a semicolon, or require its clauses
299    /// in a different order.
300    pub output_row_limit: Option<u64>,
301}
302
303/// An open transaction.
304///
305/// Dropping one does **not** abort it: `Drop` cannot await, and a silent
306/// best-effort abort would be a lie. An unfinished transaction is left to
307/// expire on the server after its timeout, and [`Transaction::abort`] is there
308/// to end it now.
309#[derive(Debug)]
310pub struct Transaction<'a> {
311    client: &'a Client,
312    id: Guid,
313    start_timestamp: Timestamp,
314    finished: bool,
315}
316
317impl Transaction<'_> {
318    pub fn id(&self) -> Guid {
319        self.id
320    }
321
322    /// The transaction's read timestamp.
323    ///
324    /// Reads inside a tablet transaction are expressed purely as this
325    /// timestamp: `TReqLookupRows` and `TReqSelectRows` have no
326    /// `transaction_id` field at all.
327    pub fn start_timestamp(&self) -> Timestamp {
328        self.start_timestamp
329    }
330
331    /// Tells the server the transaction is still wanted.
332    ///
333    /// Must be called more often than the transaction's timeout, or the server
334    /// aborts it.
335    pub async fn ping(&self) -> Result<()> {
336        let request = proto::api::TReqPingTransaction {
337            transaction_id: self.id.to_proto(),
338            ..Default::default()
339        };
340        self.client
341            .connection
342            .invoke::<proto::api::TRspPingTransaction>(
343                "PingTransaction",
344                &request,
345                Vec::new(),
346                Some(self.client.timeout),
347                "TRspPingTransaction",
348            )
349            .await?;
350        Ok(())
351    }
352
353    /// Commits the transaction.
354    pub async fn commit(mut self) -> Result<()> {
355        let request = proto::api::TReqCommitTransaction {
356            transaction_id: self.id.to_proto(),
357            ..Default::default()
358        };
359        self.client
360            .connection
361            .invoke::<proto::api::TRspCommitTransaction>(
362                "CommitTransaction",
363                &request,
364                Vec::new(),
365                Some(self.client.timeout),
366                "TRspCommitTransaction",
367            )
368            .await?;
369        self.finished = true;
370        Ok(())
371    }
372
373    /// Aborts the transaction.
374    pub async fn abort(mut self) -> Result<()> {
375        let request = proto::api::TReqAbortTransaction {
376            transaction_id: self.id.to_proto(),
377            ..Default::default()
378        };
379        self.client
380            .connection
381            .invoke::<proto::api::TRspAbortTransaction>(
382                "AbortTransaction",
383                &request,
384                Vec::new(),
385                Some(self.client.timeout),
386                "TRspAbortTransaction",
387            )
388            .await?;
389        self.finished = true;
390        Ok(())
391    }
392
393    /// Whether the transaction has been committed or aborted.
394    pub fn is_finished(&self) -> bool {
395        self.finished
396    }
397
398    /// Looks rows up as of this transaction's start timestamp.
399    pub async fn lookup_rows(
400        &self,
401        path: &str,
402        columns: &[&str],
403        keys: &[Row],
404        mut options: LookupOptions<'_>,
405    ) -> Result<Vec<MaybeRow>> {
406        options.timestamp = Some(self.start_timestamp);
407        self.client.lookup_rows(path, columns, keys, options).await
408    }
409
410    /// Looks rows up as of this transaction, with the reply's column names.
411    pub async fn lookup_rows_with_columns(
412        &self,
413        path: &str,
414        columns: &[&str],
415        keys: &[Row],
416        mut options: LookupOptions<'_>,
417    ) -> Result<(Vec<MaybeRow>, Vec<String>)> {
418        options.timestamp = Some(self.start_timestamp);
419        self.client
420            .lookup_rows_with_columns(path, columns, keys, options)
421            .await
422    }
423
424    /// Runs a query as of this transaction, with the reply's column names.
425    pub async fn select_rows_with_columns(
426        &self,
427        query: &str,
428        mut options: SelectOptions,
429    ) -> Result<(Vec<MaybeRow>, Vec<String>)> {
430        options.timestamp = Some(self.start_timestamp);
431        self.client.select_rows_with_columns(query, options).await
432    }
433
434    /// Runs a query as of this transaction's start timestamp.
435    pub async fn select_rows(
436        &self,
437        query: &str,
438        mut options: SelectOptions,
439    ) -> Result<Vec<MaybeRow>> {
440        options.timestamp = Some(self.start_timestamp);
441        self.client.select_rows(query, options).await
442    }
443
444    /// Writes rows.
445    pub async fn insert_rows(&self, path: &str, columns: &[&str], rows: &[Row]) -> Result<()> {
446        self.modify_rows(path, columns, rows, RowModificationType::Write)
447            .await
448    }
449
450    /// Deletes rows by key. Each row carries only the key columns.
451    pub async fn delete_rows(&self, path: &str, columns: &[&str], keys: &[Row]) -> Result<()> {
452        self.modify_rows(path, columns, keys, RowModificationType::Delete)
453            .await
454    }
455
456    /// Applies one modification type to every row.
457    ///
458    /// `row_modification_types` is a parallel array to the rows in the
459    /// attachment: entry *i* is the type of row *i*, and the server relies on
460    /// the two staying the same length.
461    pub async fn modify_rows(
462        &self,
463        path: &str,
464        columns: &[&str],
465        rows: &[Row],
466        modification: RowModificationType,
467    ) -> Result<()> {
468        let owned: Vec<MaybeRow> = rows.iter().cloned().map(Some).collect();
469        let request = modify_request(self.id, path, columns, rows.len(), modification);
470
471        self.client
472            .connection
473            .invoke::<proto::api::TRspModifyRows>(
474                "ModifyRows",
475                &request,
476                vec![wire::encode_rowset(&owned)?],
477                Some(self.client.timeout),
478                "TRspModifyRows",
479            )
480            .await?;
481        Ok(())
482    }
483}
484
485/// Builds the `StartTransaction` request.
486///
487/// Separated from the call so the bytes a method puts on the wire can be
488/// asserted without a proxy: these functions are where a wrong or missing field
489/// would live, and a mistake in one is invisible until a cluster rejects it.
490fn start_transaction_request(
491    transaction_type: TransactionType,
492    options: &StartTransactionOptions,
493) -> proto::api::TReqStartTransaction {
494    proto::api::TReqStartTransaction {
495        r#type: transaction_type as i32,
496        timeout: Some(options.timeout.as_micros() as i64),
497        // A tablet transaction is pinned to the proxy that created it; say so,
498        // as both reference clients do.
499        sticky: Some(transaction_type == TransactionType::Tablet),
500        atomicity: Some(options.atomicity as i32),
501        parent_id: options.parent_id.map(Guid::to_proto),
502        ..Default::default()
503    }
504}
505
506/// Builds the `LookupRows` request. The keys travel separately, in attachments.
507fn lookup_request(
508    path: &str,
509    columns: &[&str],
510    options: &LookupOptions<'_>,
511) -> proto::api::TReqLookupRows {
512    proto::api::TReqLookupRows {
513        // `bytes`, not `string`: a YPath is a byte string.
514        path: path.as_bytes().to_vec(),
515        rowset_descriptor: name_table_descriptor(columns),
516        timestamp: options.timestamp,
517        // One answer per key asked for, so a key with no row comes back as a
518        // null row rather than shortening the list and silently misaligning
519        // every answer after it.
520        keep_missing_rows: Some(true),
521        columns: options
522            .column_filter
523            .iter()
524            .map(|column| (*column).to_owned())
525            .collect(),
526        ..Default::default()
527    }
528}
529
530/// Builds the `SelectRows` request.
531fn select_request(query: &str, options: &SelectOptions) -> proto::api::TReqSelectRows {
532    proto::api::TReqSelectRows {
533        query: query.to_owned(),
534        timestamp: options.timestamp,
535        output_row_limit: options.output_row_limit,
536        ..Default::default()
537    }
538}
539
540/// Builds the `ModifyRows` request. The rows travel separately, in attachments.
541fn modify_request(
542    transaction_id: Guid,
543    path: &str,
544    columns: &[&str],
545    row_count: usize,
546    modification: RowModificationType,
547) -> proto::api::TReqModifyRows {
548    proto::api::TReqModifyRows {
549        transaction_id: transaction_id.to_proto(),
550        path: path.as_bytes().to_vec(),
551        rowset_descriptor: name_table_descriptor(columns),
552        // One entry per row, in the same order as the rows in the attachment.
553        // The server relies on the two staying the same length.
554        row_modification_types: vec![modification as i32; row_count],
555        ..Default::default()
556    }
557}
558
559/// The column names a reply's descriptor carries, in id order.
560fn descriptor_column_names(descriptor: &proto::api::TRowsetDescriptor) -> Vec<String> {
561    descriptor
562        .name_table_entries
563        .iter()
564        .map(|entry| entry.name.clone().unwrap_or_default())
565        .collect()
566}
567
568/// Builds the descriptor that names the columns a rowset's value ids refer to.
569///
570/// A value carries a numeric id, not a column name; the id indexes this table.
571fn name_table_descriptor(columns: &[&str]) -> proto::api::TRowsetDescriptor {
572    proto::api::TRowsetDescriptor {
573        wire_format_version: Some(CURRENT_WIRE_FORMAT_VERSION),
574        rowset_kind: Some(proto::api::ERowsetKind::RkUnversioned as i32),
575        name_table_entries: columns
576            .iter()
577            .map(|name| proto::api::t_rowset_descriptor::TNameTableEntry {
578                name: Some((*name).to_owned()),
579                ..Default::default()
580            })
581            .collect(),
582        ..Default::default()
583    }
584}
585
586/// `CurrentWireFormatVersion` — `yt/go/yt/internal/rpcclient/wire.go`.
587const CURRENT_WIRE_FORMAT_VERSION: i32 = 1;
588
589/// Decodes the rows an API response carries in its attachments.
590///
591/// Attachments are concatenated before decoding: a large rowset is split across
592/// several, and each is a slice of one stream rather than a self-contained
593/// rowset — `mergeAttachments` in the Go client does the same.
594fn decode_rowset_attachments(
595    attachments: &[Bytes],
596    descriptor: Option<&proto::api::TRowsetDescriptor>,
597) -> Result<Vec<MaybeRow>> {
598    if let Some(descriptor) = descriptor
599        && let Some(version) = descriptor.wire_format_version
600        && version != CURRENT_WIRE_FORMAT_VERSION
601    {
602        return Err(Error::Protocol(format!(
603            "the proxy replied with wire format version {version}, and this client speaks {CURRENT_WIRE_FORMAT_VERSION}"
604        )));
605    }
606
607    let merged = match attachments {
608        [] => return Ok(Vec::new()),
609        [single] => single.clone(),
610        many => {
611            let mut merged =
612                bytes::BytesMut::with_capacity(many.iter().map(Bytes::len).sum::<usize>());
613            for attachment in many {
614                merged.extend_from_slice(attachment);
615            }
616            merged.freeze()
617        }
618    };
619
620    Ok(wire::decode_rowset(&merged)?)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::wire::{UnversionedValue, Value};
627    use prost::Message as _;
628
629    /// The enums are hand-written mirrors of proto enums, so they are compared
630    /// against the generated types rather than against restatements of
631    /// themselves.
632    #[test]
633    fn enum_values_match_the_proto() {
634        assert_eq!(
635            RowModificationType::Write as i32,
636            proto::api::ERowModificationType::RmtWrite as i32
637        );
638        assert_eq!(
639            RowModificationType::Delete as i32,
640            proto::api::ERowModificationType::RmtDelete as i32
641        );
642        assert_eq!(
643            RowModificationType::WriteAndLock as i32,
644            proto::api::ERowModificationType::RmtModify as i32
645        );
646        assert_eq!(
647            TransactionType::Master as i32,
648            proto::api::ETransactionType::TtMaster as i32
649        );
650        assert_eq!(
651            TransactionType::Tablet as i32,
652            proto::api::ETransactionType::TtTablet as i32
653        );
654        assert_eq!(Atomicity::Full as i32, proto::api::EAtomicity::AFull as i32);
655        assert_eq!(Atomicity::None as i32, proto::api::EAtomicity::ANone as i32);
656    }
657
658    #[test]
659    fn enum_values_match_the_documented_numbers() {
660        assert_eq!(TransactionType::Master as i32, 0);
661        assert_eq!(TransactionType::Tablet as i32, 1);
662        assert_eq!(Atomicity::Full as i32, 0);
663        assert_eq!(Atomicity::None as i32, 1);
664        assert_eq!(RowModificationType::Write as i32, 0);
665        assert_eq!(RowModificationType::Delete as i32, 1);
666        // There is no 2 in the RPC-proxy enum.
667        assert_eq!(RowModificationType::WriteAndLock as i32, 3);
668    }
669
670    /// The sentinel must be the value the proxy itself defaults to, not merely
671    /// a non-zero number this crate agrees with itself about.
672    ///
673    /// Zero is `NullTimestamp` and asks for something else entirely, so a
674    /// client that sent it instead would read wrong data rather than fail.
675    #[test]
676    fn the_latest_timestamp_sentinel_is_the_proto_default() {
677        assert_ne!(LATEST_TIMESTAMP, 0, "zero is NullTimestamp, not 'latest'");
678
679        // Round-tripping through the generated type is what ties the constant
680        // to `api_service.proto`: a request that leaves `timestamp` unset is
681        // read back by the server as its declared default, and this asserts
682        // that is the value named here.
683        let mut buffer = Vec::new();
684        proto::api::TReqLookupRows {
685            path: b"//tmp/t".to_vec(),
686            timestamp: None,
687            ..Default::default()
688        }
689        .encode(&mut buffer)
690        .unwrap();
691        let parsed = proto::api::TReqLookupRows::decode(&buffer[..]).unwrap();
692        assert_eq!(
693            parsed.timestamp.unwrap_or(LATEST_TIMESTAMP),
694            LATEST_TIMESTAMP
695        );
696        assert_eq!(LATEST_TIMESTAMP, 0x3fff_ffff_ffff_ff01);
697    }
698
699    /// The four methods this crate exists for, checked field by field.
700    ///
701    /// A wrong or missing field here is invisible locally and only shows up as
702    /// a cluster rejecting the call — or worse, accepting it and doing
703    /// something subtly different from what was asked.
704    #[test]
705    fn lookup_asks_for_what_it_promises() {
706        let request = lookup_request(
707            "//tmp/table",
708            &["key"],
709            &LookupOptions {
710                timestamp: None,
711                column_filter: vec!["key", "value"],
712            },
713        );
714
715        // A YPath is `bytes`, not `string`.
716        assert_eq!(request.path, b"//tmp/table".to_vec());
717        assert_eq!(request.columns, ["key", "value"]);
718        assert_eq!(
719            request.keep_missing_rows,
720            Some(true),
721            "without this a missing key shortens the answer and misaligns the rest"
722        );
723        assert_eq!(
724            request.timestamp, None,
725            "omitted means the proto default, which is the latest committed data;              sending 0 would ask for NullTimestamp instead"
726        );
727        assert_eq!(
728            request
729                .rowset_descriptor
730                .name_table_entries
731                .iter()
732                .map(|entry| entry.name.clone().unwrap())
733                .collect::<Vec<_>>(),
734            ["key"],
735            "the descriptor names the key columns the attachment carries"
736        );
737    }
738
739    #[test]
740    fn a_lookup_in_a_transaction_reads_at_its_start_timestamp() {
741        // `TReqLookupRows` has no transaction_id field at all: a read inside a
742        // tablet transaction is expressed purely as this timestamp.
743        let request = lookup_request(
744            "//tmp/table",
745            &["key"],
746            &LookupOptions {
747                timestamp: Some(1234),
748                column_filter: Vec::new(),
749            },
750        );
751        assert_eq!(request.timestamp, Some(1234));
752        assert!(
753            request.columns.is_empty(),
754            "an empty filter means every column"
755        );
756    }
757
758    #[test]
759    fn select_carries_the_query_timestamp_and_output_limit() {
760        let request = select_request(
761            "* from [//tmp/t]",
762            &SelectOptions {
763                timestamp: Some(99),
764                output_row_limit: Some(10),
765            },
766        );
767        assert_eq!(request.query, "* from [//tmp/t]");
768        assert_eq!(request.timestamp, Some(99));
769        assert_eq!(request.output_row_limit, Some(10));
770    }
771
772    #[test]
773    fn modify_names_the_transaction_and_one_type_per_row() {
774        let transaction = Guid::random();
775        let request = modify_request(
776            transaction,
777            "//tmp/table",
778            &["key", "value"],
779            3,
780            RowModificationType::Delete,
781        );
782
783        assert_eq!(Guid::from_proto(&request.transaction_id), transaction);
784        assert_eq!(request.path, b"//tmp/table".to_vec());
785        assert_eq!(
786            request.row_modification_types,
787            vec![RowModificationType::Delete as i32; 3],
788            "one entry per row, parallel to the rows in the attachment"
789        );
790        assert!(
791            request.row_legacy_read_locks.is_empty()
792                && request.row_legacy_locks.is_empty()
793                && request.row_locks.is_empty(),
794            "the lock arrays are all-or-nothing per request; a partially filled              one breaks the server's one-per-row invariant"
795        );
796    }
797
798    #[test]
799    fn only_a_tablet_transaction_is_sticky() {
800        let options = StartTransactionOptions::default();
801        let tablet = start_transaction_request(TransactionType::Tablet, &options);
802        assert_eq!(tablet.r#type, 1);
803        assert_eq!(
804            tablet.sticky,
805            Some(true),
806            "a tablet tx belongs to one proxy"
807        );
808        assert_eq!(
809            tablet.timeout,
810            Some(options.timeout.as_micros() as i64),
811            "microseconds, not milliseconds"
812        );
813
814        let master = start_transaction_request(TransactionType::Master, &options);
815        assert_eq!(master.r#type, 0);
816        assert_eq!(master.sticky, Some(false));
817    }
818
819    #[test]
820    fn the_descriptor_numbers_columns_in_order() {
821        let descriptor = name_table_descriptor(&["key", "value", "extra"]);
822        assert_eq!(descriptor.wire_format_version, Some(1));
823        assert_eq!(
824            descriptor.rowset_kind,
825            Some(proto::api::ERowsetKind::RkUnversioned as i32)
826        );
827        let names: Vec<_> = descriptor
828            .name_table_entries
829            .iter()
830            .map(|entry| entry.name.clone().unwrap())
831            .collect();
832        assert_eq!(names, ["key", "value", "extra"]);
833    }
834
835    #[test]
836    fn attachments_are_concatenated_before_decoding() {
837        // One rowset split across two attachments must decode as one rowset,
838        // not as two.
839        let rows = vec![
840            Some(vec![UnversionedValue::new(0, Value::Int64(1))]),
841            Some(vec![UnversionedValue::new(0, Value::Int64(2))]),
842        ];
843        let encoded = wire::encode_rowset(&rows).unwrap();
844        let split = encoded.len() / 2;
845        let attachments = vec![encoded.slice(0..split), encoded.slice(split..)];
846
847        let decoded = decode_rowset_attachments(&attachments, None).unwrap();
848        assert_eq!(decoded, rows);
849    }
850
851    #[test]
852    fn no_attachments_means_no_rows() {
853        assert_eq!(decode_rowset_attachments(&[], None).unwrap(), Vec::new());
854    }
855
856    #[test]
857    fn an_unknown_wire_format_version_is_refused() {
858        let descriptor = proto::api::TRowsetDescriptor {
859            wire_format_version: Some(99),
860            ..Default::default()
861        };
862        let error = decode_rowset_attachments(&[], Some(&descriptor)).unwrap_err();
863        assert!(
864            error.to_string().contains("wire format version 99"),
865            "unexpected error: {error}"
866        );
867    }
868}