Skip to main content

faucet_source_kinesis/
stream.rs

1//! The Kinesis `Source` implementation: shard discovery, bounded-concurrency
2//! per-shard workers, page assembly with cumulative per-shard bookmarks, and
3//! idle / max-messages termination.
4
5use crate::config::KinesisSourceConfig;
6use crate::shard::{ShardEvent, run_shard};
7use crate::state::{ShardBookmarks, state_key};
8use aws_sdk_kinesis::Client;
9use faucet_core::{FaucetError, Stream, StreamPage};
10use serde_json::Value;
11use std::pin::Pin;
12use std::sync::Arc;
13use std::sync::Mutex;
14use std::time::Duration;
15
16/// AWS Kinesis Data Streams source. See the crate README for semantics.
17pub struct KinesisSource {
18    config: KinesisSourceConfig,
19    client: Client,
20    /// Bookmark applied by the pipeline before streaming (resume position).
21    start_bookmarks: Mutex<Option<ShardBookmarks>>,
22}
23
24/// One discovered shard eligible for consumption.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct EligibleShard {
27    pub id: String,
28    pub closed: bool,
29}
30
31/// Filter discovered shards per the config: explicit allowlist, and closed
32/// shards only when `include_closed`. Pure.
33pub(crate) fn filter_shards(
34    shards: Vec<EligibleShard>,
35    allowlist: &[String],
36    include_closed: bool,
37) -> Vec<EligibleShard> {
38    shards
39        .into_iter()
40        .filter(|s| allowlist.is_empty() || allowlist.iter().any(|a| a == &s.id))
41        .filter(|s| include_closed || !s.closed)
42        .collect()
43}
44
45impl KinesisSource {
46    /// Create a new Kinesis source. Validates the config and builds the AWS
47    /// client; shard discovery happens lazily at stream time (construction is
48    /// offline).
49    pub async fn new(config: KinesisSourceConfig) -> Result<Self, FaucetError> {
50        config.validate()?;
51        let client = faucet_common_kinesis::build_client(
52            config.region.as_deref(),
53            config.endpoint_url.as_deref(),
54            &config.credentials,
55        )
56        .await?;
57        Ok(Self {
58            config,
59            client,
60            start_bookmarks: Mutex::new(None),
61        })
62    }
63
64    /// Enumerate the stream's shards (paged `ListShards`), applying the
65    /// config's allowlist / closed-shard filters.
66    async fn discover_shards(&self) -> Result<Vec<EligibleShard>, FaucetError> {
67        let mut all = Vec::new();
68        let mut next_token: Option<String> = None;
69        loop {
70            let mut req = self.client.list_shards();
71            // ListShards takes either the stream name or a pagination token,
72            // never both.
73            req = match &next_token {
74                Some(token) => req.next_token(token),
75                None => req.stream_name(&self.config.stream_name),
76            };
77            let out = req.send().await.map_err(|e| {
78                FaucetError::Source(format!(
79                    "kinesis: ListShards for stream '{}' failed: {}",
80                    self.config.stream_name,
81                    e.into_service_error()
82                ))
83            })?;
84            for s in out.shards() {
85                let closed = s
86                    .sequence_number_range()
87                    .and_then(|r| r.ending_sequence_number())
88                    .is_some();
89                all.push(EligibleShard {
90                    id: s.shard_id().to_string(),
91                    closed,
92                });
93            }
94            next_token = out.next_token().map(str::to_string);
95            if next_token.is_none() {
96                break;
97            }
98        }
99        let eligible = filter_shards(all, &self.config.shard_ids, self.config.include_closed);
100        if eligible.is_empty() {
101            return Err(FaucetError::Source(format!(
102                "kinesis: stream '{}' has no eligible shards (shard_ids filter: {:?}, \
103                 include_closed: {})",
104                self.config.stream_name, self.config.shard_ids, self.config.include_closed
105            )));
106        }
107        Ok(eligible)
108    }
109}
110
111#[faucet_core::async_trait]
112impl faucet_core::Source for KinesisSource {
113    /// Drain the stream to termination (`idle_termination_secs` /
114    /// `max_messages` — at least one is enforced at construction) and return
115    /// every record.
116    async fn fetch_with_context(
117        &self,
118        context: &std::collections::HashMap<String, Value>,
119    ) -> Result<Vec<Value>, FaucetError> {
120        use futures::StreamExt;
121        let mut pages = self.stream_pages(context, self.config.batch_size);
122        let mut all = Vec::new();
123        while let Some(page) = pages.next().await {
124            all.extend(page?.records);
125        }
126        Ok(all)
127    }
128
129    fn stream_pages<'a>(
130        &'a self,
131        _context: &'a std::collections::HashMap<String, Value>,
132        _batch_size: usize,
133    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
134        let batch_size = self.config.batch_size;
135        let chunk = if batch_size == 0 {
136            usize::MAX
137        } else {
138            batch_size
139        };
140
141        Box::pin(async_stream::try_stream! {
142            let shards = self.discover_shards().await?;
143            let bookmarks = self
144                .start_bookmarks
145                .lock()
146                .expect("bookmark mutex poisoned")
147                .clone()
148                .unwrap_or_default();
149
150            // Workers push decoded chunks; the semaphore bounds concurrency.
151            let (tx, mut rx) = tokio::sync::mpsc::channel::<ShardEvent>(16);
152            let semaphore = Arc::new(tokio::sync::Semaphore::new(
153                self.config.shard_concurrency,
154            ));
155            let total_shards = shards.len();
156            let mut handles = Vec::with_capacity(total_shards);
157            for shard in &shards {
158                let permit_sem = semaphore.clone();
159                let client = self.client.clone();
160                let config = self.config.clone();
161                let shard_id = shard.id.clone();
162                let bookmark = bookmarks.get(&shard_id).map(str::to_string);
163                let tx = tx.clone();
164                handles.push(tokio::spawn(async move {
165                    let _permit = permit_sem
166                        .acquire_owned()
167                        .await
168                        .expect("semaphore closed");
169                    run_shard(client, config, shard_id, bookmark, tx).await;
170                }));
171            }
172            drop(tx); // the channel closes when every worker exits
173
174            let idle = self
175                .config
176                .idle_termination_secs
177                .map(Duration::from_secs);
178            let max_messages = self.config.max_messages;
179            let mut cumulative = bookmarks;
180            let mut buffer: Vec<Value> = Vec::new();
181            let mut total = 0usize;
182            let mut done_shards = 0usize;
183            let mut failure: Option<FaucetError> = None;
184
185            'consume: loop {
186                let event = match idle {
187                    Some(window) => match tokio::time::timeout(window, rx.recv()).await {
188                        Ok(ev) => ev,
189                        Err(_) => {
190                            tracing::info!(
191                                stream = %self.config.stream_name,
192                                idle_secs = window.as_secs(),
193                                "kinesis: idle termination reached"
194                            );
195                            break 'consume;
196                        }
197                    },
198                    None => rx.recv().await,
199                };
200                match event {
201                    Some(ShardEvent::Records { shard_id, records }) => {
202                        for (sequence, record) in records {
203                            buffer.push(record);
204                            total += 1;
205                            // Advance the shard bookmark to THIS record's sequence
206                            // before any page emit, so a yielded page's bookmark
207                            // never runs ahead of the records it actually carries
208                            // (audit #321 C2 — the old code jumped to the batch's
209                            // last sequence up front, so a mid-batch page emit
210                            // persisted a bookmark past un-emitted records → silent
211                            // loss on resume).
212                            cumulative.advance(&shard_id, &sequence);
213                            if buffer.len() >= chunk {
214                                let page = std::mem::take(&mut buffer);
215                                yield StreamPage {
216                                    records: page,
217                                    bookmark: Some(cumulative.to_value()),
218                                };
219                            }
220                            if let Some(max) = max_messages
221                                && total >= max
222                            {
223                                tracing::info!(
224                                    stream = %self.config.stream_name,
225                                    max,
226                                    "kinesis: max_messages reached"
227                                );
228                                break 'consume;
229                            }
230                        }
231                    }
232                    Some(ShardEvent::Done { shard_id }) => {
233                        done_shards += 1;
234                        tracing::debug!(shard = %shard_id, done_shards, total_shards,
235                            "kinesis: shard fully consumed");
236                    }
237                    Some(ShardEvent::Failed { shard_id, error }) => {
238                        tracing::error!(shard = %shard_id, error = %error,
239                            "kinesis: shard worker failed");
240                        failure = Some(error);
241                        break 'consume;
242                    }
243                    None => break 'consume, // every worker exited
244                }
245            }
246
247            // Stop the workers (drops their send side) and surface results.
248            rx.close();
249            for h in handles {
250                h.abort();
251            }
252            if let Some(error) = failure {
253                Err(error)?;
254            }
255            if !buffer.is_empty() || !cumulative.shards.is_empty() {
256                yield StreamPage {
257                    records: buffer,
258                    bookmark: Some(cumulative.to_value()),
259                };
260            }
261            tracing::info!(
262                stream = %self.config.stream_name,
263                records = total,
264                shards = total_shards,
265                "kinesis source stream complete"
266            );
267        })
268    }
269
270    fn config_schema(&self) -> Value {
271        serde_json::to_value(faucet_core::schema_for!(KinesisSourceConfig))
272            .expect("schema serialization")
273    }
274
275    fn state_key(&self) -> Option<String> {
276        Some(state_key(&self.config.stream_name))
277    }
278
279    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
280        *self
281            .start_bookmarks
282            .lock()
283            .expect("bookmark mutex poisoned") = Some(ShardBookmarks::from_value(&bookmark));
284        Ok(())
285    }
286
287    fn connector_name(&self) -> &'static str {
288        "kinesis"
289    }
290
291    fn dataset_uri(&self) -> String {
292        format!(
293            "kinesis://{}/{}",
294            self.config.region.as_deref().unwrap_or("default"),
295            self.config.stream_name
296        )
297    }
298
299    /// Side-effect-free probe: `DescribeStreamSummary` (no records consumed,
300    /// no iterator opened). The default first-page probe could block for the
301    /// full idle window on a quiet stream.
302    async fn check(
303        &self,
304        ctx: &faucet_core::CheckContext,
305    ) -> Result<faucet_core::CheckReport, FaucetError> {
306        use faucet_core::{CheckReport, Probe};
307        let start = std::time::Instant::now();
308        let fut = self
309            .client
310            .describe_stream_summary()
311            .stream_name(&self.config.stream_name)
312            .send();
313        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
314            Err(_) => Probe::fail("describe_stream", start.elapsed(), "timed out"),
315            Ok(Ok(_)) => Probe::pass("describe_stream", start.elapsed()),
316            Ok(Err(e)) => Probe::fail(
317                "describe_stream",
318                start.elapsed(),
319                e.into_service_error().to_string(),
320            ),
321        };
322        Ok(CheckReport::single(probe))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use faucet_core::Source as _;
330
331    fn shard(id: &str, closed: bool) -> EligibleShard {
332        EligibleShard {
333            id: id.to_string(),
334            closed,
335        }
336    }
337
338    #[test]
339    fn shard_filtering_applies_allowlist_and_closed_rules() {
340        let all = vec![
341            shard("shardId-000000000000", false),
342            shard("shardId-000000000001", true),
343            shard("shardId-000000000002", false),
344        ];
345        // Default: open shards only.
346        let open = filter_shards(all.clone(), &[], false);
347        assert_eq!(open.len(), 2);
348        assert!(open.iter().all(|s| !s.closed));
349        // include_closed keeps everything.
350        assert_eq!(filter_shards(all.clone(), &[], true).len(), 3);
351        // Allowlist narrows.
352        let picked = filter_shards(all, &["shardId-000000000001".to_string()], true);
353        assert_eq!(picked.len(), 1);
354        assert_eq!(picked[0].id, "shardId-000000000001");
355    }
356
357    async fn offline_source(mut config: KinesisSourceConfig) -> KinesisSource {
358        config.endpoint_url = Some("http://127.0.0.1:1".into()); // unroutable
359        config.region = Some("us-east-1".into());
360        config.credentials = faucet_common_kinesis::KinesisCredentials::AccessKey {
361            access_key_id: "test".into(),
362            secret_access_key: "test".into(),
363            session_token: None,
364        };
365        KinesisSource::new(config).await.expect("source builds")
366    }
367
368    #[tokio::test]
369    async fn new_validates_config() {
370        // No termination knob → rejected.
371        let err = match KinesisSource::new(KinesisSourceConfig::new("events")).await {
372            Err(e) => e,
373            Ok(_) => panic!("config without a termination knob must be rejected"),
374        };
375        assert!(err.to_string().contains("idle_termination_secs"), "{err}");
376    }
377
378    #[tokio::test]
379    async fn identity_overrides() {
380        let mut cfg = KinesisSourceConfig::new("events");
381        cfg.max_messages = Some(10);
382        let source = offline_source(cfg).await;
383        assert_eq!(source.connector_name(), "kinesis");
384        assert_eq!(source.dataset_uri(), "kinesis://us-east-1/events");
385        assert_eq!(source.state_key().as_deref(), Some("kinesis:events"));
386        assert!(!source.supports_exactly_once());
387        let schema = source.config_schema();
388        assert!(
389            schema["properties"]["stream_name"].is_object(),
390            "schema exposes config fields"
391        );
392    }
393
394    #[tokio::test]
395    async fn apply_start_bookmark_round_trips() {
396        let mut cfg = KinesisSourceConfig::new("events");
397        cfg.max_messages = Some(10);
398        let source = offline_source(cfg).await;
399        source
400            .apply_start_bookmark(serde_json::json!({
401                "shards": {"shardId-000000000000": "42"}
402            }))
403            .await
404            .unwrap();
405        let held = source.start_bookmarks.lock().unwrap().clone().unwrap();
406        assert_eq!(held.get("shardId-000000000000"), Some("42"));
407    }
408
409    #[tokio::test]
410    async fn stream_pages_surfaces_discovery_errors() {
411        use futures::StreamExt;
412        let mut cfg = KinesisSourceConfig::new("events");
413        cfg.max_messages = Some(10);
414        let source = offline_source(cfg).await;
415        let ctx = std::collections::HashMap::new();
416        let mut pages = source.stream_pages(&ctx, 10);
417        let first = pages.next().await.expect("one item");
418        let err = first.unwrap_err();
419        assert!(err.to_string().contains("ListShards"), "{err}");
420    }
421
422    #[tokio::test]
423    async fn check_probe_fails_cleanly_offline() {
424        let mut cfg = KinesisSourceConfig::new("events");
425        cfg.max_messages = Some(10);
426        let source = offline_source(cfg).await;
427        let report = source
428            .check(&faucet_core::CheckContext {
429                timeout: Duration::from_millis(500),
430            })
431            .await
432            .unwrap();
433        assert_eq!(
434            report.failed_count(),
435            1,
436            "unreachable endpoint → fail probe"
437        );
438    }
439}