#![doc = include_str!("README.md")]
pub(crate) mod event_receiver;
use crate::{
common::{recoverable::RecoverableConnection, ManagementInstance},
error::Result,
models::{ConsumerClientDetails, EventHubPartitionProperties, EventHubProperties},
EventHubsError, RetryOptions,
};
use azure_core::{credentials::TokenCredential, http::Url, time::Duration, Uuid};
#[cfg(test)]
use azure_core_amqp::AmqpError;
use azure_core_amqp::{
message::AmqpSourceFilter, AmqpDescribed, AmqpOrderedMap, AmqpReceiverOptions, AmqpSource,
AmqpSymbol, AmqpTransport, AmqpValue, ReceiverCreditMode,
};
pub use event_receiver::EventReceiver;
use std::{
default::Default,
fmt::Debug,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use tracing::{info, trace};
pub struct ConsumerClient {
recoverable_connection: Arc<RecoverableConnection>,
consumer_group: String,
eventhub: String,
endpoint: Url,
instance_id: Option<String>,
}
struct ConsumerClientOptions {
application_id: Option<String>,
instance_id: Option<String>,
retry_options: Option<RetryOptions>,
custom_endpoint: Option<Url>,
cbs_token_type: Option<&'static str>,
transport: AmqpTransport,
}
impl ConsumerClient {
pub fn builder() -> builders::ConsumerClientBuilder {
builders::ConsumerClientBuilder::new()
}
fn new(
fully_qualified_namespace: &str,
eventhub_name: String,
consumer_group: Option<String>,
credential: Arc<dyn TokenCredential>,
options: ConsumerClientOptions,
) -> Result<Self> {
let consumer_group = consumer_group.unwrap_or("$Default".into());
let url = format!(
"amqps://{}/{}/ConsumerGroups/{}",
fully_qualified_namespace, eventhub_name, consumer_group
);
let url = Url::parse(&url).map_err(azure_core::Error::from)?;
trace!("Creating consumer client for {url}.");
let retry_options = options.retry_options.unwrap_or_default();
Ok(Self {
instance_id: options.instance_id,
recoverable_connection: RecoverableConnection::new(
url.clone(),
options.application_id,
options.custom_endpoint,
options.transport,
credential,
retry_options,
options.cbs_token_type,
),
eventhub: eventhub_name,
endpoint: url,
consumer_group,
})
}
pub async fn close(self) -> Result<()> {
let connection_id = self.recoverable_connection.get_connection_id().to_string();
trace!(
connection_id = %connection_id,
source_url = %self.endpoint,
"Closing consumer client."
);
self.recoverable_connection.close_connection().await?;
trace!(
connection_id = %connection_id,
source_url = %self.endpoint,
"Closed consumer connection."
);
Ok(())
}
#[cfg(test)]
pub fn force_error(&self, error: AmqpError) -> Result<()> {
self.recoverable_connection.force_error(error)
}
#[cfg(test)]
pub(crate) fn new_unconnected(
fully_qualified_namespace: &str,
eventhub_name: &str,
credential: Arc<dyn TokenCredential>,
) -> Result<Self> {
Self::new(
fully_qualified_namespace,
eventhub_name.to_string(),
None,
credential,
ConsumerClientOptions {
application_id: None,
instance_id: None,
retry_options: None,
custom_endpoint: None,
cbs_token_type: None,
transport: AmqpTransport::default(),
},
)
}
#[cfg(test)]
pub(crate) fn recoverable_connection(&self) -> Arc<RecoverableConnection> {
self.recoverable_connection.clone()
}
pub(crate) fn get_details(&self) -> Result<ConsumerClientDetails> {
Ok(ConsumerClientDetails {
eventhub_name: self.eventhub.clone(),
consumer_group: self.consumer_group.clone(),
fully_qualified_namespace: self
.endpoint
.host()
.ok_or_else(|| {
EventHubsError::with_message("Could not find host in consumer client")
})?
.to_string(),
client_id: self.recoverable_connection.get_connection_id().to_string(),
})
}
#[tracing::instrument(
level = "debug",
skip_all,
fields(
connection_id = %self.recoverable_connection.get_connection_id(),
partition_id = %partition_id,
consumer_group = %self.consumer_group,
eventhub = %self.eventhub,
),
err,
)]
pub async fn open_receiver_on_partition(
&self,
partition_id: String,
options: Option<OpenReceiverOptions>,
) -> Result<EventReceiver> {
let options = options.unwrap_or_default();
let receiver_name = self
.instance_id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());
let start_expression = StartPosition::start_expression(&options.start_position);
trace!(
partition_id = %partition_id,
source_url = %self.endpoint,
"Opening receiver on partition."
);
let source_url = format!("{}/Partitions/{}", self.endpoint, partition_id);
let source_url = Url::parse(&source_url).map_err(azure_core::Error::from)?;
let message_source = AmqpSource::builder()
.with_address(source_url.to_string())
.add_to_filter(
AmqpSourceFilter::selector_filter().description().into(),
Box::new(AmqpDescribed::new(
AmqpSourceFilter::selector_filter().code(),
start_expression,
)),
)
.build();
let mut receiver_properties: AmqpOrderedMap<AmqpSymbol, AmqpValue> =
vec![("com.microsoft.com:receiver-name", receiver_name.clone())]
.into_iter()
.map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v)))
.collect();
if let Some(owner_level) = options.owner_level {
receiver_properties.insert("com.microsoft:epoch".into(), AmqpValue::from(owner_level));
}
let receiver_options = AmqpReceiverOptions {
name: Some(receiver_name),
properties: Some(receiver_properties),
credit_mode: Some(ReceiverCreditMode::Auto(options.prefetch.unwrap_or(300))),
auto_accept: true,
..Default::default()
};
info!(
partition_id = %partition_id,
consumer_group = %self.consumer_group,
eventhub = %self.eventhub,
source_url = %source_url,
"Receiver attached on partition."
);
Ok(EventReceiver::new(
self.recoverable_connection.clone(),
receiver_options,
message_source,
source_url,
partition_id,
options.receive_timeout,
))
}
pub async fn get_eventhub_properties(&self) -> Result<EventHubProperties> {
self.get_management_instance()
.await?
.get_eventhub_properties(&self.eventhub)
.await
}
pub async fn get_partition_properties(
&self,
partition_id: &str,
) -> Result<EventHubPartitionProperties> {
self.get_management_instance()
.await?
.get_eventhub_partition_properties(&self.eventhub, partition_id)
.await
}
async fn get_management_instance(&self) -> Result<Arc<ManagementInstance>> {
Ok(ManagementInstance::new(self.recoverable_connection.clone()))
}
async fn ensure_connection(&self) -> azure_core_amqp::Result<()> {
self.recoverable_connection.ensure_connection().await?;
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct OpenReceiverOptions {
pub owner_level: Option<i64>,
pub prefetch: Option<u32>,
pub start_position: Option<StartPosition>,
pub receive_timeout: Option<Duration>,
}
impl OpenReceiverOptions {}
#[derive(Debug, Default, PartialEq, Clone)]
pub enum StartLocation {
Offset(String),
SequenceNumber(i64),
EnqueuedTime(SystemTime),
Earliest,
#[default]
Latest,
}
pub(crate) const ENQUEUED_TIME_ANNOTATION: &str = "amqp.annotation.x-opt-enqueued-time";
pub(crate) const OFFSET_ANNOTATION: &str = "amqp.annotation.x-opt-offset";
pub(crate) const SEQUENCE_NUMBER_ANNOTATION: &str = "amqp.annotation.x-opt-sequence-number";
#[derive(Debug, PartialEq, Clone, Default)]
pub struct StartPosition {
pub location: StartLocation,
pub inclusive: bool,
}
impl StartPosition {
pub(crate) fn start_expression(position: &Option<StartPosition>) -> String {
if let Some(position) = position {
let mut greater_than: &str = ">";
if position.inclusive {
greater_than = ">=";
}
match &position.location {
StartLocation::Offset(offset) => {
format!("{} {}'{}'", OFFSET_ANNOTATION, greater_than, offset)
}
StartLocation::SequenceNumber(sequence_number) => {
format!(
"{} {}'{}'",
SEQUENCE_NUMBER_ANNOTATION, greater_than, sequence_number
)
}
StartLocation::EnqueuedTime(enqueued_time) => {
let enqueued_time = enqueued_time
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis();
format!(
"{} {}'{}'",
ENQUEUED_TIME_ANNOTATION, greater_than, enqueued_time
)
}
StartLocation::Earliest => "amqp.annotation.x-opt-offset > '-1'".to_string(),
StartLocation::Latest => "amqp.annotation.x-opt-offset > '@latest'".to_string(),
}
} else {
"amqp.annotation.x-opt-offset > '@latest'".to_string()
}
}
}
pub mod builders {
use super::*;
use crate::{
common::{
connection_string::{resolve_eventhub, ConnectionString},
sas_credential::SasCredential,
SAS_TOKEN_TYPE,
},
Result,
};
use azure_core_amqp::AmqpTransport;
use std::sync::Arc;
#[derive(Default)]
pub struct ConsumerClientBuilder {
consumer_group: Option<String>,
application_id: Option<String>,
instance_id: Option<String>,
retry_options: Option<RetryOptions>,
custom_endpoint: Option<String>,
transport: Option<AmqpTransport>,
}
impl ConsumerClientBuilder {
pub(super) fn new() -> Self {
Self {
..Default::default()
}
}
pub fn with_application_id(mut self, application_id: String) -> Self {
self.application_id = Some(application_id);
self
}
pub fn with_consumer_group(mut self, consumer_group: String) -> Self {
self.consumer_group = Some(consumer_group);
self
}
pub fn with_instance_id(mut self, instance_id: String) -> Self {
self.instance_id = Some(instance_id);
self
}
pub fn with_retry_options(mut self, retry_options: RetryOptions) -> Self {
self.retry_options = Some(retry_options);
self
}
pub fn with_custom_endpoint(mut self, endpoint: String) -> Self {
self.custom_endpoint = Some(endpoint);
self
}
pub fn with_transport(mut self, transport: AmqpTransport) -> Self {
self.transport = Some(transport);
self
}
pub(crate) fn transport(&self) -> AmqpTransport {
self.transport.unwrap_or_default()
}
pub async fn open(
self,
fully_qualified_namespace: &str,
eventhub_name: String,
credential: Arc<dyn azure_core::credentials::TokenCredential>,
) -> Result<super::ConsumerClient> {
let transport = self.transport();
let custom_endpoint = match self.custom_endpoint {
Some(endpoint) => Some(Url::parse(&endpoint).map_err(azure_core::Error::from)?),
None => None,
};
trace!("Opening consumer client on {fully_qualified_namespace}.");
let consumer = super::ConsumerClient::new(
fully_qualified_namespace,
eventhub_name,
self.consumer_group,
credential,
ConsumerClientOptions {
application_id: self.application_id,
instance_id: self.instance_id,
retry_options: self.retry_options,
custom_endpoint,
cbs_token_type: None,
transport,
},
)?;
consumer.ensure_connection().await?;
Ok(consumer)
}
pub async fn open_with_connection_string(
self,
connection_string: &str,
eventhub: Option<&str>,
) -> Result<super::ConsumerClient> {
let transport = self.transport();
let connection_string: ConnectionString = connection_string.parse()?;
let eventhub = resolve_eventhub(&connection_string, eventhub)?;
let credential = Arc::new(SasCredential::from_connection_string(
&connection_string,
&eventhub,
)?);
let custom_endpoint = match self.custom_endpoint {
Some(endpoint) => Some(Url::parse(&endpoint).map_err(azure_core::Error::from)?),
None => None,
};
let consumer = super::ConsumerClient::new(
&connection_string.fully_qualified_namespace,
eventhub,
self.consumer_group,
credential,
ConsumerClientOptions {
application_id: self.application_id,
instance_id: self.instance_id,
retry_options: self.retry_options,
custom_endpoint,
cbs_token_type: Some(SAS_TOKEN_TYPE),
transport,
},
)?;
consumer.ensure_connection().await?;
Ok(consumer)
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use crate::{
common::tests::force_errors, models::EventData, ConsumerClient, EventDataBatchOptions,
ProducerClient, Result, StartLocation, StartPosition,
};
use azure_core::{sleep::sleep, time::Duration};
use azure_core_amqp::{error::AmqpErrorKind, AmqpError, AmqpTransport};
use azure_core_test::{recorded, TestContext};
use futures::stream::StreamExt;
use std::{
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
#[test]
fn builder_reads_the_transport_through_one_helper() {
assert_eq!(
ConsumerClient::builder()
.with_transport(AmqpTransport::WebSocket)
.transport(),
AmqpTransport::WebSocket
);
assert_eq!(
ConsumerClient::builder()
.with_transport(AmqpTransport::Tcp)
.transport(),
AmqpTransport::Tcp
);
assert_eq!(ConsumerClient::builder().transport(), AmqpTransport::Tcp);
}
use tracing::info;
#[recorded::test]
async fn test_start_position_builder_with_sequence_number(_ctx: TestContext) -> Result<()> {
let sequence_number = 12345i64;
let start_position = StartPosition {
location: StartLocation::SequenceNumber(sequence_number),
..Default::default()
};
assert_eq!(
start_position.location,
StartLocation::SequenceNumber(sequence_number)
);
assert_eq!(
StartPosition::start_expression(&Some(start_position)),
"amqp.annotation.x-opt-sequence-number >'12345'"
);
let start_position = StartPosition {
location: StartLocation::SequenceNumber(sequence_number),
inclusive: true,
};
assert_eq!(
StartPosition::start_expression(&Some(start_position)),
"amqp.annotation.x-opt-sequence-number >='12345'"
);
Ok(())
}
#[recorded::test]
async fn test_start_position_builder_with_enqueued_time(_ctx: TestContext) -> Result<()> {
let enqueued_time = SystemTime::now();
let start_position = StartPosition {
location: StartLocation::EnqueuedTime(enqueued_time),
..Default::default()
};
info!("enqueued_time: {:?}", enqueued_time);
info!(
"enqueued_time: {:?}",
enqueued_time.duration_since(UNIX_EPOCH)
);
info!(
"enqueued_time: {:?}",
enqueued_time
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
);
assert_eq!(
start_position.location,
StartLocation::EnqueuedTime(enqueued_time)
);
assert!(!start_position.inclusive);
assert_eq!(
StartPosition::start_expression(&Some(start_position)),
format!(
"amqp.annotation.x-opt-enqueued-time >'{}'",
enqueued_time
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
)
);
let start_position = StartPosition {
location: StartLocation::EnqueuedTime(enqueued_time),
inclusive: true,
};
assert_eq!(
StartPosition::start_expression(&Some(start_position)),
format!(
"amqp.annotation.x-opt-enqueued-time >='{}'",
enqueued_time
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
)
);
Ok(())
}
#[recorded::test]
async fn test_start_position_builder_with_offset(_ctx: TestContext) -> Result<()> {
let offset = "12345".to_string();
let start_position = StartPosition {
location: StartLocation::Offset(offset.clone()),
..Default::default()
};
assert_eq!(
start_position.location,
StartLocation::Offset(offset.clone())
);
assert_eq!(
"amqp.annotation.x-opt-offset >'12345'",
StartPosition::start_expression(&Some(start_position)),
);
let start_position = StartPosition {
location: StartLocation::Offset(offset.clone()),
inclusive: true,
};
assert_eq!(
"amqp.annotation.x-opt-offset >='12345'",
StartPosition::start_expression(&Some(start_position)),
);
Ok(())
}
#[recorded::test]
async fn test_start_position_builder_inclusive(_ctx: TestContext) -> Result<()> {
let start_position = StartPosition {
inclusive: true,
..Default::default()
};
assert!(start_position.inclusive);
let start_position = StartPosition::default();
assert!(!start_position.inclusive);
Ok(())
}
#[recorded::test(live)]
async fn force_errors_consumer_properties_link(ctx: TestContext) -> Result<()> {
const TEST_NAME: &str = "force_errors_consumer_properties_link";
let recording = ctx.recording();
let host = recording.var("EVENTHUBS_HOST", None);
let eventhub = recording.var("EVENTHUB_NAME", None);
let credential = recording.credential();
let consumer = Arc::new(
ConsumerClient::builder()
.with_application_id(TEST_NAME.to_string())
.open(host.as_str(), eventhub, credential.clone())
.await?,
);
force_errors(
consumer.clone(),
|consumer: Arc<ConsumerClient>| {
let consumer = consumer.clone();
async move {
loop {
consumer.get_eventhub_properties().await.unwrap();
}
}
},
|consumer| {
consumer
.force_error(azure_core_amqp::AmqpError::from(
AmqpErrorKind::LinkClosedByRemote(Box::new(azure_core::error::Error::new(
azure_core::error::ErrorKind::Other,
"Forced error",
))),
))
.unwrap();
},
Duration::seconds(10), Duration::seconds(20), )
.await?;
if let Ok(consumer) = Arc::try_unwrap(consumer) {
consumer.close().await?;
} else {
panic!("Consumer client has unresolved references.");
}
Ok(())
}
#[recorded::test(live)]
async fn force_errors_consumer_properties_session(ctx: TestContext) -> Result<()> {
const TEST_NAME: &str = "force_errors_consumer_properties_session";
let recording = ctx.recording();
let host = recording.var("EVENTHUBS_HOST", None);
let eventhub = recording.var("EVENTHUB_NAME", None);
let credential = recording.credential();
let consumer = Arc::new(
ConsumerClient::builder()
.with_application_id(TEST_NAME.to_string())
.open(host.as_str(), eventhub, credential.clone())
.await?,
);
force_errors(
consumer.clone(),
|consumer: Arc<ConsumerClient>| {
let consumer = consumer.clone();
async move {
loop {
consumer.get_eventhub_properties().await.unwrap();
}
}
},
|consumer| {
consumer
.force_error(azure_core_amqp::AmqpError::from(
AmqpErrorKind::SessionClosedByRemote(Box::new(
azure_core::error::Error::new(
azure_core::error::ErrorKind::Other,
"Forced error",
),
)),
))
.unwrap();
},
Duration::seconds(10), Duration::seconds(20), )
.await?;
if let Ok(consumer) = Arc::try_unwrap(consumer) {
consumer.close().await?;
} else {
panic!("Consumer client has unresolved references.");
}
Ok(())
}
#[recorded::test(live)]
async fn force_errors_consumer_properties_connection(ctx: TestContext) -> Result<()> {
const TEST_NAME: &str = "force_errors_consumer_properties_connection";
let recording = ctx.recording();
let host = recording.var("EVENTHUBS_HOST", None);
let eventhub = recording.var("EVENTHUB_NAME", None);
let credential = recording.credential();
let consumer = Arc::new(
ConsumerClient::builder()
.with_application_id(TEST_NAME.to_string())
.open(host.as_str(), eventhub, credential.clone())
.await?,
);
force_errors(
consumer.clone(),
|consumer: Arc<ConsumerClient>| {
let consumer = consumer.clone();
async move {
loop {
consumer.get_eventhub_properties().await.unwrap();
}
}
},
|consumer| {
consumer
.force_error(azure_core_amqp::AmqpError::from(
AmqpErrorKind::ConnectionClosedByRemote(Box::new(
azure_core::error::Error::new(
azure_core::error::ErrorKind::Other,
"Forced error",
),
)),
))
.unwrap();
},
Duration::seconds(10), Duration::seconds(20), )
.await?;
Ok(())
}
const RECEIVE_TEST_PARTITION: &str = "0";
async fn run_receive_recovery(
ctx: &TestContext,
test_name: &str,
make_error: fn() -> AmqpError,
) -> Result<()> {
let recording = ctx.recording();
let host = recording.var("EVENTHUBS_HOST", None);
let eventhub = recording.var("EVENTHUB_NAME", None);
let credential = recording.credential();
let producer = Arc::new(
ProducerClient::builder()
.with_application_id(format!("{test_name}-feed"))
.open(host.as_str(), eventhub.as_str(), credential.clone())
.await?,
);
let feed = tokio::spawn({
let producer = producer.clone();
async move {
loop {
let batch = producer
.create_batch(Some(EventDataBatchOptions {
partition_id: Some(RECEIVE_TEST_PARTITION.to_string()),
..Default::default()
}))
.await
.expect("feed: create_batch");
batch
.try_add_event_data(
EventData::builder().with_body(b"heartbeat").build(),
None,
)
.expect("feed: add heartbeat event");
producer
.send_batch(batch, None)
.await
.expect("feed: send_batch");
sleep(Duration::milliseconds(250)).await;
}
}
});
let consumer = Arc::new(
ConsumerClient::builder()
.with_application_id(test_name.to_string())
.open(host.as_str(), eventhub, credential.clone())
.await?,
);
force_errors(
consumer.clone(),
|consumer: Arc<ConsumerClient>| {
let consumer = consumer.clone();
async move {
let receiver = consumer
.open_receiver_on_partition(RECEIVE_TEST_PARTITION.to_string(), None)
.await
.unwrap();
let mut stream = std::pin::pin!(receiver.stream_events());
while let Some(event) = stream.next().await {
event.unwrap();
}
}
},
move |consumer: Arc<ConsumerClient>| {
info!("Forcing error on consumer receiver");
consumer.force_error(make_error()).unwrap();
},
Duration::seconds(10), Duration::seconds(30), )
.await?;
feed.abort();
match feed.await {
Ok(()) => {}
Err(err) if err.is_cancelled() => {}
Err(err) => std::panic::resume_unwind(err.into_panic()),
}
if let Ok(producer) = Arc::try_unwrap(producer) {
producer.close().await?;
}
if let Ok(consumer) = Arc::try_unwrap(consumer) {
consumer.close().await?;
}
Ok(())
}
#[recorded::test(live)]
async fn force_errors_receive_link(ctx: TestContext) -> Result<()> {
run_receive_recovery(&ctx, "force_errors_receive_link", || {
AmqpError::from(AmqpErrorKind::LinkClosedByRemote(Box::new(
azure_core::error::Error::new(azure_core::error::ErrorKind::Other, "Forced error"),
)))
})
.await
}
#[recorded::test(live)]
async fn force_errors_receive_session(ctx: TestContext) -> Result<()> {
run_receive_recovery(&ctx, "force_errors_receive_session", || {
AmqpError::from(AmqpErrorKind::SessionClosedByRemote(Box::new(
azure_core::error::Error::new(azure_core::error::ErrorKind::Other, "Forced error"),
)))
})
.await
}
#[recorded::test(live)]
async fn force_errors_receive_connection(ctx: TestContext) -> Result<()> {
run_receive_recovery(&ctx, "force_errors_receive_connection", || {
AmqpError::from(AmqpErrorKind::ConnectionClosedByRemote(Box::new(
azure_core::error::Error::new(azure_core::error::ErrorKind::Other, "Forced error"),
)))
})
.await
}
}