#![cfg_attr(
not(feature = "testing"),
allow(clippy::infallible_destructuring_match)
)]
use std::future::{Future, ready};
use std::hash::{BuildHasher, Hasher, RandomState};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use aws_config::{BehaviorVersion, Region, SdkConfig};
use aws_sdk_kinesis::client::Waiters;
#[cfg(feature = "testing")]
use ruststream::testing::{Coordinator, InProcess, TestableBroker};
use ruststream::{
AddressedCopies, Broker, ConnectedBroker, DefaultPublish, DescribeServer, ServerSpec, Subscribe,
};
#[cfg(feature = "testing")]
use ruststream::{OutgoingMessage, RawMessage};
use tokio::runtime::Handle;
use tokio::sync::OnceCell;
use crate::claim::ReadStreams;
use crate::clients::RuntimeClients;
use crate::error::{KinesisError, sdk_err};
#[cfg(feature = "testing")]
use crate::in_process::Bus;
use crate::lease::{LeaseStore, MemoryLeaseStore};
use crate::publisher::{KinesisPublish, KinesisPublisher};
use crate::stream::KinesisStream;
use crate::subscriber::KinesisSubscriber;
pub(crate) struct Core {
pub(crate) client: RuntimeClients<aws_sdk_kinesis::Client>,
pub(crate) store: Arc<dyn LeaseStore>,
pub(crate) owner: String,
pub(crate) closed: AtomicBool,
pub(crate) runtime: Handle,
}
impl Core {
pub(crate) fn ensure_open(&self) -> Result<(), KinesisError> {
if self.closed.load(Ordering::Acquire) {
return Err(KinesisError::NotConnected);
}
Ok(())
}
}
impl std::fmt::Debug for Core {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Core")
.field("owner", &self.owner)
.field("closed", &self.closed.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub(crate) enum Link {
Aws(Arc<Core>),
#[cfg(feature = "testing")]
InProcess(Arc<Bus>),
}
#[cfg(not(feature = "testing"))]
const _: () = assert!(size_of::<Link>() == size_of::<Arc<Core>>());
pub(crate) type CoreCell = Arc<OnceCell<Link>>;
#[derive(Clone, Default)]
#[must_use]
pub struct KinesisBroker {
endpoint: Option<String>,
region: Option<String>,
test_credentials: bool,
sdk_config: Option<SdkConfig>,
store: Option<Arc<dyn LeaseStore>>,
owner: Option<String>,
cell: CoreCell,
}
impl std::fmt::Debug for KinesisBroker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KinesisBroker")
.field("endpoint", &self.endpoint)
.finish_non_exhaustive()
}
}
impl KinesisBroker {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: SdkConfig) -> Self {
Self {
sdk_config: Some(config),
..Self::default()
}
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn test_credentials(mut self) -> Self {
self.test_credentials = true;
self
}
pub fn lease_store(mut self, store: Arc<dyn LeaseStore>) -> Self {
self.store = Some(store);
self
}
pub fn owner_id(mut self, owner: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}
#[must_use]
pub fn publisher(&self) -> KinesisPublisher {
KinesisPublisher::new(Arc::clone(&self.cell))
}
}
fn default_owner() -> String {
static SEQ: AtomicU64 = AtomicU64::new(0);
owner_on(
std::env::var("HOSTNAME").ok().as_deref(),
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed),
)
}
fn owner_on(host: Option<&str>, pid: u32, seq: u64) -> String {
let mut hasher = RandomState::new().build_hasher();
hasher.write_u64(seq);
let nonce = hasher.finish();
match host {
Some(host) if !host.is_empty() => format!("rs-{host}-{pid}-{nonce:016x}"),
_ => format!("rs-{pid}-{nonce:016x}"),
}
}
impl Broker for KinesisBroker {
type Error = KinesisError;
type Connected = ConnectedKinesisBroker;
async fn connect(self) -> Result<Self::Connected, Self::Error> {
let link = self
.cell
.get_or_try_init(async || {
let config = if let Some(config) = self.sdk_config.clone() {
config
} else {
let mut loader = aws_config::defaults(BehaviorVersion::latest());
if let Some(endpoint) = &self.endpoint {
loader = loader.endpoint_url(endpoint.clone());
}
if let Some(region) = &self.region {
loader = loader.region(Region::new(region.clone()));
}
if self.test_credentials {
loader = loader.test_credentials();
}
loader.load().await
};
let runtime = Handle::current();
Ok::<_, KinesisError>(Link::Aws(Arc::new(Core {
client: RuntimeClients::homed(config, &runtime),
store: self
.store
.clone()
.unwrap_or_else(|| Arc::new(MemoryLeaseStore::new())),
owner: self.owner.clone().unwrap_or_else(default_owner),
closed: AtomicBool::new(false),
runtime,
})))
})
.await?
.clone();
Ok(ConnectedKinesisBroker {
link,
cell: self.cell,
streams: Arc::default(),
})
}
}
#[cfg(feature = "testing")]
impl InProcess for KinesisBroker {
async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
let link = self
.cell
.get_or_init(async || Link::InProcess(Bus::new()))
.await
.clone();
Ok(ConnectedKinesisBroker {
link,
cell: self.cell,
streams: Arc::default(),
})
}
}
#[cfg(feature = "testing")]
ruststream::register_testable_broker!(KinesisBroker);
impl DescribeServer for KinesisBroker {
fn describe_server(&self) -> ServerSpec {
let host = self.endpoint.as_deref().map_or_else(
|| {
self.region.as_ref().map_or_else(
|| "kinesis.amazonaws.com".to_owned(),
|region| format!("kinesis.{region}.amazonaws.com"),
)
},
ServerSpec::host_from_url,
);
ServerSpec::new(host, "kinesis")
}
}
#[derive(Debug)]
pub struct ConnectedKinesisBroker {
link: Link,
cell: CoreCell,
streams: Arc<ReadStreams>,
}
impl ConnectedKinesisBroker {
#[must_use]
pub fn publisher(&self) -> KinesisPublisher {
KinesisPublisher::new(Arc::clone(&self.cell))
}
pub(crate) fn publisher_keyed(&self, partition_key: Option<Arc<str>>) -> KinesisPublisher {
KinesisPublisher::keyed(Arc::clone(&self.cell), partition_key)
}
pub async fn subscribe_stream(
&self,
descriptor: KinesisStream,
) -> Result<KinesisSubscriber, KinesisError> {
descriptor.validate()?;
let claim = self.streams.claim(descriptor.stream())?;
let core = match &self.link {
Link::Aws(core) => core,
#[cfg(feature = "testing")]
Link::InProcess(bus) => return bus.subscribe(&descriptor, claim),
};
core.ensure_open()?;
if let Some(shards) = descriptor.create_value() {
Self::ensure_stream(core, descriptor.stream(), shards).await?;
} else {
Self::require_stream(core, descriptor.stream()).await?;
}
Ok(KinesisSubscriber::open(core, descriptor, claim).await)
}
async fn require_stream(core: &Core, stream: &str) -> Result<(), KinesisError> {
core.client
.with(|client| client.describe_stream_summary().stream_name(stream))
.send()
.await
.map(|_| ())
.map_err(|e| KinesisError::Stream {
stream: stream.to_owned(),
source: sdk_err(&e),
})
}
async fn ensure_stream(core: &Core, stream: &str, shards: i32) -> Result<(), KinesisError> {
let exists = core
.client
.with(|client| client.describe_stream_summary().stream_name(stream))
.send()
.await
.is_ok();
if !exists {
let created = core
.client
.with(|client| {
client
.create_stream()
.stream_name(stream)
.shard_count(shards)
})
.send()
.await;
if let Err(err) = created {
let raced = err
.as_service_error()
.is_some_and(|e| e.to_string().contains("ResourceInUse"));
if !raced {
return Err(KinesisError::Stream {
stream: stream.to_owned(),
source: sdk_err(&err),
});
}
}
}
core.client
.with(|client| client.wait_until_stream_exists().stream_name(stream))
.wait(Duration::from_mins(1))
.await
.map_err(|e| KinesisError::Stream {
stream: stream.to_owned(),
source: Box::new(e),
})?;
Ok(())
}
}
impl ConnectedBroker for ConnectedKinesisBroker {
type Error = KinesisError;
type Closed = ();
fn shutdown(self) -> impl Future<Output = Result<(), Self::Error>> {
match self.link {
Link::Aws(core) => core.closed.store(true, Ordering::Release),
#[cfg(feature = "testing")]
Link::InProcess(bus) => bus.close(),
}
ready(Ok(()))
}
}
impl Subscribe for ConnectedKinesisBroker {
type Subscriber = KinesisSubscriber;
type Copies = AddressedCopies;
async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
self.subscribe_stream(KinesisStream::new(name)).await
}
}
impl DefaultPublish for ConnectedKinesisBroker {
type Policy = KinesisPublish;
}
#[cfg(feature = "testing")]
impl TestableBroker for ConnectedKinesisBroker {
fn install_coordinator(&self, coordinator: Coordinator) {
if let Link::InProcess(bus) = &self.link {
bus.install(coordinator);
}
}
fn inject(&self, message: OutgoingMessage<'_>) {
let stream = message.name().to_owned();
if let Err(err) = self.bus("inject").inject(message) {
panic!("the injected record to {stream:?} is not one the service takes: {err}");
}
}
fn published(&self, name: &str) -> Vec<RawMessage> {
self.bus("published").published(name)
}
fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
subscriptions
.iter()
.enumerate()
.filter(|(_, stream)| **stream == destination)
.map(|(position, _)| position)
.collect()
}
}
#[cfg(feature = "testing")]
impl ConnectedKinesisBroker {
fn bus(&self, what: &str) -> &Arc<Bus> {
match &self.link {
Link::InProcess(bus) => bus,
Link::Aws(_) => panic!(
"TestableBroker::{what} reached a broker connected with `connect`; the harness \
drives the transport `connect_in_process` produces"
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_hosts_with_the_same_pid_and_sequence_get_different_owner_ids() {
let first = owner_on(None, 1, 0);
let second = owner_on(None, 1, 0);
assert_ne!(first, second);
}
#[test]
fn the_described_server_is_a_host_not_the_configured_url() {
let spec = KinesisBroker::new()
.endpoint("https://kinesis.eu-west-1.amazonaws.com:443")
.describe_server();
assert_eq!(
spec.host.as_deref(),
Some("kinesis.eu-west-1.amazonaws.com:443")
);
}
#[test]
fn credentials_in_an_endpoint_url_stay_out_of_the_description() {
let spec = KinesisBroker::new()
.endpoint("http://key:secret@localhost:4566")
.describe_server();
assert_eq!(spec.host.as_deref(), Some("localhost:4566"));
}
#[test]
fn a_named_region_names_the_regional_endpoint() {
let spec = KinesisBroker::new().region("eu-west-1").describe_server();
assert_eq!(
spec.host.as_deref(),
Some("kinesis.eu-west-1.amazonaws.com")
);
}
}