ruststream_kinesis/message.rs
1//! [`KinesisMessage`]: a delivered record whose acknowledgement is a checkpoint.
2
3use std::sync::Arc;
4
5use bytes::Bytes;
6use ruststream::{AckError, Headers, IncomingMessage, Partitioned, Positioned};
7
8use crate::lease::LeaseStore;
9use crate::track::Watermark;
10
11/// Header carrying the partition key, mapped onto the record's own partition key.
12///
13/// Mirrors the in-memory broker's convention, so services can switch brokers without changing
14/// their headers.
15pub const PARTITION_KEY_HEADER: &str = "partition-key";
16
17/// Header exposing the record's sequence number on received messages.
18pub const SEQUENCE_HEADER: &str = "kinesis-sequence-number";
19
20/// Header exposing the shard a record arrived on.
21pub const SHARD_HEADER: &str = "kinesis-shard-id";
22
23/// The KPL aggregation magic prefix; such records are refused loudly (deaggregation is a
24/// follow-up), never handed to a handler as opaque protobuf.
25pub(crate) const KPL_MAGIC: [u8; 4] = [0xF3, 0x89, 0x9A, 0xC2];
26
27/// The conditional header-envelope magic: Kinesis records carry only a data blob and a
28/// partition key, so user headers (beyond the partition key, which travels natively) ride a
29/// small prefix - applied only when such headers are present, so plain payloads stay readable
30/// by any consumer.
31pub(crate) const ENVELOPE_MAGIC: [u8; 4] = *b"RSK1";
32
33/// Encodes a payload with its user headers (partition key excluded - it travels natively).
34pub(crate) fn encode_envelope(headers: &Headers, payload: &[u8]) -> Vec<u8> {
35 let mut lines = String::new();
36 for (name, value) in headers.iter() {
37 if name == PARTITION_KEY_HEADER {
38 continue;
39 }
40 lines.push_str(name);
41 lines.push_str(": ");
42 lines.push_str(&String::from_utf8_lossy(value));
43 lines.push('\n');
44 }
45 if lines.is_empty() {
46 return payload.to_vec();
47 }
48 let header_bytes = lines.as_bytes();
49 let mut out = Vec::with_capacity(8 + header_bytes.len() + payload.len());
50 out.extend_from_slice(&ENVELOPE_MAGIC);
51 out.extend_from_slice(&u32::try_from(header_bytes.len()).unwrap_or(0).to_be_bytes());
52 out.extend_from_slice(header_bytes);
53 out.extend_from_slice(payload);
54 out
55}
56
57/// Splits an enveloped payload back into headers and raw payload; a payload without the
58/// magic reads as headerless.
59pub(crate) fn decode_envelope(data: &[u8]) -> (Headers, Bytes) {
60 if data.len() >= 8 && data[0..4] == ENVELOPE_MAGIC {
61 let len = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize;
62 if data.len() >= 8 + len {
63 let mut headers = Headers::new();
64 let text = String::from_utf8_lossy(&data[8..8 + len]);
65 for line in text.lines() {
66 if let Some((name, value)) = line.split_once(':') {
67 headers.insert(name.trim().to_owned(), value.trim().to_owned());
68 }
69 }
70 return (headers, Bytes::copy_from_slice(&data[8 + len..]));
71 }
72 }
73 (Headers::new(), Bytes::copy_from_slice(data))
74}
75
76/// A position in the stream's retained log: the whole start vocabulary of this broker,
77/// accepted by [`Seeker::seek`](ruststream::Seeker::seek) and by the `start_at(..)` clause of
78/// `#[subscriber(..)]`.
79///
80/// Repositioning resets the checkpoint bookkeeping of every shard it moves: acknowledgements
81/// of records delivered before the seek stop advancing the watermark, so a stale checkpoint
82/// cannot drag the cursor back over the position just taken. Records from the new position
83/// onward are delivered again, which at-least-once permits.
84///
85/// Without a position a subscription resumes from the stored checkpoint of each shard, and
86/// starts at the tip on a shard that has none.
87///
88/// # Examples
89///
90/// ```
91/// use ruststream_kinesis::KinesisPosition;
92///
93/// // Every retained record on every shard, replayed from the trim horizon.
94/// let backlog = KinesisPosition::horizon();
95/// # let _ = backlog;
96/// ```
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum KinesisPosition {
99 /// The trim horizon: everything the stream still retains.
100 ///
101 /// Stream-wide - it applies to every shard of the subscription, including shards
102 /// discovered later (the children of a split or merge).
103 Horizon,
104 /// The tip: only records published after the reposition.
105 ///
106 /// Stream-wide, and the position a shard without a checkpoint starts at by default.
107 Latest,
108 /// The first record at or after this timestamp, in milliseconds since the Unix epoch.
109 ///
110 /// Stream-wide; each shard opens at its own first record from that instant.
111 Timestamp(u64),
112 /// Exactly one record on one shard.
113 ///
114 /// This is the pinned form the framework defines for captured positions
115 /// ([`Positioned::position`]): seeking to one redelivers that very record. It addresses a
116 /// single shard, so it moves that shard's reader only, the way a partitioned log seeks
117 /// per partition, and the shard must be owned and live.
118 Sequence {
119 /// The shard the position lives on.
120 shard: String,
121 /// The record's sequence number.
122 sequence: String,
123 },
124}
125
126impl KinesisPosition {
127 /// The trim horizon, for every shard: see [`KinesisPosition::Horizon`].
128 #[must_use]
129 pub const fn horizon() -> Self {
130 Self::Horizon
131 }
132
133 /// The tip, for every shard: see [`KinesisPosition::Latest`].
134 #[must_use]
135 pub const fn latest() -> Self {
136 Self::Latest
137 }
138
139 /// A wall-clock instant (milliseconds since the Unix epoch), for every shard: see
140 /// [`KinesisPosition::Timestamp`].
141 #[must_use]
142 pub const fn timestamp(millis: u64) -> Self {
143 Self::Timestamp(millis)
144 }
145
146 /// One record on one shard: see [`KinesisPosition::Sequence`].
147 ///
148 /// Captured positions come from [`Positioned::position`]; this constructor is for a
149 /// sequence number carried in from elsewhere (an operator's replay request, say).
150 #[must_use]
151 pub fn sequence(shard: impl Into<String>, sequence: impl Into<String>) -> Self {
152 Self::Sequence {
153 shard: shard.into(),
154 sequence: sequence.into(),
155 }
156 }
157}
158
159pub(crate) struct Settlement {
160 pub(crate) tracker: Arc<Watermark>,
161 pub(crate) index: u64,
162 pub(crate) store: Arc<dyn LeaseStore>,
163 pub(crate) shard: String,
164 pub(crate) owner: String,
165 /// The reader's delivery generation at delivery time; a seek bumps the shared gate, and
166 /// stale settlements skip checkpointing (the watermark was reset).
167 pub(crate) epoch: u64,
168 pub(crate) gate: Arc<std::sync::atomic::AtomicU64>,
169}
170
171/// A record delivered by a [`KinesisSubscriber`](crate::KinesisSubscriber).
172///
173/// Acknowledgement is a per-shard checkpoint, not per-message settlement: `ack` marks this
174/// record handled, and when every earlier record on the shard is handled too, the watermark
175/// advances and is persisted to the lease store. `nack(requeue = true)` leaves the record
176/// unhandled - the watermark stops advancing, and the records from it onward redeliver when
177/// the shard's lease is next taken (a sharded log repositions; it cannot requeue one
178/// message). `nack(requeue = false)` skips the record (checkpoints past it).
179pub struct KinesisMessage {
180 payload: Bytes,
181 headers: Headers,
182 sequence: String,
183 settlement: Settlement,
184}
185
186impl std::fmt::Debug for KinesisMessage {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("KinesisMessage")
189 .field("shard", &self.settlement.shard)
190 .field("payload_len", &self.payload.len())
191 .finish_non_exhaustive()
192 }
193}
194
195impl KinesisMessage {
196 pub(crate) fn new(
197 data: &[u8],
198 partition_key: &str,
199 sequence: &str,
200 settlement: Settlement,
201 ) -> Self {
202 let (mut headers, payload) = decode_envelope(data);
203 headers.insert(PARTITION_KEY_HEADER, partition_key.to_owned());
204 headers.insert(SEQUENCE_HEADER, sequence.to_owned());
205 headers.insert(SHARD_HEADER, settlement.shard.clone());
206 Self {
207 payload,
208 headers,
209 sequence: sequence.to_owned(),
210 settlement,
211 }
212 }
213
214 async fn settle(self) -> Result<(), AckError> {
215 let Settlement {
216 tracker,
217 index,
218 store,
219 shard,
220 owner,
221 epoch,
222 gate,
223 } = self.settlement;
224 if gate.load(std::sync::atomic::Ordering::Acquire) != epoch {
225 // The subscription repositioned after this delivery: its watermark was reset,
226 // and a stale checkpoint would move the cursor somewhere the seek just left.
227 return Ok(());
228 }
229 let Some(sequence) = tracker.settle(index) else {
230 return Ok(()); // handled, but the watermark waits on an earlier record
231 };
232 match store.checkpoint(&shard, &owner, &sequence).await {
233 // A fenced checkpoint (another owner took the shard) is fine: the record was
234 // handled, and the new owner replays from its checkpoint, which at-least-once
235 // permits.
236 Ok(_) => Ok(()),
237 Err(err) => Err(AckError::Broker(err)),
238 }
239 }
240}
241
242impl Positioned for KinesisMessage {
243 type Position = KinesisPosition;
244
245 fn position(&self) -> KinesisPosition {
246 KinesisPosition::sequence(self.settlement.shard.clone(), self.sequence.clone())
247 }
248}
249
250impl Partitioned for KinesisMessage {
251 fn partition_key(&self) -> Option<&[u8]> {
252 self.headers.get(PARTITION_KEY_HEADER)
253 }
254}
255
256impl IncomingMessage for KinesisMessage {
257 fn payload(&self) -> &[u8] {
258 &self.payload
259 }
260
261 fn headers(&self) -> &Headers {
262 &self.headers
263 }
264
265 async fn ack(self) -> Result<(), AckError> {
266 self.settle().await
267 }
268
269 async fn nack(self, requeue: bool) -> Result<(), AckError> {
270 if requeue {
271 // Leaving the record unhandled wedges the watermark: no later checkpoint can
272 // pass it, so the shard replays from here when its lease is next taken.
273 Ok(())
274 } else {
275 self.settle().await
276 }
277 }
278
279 fn partition_key(&self) -> Option<&[u8]> {
280 Partitioned::partition_key(self)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn the_envelope_applies_only_when_user_headers_exist() {
290 let mut headers = Headers::new();
291 headers.insert(PARTITION_KEY_HEADER, "user-42");
292 // Only the partition key: no envelope, the payload stays plain.
293 assert_eq!(encode_envelope(&headers, b"raw"), b"raw");
294
295 headers.insert("x-tenant", "acme");
296 let enveloped = encode_envelope(&headers, b"raw");
297 assert_eq!(enveloped[0..4], ENVELOPE_MAGIC);
298 let (decoded, payload) = decode_envelope(&enveloped);
299 assert_eq!(decoded.get_str("x-tenant"), Some("acme"));
300 assert!(decoded.get(PARTITION_KEY_HEADER).is_none());
301 assert_eq!(payload.as_ref(), b"raw");
302 }
303
304 #[test]
305 fn plain_payloads_read_as_headerless() {
306 let (headers, payload) = decode_envelope(b"{\"id\":1}");
307 assert!(headers.is_empty());
308 assert_eq!(payload.as_ref(), b"{\"id\":1}");
309 }
310}