Skip to main content

rings_core/chunk/
framing.rs

1use bytes::Bytes;
2use serde::Deserialize;
3use serde::Serialize;
4
5use crate::consts::DEFAULT_TTL_MS;
6use crate::consts::MAX_CHUNK_ENVELOPE_OVERHEAD;
7use crate::consts::MIN_CHUNK_DATA;
8use crate::consts::TRANSPORT_CUSTOM_OVERHEAD;
9use crate::error::Error;
10use crate::error::Result;
11use crate::utils::get_epoch_ms;
12
13/// One chunk of a chunked message, as it travels on the wire.
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct Chunk {
16    /// `[position, total]` - this chunk's index and the number of chunks in the message.
17    pub chunk: [usize; 2],
18    /// chunk payload bytes
19    pub data: Bytes,
20    /// meta data of chunk
21    pub meta: ChunkMeta,
22}
23
24impl Chunk {
25    /// Serialize chunk to the Rings wire encoding.
26    pub fn to_wire(&self) -> Result<Bytes> {
27        rings_codec::serialize(self)
28            .map(Bytes::from)
29            .map_err(Error::CodecSerialize)
30    }
31
32    /// Deserialize chunk from the Rings wire encoding.
33    pub fn from_wire(data: &[u8]) -> Result<Self> {
34        rings_codec::deserialize(data).map_err(Error::CodecDeserialize)
35    }
36}
37
38/// Meta data of a chunk
39#[derive(Debug, Copy, Clone, Deserialize, Serialize)]
40pub struct ChunkMeta {
41    /// uuid of msg
42    pub id: uuid::Uuid,
43    /// Created time
44    pub ts_ms: u128,
45    /// Time to live
46    pub ttl_ms: u64,
47}
48
49impl Default for ChunkMeta {
50    fn default() -> Self {
51        Self {
52            id: crate::utils::new_uuid(),
53            ts_ms: get_epoch_ms(),
54            ttl_ms: DEFAULT_TTL_MS,
55        }
56    }
57}
58
59/// Sender side: an ordered list of [`Chunk`]s for one message. Build it from the payload with
60/// [`ChunkList::split`], passing the per-message data size to cut at (the connection's negotiated
61/// `max_message_size` minus the envelope reserve), then iterate (or convert to `Vec<Chunk>`) to put
62/// each chunk on the wire. The cut size is a runtime argument rather than a type parameter because
63/// it is decided per connection from the negotiated limit. Reassembly is the receiver's job - see
64/// [`super::MessageReassembler`].
65#[derive(Debug, Clone, Default, Deserialize, Serialize)]
66pub struct ChunkList(Vec<Chunk>);
67
68impl ChunkList {
69    /// Eagerly split `bytes` into chunks of at most `chunk_size` data bytes each, tagged
70    /// `[i, total]`. A **test/helper** constructor (the production send path uses
71    /// [`stream`](Self::stream), and [`WireReserves::plan`] never yields an unusable `chunk_size` -
72    /// it returns `None` instead). `chunk_size` is clamped to at least 1 only as a defensive guard
73    /// against a caller passing `0`; it is not a sanctioned way to produce 1-byte chunks on the
74    /// wire.
75    pub fn split(bytes: &Bytes, chunk_size: usize) -> Self {
76        let chunk_size = chunk_size.max(1);
77        let chunks: Vec<Bytes> = bytes
78            .chunks(chunk_size)
79            .map(|c| c.to_vec().into())
80            .collect();
81        let chunks_len: usize = chunks.len();
82        let meta = ChunkMeta::default();
83        Self(
84            chunks
85                .into_iter()
86                .enumerate()
87                .map(|(i, data)| Chunk {
88                    meta,
89                    chunk: [i, chunks_len],
90                    data,
91                })
92                .collect::<Vec<Chunk>>(),
93        )
94    }
95
96    /// Stream `bytes` into chunks of at most `chunk_size` data bytes each **without materializing
97    /// the whole list**: each chunk's `data` is a zero-copy [`Bytes::slice`] of the input, and the
98    /// chunks are yielded lazily, so a sender can frame and flush one chunk at a time with bounded
99    /// memory (rather than allocating every chunk up front). All chunks share one `[i, total]`
100    /// numbering and one [`ChunkMeta`]. `chunk_size` is clamped to at least 1 so a degenerate value
101    /// still terminates; empty input yields **no** chunks, agreeing with [`split`](Self::split).
102    pub fn stream(bytes: Bytes, chunk_size: usize) -> impl Iterator<Item = Chunk> {
103        let chunk_size = chunk_size.max(1);
104        let total = bytes.len().div_ceil(chunk_size);
105        let meta = ChunkMeta::default();
106        (0..total).map(move |i| {
107            let start = i * chunk_size;
108            let end = start.saturating_add(chunk_size).min(bytes.len());
109            Chunk {
110                meta,
111                chunk: [i, total],
112                data: bytes.slice(start..end),
113            }
114        })
115    }
116
117    /// Clone out the chunks.
118    pub fn to_vec(&self) -> Vec<Chunk> {
119        self.0.clone()
120    }
121
122    /// Borrow the chunks.
123    pub fn as_vec(&self) -> &Vec<Chunk> {
124        &self.0
125    }
126}
127
128impl IntoIterator for &ChunkList {
129    type Item = Chunk;
130    type IntoIter = std::vec::IntoIter<Chunk>;
131
132    fn into_iter(self) -> Self::IntoIter {
133        self.to_vec().into_iter()
134    }
135}
136
137impl IntoIterator for ChunkList {
138    type Item = Chunk;
139    type IntoIter = std::vec::IntoIter<Chunk>;
140
141    fn into_iter(self) -> Self::IntoIter {
142        self.0.into_iter()
143    }
144}
145
146impl From<ChunkList> for Vec<Chunk> {
147    fn from(l: ChunkList) -> Self {
148        l.0
149    }
150}
151
152impl From<Vec<Chunk>> for ChunkList {
153    fn from(data: Vec<Chunk>) -> Self {
154        Self(data)
155    }
156}
157
158/// How one payload should be framed for a size-limited connection: sent whole, or split.
159///
160/// This is the *decision* only - a value, with no I/O - so the sender's effectful path
161/// (`do_send_payload`) is a thin shell that matches on it. Separating the rule from the act keeps
162/// the rule exhaustively testable in isolation (functional core / imperative shell).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Framing {
165    /// The payload is within the connection's limit; send it as a single message, unchanged.
166    Whole,
167    /// The payload exceeds the limit; split it into [`Chunk`]s of at most `chunk_size` data bytes
168    /// each (via [`ChunkList::split`]), each then re-wrapped in its own envelope.
169    Chunked {
170        /// Maximum data bytes per chunk.
171        chunk_size: usize,
172    },
173}
174
175/// The bytes the transport adds around a payload on the wire, per framing path. Bundled as a named
176/// value so the framing rule reads `reserves.plan(len, limit)` instead of a row of positional
177/// `usize`s, and so the production reserves live in exactly one place ([`WireReserves::PRODUCTION`]).
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct WireReserves {
180    /// Bytes added around a *whole* payload - the outer `TransportMessage::Custom` frame.
181    pub whole: usize,
182    /// Bytes added around *each chunk's* data - its `MessagePayload` envelope **and** the outer
183    /// `TransportMessage::Custom` frame.
184    pub chunk: usize,
185    /// Smallest per-chunk data payload worth producing; a limit that cannot fit `chunk +
186    /// min_chunk_data` is rejected rather than fragmented into near-empty chunks.
187    pub min_chunk_data: usize,
188}
189
190impl WireReserves {
191    /// The reserves used in production, derived from the transport/message ceilings.
192    pub const PRODUCTION: Self = Self {
193        whole: TRANSPORT_CUSTOM_OVERHEAD,
194        chunk: MAX_CHUNK_ENVELOPE_OVERHEAD + TRANSPORT_CUSTOM_OVERHEAD,
195        min_chunk_data: MIN_CHUNK_DATA,
196    };
197
198    /// Frame a `payload_len`-byte payload for a connection whose negotiated per-message limit is
199    /// `max_message_size`. The decision is taken against the *wire* bytes (payload + reserves), not
200    /// the bare payload, and is a pure total function:
201    ///
202    /// ```text
203    ///   plan : (len, limit) -> Whole                   if len + whole <= limit
204    ///                       -> Chunked(limit - chunk)  if limit >= chunk + min_chunk_data
205    ///                       -> None                    otherwise
206    /// ```
207    ///
208    /// `None` means the peer's limit is too small for even one useful chunk - a failure the caller
209    /// surfaces, never a flood of 1-byte chunks. When `Chunked { chunk_size }` is returned,
210    /// `min_chunk_data <= chunk_size` and `chunk_size + chunk <= limit`, so every wrapped chunk fits
211    /// and a payload yields at most `ceil(len / min_chunk_data)` chunks. Every sum is `checked`, so
212    /// the function is total over all `usize` inputs (no overflow/underflow).
213    pub fn plan(&self, payload_len: usize, max_message_size: usize) -> Option<Framing> {
214        let whole_fits = payload_len
215            .checked_add(self.whole)
216            .is_some_and(|wire| wire <= max_message_size);
217        if whole_fits {
218            return Some(Framing::Whole);
219        }
220        let min_viable = self.chunk.checked_add(self.min_chunk_data)?;
221        (max_message_size >= min_viable).then(|| Framing::Chunked {
222            chunk_size: max_message_size - self.chunk,
223        })
224    }
225}