Skip to main content

ruststream_kinesis/
message.rs

1//! [`KinesisMessage`]: a delivered record whose acknowledgement is a checkpoint.
2
3// Without the `testing` feature a settlement has one variant, so a `match` on it has a single arm;
4// the match stays so that the in-process arm has its place when the feature is on.
5#![cfg_attr(
6    not(feature = "testing"),
7    allow(clippy::infallible_destructuring_match)
8)]
9
10use std::sync::Arc;
11
12use bytes::Bytes;
13use ruststream::{AckError, BytesMut, HeaderMap, IncomingMessage, Partitioned, Positioned, Str};
14
15#[cfg(feature = "testing")]
16use crate::in_process::Release;
17use crate::lease::{LeaseKey, LeaseStore};
18use crate::subscriber::KinesisSeeker;
19use crate::track::Watermark;
20
21/// Header carrying the partition key, mapped onto the record's own partition key.
22///
23/// Every delivery carries it, and it is where `IncomingMessage::partition_key` and the
24/// framework's per-key dispatch find the key. On a publish it is the portable way to name the key.
25///
26/// Mirrors the in-memory broker's convention, so services can switch brokers without changing
27/// their headers.
28pub const PARTITION_KEY_HEADER: &str = "partition-key";
29
30/// Header exposing the record's sequence number; every delivery carries it.
31pub const SEQUENCE_HEADER: &str = "kinesis-sequence-number";
32
33/// Header exposing the shard a record arrived on; every delivery carries it.
34pub const SHARD_HEADER: &str = "kinesis-shard-id";
35
36/// The KPL aggregation magic prefix; such records are refused loudly (deaggregation is a
37/// follow-up), never handed to a handler as opaque protobuf.
38pub(crate) const KPL_MAGIC: [u8; 4] = [0xF3, 0x89, 0x9A, 0xC2];
39
40/// The conditional header-envelope magic: Kinesis records carry only a data blob and a
41/// partition key, so user headers (beyond the partition key, which travels natively) ride a
42/// small prefix - applied only when such headers are present, so plain payloads stay readable
43/// by any consumer. Each header is a length-prefixed name and a length-prefixed value, so a
44/// value is carried byte for byte whatever it holds.
45pub(crate) const ENVELOPE_MAGIC: [u8; 4] = *b"RSK2";
46
47/// The magic of the envelope earlier releases wrote, one `name: value` text line per header.
48/// Records keep their bytes for as long as the stream retains them, so it is still read.
49const TEXT_ENVELOPE_MAGIC: [u8; 4] = *b"RSK1";
50
51/// The magic and the header-block length in front of the header block.
52const ENVELOPE_PREFIX: usize = 8;
53
54/// The length prefix of a header name or value.
55const FIELD_LEN: usize = 4;
56
57/// Encodes a payload with its user headers (partition key excluded - it travels natively).
58pub(crate) fn encode_envelope(headers: &HeaderMap, payload: BytesMut) -> Vec<u8> {
59    let user = || {
60        headers
61            .iter()
62            .filter(|(name, _)| *name != PARTITION_KEY_HEADER)
63    };
64    let block: usize = user()
65        .map(|(name, value)| 2 * FIELD_LEN + name.len() + value.len())
66        .sum();
67    if block == 0 {
68        // `Vec::from` reclaims the buffer the framework wrote: a record with nothing in front of
69        // its payload is that buffer.
70        return Vec::from(payload);
71    }
72    let mut out = Vec::with_capacity(ENVELOPE_PREFIX + block + payload.len());
73    out.extend_from_slice(&ENVELOPE_MAGIC);
74    out.extend_from_slice(&field_len(block));
75    for (name, value) in user() {
76        out.extend_from_slice(&field_len(name.len()));
77        out.extend_from_slice(name.as_bytes());
78        out.extend_from_slice(&field_len(value.len()));
79        out.extend_from_slice(value);
80    }
81    out.extend_from_slice(&payload);
82    out
83}
84
85/// A length as the envelope writes it. A record is at most 10 MiB, so every length fits; one
86/// that does not writes zero, and the service refuses the record for its size anyway.
87fn field_len(len: usize) -> [u8; FIELD_LEN] {
88    u32::try_from(len).unwrap_or(0).to_be_bytes()
89}
90
91/// Splits an enveloped payload back into headers and raw payload; a payload without the
92/// magic, or whose header block does not parse, reads as headerless.
93pub(crate) fn decode_envelope(data: &[u8]) -> (HeaderMap, Bytes) {
94    let parsed = match data.get(..4) {
95        Some(magic) if magic == ENVELOPE_MAGIC => envelope_parts(data, decode_fields),
96        Some(magic) if magic == TEXT_ENVELOPE_MAGIC => {
97            envelope_parts(data, |block| Some(decode_text(block)))
98        }
99        _ => None,
100    };
101    parsed.map_or_else(
102        || (HeaderMap::new(), Bytes::copy_from_slice(data)),
103        |(headers, payload)| (headers, Bytes::copy_from_slice(payload)),
104    )
105}
106
107/// The headers and the payload of an enveloped record, the header block read by `decode`.
108fn envelope_parts(
109    data: &[u8],
110    decode: fn(&[u8]) -> Option<HeaderMap>,
111) -> Option<(HeaderMap, &[u8])> {
112    let (len, rest) = read_len(data.get(4..)?)?;
113    let block = rest.get(..len)?;
114    Some((decode(block)?, &rest[len..]))
115}
116
117/// Reads one length prefix off the front of `data`.
118fn read_len(data: &[u8]) -> Option<(usize, &[u8])> {
119    let (len, rest) = data.split_first_chunk::<FIELD_LEN>()?;
120    Some((usize::try_from(u32::from_be_bytes(*len)).ok()?, rest))
121}
122
123/// Reads one length-prefixed field off the front of `data`.
124fn read_field(data: &[u8]) -> Option<(&[u8], &[u8])> {
125    let (len, rest) = read_len(data)?;
126    rest.split_at_checked(len)
127}
128
129/// The header block of an envelope: length-prefixed names and values.
130fn decode_fields(mut block: &[u8]) -> Option<HeaderMap> {
131    let mut headers = HeaderMap::new();
132    while !block.is_empty() {
133        let (name, rest) = read_field(block)?;
134        let (value, rest) = read_field(rest)?;
135        headers.insert(
136            String::from_utf8(name.to_vec()).ok()?,
137            Bytes::copy_from_slice(value),
138        );
139        block = rest;
140    }
141    Some(headers)
142}
143
144/// The header block of the text envelope earlier releases wrote.
145fn decode_text(block: &[u8]) -> HeaderMap {
146    let mut headers = HeaderMap::new();
147    for line in String::from_utf8_lossy(block).lines() {
148        if let Some((name, value)) = line.split_once(':') {
149            headers.insert(name.trim().to_owned(), value.trim().to_owned());
150        }
151    }
152    headers
153}
154
155/// A position in the stream's retained log: the whole start vocabulary of this broker,
156/// accepted by [`Seeker::seek`](ruststream::Seeker::seek) and by the `start_at(..)` clause of
157/// `#[subscriber(..)]`.
158///
159/// Repositioning resets the checkpoint bookkeeping of every shard it moves: acknowledgements
160/// of records delivered before the seek stop advancing the watermark, so a stale checkpoint
161/// cannot drag the cursor back over the position just taken. Records from the new position
162/// onward are delivered again, which at-least-once permits.
163///
164/// Without a position a subscription resumes from the stored checkpoint of each shard, and
165/// starts at the tip on a shard that has none. A position given to `start_at(..)` applies to
166/// the shards the subscription opens with; a shard it takes on later resumes from its
167/// checkpoint.
168///
169/// Opening a subscription returns once every shard it took has its reader and its starting
170/// cursor, so the tip is the moment the subscription opened: a record published after that is
171/// read.
172///
173/// # Examples
174///
175/// ```
176/// # mod demo {
177/// use ruststream_kinesis::prelude::*;
178/// use serde::Deserialize;
179///
180/// #[derive(Deserialize)]
181/// struct Order {
182///     id: u64,
183/// }
184///
185/// /// Rebuilds a read model from every record the stream still retains, on every shard.
186/// #[subscriber(KinesisStream::new("orders"), start_at(KinesisPosition::horizon()))]
187/// async fn backfill(order: &Order) -> HandlerOutcome {
188///     tracing::info!(order.id, "backfilled");
189///     HandlerOutcome::ack()
190/// }
191///
192/// #[ruststream::app]
193/// fn app() -> impl App {
194///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(KinesisBroker::new(), |b| {
195///         b.include(backfill);
196///     })
197/// }
198/// # }
199/// # fn main() {}
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum KinesisPosition {
203    /// The trim horizon: everything the stream still retains.
204    ///
205    /// Stream-wide - it applies to every shard the subscription reads when it lands, and to a
206    /// shard whose reader starts later without a checkpoint.
207    Horizon,
208    /// The tip: only records published after the reposition.
209    ///
210    /// Stream-wide, and the position a shard without a checkpoint starts at by default. A child
211    /// of a split or a merge whose parent was read to its end opens at its own beginning
212    /// instead, so nothing written to it before its reader started is skipped.
213    Latest,
214    /// The first record at or after this timestamp, in milliseconds since the Unix epoch.
215    ///
216    /// Stream-wide; each shard opens at its own first record from that instant. The instant is
217    /// matched against the arrival timestamp the service stamped on the record, and the service
218    /// calls that stamp approximate: records that arrived around the same moment are not
219    /// separated exactly.
220    Timestamp(u64),
221    /// Exactly one record on one shard.
222    ///
223    /// This is the pinned form the framework defines for captured positions
224    /// ([`Positioned::position`]): seeking to one redelivers that very record. It addresses a
225    /// single shard, so it moves that shard's reader only, the way a partitioned log seeks
226    /// per partition, and the shard must be owned and live.
227    Sequence {
228        /// The shard the position lives on.
229        shard: String,
230        /// The record's sequence number.
231        sequence: String,
232    },
233}
234
235impl KinesisPosition {
236    /// The trim horizon, for every shard: see [`KinesisPosition::Horizon`].
237    #[must_use]
238    pub const fn horizon() -> Self {
239        Self::Horizon
240    }
241
242    /// The tip, for every shard: see [`KinesisPosition::Latest`].
243    #[must_use]
244    pub const fn latest() -> Self {
245        Self::Latest
246    }
247
248    /// A wall-clock instant (milliseconds since the Unix epoch), for every shard: see
249    /// [`KinesisPosition::Timestamp`].
250    #[must_use]
251    pub const fn timestamp(millis: u64) -> Self {
252        Self::Timestamp(millis)
253    }
254
255    /// One record on one shard: see [`KinesisPosition::Sequence`].
256    ///
257    /// Captured positions come from [`Positioned::position`]; this constructor is for a
258    /// sequence number carried in from elsewhere (an operator's replay request, say).
259    #[must_use]
260    pub fn sequence(shard: impl Into<String>, sequence: impl Into<String>) -> Self {
261        Self::Sequence {
262            shard: shard.into(),
263            sequence: sequence.into(),
264        }
265    }
266}
267
268pub(crate) struct Settlement {
269    pub(crate) tracker: Arc<Watermark>,
270    pub(crate) index: u64,
271    pub(crate) store: Arc<dyn LeaseStore>,
272    // The lease key and the owner are per-reader constants stamped onto every record it
273    // forwards, and the per-delivery context reads the shard back: sharing them keeps both paths
274    // to reference-count bumps instead of a string copy per record.
275    pub(crate) lease: Arc<LeaseKey>,
276    pub(crate) owner: Arc<str>,
277    /// The reader's delivery generation at delivery time; a seek bumps the shared gate, and
278    /// stale settlements skip checkpointing (the watermark was reset).
279    pub(crate) epoch: u64,
280    pub(crate) gate: Arc<std::sync::atomic::AtomicU64>,
281}
282
283/// How a delivery settles: against its shard's watermark and the lease store, or, under the
284/// `testing` feature, against the in-process transport the test harness connected instead.
285///
286/// Without the feature there is one variant, so the type is the checkpoint settlement itself and
287/// every `match` on it resolves at compile time: a production build carries no second settlement
288/// and no branch to it.
289pub(crate) enum Settle {
290    Checkpoint(Settlement),
291    #[cfg(feature = "testing")]
292    InProcess(Release),
293}
294
295// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
296// settlement exactly the size of the checkpoint it wraps.
297#[cfg(not(feature = "testing"))]
298const _: () = assert!(size_of::<Settle>() == size_of::<Settlement>());
299
300impl Settle {
301    fn shard(&self) -> &Arc<str> {
302        match self {
303            Self::Checkpoint(settlement) => settlement.lease.shard_shared(),
304            #[cfg(feature = "testing")]
305            Self::InProcess(release) => release.shard(),
306        }
307    }
308}
309
310/// A record delivered by a [`KinesisSubscriber`](crate::KinesisSubscriber).
311///
312/// Acknowledgement is a per-shard checkpoint, not per-message settlement: `ack` marks this
313/// record handled, and when every earlier record on the shard is handled too, the watermark
314/// advances and is persisted to the lease store. `nack(requeue = true)` answers
315/// [`AckError::Unsupported`]: a sharded log repositions, it cannot requeue one message. The record
316/// stays unhandled, so the watermark stops advancing, and the records from it onward are delivered
317/// again when the shard's lease is next taken. `nack(requeue = false)` skips the record
318/// (checkpoints past it).
319///
320/// In a handler, `HandlerOutcome::ack()` is the ack, `HandlerOutcome::drop()` the skip, and
321/// `HandlerOutcome::retry()` the requeue, unless the registration declares `max_attempts(..)` or
322/// a dead-letter stream: then the retry publishes a counted copy of the record back to its stream.
323pub struct KinesisMessage {
324    payload: Bytes,
325    headers: HeaderMap,
326    sequence: Arc<str>,
327    seeker: KinesisSeeker,
328    settlement: Settle,
329}
330
331impl std::fmt::Debug for KinesisMessage {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        let mut debug = f.debug_struct("KinesisMessage");
334        match &self.settlement {
335            Settle::Checkpoint(settlement) => {
336                debug.field("stream", &settlement.lease.stream());
337            }
338            #[cfg(feature = "testing")]
339            Settle::InProcess(_) => {}
340        }
341        debug
342            .field("shard", self.settlement.shard())
343            .field("payload_len", &self.payload.len())
344            .finish_non_exhaustive()
345    }
346}
347
348impl KinesisMessage {
349    pub(crate) fn new(
350        data: &[u8],
351        partition_key: &str,
352        sequence: &str,
353        seeker: KinesisSeeker,
354        settlement: Settle,
355    ) -> Self {
356        let (mut headers, payload) = decode_envelope(data);
357        headers.insert(
358            Str::from_static(PARTITION_KEY_HEADER),
359            partition_key.to_owned(),
360        );
361        headers.insert(Str::from_static(SEQUENCE_HEADER), sequence.to_owned());
362        headers.insert(
363            Str::from_static(SHARD_HEADER),
364            settlement.shard().to_string(),
365        );
366        Self {
367            payload,
368            headers,
369            sequence: Arc::from(sequence),
370            seeker,
371            settlement,
372        }
373    }
374
375    /// The shard this record arrived on; the per-delivery context borrows it.
376    pub(crate) fn shard(&self) -> &Arc<str> {
377        self.settlement.shard()
378    }
379
380    /// This record's sequence number; the per-delivery context borrows it.
381    pub(crate) fn sequence(&self) -> &Arc<str> {
382        &self.sequence
383    }
384
385    /// The subscription's reposition handle, minted once when the subscription opened.
386    pub(crate) fn seeker(&self) -> &KinesisSeeker {
387        &self.seeker
388    }
389
390    async fn settle(self) -> Result<(), AckError> {
391        let settlement = match self.settlement {
392            Settle::Checkpoint(settlement) => settlement,
393            // Nothing is checkpointed in process: a handled record is simply consumed.
394            #[cfg(feature = "testing")]
395            Settle::InProcess(_) => return Ok(()),
396        };
397        let Settlement {
398            tracker,
399            index,
400            store,
401            lease,
402            owner,
403            epoch,
404            gate,
405        } = settlement;
406        if gate.load(std::sync::atomic::Ordering::Acquire) != epoch {
407            // The subscription repositioned after this delivery: its watermark was reset,
408            // and a stale checkpoint would move the cursor somewhere the seek just left.
409            return Ok(());
410        }
411        let Some(sequence) = tracker.settle(index) else {
412            return Ok(()); // handled, but the watermark waits on an earlier record
413        };
414        match store.checkpoint(&lease, &owner, &sequence).await {
415            // A fenced checkpoint (another owner took the shard) is fine: the record was
416            // handled, and the new owner replays from its checkpoint, which at-least-once
417            // permits.
418            Ok(_) => Ok(()),
419            Err(err) => Err(AckError::Broker(err)),
420        }
421    }
422}
423
424impl Positioned for KinesisMessage {
425    type Position = KinesisPosition;
426
427    fn position(&self) -> KinesisPosition {
428        KinesisPosition::sequence(&**self.settlement.shard(), &*self.sequence)
429    }
430}
431
432impl Partitioned for KinesisMessage {
433    fn partition_key(&self) -> Option<&[u8]> {
434        self.headers.get(PARTITION_KEY_HEADER)
435    }
436}
437
438impl IncomingMessage for KinesisMessage {
439    fn payload(&self) -> &[u8] {
440        &self.payload
441    }
442
443    fn headers(&self) -> &HeaderMap {
444        &self.headers
445    }
446
447    async fn ack(self) -> Result<(), AckError> {
448        self.settle().await
449    }
450
451    async fn nack(self, requeue: bool) -> Result<(), AckError> {
452        if requeue {
453            // A shard is read in order and the service holds no redelivery of its own, so there
454            // is nothing to requeue one record into. The record stays unhandled: the watermark
455            // stops at it, and the shard is read again from it when its lease is next taken.
456            Err(AckError::Unsupported)
457        } else {
458            self.settle().await
459        }
460    }
461
462    fn partition_key(&self) -> Option<&[u8]> {
463        Partitioned::partition_key(self)
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use ruststream::BytesMut;
470
471    use super::*;
472
473    /// Content equality cannot tell a hand-over from a copy, so the buffer the framework wrote is
474    /// identified by its address.
475    #[test]
476    fn a_record_without_an_envelope_keeps_the_buffer_the_framework_wrote() {
477        let payload = BytesMut::from(&br#"{"id":1}"#[..]);
478        let written = payload.as_ptr();
479
480        let data = encode_envelope(&HeaderMap::new(), payload);
481
482        assert_eq!(
483            data.as_ptr(),
484            written,
485            "a publish carrying no header is the record's data as it stands, so the buffer is \
486             handed over rather than copied"
487        );
488    }
489
490    #[test]
491    fn the_envelope_applies_only_when_user_headers_exist() {
492        let mut headers = HeaderMap::new();
493        headers.insert(PARTITION_KEY_HEADER, "user-42");
494        // Only the partition key: no envelope, the payload stays plain.
495        assert_eq!(
496            encode_envelope(&headers, BytesMut::from(&b"raw"[..])),
497            b"raw"
498        );
499
500        headers.insert("x-tenant", "acme");
501        let enveloped = encode_envelope(&headers, BytesMut::from(&b"raw"[..]));
502        assert_eq!(enveloped[0..4], ENVELOPE_MAGIC);
503        let (decoded, payload) = decode_envelope(&enveloped);
504        assert_eq!(decoded.get_str("x-tenant"), Some("acme"));
505        assert!(decoded.get(PARTITION_KEY_HEADER).is_none());
506        assert_eq!(payload.as_ref(), b"raw");
507    }
508
509    /// A header value is bytes, not text: whatever it holds - a byte that is not UTF-8, a line
510    /// break, a colon, surrounding spaces - comes back exactly as it was written.
511    #[test]
512    fn a_header_value_travels_byte_for_byte() {
513        let binary: &[u8] = &[0x00, 0x80, b'\r', b'\n', 0xfe, 0xff, 0xc3];
514        let mut headers = HeaderMap::new();
515        headers.insert("x-binary", Bytes::from_static(binary));
516        headers.insert("x-text", " a: b\nc ");
517        headers.insert("x-empty", "");
518
519        let enveloped = encode_envelope(&headers, BytesMut::from(&b"raw"[..]));
520        let (decoded, payload) = decode_envelope(&enveloped);
521
522        assert_eq!(decoded.get("x-binary"), Some(binary));
523        assert_eq!(decoded.get_str("x-text"), Some(" a: b\nc "));
524        assert_eq!(decoded.get("x-empty"), Some(&b""[..]));
525        assert_eq!(decoded.iter().count(), 3);
526        assert_eq!(payload.as_ref(), b"raw");
527    }
528
529    /// A record an earlier release wrote keeps its text envelope for as long as the stream
530    /// retains it, and still reads back with its headers.
531    #[test]
532    fn a_record_in_the_earlier_text_envelope_reads_back() {
533        let block = b"x-tenant: acme\n";
534        let mut data = b"RSK1".to_vec();
535        data.extend_from_slice(&u32::try_from(block.len()).unwrap().to_be_bytes());
536        data.extend_from_slice(block);
537        data.extend_from_slice(b"raw");
538
539        let (decoded, payload) = decode_envelope(&data);
540
541        assert_eq!(decoded.get_str("x-tenant"), Some("acme"));
542        assert_eq!(payload.as_ref(), b"raw");
543    }
544
545    /// A blob that starts like an envelope and does not parse as one is somebody else's payload,
546    /// handed over whole rather than cut at a guessed boundary.
547    #[test]
548    fn a_blob_that_does_not_parse_as_an_envelope_reads_as_headerless() {
549        let mut headers = HeaderMap::new();
550        headers.insert("x-tenant", "acme");
551        let enveloped = encode_envelope(&headers, BytesMut::new());
552        for cut in [6, enveloped.len() - 1] {
553            let (decoded, payload) = decode_envelope(&enveloped[..cut]);
554            assert_eq!(decoded, HeaderMap::new(), "cut at {cut}");
555            assert_eq!(payload.as_ref(), &enveloped[..cut]);
556        }
557        let mut not_utf8_name = enveloped;
558        not_utf8_name[12] = 0xff;
559        assert_eq!(decode_envelope(&not_utf8_name).0, HeaderMap::new());
560    }
561
562    #[test]
563    fn plain_payloads_read_as_headerless() {
564        let (headers, payload) = decode_envelope(b"{\"id\":1}");
565        assert_eq!(headers, HeaderMap::new());
566        assert_eq!(payload.as_ref(), b"{\"id\":1}");
567    }
568}