use faucet_core::Source;
use faucet_source_kinesis::{KinesisCredentials, KinesisSource, KinesisSourceConfig};
use futures::StreamExt;
use std::collections::HashMap;
use testcontainers::{ContainerAsync, runners::AsyncRunner};
use testcontainers_modules::localstack::LocalStack;
async fn start_localstack() -> (ContainerAsync<LocalStack>, String) {
use testcontainers::ImageExt;
let image = LocalStack::default().with_env_var("SERVICES", "kinesis");
let container = image.start().await.expect("localstack start");
let port = container
.get_host_port_ipv4(4566)
.await
.expect("localstack port");
(container, format!("http://127.0.0.1:{port}"))
}
fn test_credentials() -> KinesisCredentials {
KinesisCredentials::AccessKey {
access_key_id: "test".into(),
secret_access_key: "test".into(),
session_token: None,
}
}
async fn raw_client(endpoint: &str) -> aws_sdk_kinesis::Client {
faucet_source_kinesis::build_client(Some("us-east-1"), Some(endpoint), &test_credentials())
.await
.expect("client")
}
async fn await_ready(client: &aws_sdk_kinesis::Client) {
for _ in 0..120 {
if client.list_streams().send().await.is_ok() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
panic!("localstack kinesis never became ready");
}
async fn create_stream(client: &aws_sdk_kinesis::Client, name: &str, shards: i32) {
await_ready(client).await;
client
.create_stream()
.stream_name(name)
.shard_count(shards)
.send()
.await
.expect("create stream");
for _ in 0..60 {
let out = client
.describe_stream_summary()
.stream_name(name)
.send()
.await
.expect("describe");
if out
.stream_description_summary()
.map(|d| d.stream_status() == &aws_sdk_kinesis::types::StreamStatus::Active)
.unwrap_or(false)
{
return;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
panic!("stream {name} never became ACTIVE");
}
async fn put_records(client: &aws_sdk_kinesis::Client, stream: &str, start: usize, n: usize) {
use aws_sdk_kinesis::primitives::Blob;
use aws_sdk_kinesis::types::PutRecordsRequestEntry;
for chunk_start in (start..start + n).step_by(100) {
let entries: Vec<PutRecordsRequestEntry> = (chunk_start
..(chunk_start + 100).min(start + n))
.map(|i| {
PutRecordsRequestEntry::builder()
.partition_key(format!("user-{}", i % 7))
.data(Blob::new(format!("{{\"i\":{i}}}").into_bytes()))
.build()
.expect("entry")
})
.collect();
let out = client
.put_records()
.stream_name(stream)
.set_records(Some(entries))
.send()
.await
.expect("put_records");
assert_eq!(
out.failed_record_count().unwrap_or(0),
0,
"seeding must not throttle"
);
}
}
fn source_config(endpoint: &str, stream: &str) -> KinesisSourceConfig {
let mut cfg = KinesisSourceConfig::new(stream);
cfg.region = Some("us-east-1".into());
cfg.endpoint_url = Some(endpoint.into());
cfg.credentials = test_credentials();
cfg.poll_interval_ms = 200;
cfg.idle_termination_secs = Some(3);
cfg.batch_size = 25;
cfg
}
#[tokio::test(flavor = "multi_thread")]
async fn consumes_across_shards_with_metadata_and_bookmarks() {
let (_container, endpoint) = start_localstack().await;
let client = raw_client(&endpoint).await;
create_stream(&client, "events", 3).await;
put_records(&client, "events", 0, 90).await;
let source = KinesisSource::new(source_config(&endpoint, "events"))
.await
.expect("source");
let ctx: HashMap<String, serde_json::Value> = HashMap::new();
let mut pages = source.stream_pages(&ctx, 25);
let mut records = Vec::new();
let mut last_bookmark = None;
while let Some(page) = pages.next().await {
let page = page.expect("page");
assert!(page.bookmark.is_some(), "every page carries the shard map");
last_bookmark = page.bookmark;
records.extend(page.records);
}
assert_eq!(records.len(), 90, "all seeded records consumed");
let mut by_shard: HashMap<String, Vec<String>> = HashMap::new();
let mut seen_is: Vec<i64> = Vec::new();
for r in &records {
assert!(r["partition_key"].as_str().unwrap().starts_with("user-"));
assert!(r["approximate_arrival_timestamp_ms"].as_i64().unwrap() > 0);
seen_is.push(r["data"]["i"].as_i64().expect("json payload"));
by_shard
.entry(r["shard_id"].as_str().unwrap().to_string())
.or_default()
.push(r["sequence_number"].as_str().unwrap().to_string());
}
seen_is.sort_unstable();
assert_eq!(seen_is, (0..90).collect::<Vec<i64>>(), "no loss, no dupes");
assert!(by_shard.len() > 1, "records spread across shards");
for (shard, seqs) in &by_shard {
let mut sorted = seqs.clone();
sorted.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
assert_eq!(&sorted, seqs, "sequences monotonic within {shard}");
}
let bookmark = last_bookmark.expect("bookmark");
let shards = bookmark["shards"].as_object().expect("shard map");
assert_eq!(shards.len(), by_shard.len());
put_records(&client, "events", 100, 20).await;
let resumed = KinesisSource::new(source_config(&endpoint, "events"))
.await
.expect("resumed source");
resumed
.apply_start_bookmark(bookmark)
.await
.expect("apply bookmark");
let records = resumed.fetch_all().await.expect("resume fetch");
let mut is: Vec<i64> = records
.iter()
.map(|r| r["data"]["i"].as_i64().unwrap())
.collect();
is.sort_unstable();
assert_eq!(
is,
(100..120).collect::<Vec<i64>>(),
"resume replays nothing already bookmarked"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn mid_batch_page_bookmark_is_a_valid_resume_point() {
let (_container, endpoint) = start_localstack().await;
let client = raw_client(&endpoint).await;
create_stream(&client, "midbatch", 1).await;
put_records(&client, "midbatch", 0, 30).await;
let mut cfg = source_config(&endpoint, "midbatch");
cfg.batch_size = 10;
cfg.records_per_request = 10_000; let source = KinesisSource::new(cfg).await.expect("source");
let ctx: HashMap<String, serde_json::Value> = HashMap::new();
let mut pages = source.stream_pages(&ctx, 10);
let first = pages.next().await.expect("a page").expect("ok");
assert_eq!(
first.records.len(),
10,
"first page holds one batch_size chunk"
);
let first_bookmark = first.bookmark.expect("first page carries a bookmark");
while let Some(p) = pages.next().await {
let _ = p.expect("ok");
}
let mut rcfg = source_config(&endpoint, "midbatch");
rcfg.records_per_request = 10_000;
let resumed = KinesisSource::new(rcfg).await.expect("resumed source");
resumed
.apply_start_bookmark(first_bookmark)
.await
.expect("apply bookmark");
let records = resumed.fetch_all().await.expect("resume fetch");
let mut is: Vec<i64> = records
.iter()
.map(|r| r["data"]["i"].as_i64().unwrap())
.collect();
is.sort_unstable();
assert_eq!(
is,
(10..30).collect::<Vec<i64>>(),
"resume from the first page's bookmark must lose none of records 10..30"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn max_messages_and_check_probe() {
let (_container, endpoint) = start_localstack().await;
let client = raw_client(&endpoint).await;
create_stream(&client, "capped", 1).await;
put_records(&client, "capped", 0, 50).await;
let mut cfg = source_config(&endpoint, "capped");
cfg.idle_termination_secs = None;
cfg.max_messages = Some(10);
let source = KinesisSource::new(cfg).await.expect("source");
let records = source.fetch_all().await.expect("fetch");
assert_eq!(records.len(), 10, "stops at max_messages");
let report = source
.check(&faucet_core::CheckContext::default())
.await
.expect("check");
assert_eq!(report.failed_count(), 0);
let mut missing = source_config(&endpoint, "no-such-stream");
missing.max_messages = Some(1);
let missing = KinesisSource::new(missing).await.expect("source");
let report = missing
.check(&faucet_core::CheckContext::default())
.await
.expect("check");
assert_eq!(report.failed_count(), 1, "missing stream fails the probe");
}