use faucet_common_kinesis::KinesisCredentials;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StartPosition {
#[default]
TrimHorizon,
Latest,
AtTimestamp {
timestamp_secs: i64,
},
AtSequenceNumber {
sequence: String,
},
AfterSequenceNumber {
sequence: String,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ValueFormat {
#[default]
Json,
String,
Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct KinesisSourceConfig {
pub stream_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_url: Option<String>,
#[serde(default)]
pub credentials: KinesisCredentials,
#[serde(default)]
pub start_position: StartPosition,
#[serde(default)]
pub shard_ids: Vec<String>,
#[serde(default)]
pub include_closed: bool,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u64,
#[serde(default = "default_records_per_request")]
pub records_per_request: usize,
#[serde(default = "default_shard_concurrency")]
pub shard_concurrency: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_termination_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_messages: Option<usize>,
#[serde(default)]
pub value_format: ValueFormat,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
}
fn default_poll_interval_ms() -> u64 {
1000
}
fn default_records_per_request() -> usize {
500
}
fn default_shard_concurrency() -> usize {
4
}
fn default_batch_size() -> usize {
faucet_core::DEFAULT_BATCH_SIZE
}
impl KinesisSourceConfig {
pub fn new(stream_name: impl Into<String>) -> Self {
Self {
stream_name: stream_name.into(),
region: None,
endpoint_url: None,
credentials: KinesisCredentials::default(),
start_position: StartPosition::default(),
shard_ids: Vec::new(),
include_closed: false,
poll_interval_ms: default_poll_interval_ms(),
records_per_request: default_records_per_request(),
shard_concurrency: default_shard_concurrency(),
idle_termination_secs: None,
max_messages: None,
value_format: ValueFormat::default(),
batch_size: default_batch_size(),
}
}
pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
use faucet_core::FaucetError;
if self.stream_name.trim().is_empty() {
return Err(FaucetError::Config(
"kinesis source: stream_name must not be empty".into(),
));
}
faucet_core::validate_batch_size(self.batch_size)?;
if self.records_per_request == 0 || self.records_per_request > 10_000 {
return Err(FaucetError::Config(format!(
"kinesis source: records_per_request must be 1..=10000 (got {})",
self.records_per_request
)));
}
if self.shard_concurrency == 0 {
return Err(FaucetError::Config(
"kinesis source: shard_concurrency must be at least 1".into(),
));
}
if self.idle_termination_secs.is_none() && self.max_messages.is_none() {
return Err(FaucetError::Config(
"kinesis source: set at least one of idle_termination_secs / max_messages so \
a run can terminate (mirrors the kafka source)"
.into(),
));
}
if self.idle_termination_secs == Some(0) {
return Err(FaucetError::Config(
"kinesis source: idle_termination_secs must be at least 1".into(),
));
}
if self.max_messages == Some(0) {
return Err(FaucetError::Config(
"kinesis source: max_messages must be at least 1".into(),
));
}
Ok(())
}
pub fn poll_interval(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.poll_interval_ms.max(200))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid() -> KinesisSourceConfig {
let mut c = KinesisSourceConfig::new("events");
c.max_messages = Some(100);
c
}
#[test]
fn defaults_are_sensible() {
let c = KinesisSourceConfig::new("events");
assert_eq!(c.start_position, StartPosition::TrimHorizon);
assert_eq!(c.value_format, ValueFormat::Json);
assert_eq!(c.records_per_request, 500);
assert_eq!(c.shard_concurrency, 4);
assert_eq!(c.poll_interval_ms, 1000);
assert!(!c.include_closed);
}
#[test]
fn validation_bounds() {
valid().validate().unwrap();
let mut c = valid();
c.stream_name = " ".into();
assert!(c.validate().is_err());
let mut c = valid();
c.records_per_request = 0;
assert!(c.validate().is_err());
c.records_per_request = 10_001;
assert!(c.validate().is_err());
let mut c = valid();
c.shard_concurrency = 0;
assert!(c.validate().is_err());
let mut c = valid();
c.max_messages = None;
c.idle_termination_secs = None;
let err = c.validate().unwrap_err();
assert!(err.to_string().contains("idle_termination_secs"), "{err}");
let mut c = valid();
c.idle_termination_secs = Some(0);
assert!(c.validate().is_err());
let mut c = valid();
c.max_messages = Some(0);
assert!(c.validate().is_err());
let mut c = valid();
c.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
assert!(c.validate().is_err());
}
#[test]
fn poll_interval_floored_at_200ms() {
let mut c = valid();
c.poll_interval_ms = 50;
assert_eq!(c.poll_interval(), std::time::Duration::from_millis(200));
c.poll_interval_ms = 1500;
assert_eq!(c.poll_interval(), std::time::Duration::from_millis(1500));
}
#[test]
fn start_positions_parse() {
let p: StartPosition = serde_yaml::from_str("type: trim_horizon").unwrap();
assert_eq!(p, StartPosition::TrimHorizon);
let p: StartPosition = serde_yaml::from_str("type: latest").unwrap();
assert_eq!(p, StartPosition::Latest);
let p: StartPosition =
serde_yaml::from_str("type: at_timestamp\ntimestamp_secs: 1716700000").unwrap();
assert_eq!(
p,
StartPosition::AtTimestamp {
timestamp_secs: 1_716_700_000
}
);
let p: StartPosition =
serde_yaml::from_str("type: after_sequence_number\nsequence: '49'").unwrap();
assert_eq!(
p,
StartPosition::AfterSequenceNumber {
sequence: "49".into()
}
);
}
#[test]
fn full_config_parses_from_yaml() {
let yaml = r#"
stream_name: events
region: us-east-1
endpoint_url: http://127.0.0.1:4566
credentials: { type: access_key, config: { access_key_id: a, secret_access_key: b } }
start_position: { type: latest }
shard_ids: ["shardId-000000000000"]
include_closed: true
poll_interval_ms: 500
records_per_request: 1000
shard_concurrency: 2
idle_termination_secs: 5
max_messages: 250
value_format: string
batch_size: 100
"#;
let c: KinesisSourceConfig = serde_yaml::from_str(yaml).unwrap();
c.validate().unwrap();
assert_eq!(c.start_position, StartPosition::Latest);
assert_eq!(c.value_format, ValueFormat::String);
assert!(c.include_closed);
}
}