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}