Skip to main content

ytsaurus_rpc/
rpc.rs

1//! Layer 2: the RPC envelope that rides inside a bus message.
2//!
3//! A bus message is a list of parts, and the RPC layer gives them meaning:
4//!
5//! ```text
6//!   part 0   u32 message type, then the serialized TRequestHeader
7//!   part 1   the serialized request body (a TReq* message)
8//!   part 2+  attachments
9//! ```
10//!
11//! and symmetrically for a response, whose part 0 carries a `TResponseHeader`.
12//! The 4-byte type word is `TFixedMessageHeader` in
13//! `yt/yt/core/rpc/message.cpp`, declared under `#pragma pack(push, 1)` around
14//! a single `ui32`, so it is exactly four little-endian bytes with nothing
15//! after it but the protobuf.
16//!
17//! Sans-io, like [`crate::bus::packet`]: these are functions from parts to
18//! parts, and the connection actor is the only thing that touches a socket.
19
20use bytes::Bytes;
21use prost::Message;
22
23use crate::error::{Error, Result, YtError};
24use crate::guid::Guid;
25use crate::proto;
26
27/// The RPC service this crate speaks to — `api_service_proxy.h`.
28pub const API_SERVICE: &str = "ApiService";
29
30/// The discovery service, which runs alongside the API service on a proxy.
31pub const DISCOVERY_SERVICE: &str = "DiscoveryService";
32
33/// `ProtocolVersionMajor` for `ApiService` —
34/// `yt/go/yt/internal/rpcclient/rpc_proxy.go`.
35pub const PROTOCOL_VERSION_MAJOR: i32 = 1;
36
37/// The major protocol version of every other service on the proxy.
38///
39/// The version is **per service**, not per connection: the C++ takes it from
40/// the service descriptor (`client.cpp` sets `protocol_version_major` from
41/// `serviceDescriptor.ProtocolVersion.Major`), and `ApiService` is the only one
42/// that declares a major version of 1. `DiscoveryService` is still at 0, and
43/// announcing 1 to it earns a flat refusal from a real proxy — "Server major
44/// protocol version differs from client major protocol version" — which is how
45/// it was found here.
46pub const DEFAULT_PROTOCOL_VERSION_MAJOR: i32 = 0;
47
48/// The major protocol version to announce when calling `service`.
49pub fn protocol_version_major(service: &str) -> i32 {
50    match service {
51        API_SERVICE => PROTOCOL_VERSION_MAJOR,
52        _ => DEFAULT_PROTOCOL_VERSION_MAJOR,
53    }
54}
55
56/// `ECodec::None` — `yt/yt/core/compression/public.h`.
57///
58/// The header must say which codec the body and attachments use. This crate
59/// only implements the identity codec, so it always says `None`; the field is
60/// still set explicitly, because leaving it out puts the request into the
61/// legacy-codec path (`EnableLegacyRpcCodecs`) where the body would need a
62/// serialization envelope instead.
63pub const CODEC_NONE: i32 = 0;
64
65/// `EMessageType` — `yt/yt/core/rpc/message.h`. The values spell "rpci",
66/// "rpcc" and "rpco" when written little-endian.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[repr(u32)]
69pub enum MessageType {
70    Request = 0x6963_7072,
71    RequestCancelation = 0x6363_7072,
72    Response = 0x6f63_7072,
73}
74
75impl MessageType {
76    fn from_wire(value: u32) -> Option<Self> {
77        match value {
78            0x6963_7072 => Some(Self::Request),
79            0x6363_7072 => Some(Self::RequestCancelation),
80            0x6f63_7072 => Some(Self::Response),
81            _ => None,
82        }
83    }
84}
85
86/// The protobuf field number of the `TCredentialsExt` extension of
87/// `TRequestHeader` — `yt/yt_proto/yt/core/rpc/proto/rpc.proto`.
88///
89/// `prost` does not generate proto2 extensions, so the field is appended by
90/// hand. That is wire-identical: an extension is an ordinary field with a
91/// reserved number, and protobuf does not care in what order fields appear.
92const CREDENTIALS_EXT_FIELD: u32 = 110;
93
94/// Builds the `TRequestHeader` for one call.
95#[derive(Debug, Clone)]
96pub struct RequestHeaderBuilder {
97    pub request_id: Guid,
98    pub service: String,
99    pub method: String,
100    pub timeout: Option<std::time::Duration>,
101    pub mutation_id: Option<Guid>,
102    pub retry: bool,
103    pub user: Option<String>,
104    pub token: Option<String>,
105}
106
107impl RequestHeaderBuilder {
108    pub fn new(service: impl Into<String>, method: impl Into<String>) -> Self {
109        Self {
110            request_id: Guid::random(),
111            service: service.into(),
112            method: method.into(),
113            timeout: None,
114            mutation_id: None,
115            retry: false,
116            user: None,
117            token: None,
118        }
119    }
120
121    /// The protobuf header, without the credentials extension — that is added
122    /// by [`encode_request`], which is the only place that can append it after
123    /// serialization.
124    pub fn build(&self) -> proto::rpc::TRequestHeader {
125        proto::rpc::TRequestHeader {
126            request_id: Some(self.request_id.to_proto()),
127            service: self.service.clone(),
128            method: self.method.clone(),
129            protocol_version_major: Some(protocol_version_major(&self.service)),
130            // Microseconds: `TRequestHeader.timeout` is a TDuration, and
131            // YTsaurus durations are microsecond counts.
132            timeout: self.timeout.map(|timeout| timeout.as_micros() as i64),
133            mutation_id: self.mutation_id.map(Guid::to_proto),
134            retry: Some(self.retry),
135            user: self.user.clone(),
136            request_codec: Some(CODEC_NONE),
137            response_codec: Some(CODEC_NONE),
138            ..Default::default()
139        }
140    }
141}
142
143/// Appends a length-delimited protobuf field by number.
144///
145/// Used for the extension fields `prost` will not generate.
146fn append_length_delimited_field(buffer: &mut Vec<u8>, field_number: u32, payload: &[u8]) {
147    prost::encoding::encode_key(
148        field_number,
149        prost::encoding::WireType::LengthDelimited,
150        buffer,
151    );
152    prost::encoding::encode_varint(payload.len() as u64, buffer);
153    buffer.extend_from_slice(payload);
154}
155
156/// Serializes part 0 of a message: the type word, then the header protobuf.
157fn encode_header_part(
158    message_type: MessageType,
159    header: &impl Message,
160    token: Option<&str>,
161) -> Bytes {
162    let mut buffer = Vec::with_capacity(4 + header.encoded_len());
163    buffer.extend_from_slice(&(message_type as u32).to_le_bytes());
164    header
165        .encode(&mut buffer)
166        .expect("a Vec never runs out of room");
167
168    if let Some(token) = token {
169        let credentials = proto::rpc::TCredentialsExt {
170            token: Some(token.to_owned()),
171            ..Default::default()
172        };
173        append_length_delimited_field(
174            &mut buffer,
175            CREDENTIALS_EXT_FIELD,
176            &credentials.encode_to_vec(),
177        );
178    }
179
180    Bytes::from(buffer)
181}
182
183/// Builds the bus parts for one request.
184///
185/// The body and the attachments are written as they are: with the codec set to
186/// `None`, "compressing" is the identity, which is what
187/// `yt/go/bus/client.go` does through `compression.NewCodec(CodecIDNone)`.
188pub fn encode_request(
189    header: &proto::rpc::TRequestHeader,
190    token: Option<&str>,
191    body: &impl Message,
192    attachments: Vec<Bytes>,
193) -> Vec<Option<Bytes>> {
194    let mut parts = Vec::with_capacity(2 + attachments.len());
195    parts.push(Some(encode_header_part(
196        MessageType::Request,
197        header,
198        token,
199    )));
200    parts.push(Some(Bytes::from(body.encode_to_vec())));
201    parts.extend(attachments.into_iter().map(Some));
202    parts
203}
204
205/// Builds the bus parts for a cancellation.
206///
207/// One part, as `CreateRequestCancelationMessage` in
208/// `yt/yt/core/rpc/message.cpp` builds it. Dropping a future has to send this
209/// or the proxy keeps working on a result nobody will read.
210pub fn encode_cancelation(request_id: Guid, service: &str, method: &str) -> Vec<Option<Bytes>> {
211    let header = proto::rpc::TRequestCancelationHeader {
212        request_id: request_id.to_proto(),
213        service: service.to_owned(),
214        method: method.to_owned(),
215        realm_id: None,
216    };
217    vec![Some(encode_header_part(
218        MessageType::RequestCancelation,
219        &header,
220        None,
221    ))]
222}
223
224/// A response, taken apart.
225#[derive(Debug, Clone)]
226pub struct ResponseMessage {
227    pub header: proto::rpc::TResponseHeader,
228    pub body: Option<Bytes>,
229    pub attachments: Vec<Bytes>,
230}
231
232impl ResponseMessage {
233    /// The request this response answers.
234    pub fn request_id(&self) -> Option<Guid> {
235        self.header.request_id.as_ref().map(Guid::from_proto)
236    }
237
238    /// The server-reported failure, if there is one.
239    ///
240    /// `TResponseHeader.error` is optional and "if omitted then OK is assumed"
241    /// — and an error with code 0 is also success, which is why this checks the
242    /// code rather than the presence of the field. `NewErrorFromProto` in
243    /// `yt/go/proto/core/misc/convert.go` makes the same test.
244    pub fn error(&self) -> Option<YtError> {
245        let error = self.header.error.as_ref()?;
246        let converted = YtError::from_proto(error);
247        (converted.code != crate::error::codes::OK).then_some(converted)
248    }
249
250    /// Decodes the body into `T`.
251    pub fn decode_body<T: Message + Default>(&self, message_name: &'static str) -> Result<T> {
252        let body = self.body.as_ref().ok_or_else(|| {
253            Error::Protocol(format!("response to {message_name} has no body part"))
254        })?;
255        T::decode(body.clone()).map_err(|source| Error::Decode {
256            message: message_name,
257            source,
258        })
259    }
260}
261
262/// Takes apart the parts of a received message.
263///
264/// Rejects anything that is not a response, which is what the Go client does —
265/// it warns and ignores. Here it is an error, because the connection actor
266/// routes by request id and has nowhere to put a message it cannot classify.
267pub fn decode_response(parts: Vec<Option<Bytes>>) -> Result<ResponseMessage> {
268    let mut parts = parts.into_iter();
269    let header_part = parts
270        .next()
271        .flatten()
272        .ok_or_else(|| Error::Protocol("message has no header part".to_owned()))?;
273
274    if header_part.len() < 4 {
275        return Err(Error::Protocol(format!(
276            "message header part is {} bytes, too short for a message type",
277            header_part.len()
278        )));
279    }
280    let raw_type = u32::from_le_bytes(header_part[0..4].try_into().unwrap());
281    match MessageType::from_wire(raw_type) {
282        Some(MessageType::Response) => {}
283        Some(other) => {
284            return Err(Error::Protocol(format!(
285                "expected a response message, got {other:?}"
286            )));
287        }
288        None => {
289            return Err(Error::Protocol(format!(
290                "unknown RPC message type {raw_type:#010x}"
291            )));
292        }
293    }
294
295    let header =
296        proto::rpc::TResponseHeader::decode(&header_part[4..]).map_err(|source| Error::Decode {
297            message: "TResponseHeader",
298            source,
299        })?;
300
301    // An error response carries no body, so a missing part 1 is not itself a
302    // fault; `decode_body` complains only when a body is actually wanted.
303    let body = parts.next().flatten();
304    let attachments = parts.flatten().collect();
305
306    Ok(ResponseMessage {
307        header,
308        body,
309        attachments,
310    })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    /// The proxy refuses a call that announces the wrong major version, and the
318    /// right version depends on which service is being called — not on the
319    /// connection. Getting this wrong fails every call to that service.
320    /// Numbers the server matches on, written out.
321    #[test]
322    fn the_wire_constants_are_the_documented_ones() {
323        assert_eq!(
324            CREDENTIALS_EXT_FIELD, 110,
325            "TCredentialsExt is extension 110"
326        );
327        assert_eq!(CODEC_NONE, 0, "ECodec::None is 0");
328        assert_eq!(PROTOCOL_VERSION_MAJOR, 1);
329        assert_eq!(DEFAULT_PROTOCOL_VERSION_MAJOR, 0);
330        assert_eq!(API_SERVICE, "ApiService");
331        assert_eq!(DISCOVERY_SERVICE, "DiscoveryService");
332    }
333
334    #[test]
335    fn the_protocol_version_is_per_service() {
336        assert_eq!(protocol_version_major(API_SERVICE), 1);
337        assert_eq!(protocol_version_major(DISCOVERY_SERVICE), 0);
338
339        let header = RequestHeaderBuilder::new(DISCOVERY_SERVICE, "DiscoverProxies").build();
340        assert_eq!(header.protocol_version_major, Some(0));
341        let header = RequestHeaderBuilder::new(API_SERVICE, "LookupRows").build();
342        assert_eq!(header.protocol_version_major, Some(1));
343    }
344
345    #[test]
346    fn message_type_words_spell_rpci_rpcc_and_rpco() {
347        // The C++ comments name the spelling; this asserts the byte order that
348        // makes it true, which is the part an implementation gets wrong.
349        assert_eq!(&(MessageType::Request as u32).to_le_bytes(), b"rpci");
350        assert_eq!(
351            &(MessageType::RequestCancelation as u32).to_le_bytes(),
352            b"rpcc"
353        );
354        assert_eq!(&(MessageType::Response as u32).to_le_bytes(), b"rpco");
355    }
356
357    #[test]
358    fn a_request_has_a_header_part_a_body_part_and_then_attachments() {
359        let header = RequestHeaderBuilder::new(API_SERVICE, "LookupRows").build();
360        let body = proto::api::TReqLookupRows::default();
361        let parts = encode_request(
362            &header,
363            None,
364            &body,
365            vec![Bytes::from_static(b"rowset"), Bytes::from_static(b"more")],
366        );
367
368        assert_eq!(parts.len(), 4);
369        let header_part = parts[0].as_ref().unwrap();
370        assert_eq!(&header_part[0..4], b"rpci");
371        assert_eq!(parts[2].as_ref().unwrap(), &Bytes::from_static(b"rowset"));
372        assert_eq!(parts[3].as_ref().unwrap(), &Bytes::from_static(b"more"));
373    }
374
375    #[test]
376    fn the_header_part_parses_back_as_a_request_header() {
377        let built = RequestHeaderBuilder {
378            timeout: Some(std::time::Duration::from_secs(30)),
379            ..RequestHeaderBuilder::new(API_SERVICE, "StartTransaction")
380        };
381        let header = built.build();
382        let parts = encode_request(
383            &header,
384            None,
385            &proto::api::TReqStartTransaction::default(),
386            vec![],
387        );
388        let header_part = parts[0].as_ref().unwrap();
389
390        let parsed = proto::rpc::TRequestHeader::decode(&header_part[4..]).unwrap();
391        assert_eq!(parsed.service, API_SERVICE);
392        assert_eq!(parsed.method, "StartTransaction");
393        assert_eq!(parsed.protocol_version_major, Some(PROTOCOL_VERSION_MAJOR));
394        assert_eq!(parsed.request_codec, Some(CODEC_NONE));
395        assert_eq!(parsed.response_codec, Some(CODEC_NONE));
396        // Microseconds, not milliseconds and not nanoseconds.
397        assert_eq!(parsed.timeout, Some(30_000_000));
398        assert_eq!(
399            Guid::from_proto(&parsed.request_id.unwrap()),
400            built.request_id
401        );
402    }
403
404    /// The credentials extension is appended by hand because `prost` does not
405    /// generate proto2 extensions. This checks the bytes really are field 110,
406    /// length-delimited, holding a `TCredentialsExt` — the only way to know the
407    /// hand-rolled encoding is right without a server.
408    #[test]
409    fn the_token_is_appended_as_extension_field_110() {
410        let header = RequestHeaderBuilder::new(API_SERVICE, "LookupRows").build();
411        let parts = encode_request(
412            &header,
413            Some("secret-token"),
414            &proto::api::TReqLookupRows::default(),
415            vec![],
416        );
417        let header_part = parts[0].as_ref().unwrap();
418        let body = &header_part[4..];
419
420        // Field 110, wire type 2 -> key varint (110 << 3) | 2 = 882.
421        let mut expected_key = Vec::new();
422        prost::encoding::encode_key(
423            CREDENTIALS_EXT_FIELD,
424            prost::encoding::WireType::LengthDelimited,
425            &mut expected_key,
426        );
427        let key_at = body
428            .windows(expected_key.len())
429            .position(|window| window == expected_key)
430            .expect("the credentials key must be in the header bytes");
431
432        let payload = &body[key_at + expected_key.len()..];
433        let (length, rest) = {
434            let mut cursor = payload;
435            let length = prost::encoding::decode_varint(&mut cursor).unwrap();
436            (length as usize, cursor)
437        };
438        let credentials = proto::rpc::TCredentialsExt::decode(&rest[..length]).unwrap();
439        assert_eq!(credentials.token.as_deref(), Some("secret-token"));
440    }
441
442    #[test]
443    fn no_token_means_no_extension() {
444        let header = RequestHeaderBuilder::new(API_SERVICE, "LookupRows").build();
445        let with = encode_request(
446            &header,
447            Some("t"),
448            &proto::api::TReqLookupRows::default(),
449            vec![],
450        );
451        let without = encode_request(
452            &header,
453            None,
454            &proto::api::TReqLookupRows::default(),
455            vec![],
456        );
457        assert!(without[0].as_ref().unwrap().len() < with[0].as_ref().unwrap().len());
458    }
459
460    fn response_parts(
461        header: proto::rpc::TResponseHeader,
462        body: Option<&[u8]>,
463    ) -> Vec<Option<Bytes>> {
464        let mut header_part = Vec::new();
465        header_part.extend_from_slice(&(MessageType::Response as u32).to_le_bytes());
466        header.encode(&mut header_part).unwrap();
467        let mut parts = vec![Some(Bytes::from(header_part))];
468        if let Some(body) = body {
469            parts.push(Some(Bytes::copy_from_slice(body)));
470        }
471        parts
472    }
473
474    #[test]
475    fn a_successful_response_decodes_with_its_body() {
476        let request_id = Guid::random();
477        let body = proto::api::TRspStartTransaction::default();
478        let parts = response_parts(
479            proto::rpc::TResponseHeader {
480                request_id: Some(request_id.to_proto()),
481                ..Default::default()
482            },
483            Some(&body.encode_to_vec()),
484        );
485
486        let response = decode_response(parts).unwrap();
487        assert_eq!(response.request_id(), Some(request_id));
488        assert!(response.error().is_none());
489        response
490            .decode_body::<proto::api::TRspStartTransaction>("TRspStartTransaction")
491            .unwrap();
492    }
493
494    #[test]
495    fn an_error_response_surfaces_the_error_and_has_no_body() {
496        let parts = response_parts(
497            proto::rpc::TResponseHeader {
498                request_id: Some(Guid::random().to_proto()),
499                error: Some(proto::misc::TError {
500                    code: crate::error::codes::RESOLVE_ERROR,
501                    message: Some("no such table".to_owned()),
502                    attributes: None,
503                    inner_errors: vec![],
504                }),
505                ..Default::default()
506            },
507            None,
508        );
509
510        let response = decode_response(parts).unwrap();
511        let error = response.error().expect("the header carries an error");
512        assert_eq!(error.code, crate::error::codes::RESOLVE_ERROR);
513        assert!(
514            response
515                .decode_body::<proto::api::TRspLookupRows>("TRspLookupRows")
516                .is_err()
517        );
518    }
519
520    /// "If omitted then OK is assumed" — and a present error with code 0 is
521    /// also success. Treating any present `error` field as a failure would turn
522    /// good responses into errors.
523    #[test]
524    fn an_error_with_code_zero_is_success() {
525        let parts = response_parts(
526            proto::rpc::TResponseHeader {
527                request_id: Some(Guid::random().to_proto()),
528                error: Some(proto::misc::TError {
529                    code: 0,
530                    message: Some(String::new()),
531                    attributes: None,
532                    inner_errors: vec![],
533                }),
534                ..Default::default()
535            },
536            Some(&[]),
537        );
538        assert!(decode_response(parts).unwrap().error().is_none());
539    }
540
541    #[test]
542    fn a_message_that_is_not_a_response_is_rejected() {
543        let mut header_part = Vec::new();
544        header_part.extend_from_slice(&(MessageType::Request as u32).to_le_bytes());
545        proto::rpc::TRequestHeader {
546            service: API_SERVICE.to_owned(),
547            method: "LookupRows".to_owned(),
548            ..Default::default()
549        }
550        .encode(&mut header_part)
551        .unwrap();
552
553        let error = decode_response(vec![Some(Bytes::from(header_part))]).unwrap_err();
554        assert!(error.to_string().contains("expected a response message"));
555    }
556
557    #[test]
558    fn a_short_or_missing_header_part_is_rejected_not_panicked_on() {
559        assert!(decode_response(vec![]).is_err());
560        assert!(decode_response(vec![None]).is_err());
561        assert!(decode_response(vec![Some(Bytes::from_static(b"rp"))]).is_err());
562        assert!(decode_response(vec![Some(Bytes::from_static(b"nope"))]).is_err());
563    }
564
565    #[test]
566    fn cancelation_is_one_part_naming_the_request() {
567        let request_id = Guid::random();
568        let parts = encode_cancelation(request_id, API_SERVICE, "SelectRows");
569        assert_eq!(parts.len(), 1, "the C++ builds a single-part message");
570
571        let part = parts[0].as_ref().unwrap();
572        assert_eq!(&part[0..4], b"rpcc");
573        let header = proto::rpc::TRequestCancelationHeader::decode(&part[4..]).unwrap();
574        assert_eq!(Guid::from_proto(&header.request_id), request_id);
575        assert_eq!(header.service, API_SERVICE);
576        assert_eq!(header.method, "SelectRows");
577    }
578
579    #[test]
580    fn attachments_survive_the_round_trip() {
581        let parts = response_parts(proto::rpc::TResponseHeader::default(), Some(b"body"));
582        let mut parts = parts;
583        parts.push(Some(Bytes::from_static(b"attachment one")));
584        parts.push(Some(Bytes::from_static(b"attachment two")));
585
586        let response = decode_response(parts).unwrap();
587        assert_eq!(response.body.as_deref(), Some(&b"body"[..]));
588        assert_eq!(response.attachments.len(), 2);
589        assert_eq!(
590            response.attachments[1],
591            Bytes::from_static(b"attachment two")
592        );
593    }
594}