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}