1use 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
16pub struct KinesisSource {
18 config: KinesisSourceConfig,
19 client: Client,
20 start_bookmarks: Mutex<Option<ShardBookmarks>>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub(crate) struct EligibleShard {
27 pub id: String,
28 pub closed: bool,
29}
30
31pub(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 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 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 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 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 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); 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 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, }
245 }
246
247 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 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 let open = filter_shards(all.clone(), &[], false);
347 assert_eq!(open.len(), 2);
348 assert!(open.iter().all(|s| !s.closed));
349 assert_eq!(filter_shards(all.clone(), &[], true).len(), 3);
351 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()); 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 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}