Skip to main content

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}