Skip to main content

aranya_runtime/sync/
mod.rs

1//! Interface for syncing state between clients.
2//!
3//! Transports decode incoming bytes into a [`SyncIncoming`] and dispatch on
4//! its variants; the on-wire postcard layout is hidden behind that
5//! enumeration so it can evolve without breaking consumers.
6
7use core::time::Duration;
8
9use buggy::Bug;
10use heapless::Vec;
11use postcard::Error as PostcardError;
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    Address, Prior,
16    command::{CmdId, Command, Priority},
17    storage::{GraphId, LocatedAddress, Location, MAX_COMMAND_LENGTH, StorageError},
18};
19
20mod requester;
21mod responder;
22pub(crate) mod wire;
23
24pub(crate) use requester::SyncRequestMessage;
25pub use requester::SyncRequester;
26use responder::SyncResponseMessage;
27pub use responder::{PeerCache, SyncResponder};
28use wire::{SubscribeResult, SyncHelloType, SyncType};
29
30/// The frontier a requester advertises during a sync session: committed
31/// commands the peer is known to have ([`PeerCache`]) plus, when a
32/// transaction is open, its uncommitted heads. Construct with
33/// [`PeerCache::session_heads`] or `Transaction::session_heads`. Borrowing
34/// the transaction keeps uncommitted heads from outliving it: `commit`
35/// consumes the transaction, so they can never be held across a commit or
36/// persisted.
37pub struct SessionHeads<'a> {
38    pub(crate) cache: &'a PeerCache,
39    pub(crate) session: Option<&'a alloc::collections::BTreeMap<CmdId, Location>>,
40}
41
42impl SessionHeads<'_> {
43    /// The open transaction's uncommitted frontier, if any.
44    pub(crate) fn session_iter(&self) -> impl Iterator<Item = LocatedAddress> + '_ {
45        self.session
46            .into_iter()
47            .flatten()
48            .map(|(id, loc)| LocatedAddress {
49                id: *id,
50                segment: loc.segment,
51                max_cut: loc.max_cut,
52            })
53    }
54
55    /// Committed commands the peer is known to have.
56    pub(crate) fn cache_heads(&self) -> &[LocatedAddress] {
57        self.cache.heads()
58    }
59}
60
61impl PeerCache {
62    /// The frontier to advertise to a sync peer when no transaction is open.
63    pub fn session_heads(&self) -> SessionHeads<'_> {
64        SessionHeads {
65            cache: self,
66            session: None,
67        }
68    }
69}
70
71// TODO: These should all be compile time parameters
72
73/// The maximum number of heads that will be stored for a peer.
74pub const PEER_HEAD_MAX: usize = 10;
75
76/// The maximum number of samples in a request
77#[cfg(feature = "low-mem-usage")]
78const COMMAND_SAMPLE_MAX: usize = 20;
79#[cfg(not(feature = "low-mem-usage"))]
80const COMMAND_SAMPLE_MAX: usize = 100;
81
82/// The maximum number of missing segments that can be requested
83/// in a single message
84#[cfg(feature = "low-mem-usage")]
85const REQUEST_MISSING_MAX: usize = 1;
86#[cfg(not(feature = "low-mem-usage"))]
87const REQUEST_MISSING_MAX: usize = 100;
88
89/// The maximum number of commands in a response
90#[cfg(feature = "low-mem-usage")]
91pub const COMMAND_RESPONSE_MAX: usize = 5;
92#[cfg(not(feature = "low-mem-usage"))]
93pub const COMMAND_RESPONSE_MAX: usize = 100;
94
95/// The maximum number of segments which can be stored to send
96#[cfg(feature = "low-mem-usage")]
97const SEGMENT_BUFFER_MAX: usize = 10;
98#[cfg(not(feature = "low-mem-usage"))]
99const SEGMENT_BUFFER_MAX: usize = 100;
100
101/// The maximum size of a sync message
102// TODO: Use postcard to calculate max size (which accounts for overhead)
103// https://docs.rs/postcard/latest/postcard/experimental/max_size/index.html
104pub const MAX_SYNC_MESSAGE_SIZE: usize = 1024 + MAX_COMMAND_LENGTH * COMMAND_RESPONSE_MAX;
105
106/// An error returned by the syncer.
107#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum SyncError {
110    #[error("sync session ID does not match")]
111    SessionMismatch,
112    #[error("missing sync response")]
113    MissingSyncResponse,
114    #[error("syncer state not valid for this message")]
115    SessionState,
116    #[error("syncer not ready for operation")]
117    NotReady,
118    #[error("too many commands sent")]
119    CommandOverflow,
120    #[error("target buffer too small for sync message")]
121    BufferTooSmall,
122    #[error("malformed sync response")]
123    MalformedResponse,
124    #[error("unsupported sync request")]
125    UnsupportedRequest,
126    #[error("storage error: {0}")]
127    Storage(#[from] StorageError),
128    #[error("serialize error: {0}")]
129    Serialize(#[from] PostcardError),
130    #[error(transparent)]
131    Bug(#[from] Bug),
132}
133
134/// Sync command to be committed to graph.
135#[derive(Serialize, Deserialize, Debug)]
136pub struct SyncCommand<'a> {
137    priority: Priority,
138    id: CmdId,
139    parent: Prior<Address>,
140    policy: Option<&'a [u8]>,
141    data: &'a [u8],
142}
143
144impl<'a> Command for SyncCommand<'a> {
145    fn priority(&self) -> Priority {
146        self.priority.clone()
147    }
148
149    fn id(&self) -> CmdId {
150        self.id
151    }
152
153    fn parent(&self) -> Prior<Address> {
154        self.parent
155    }
156
157    fn policy(&self) -> Option<&'a [u8]> {
158        self.policy
159    }
160
161    fn bytes(&self) -> &'a [u8] {
162        self.data
163    }
164}
165
166/// A decoded incoming sync message.
167///
168/// Transports decode the raw bytes received over the network with
169/// [`SyncIncoming::decode`] and then dispatch on the returned variant.
170/// The on-wire postcard layout is encapsulated by this enum and the
171/// associated helper types — consumers don't depend on the wire
172/// representation directly.
173#[allow(clippy::large_enum_variant)]
174pub enum SyncIncoming<'a> {
175    /// A sync poll. Hand to [`SyncResponder::receive`].
176    Poll(PollIncoming),
177    /// A subscription request from a peer.
178    Subscribe(SubscribeIncoming),
179    /// An unsubscribe request from a peer.
180    Unsubscribe(UnsubscribeIncoming),
181    /// A push from a subscribed peer. Hand to [`SyncRequester::receive_push`].
182    Push(PushIncoming<'a>),
183    /// A subscription-control hello message.
184    Hello(SyncHello),
185}
186
187impl<'a> SyncIncoming<'a> {
188    /// Decode an incoming sync message from raw bytes.
189    pub fn decode(data: &'a [u8]) -> Result<Self, SyncError> {
190        let (sync_type, remaining) = postcard::take_from_bytes::<SyncType>(data)?;
191        Ok(match sync_type {
192            SyncType::Poll { request } => Self::Poll(PollIncoming {
193                session_id: request.session_id(),
194                message: request,
195            }),
196            SyncType::Subscribe {
197                remain_open,
198                max_bytes,
199                commands,
200                graph_id,
201            } => Self::Subscribe(SubscribeIncoming {
202                graph_id,
203                remain_open,
204                max_bytes,
205                heads: SyncHeads { inner: commands },
206            }),
207            SyncType::Unsubscribe { graph_id } => {
208                Self::Unsubscribe(UnsubscribeIncoming { graph_id })
209            }
210            SyncType::Push { message, graph_id } => Self::Push(PushIncoming {
211                graph_id,
212                session_id: message.session_id(),
213                message,
214                command_data: remaining,
215            }),
216            SyncType::Hello(hello) => Self::Hello(hello.into()),
217        })
218    }
219}
220
221/// A peer's sample of known graph heads, carried in [`SyncIncoming::Subscribe`].
222pub struct SyncHeads {
223    inner: Vec<Address, COMMAND_SAMPLE_MAX>,
224}
225
226impl SyncHeads {
227    /// Returns the heads as a slice.
228    pub fn as_slice(&self) -> &[Address] {
229        &self.inner
230    }
231
232    /// Iterates over the heads.
233    pub fn iter(&self) -> impl DoubleEndedIterator<Item = Address> + ExactSizeIterator + '_ {
234        self.inner.iter().copied()
235    }
236}
237
238/// A subscription-control message: subscribe, unsubscribe, or hello.
239#[derive(Debug)]
240pub enum SyncHello {
241    /// Subscribe to receive hello notifications from this peer.
242    Subscribe(HelloSubscribe),
243    /// Unsubscribe from hello notifications.
244    Unsubscribe(HelloUnsubscribe),
245    /// Notification message sent to subscribers.
246    Hello(HelloNotification),
247}
248
249impl From<SyncHelloType> for SyncHello {
250    fn from(t: SyncHelloType) -> Self {
251        match t {
252            SyncHelloType::Subscribe {
253                graph_id,
254                graph_change_delay,
255                duration,
256                schedule_delay,
257            } => Self::Subscribe(HelloSubscribe {
258                graph_id,
259                graph_change_delay,
260                duration,
261                schedule_delay,
262            }),
263            SyncHelloType::Unsubscribe { graph_id } => {
264                Self::Unsubscribe(HelloUnsubscribe { graph_id })
265            }
266            SyncHelloType::Hello { graph_id, head } => {
267                Self::Hello(HelloNotification { graph_id, head })
268            }
269        }
270    }
271}
272
273/// An opaque container for a hello-protocol subscribe request.
274#[derive(Debug)]
275pub struct HelloSubscribe {
276    graph_id: GraphId,
277    graph_change_delay: Duration,
278    duration: Duration,
279    schedule_delay: Duration,
280}
281
282impl HelloSubscribe {
283    /// Returns the graph being subscribed to.
284    pub fn graph_id(&self) -> GraphId {
285        self.graph_id
286    }
287
288    /// Returns the delay between notifications when the graph changes (rate limiting).
289    pub fn graph_change_delay(&self) -> Duration {
290        self.graph_change_delay
291    }
292
293    /// Returns how long the subscription should last.
294    pub fn duration(&self) -> Duration {
295        self.duration
296    }
297
298    /// Returns the schedule-based hello sending delay.
299    pub fn schedule_delay(&self) -> Duration {
300        self.schedule_delay
301    }
302}
303
304/// An opaque container for a hello-protocol unsubscribe request.
305#[derive(Debug)]
306pub struct HelloUnsubscribe {
307    graph_id: GraphId,
308}
309
310impl HelloUnsubscribe {
311    /// Returns the graph being unsubscribed from.
312    pub fn graph_id(&self) -> GraphId {
313        self.graph_id
314    }
315}
316
317/// An opaque container for a hello notification sent to subscribers.
318#[derive(Debug)]
319pub struct HelloNotification {
320    graph_id: GraphId,
321    head: Address,
322}
323
324impl HelloNotification {
325    /// Returns the graph this notification is for.
326    pub fn graph_id(&self) -> GraphId {
327        self.graph_id
328    }
329
330    /// Returns the current head of the sender's graph.
331    pub fn head(&self) -> Address {
332        self.head
333    }
334}
335
336/// An opaque container for a received poll message. Hand to
337/// [`SyncResponder::receive`] to update the responder's state.
338pub struct PollIncoming {
339    session_id: u128,
340    pub(crate) message: SyncRequestMessage,
341}
342
343impl PollIncoming {
344    /// Returns the sender's session identifier.
345    pub fn session_id(&self) -> u128 {
346        self.session_id
347    }
348}
349
350/// An opaque container for a received subscribe message.
351pub struct SubscribeIncoming {
352    graph_id: GraphId,
353    remain_open: u64,
354    max_bytes: u64,
355    heads: SyncHeads,
356}
357
358impl SubscribeIncoming {
359    /// Returns the graph being subscribed to.
360    pub fn graph_id(&self) -> GraphId {
361        self.graph_id
362    }
363
364    /// Returns how long the subscription should remain open.
365    pub fn remain_open(&self) -> Duration {
366        Duration::from_secs(self.remain_open)
367    }
368
369    /// Returns the maximum number of bytes the responder may push.
370    pub fn max_bytes(&self) -> u64 {
371        self.max_bytes
372    }
373
374    /// Returns the peer's known graph heads.
375    pub fn heads(&self) -> &SyncHeads {
376        &self.heads
377    }
378}
379
380/// An opaque container for a received unsubscribe message.
381pub struct UnsubscribeIncoming {
382    graph_id: GraphId,
383}
384
385impl UnsubscribeIncoming {
386    /// Returns the graph being unsubscribed from.
387    pub fn graph_id(&self) -> GraphId {
388        self.graph_id
389    }
390}
391
392/// An opaque container for a received push message. Hand to
393/// [`SyncRequester::receive_push`] to extract the contained commands.
394pub struct PushIncoming<'a> {
395    graph_id: GraphId,
396    session_id: u128,
397    pub(crate) message: SyncResponseMessage,
398    pub(crate) command_data: &'a [u8],
399}
400
401impl PushIncoming<'_> {
402    /// Returns the graph this push targets.
403    pub fn graph_id(&self) -> GraphId {
404        self.graph_id
405    }
406
407    /// Returns the sender's session identifier.
408    pub fn session_id(&self) -> u128 {
409        self.session_id
410    }
411}
412
413/// The result of a [`SyncIncoming::Subscribe`] dispatch, sent back to the
414/// requester so it knows whether the subscription was accepted.
415#[derive(Debug)]
416pub enum SubscribeResponse {
417    /// The subscription was accepted.
418    Success,
419    /// The responder is at its subscription limit.
420    TooManySubscriptions,
421}
422
423impl SubscribeResponse {
424    /// Encode into `target`. Returns the number of bytes written.
425    pub fn encode_to(self, target: &mut [u8]) -> Result<usize, SyncError> {
426        let inner = match self {
427            Self::Success => SubscribeResult::Success,
428            Self::TooManySubscriptions => SubscribeResult::TooManySubscriptions,
429        };
430        Ok(postcard::to_slice(&inner, target)?.len())
431    }
432
433    /// Decode from raw bytes.
434    pub fn decode(data: &[u8]) -> Result<Self, SyncError> {
435        let inner: SubscribeResult = postcard::from_bytes(data)?;
436        Ok(match inner {
437            SubscribeResult::Success => Self::Success,
438            SubscribeResult::TooManySubscriptions => Self::TooManySubscriptions,
439        })
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::command::Priority;
447
448    fn meta(policy_length: u32, length: u32) -> wire::CommandMeta {
449        wire::CommandMeta {
450            id: CmdId::default(),
451            priority: Priority::Basic(0),
452            parent: Prior::None,
453            policy_length,
454            length,
455        }
456    }
457
458    /// A peer that claims longer commands than it actually sent must not
459    /// panic the requester.
460    #[test]
461    fn truncated_sync_response_is_rejected() {
462        let session_id = 42;
463        let mut commands: Vec<wire::CommandMeta, COMMAND_RESPONSE_MAX> = Vec::new();
464        // Claims 4 KiB of payload, but no command bytes follow the message.
465        commands.push(meta(0, 4096)).expect("push meta");
466        let message = SyncResponseMessage::SyncResponse {
467            session_id,
468            response_index: 0,
469            commands,
470        };
471        let mut buf = [0u8; MAX_SYNC_MESSAGE_SIZE];
472        let len = postcard::to_slice(&message, &mut buf)
473            .expect("serialize")
474            .len();
475
476        let mut requester = SyncRequester::new_session_id(GraphId::default(), session_id);
477        let err = requester.receive(&buf[..len]).expect_err("must not panic");
478        assert!(matches!(err, SyncError::MalformedResponse), "got {err:?}");
479    }
480
481    /// Same, but the policy length is the one that overruns.
482    #[test]
483    fn truncated_policy_is_rejected() {
484        let session_id = 43;
485        let mut commands: Vec<wire::CommandMeta, COMMAND_RESPONSE_MAX> = Vec::new();
486        commands.push(meta(4096, 0)).expect("push meta");
487        let message = SyncResponseMessage::SyncResponse {
488            session_id,
489            response_index: 0,
490            commands,
491        };
492        let mut buf = [0u8; MAX_SYNC_MESSAGE_SIZE];
493        let len = postcard::to_slice(&message, &mut buf)
494            .expect("serialize")
495            .len();
496
497        let mut requester = SyncRequester::new_session_id(GraphId::default(), session_id);
498        let err = requester.receive(&buf[..len]).expect_err("must not panic");
499        assert!(matches!(err, SyncError::MalformedResponse), "got {err:?}");
500    }
501
502    fn poll_bytes(request: SyncRequestMessage, buf: &mut [u8]) -> usize {
503        postcard::to_slice(&SyncType::Poll { request }, buf)
504            .expect("serialize")
505            .len()
506    }
507
508    /// Unimplemented request messages must be rejected, not panic the
509    /// responder.
510    #[test]
511    fn unimplemented_requests_are_rejected() {
512        let session_id = 44;
513        let unsupported = [
514            SyncRequestMessage::RequestMissing {
515                session_id,
516                indexes: Vec::new(),
517            },
518            SyncRequestMessage::SyncResume {
519                session_id,
520                response_index: 0,
521                max_bytes: 0,
522            },
523        ];
524
525        for request in unsupported {
526            let mut buf = [0u8; MAX_SYNC_MESSAGE_SIZE];
527            let len = poll_bytes(request, &mut buf);
528            let SyncIncoming::Poll(poll) = SyncIncoming::decode(&buf[..len]).expect("decode")
529            else {
530                panic!("expected a poll");
531            };
532
533            let mut responder = SyncResponder::new();
534            let err = responder.receive(poll).expect_err("must not panic");
535            assert!(matches!(err, SyncError::UnsupportedRequest), "got {err:?}");
536            // The session is torn down, so the next poll sends `EndSession`.
537            assert!(responder.ready());
538        }
539    }
540}