Skip to main content

ruststream_kinesis/
stream.rs

1//! [`KinesisStream`]: the subscription descriptor, and the mount-site settings over it.
2//!
3//! The consumer model decides both cost and latency, so it is explicit on the descriptor.
4//! What it does not carry is how many records a delivered batch holds: that is the framework's
5//! `batch(n)`, which reaches the reader as the `GetRecords` limit. Where the subscription
6//! starts is not part of it either - that vocabulary is
7//! [`KinesisPosition`](crate::KinesisPosition), spoken through the framework's `start_at(..)`
8//! clause and the `Seekable` capability.
9
10use std::future::{Future, ready};
11use std::time::Duration;
12
13#[cfg(feature = "asyncapi")]
14use ruststream::asyncapi::{Binding, Bindings};
15use ruststream::runtime::{Declared, IntoSource, SubscriberBuilder, SubscriberSettings};
16use ruststream::{AddressedCopies, RedeliveryAddress, RedeliveryAddressed, SubscriptionSource};
17#[cfg(feature = "asyncapi")]
18use serde::Serialize;
19
20use crate::broker::ConnectedKinesisBroker;
21use crate::error::KinesisError;
22use crate::subscriber::KinesisSubscriber;
23
24/// A subscription descriptor for one Kinesis stream.
25///
26/// Every shard resumes from its stored checkpoint, and a shard without one starts at the tip
27/// (a child whose parent was read to its end starts at its own beginning). To open somewhere else, wrap the descriptor in the framework's `start_at(..)` clause with a
28/// [`KinesisPosition`](crate::KinesisPosition).
29///
30/// A string literal names a stream too: `#[subscriber("orders")]` builds this descriptor with its
31/// defaults. An invalid descriptor (an empty stream name, a zero shard count) fails the mount
32/// before any I/O, and a stream the service does not have fails it when the subscription opens,
33/// unless the descriptor [creates it](Self::create_if_missing).
34///
35/// The subscription lists the stream's shards and lists them again as splits and merges change
36/// the set, takes a lease per shard, and runs one reader per shard it holds. A child of a split
37/// or a merge starts only after its parents are fully consumed, which keeps per-key ordering
38/// across resharding.
39///
40/// A broker reads a stream through one subscription: a second subscription to the same stream
41/// on one broker is refused when it opens. A second [`KinesisBroker`](crate::KinesisBroker) with
42/// a lease store of its own reads the stream again. A KPL-aggregated record is reported as
43/// [`KinesisError::AggregatedRecord`](crate::KinesisError::AggregatedRecord).
44///
45/// Implements [`SubscriptionSource`], so it can sit inline in the `#[subscriber(..)]`
46/// decorator, and [`IntoSource`], so the manual path's
47/// [`subscriber`](ruststream::runtime::subscriber) constructor names a stream with it too:
48///
49/// ```
50/// # mod demo {
51/// use std::error::Error;
52/// use std::time::Duration;
53///
54/// use ruststream::prelude::*;
55/// use ruststream_kinesis::{KinesisBroker, KinesisStream};
56/// use schemars::JsonSchema;
57/// use serde::Deserialize;
58///
59/// #[derive(Deserialize, JsonSchema)]
60/// struct Order {
61///     id: u64,
62/// }
63///
64/// struct Audit;
65///
66/// impl Handle<Order> for Audit {
67///     async fn handle(
68///         &self,
69///         order: &Order,
70///         _outs: &(),
71///         _ctx: &mut Context<'_>,
72///     ) -> Result<(), HandlerOutcome> {
73///         tracing::info!(order.id, "audited");
74///         Ok(())
75///     }
76/// }
77///
78/// pub async fn run() -> Result<(), Box<dyn Error>> {
79///     let orders = KinesisStream::new("orders").poll_interval(Duration::from_millis(500));
80///     RustStream::new(AppInfo::new("orders", "0.1.0"))
81///         .with_broker(KinesisBroker::new(), |b| {
82///             b.include(subscriber(orders, Audit).build());
83///         })
84///         .run()
85///         .await?;
86///     Ok(())
87/// }
88/// # }
89/// # fn main() {}
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[must_use]
93pub struct KinesisStream {
94    stream: String,
95    poll_interval: Duration,
96    create_shards: Option<i32>,
97}
98
99impl KinesisStream {
100    /// Names the stream by its name, which is also the stream half of every [`LeaseKey`](crate::LeaseKey) the
101    /// subscription's shards are leased under.
102    ///
103    /// A stream is named by its name; every call the subscription makes passes it as the stream
104    /// name.
105    pub fn new(stream: impl Into<String>) -> Self {
106        Self {
107            stream: stream.into(),
108            // The service recommends waiting a second between reads to stay within the
109            // per-shard budget.
110            poll_interval: Duration::from_secs(1),
111            create_shards: None,
112        }
113    }
114
115    /// The pause between reads on an idle shard. Defaults to 1 second, the service's own
116    /// recommendation; lower values spend the 5-reads-per-second budget faster.
117    pub fn poll_interval(mut self, interval: Duration) -> Self {
118        self.poll_interval = interval;
119        self
120    }
121
122    /// Creates the stream with `shards` provisioned shards on subscribe when it does not
123    /// exist yet, then waits up to a minute for it to become active. Meant for local development
124    /// and tests; production streams are usually managed as infrastructure.
125    ///
126    /// Without it a stream the service does not have refuses the subscription when it opens,
127    /// so the name is checked while a service is starting rather than after it has started.
128    pub fn create_if_missing(mut self, shards: i32) -> Self {
129        self.create_shards = Some(shards);
130        self
131    }
132
133    /// The stream name this descriptor resolves.
134    #[must_use]
135    pub fn stream(&self) -> &str {
136        &self.stream
137    }
138
139    pub(crate) fn poll_value(&self) -> Duration {
140        self.poll_interval
141    }
142
143    pub(crate) fn create_value(&self) -> Option<i32> {
144        self.create_shards
145    }
146
147    /// Rejects descriptors that cannot form a subscription, before any I/O.
148    pub(crate) fn validate(&self) -> Result<(), KinesisError> {
149        if self.stream.is_empty() {
150            return Err(KinesisError::Invalid("stream must be non-empty".into()));
151        }
152        if let Some(shards) = self.create_shards
153            && shards < 1
154        {
155            return Err(KinesisError::Invalid(
156                "create_if_missing needs at least one shard".into(),
157            ));
158        }
159        Ok(())
160    }
161}
162
163/// The Kinesis subscription settings, chained on a mount site after the framework's own.
164///
165/// The framework carries one subscription parameter down to a broker - the batch size, named
166/// with `batch(n)` - and leaves every other knob to the broker's own vocabulary. These are this
167/// crate's, and they read as a chain after it:
168///
169/// ```
170/// # mod demo {
171/// use std::time::Duration;
172///
173/// use ruststream_kinesis::prelude::*;
174/// use serde::Deserialize;
175///
176/// #[derive(Deserialize)]
177/// struct Job {
178///     id: u64,
179/// }
180///
181/// #[subscriber(KinesisStream::new("jobs"))]
182/// async fn work(batch: &[Job]) -> Vec<HandlerOutcome> {
183///     batch
184///         .iter()
185///         .map(|job| {
186///             tracing::info!(job.id, "done");
187///             HandlerOutcome::ack()
188///         })
189///         .collect()
190/// }
191///
192/// #[ruststream::app]
193/// fn app() -> impl App {
194///     RustStream::new(AppInfo::new("jobs", "0.1.0")).with_broker(KinesisBroker::new(), |b| {
195///         b.include(
196///             work.batch(nonzero!(500))
197///                 .poll_interval(Duration::from_millis(500))
198///                 .create_if_missing(1),
199///         );
200///     })
201/// }
202/// # }
203/// # fn main() {}
204/// ```
205///
206/// Both settings transform the descriptor, so they need one to transform: `start_at(..)`
207/// replaces the source with the framework's position wrapper, and these chain before it. A
208/// subscriber whose attribute already named a start position names them on the descriptor
209/// instead - `#[subscriber(KinesisStream::new("jobs").poll_interval(..), start_at(..))]` - which
210/// is the same two methods on the same type.
211pub trait KinesisSubscriberExt {
212    /// The pause between reads on an idle shard. The mount-site spelling of
213    /// [`KinesisStream::poll_interval`].
214    #[must_use]
215    fn poll_interval(self, interval: Duration) -> Self;
216
217    /// Creates the stream with `shards` provisioned shards on subscribe when it does not exist
218    /// yet. The mount-site spelling of [`KinesisStream::create_if_missing`].
219    #[must_use]
220    fn create_if_missing(self, shards: i32) -> Self;
221}
222
223impl<Def, State, DefCodec> KinesisSubscriberExt
224    for SubscriberBuilder<Def, KinesisStream, State, DefCodec>
225where
226    Def: Declared,
227{
228    fn poll_interval(self, interval: Duration) -> Self {
229        self.map_source(|source| source.poll_interval(interval))
230    }
231
232    fn create_if_missing(self, shards: i32) -> Self {
233        self.map_source(|source| source.create_if_missing(shards))
234    }
235}
236
237/// The manual path's constructor takes a subject string or a source, and this is what makes the
238/// crate's own descriptor the second: a service without the `macros` feature names its stream,
239/// and the settings that price a read, exactly as an attribute one does.
240impl IntoSource for KinesisStream {
241    type Source = Self;
242
243    fn into_source(self) -> Self {
244        self
245    }
246}
247
248/// What a Kinesis subscription writes into the generated document.
249///
250/// The `AsyncAPI` specification lists no Kinesis binding, and the protocol keys of a binding object
251/// are a closed list, so this travels as an `x-` extension at the same level. Only what the
252/// descriptor itself holds goes in, because the document is built before anything connects.
253#[cfg(feature = "asyncapi")]
254#[derive(Debug, Serialize)]
255struct KinesisChannelBinding<'a> {
256    stream: &'a str,
257    #[serde(rename = "pollIntervalMs")]
258    poll_interval_ms: u64,
259    /// The shards the descriptor would provision, present only where it creates the stream.
260    #[serde(rename = "shardCount", skip_serializing_if = "Option::is_none")]
261    shard_count: Option<i32>,
262}
263
264/// The extension key this crate writes its bindings under, on the channel and on the message.
265#[cfg(feature = "asyncapi")]
266pub(crate) const BINDING_KEY: &str = "x-ruststream-kinesis";
267
268impl KinesisStream {
269    /// The channel binding of this descriptor, or nothing when it cannot be built.
270    ///
271    /// A document is a description of the service, so a binding that fails to serialize is one
272    /// the document goes without rather than a start-up the service loses.
273    #[cfg(feature = "asyncapi")]
274    fn binding(&self) -> Bindings {
275        let body = KinesisChannelBinding {
276            stream: self.stream(),
277            poll_interval_ms: u64::try_from(self.poll_interval.as_millis()).unwrap_or(u64::MAX),
278            shard_count: self.create_shards,
279        };
280        Binding::extension(BINDING_KEY, &body)
281            .map_or_else(|_| Bindings::new(), |binding| Bindings::new().with(binding))
282    }
283}
284
285impl SubscriptionSource<ConnectedKinesisBroker> for KinesisStream {
286    type Subscriber = KinesisSubscriber;
287    /// Kinesis moves nothing on its own: it holds no redelivery timer, no delivery counter and
288    /// no dead-letter mechanism, so the framework publishes every copy. A stream is what a
289    /// subscription reads and what a publish writes to, so this descriptor knows where a copy
290    /// goes.
291    type Copies = AddressedCopies;
292
293    fn name(&self) -> &str {
294        self.stream()
295    }
296
297    async fn subscribe(
298        self,
299        connected: &ConnectedKinesisBroker,
300    ) -> Result<KinesisSubscriber, KinesisError> {
301        connected.subscribe_stream(self).await
302    }
303
304    #[cfg(feature = "asyncapi")]
305    fn channel_bindings(&self) -> Bindings {
306        self.binding()
307    }
308}
309
310impl RedeliveryAddressed<ConnectedKinesisBroker> for KinesisStream {
311    /// The stream this descriptor names, which is where a deferred copy and a spent delivery of
312    /// this registration are published.
313    ///
314    /// The copy keeps the record's partition key and lands on that key's shard, at the tip rather
315    /// than in the place the original held, so a deferred record loses its position in the
316    /// stream's order. Where that order matters, declare no cap and let `HandlerOutcome::retry()` hold the
317    /// shard: the checkpoint stops at the record, and the shard is read again from it.
318    ///
319    /// `max_attempts(n)` counts the first delivery, and under it `HandlerOutcome::retry()` becomes
320    /// a published copy as well, because only a copy carries the attempt count forward.
321    /// `dead_letter(name)` is the stream a spent record leaves for, payload and headers as it
322    /// arrived; a cap declared without one checkpoints past the spent record, as `drop()` does.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// # mod demo {
328    /// use std::time::Duration;
329    ///
330    /// use ruststream_kinesis::prelude::*;
331    /// use serde::Deserialize;
332    ///
333    /// #[derive(Deserialize)]
334    /// struct Order {
335    ///     id: u64,
336    /// }
337    ///
338    /// /// Asks for a pause when the order cannot be settled yet, and counts nothing: how many pauses
339    /// /// it gets is the mount site's to say.
340    /// #[subscriber(KinesisStream::new("orders"))]
341    /// async fn reconcile(order: &Order) -> HandlerOutcome {
342    ///     if order.id.is_multiple_of(2) {
343    ///         HandlerOutcome::ack()
344    ///     } else {
345    ///         HandlerOutcome::retry_after(Duration::from_secs(30))
346    ///     }
347    /// }
348    ///
349    /// #[ruststream::app]
350    /// fn app() -> impl App {
351    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(KinesisBroker::new(), |b| {
352    ///         // Four deliveries per order, then the record leaves for a stream an operator reads.
353    ///         b.include(reconcile)
354    ///             .max_attempts(nonzero!(4))
355    ///             .dead_letter("orders.unsettled");
356    ///     })
357    /// }
358    /// # }
359    /// # fn main() {}
360    /// ```
361    fn redelivery_address(
362        &self,
363        _connected: &ConnectedKinesisBroker,
364    ) -> impl Future<Output = Result<RedeliveryAddress, KinesisError>> + Send {
365        // The stream name is the answer, so nothing is asked of the broker and nothing is awaited.
366        ready(Ok(RedeliveryAddress::new(self.stream().to_owned())))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn invalid_descriptors_are_rejected_before_io() {
376        assert!(KinesisStream::new("").validate().is_err());
377        assert!(
378            KinesisStream::new("s")
379                .create_if_missing(0)
380                .validate()
381                .is_err()
382        );
383    }
384}