use crate::capabilities::{Capabilities, HelloOutcome};
use crate::connect_options::{ConnectOptions, connect_before};
use crate::error::LaserError;
pub use crate::publish_options::PublishOptions;
use bytes::Bytes;
use dashmap::DashMap;
#[cfg(feature = "streaming")]
use iggy::prelude::*;
use laser_wire::framing::decode_named;
use laser_wire::validate::Validate;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::OnceCell;
use tokio::time::{Duration, sleep};
pub use laser_wire::headers::{
CORRELATION_ID, HEADER_FRAMING_BYTES, HEADER_SOFT_CAP, HEADER_VALUE_MAX,
};
pub const OPS_STREAM_DEFAULT: &str = "_agdx";
type ProducerKey = (String, String);
type ProducerCell = Arc<OnceCell<Arc<IggyProducer>>>;
const TRANSIENT_SEND_ATTEMPTS: usize = 10;
const PUBLISH_BATCH_LENGTH: usize = 1000;
#[derive(Clone)]
pub struct Laser {
inner: Arc<LaserInner>,
capability_override: Option<Capabilities>,
ops_stream_override: Option<String>,
control_topic_override: Option<String>,
dlq_topic_override: Option<String>,
changes_topic_override: Option<String>,
stream: Option<String>,
#[cfg(feature = "agent")]
pub(crate) governor: Option<Arc<crate::govern::GovernorState>>,
}
struct LaserInner {
client: std::sync::RwLock<Arc<IggyClient>>,
reconnect_gate: tokio::sync::Mutex<()>,
publish_generation: std::sync::atomic::AtomicU64,
publish_options: PublishOptions,
connection_string: Option<String>,
#[cfg(feature = "kv")]
coordination: OnceCell<Arc<crate::kv::FencedLeaseClient<crate::kv::DedicatedKvTransport>>>,
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex<()>,
producers: DashMap<ProducerKey, ProducerCell>,
pub(crate) producer_statistics: Arc<crate::stream::producer_statistics::ProducerRegistry>,
negotiated: std::sync::RwLock<NegotiatedState>,
#[cfg(feature = "agent")]
registry_caches: DashMap<String, Arc<std::sync::Mutex<crate::agent::registry::RegistryCache>>>,
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex<Option<crate::types::AgentId>>,
#[cfg(feature = "agent")]
reply_hubs:
DashMap<(String, String), Arc<tokio::sync::OnceCell<crate::agent::replies::ReplyHub>>>,
#[cfg(feature = "sign")]
verifier: Option<Arc<crate::sign::KeyRegistry>>,
}
struct NegotiatedState {
configured_capabilities: Capabilities,
capabilities: Capabilities,
topology: laser_wire::topology::WireTopology,
}
impl Laser {
#[cfg(all(feature = "agent", feature = "query"))]
pub(crate) fn claim_presence(
&self,
requested: crate::types::AgentId,
) -> Result<(), LaserError> {
claim_presence_slot(&self.inner.advertised_agent, requested)
}
#[cfg(all(feature = "agent", feature = "query"))]
pub(crate) fn release_presence(&self) {
*self
.inner
.advertised_agent
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
}
#[tracing::instrument(
target = "laser",
level = "info",
skip_all,
fields(operation = "connect")
)]
pub async fn connect(connection_string: &str) -> Result<Self, LaserError> {
LaserBuilder::default()
.connection_string(connection_string)
.build()
.await
}
pub async fn connect_env() -> Result<Self, LaserError> {
let connection = std::env::var("LASER_CONNECTION_STRING")
.map_err(|_| LaserError::Config("LASER_CONNECTION_STRING is not set"))?;
match std::env::var("LASER_STREAM") {
Ok(stream) => Self::connect_with_stream(&connection, &stream).await,
Err(_) => Self::connect(&connection).await,
}
}
pub async fn local() -> Result<Self, LaserError> {
Self::connect("iggy:iggy@127.0.0.1:8090").await
}
pub async fn connect_with_stream(
connection_string: &str,
stream: &str,
) -> Result<Self, LaserError> {
LaserBuilder::default()
.connection_string(connection_string)
.stream(stream)
.build()
.await
}
pub fn builder() -> LaserBuilder {
LaserBuilder::default()
}
pub fn from_client(client: IggyClient) -> Self {
Self {
inner: Arc::new(LaserInner {
client: std::sync::RwLock::new(Arc::new(client)),
publish_options: PublishOptions::default(),
reconnect_gate: tokio::sync::Mutex::new(()),
publish_generation: std::sync::atomic::AtomicU64::new(0),
connection_string: None,
#[cfg(feature = "kv")]
coordination: OnceCell::new(),
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex::new(()),
producers: DashMap::new(),
producer_statistics: Default::default(),
negotiated: std::sync::RwLock::new(NegotiatedState {
configured_capabilities: Capabilities::OPEN,
capabilities: Capabilities::OPEN,
topology: laser_wire::topology::WireTopology::default(),
}),
#[cfg(feature = "agent")]
registry_caches: DashMap::new(),
#[cfg(feature = "agent")]
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex::new(None),
#[cfg(feature = "agent")]
reply_hubs: DashMap::new(),
#[cfg(feature = "sign")]
verifier: None,
}),
capability_override: None,
ops_stream_override: None,
control_topic_override: None,
dlq_topic_override: None,
changes_topic_override: None,
stream: None,
#[cfg(feature = "agent")]
governor: None,
}
}
#[cfg(feature = "agent")]
pub(crate) async fn reply_hub(
&self,
reply_topic: &crate::provenance::AgentTopic<'_>,
) -> Result<crate::agent::replies::ReplyHub, LaserError> {
let stream = self.stream_required()?.to_owned();
let topic = reply_topic.topic_string();
let cell = {
self.inner
.reply_hubs
.entry((stream.clone(), topic))
.or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
.clone()
};
let hub = cell
.get_or_try_init(|| {
crate::agent::replies::ReplyHub::create(
self.client(),
stream,
reply_topic.as_identifier(),
#[cfg(feature = "sign")]
self.inner.verifier.clone(),
)
})
.await?;
Ok(hub.clone())
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
self.capability_override = Some(capabilities);
self
}
#[must_use]
pub fn with_ops_stream(mut self, ops_stream: impl Into<String>) -> Self {
self.ops_stream_override = Some(ops_stream.into());
self
}
#[must_use]
pub fn with_control_topic(mut self, control_topic: impl Into<String>) -> Self {
self.control_topic_override = Some(control_topic.into());
self
}
#[must_use]
pub fn with_dlq_topic(mut self, dlq_topic: impl Into<String>) -> Self {
self.dlq_topic_override = Some(dlq_topic.into());
self
}
#[must_use]
pub fn with_changes_topic(mut self, changes_topic: impl Into<String>) -> Self {
self.changes_topic_override = Some(changes_topic.into());
self
}
#[must_use]
pub fn with_default_stream(&self, stream: impl Into<String>) -> Self {
let mut scoped = self.clone();
scoped.stream = Some(stream.into());
scoped
}
pub(crate) fn publish_options(&self) -> PublishOptions {
self.inner.publish_options
}
pub fn client(&self) -> Arc<IggyClient> {
self.inner
.client
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub async fn close(&self) -> Result<(), LaserError> {
self.inner.producer_statistics.close();
#[cfg(feature = "kv")]
if self.inner.connection_string.is_some() {
self.coordination_client().await?.close().await;
}
#[cfg(feature = "agent")]
self.inner.reply_hubs.clear();
self.client().shutdown().await.map_err(LaserError::from)
}
pub(crate) fn publish_generation(&self) -> u64 {
self.inner
.publish_generation
.load(std::sync::atomic::Ordering::Acquire)
}
pub(crate) async fn reconnect_for_publish(&self, observed: u64) -> Result<(), LaserError> {
let _gate = self.inner.reconnect_gate.lock().await;
if self.publish_generation() != observed {
return Ok(());
}
let client = self.client();
client.disconnect().await?;
client.connect().await?;
self.inner.producers.clear();
self.inner
.publish_generation
.fetch_add(1, std::sync::atomic::Ordering::Release);
Ok(())
}
pub(crate) fn producer_statistics_registry(
&self,
) -> &Arc<crate::stream::producer_statistics::ProducerRegistry> {
&self.inner.producer_statistics
}
pub(crate) fn connection_string(&self) -> Option<&str> {
self.inner.connection_string.as_deref()
}
#[cfg(feature = "kv")]
pub(crate) async fn coordination_client(
&self,
) -> Result<Arc<crate::kv::FencedLeaseClient<crate::kv::DedicatedKvTransport>>, LaserError>
{
let connection = self
.inner
.connection_string
.as_ref()
.ok_or(LaserError::Config(
"fenced lease convenience methods need a connection-backed Laser; use FencedLeaseClient with a dedicated transport for a bring-your-own IggyClient",
))?
.clone();
Ok(self
.inner
.coordination
.get_or_init(|| async move {
Arc::new(crate::kv::FencedLeaseClient::connect_dedicated(connection))
})
.await
.clone())
}
#[cfg(feature = "kv")]
pub(crate) async fn acquire_lease(
&self,
request: laser_wire::kv::KvLease,
) -> Result<crate::kv::Lease, LaserError> {
let laser = self.clone();
tokio::spawn(async move {
let _acquire = laser.inner.coordination_acquire_gate.lock().await;
let coordination = laser.coordination_client().await?;
let prepared = coordination.prepare_acquire(&request)?;
match coordination.acquire(&prepared).await {
Err(error @ LaserError::AmbiguousMutation(_)) => {
sleep(Duration::from_micros(request.lease_ttl_micros)).await;
Err(error)
}
result => result,
}
})
.await
.map_err(|error| {
LaserError::HandlerConfig(format!("coordination acquisition task failed: {error}"))
})?
}
pub fn default_stream(&self) -> Option<&str> {
self.stream.as_deref().filter(|value| !value.is_empty())
}
pub(crate) fn stream_required(&self) -> Result<&str, LaserError> {
self.default_stream().ok_or(LaserError::NoStream)
}
#[cfg(feature = "agent")]
pub(crate) fn registry_cache(
&self,
) -> Result<Arc<std::sync::Mutex<crate::agent::registry::RegistryCache>>, LaserError> {
let stream = self.stream_required()?;
Ok(self
.inner
.registry_caches
.entry(stream.to_owned())
.or_default()
.clone())
}
#[cfg(feature = "sign")]
pub(crate) fn registry_verifier(&self) -> Option<Arc<crate::sign::KeyRegistry>> {
self.inner.verifier.clone()
}
pub fn ops_stream(&self) -> String {
self.ops_stream_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.ops_stream
.clone()
})
}
pub fn control_topic(&self) -> String {
self.control_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.control_topic
.clone()
})
}
pub fn dlq_topic(&self) -> String {
self.dlq_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.dlq_topic
.clone()
})
}
pub fn changes_topic(&self) -> String {
self.changes_topic_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.topology
.changes_topic
.clone()
})
}
pub async fn capabilities(&self) -> Capabilities {
self.current_capabilities()
}
pub(crate) fn current_capabilities(&self) -> Capabilities {
self.capability_override.clone().unwrap_or_else(|| {
self.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.capabilities
.clone()
})
}
pub async fn refresh_capabilities(&self) -> Capabilities {
#[allow(unused_mut)]
let mut capabilities = self
.inner
.negotiated
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.configured_capabilities
.clone();
#[allow(unused_mut)]
let mut topology = None;
match probe_managed_host(&self.client()).await {
Ok(Some(announce)) => {
merge_announcement(&mut capabilities, &announce);
topology = announce.topology;
capabilities.hello = HelloOutcome::Answered;
}
Ok(None) => capabilities.hello = HelloOutcome::Rejected,
Err(()) => capabilities.hello = HelloOutcome::Failed,
}
let mut negotiated = self
.inner
.negotiated
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
negotiated.capabilities = capabilities;
if let Some(topology) = topology {
negotiated.topology = topology;
}
self.capability_override
.clone()
.unwrap_or_else(|| negotiated.capabilities.clone())
}
pub async fn wait_until_ready(&self, timeout: Duration) -> Result<Capabilities, LaserError> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let capabilities = self.refresh_capabilities().await;
if capabilities.is_ready() {
return Ok(capabilities);
}
if capabilities.backends.is_empty() {
return Err(LaserError::unsupported(
"readiness",
"server has no managed backend descriptors",
));
}
if tokio::time::Instant::now() >= deadline {
return Err(LaserError::Timeout("managed backend readiness"));
}
sleep(Duration::from_millis(100)).await;
}
}
pub(crate) async fn ensure_topic_on(
&self,
stream: &str,
topic: &str,
partitions: u32,
) -> Result<(), LaserError> {
let client = self.client();
ensure_stream(&client, stream).await?;
ensure_topic(&client, stream, topic, partitions).await
}
#[cfg(feature = "agent")]
pub(crate) async fn ensure_topic_on_with(
&self,
stream: &str,
topic: &str,
partitions: u32,
expiry: IggyExpiry,
) -> Result<(), LaserError> {
let client = self.client();
ensure_stream(&client, stream).await?;
ensure_topic_with(&client, stream, topic, partitions, expiry).await
}
pub(crate) async fn send_with_headers(
&self,
topic: &str,
payload: impl Into<Vec<u8>>,
headers: BTreeMap<HeaderKey, HeaderValue>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
self.send_with_headers_on(
self.stream_required()?,
topic,
payload,
headers,
partition_key,
)
.await
}
pub(crate) async fn send_with_headers_on(
&self,
stream: &str,
topic: &str,
payload: impl Into<Vec<u8>>,
headers: BTreeMap<HeaderKey, HeaderValue>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
let payload: Vec<u8> = payload.into();
let message = IggyMessage::builder()
.payload(payload.into())
.user_headers(headers)
.build()?;
self.send_batch_on(stream, topic, vec![message], partition_key)
.await
}
pub(crate) async fn send_batch(
&self,
topic: &str,
messages: Vec<IggyMessage>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
let stream = self.stream_required()?.to_owned();
self.send_batch_on(&stream, topic, messages, partition_key)
.await
}
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(topic = %topic, operation = "publish"))]
pub(crate) async fn send_batch_on(
&self,
stream: &str,
topic: &str,
mut messages: Vec<IggyMessage>,
partition_key: Option<&str>,
) -> Result<SendMessagesResponse, LaserError> {
if messages.is_empty() {
return Ok(SendMessagesResponse {
confirmations: Vec::new(),
});
}
prepare_publish_messages(&mut messages);
let partitioning = Arc::new(match partition_key {
Some(key) => Partitioning::messages_key_str(key)?,
None => Partitioning::balanced(),
});
let mut confirmations = Vec::new();
let observed = std::sync::atomic::AtomicU64::new(0);
for (index, chunk) in messages.chunks(PUBLISH_BATCH_LENGTH).enumerate() {
let response = self
.inner
.publish_options
.run(
|| async {
observed.store(
self.publish_generation(),
std::sync::atomic::Ordering::Release,
);
let producer = self.producer_on(stream, topic).await?;
Ok(producer
.send_with_partitioning(
chunk.iter().map(clone_iggy_message).collect(),
Some(partitioning.clone()),
)
.await?)
},
|| {
self.reconnect_for_publish(
observed.load(std::sync::atomic::Ordering::Acquire),
)
},
)
.await
.map_err(|error| {
publish_failure(
error,
&messages[index * PUBLISH_BATCH_LENGTH + chunk.len()..],
std::mem::take(&mut confirmations),
)
})?;
confirmations.extend(response.confirmations);
}
Ok(SendMessagesResponse { confirmations })
}
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(code = code, operation = "managed"))]
pub(crate) async fn send_raw_with_response(
&self,
code: u32,
payload: Vec<u8>,
) -> Result<Vec<u8>, IggyError> {
let payload = if laser_wire::codes::is_idempotent_managed_request(code) {
laser_wire::framing::encode_named(&laser_wire::mutation::ManagedRequestEnvelope {
v: laser_wire::mutation::MANAGED_REQUEST_VERSION,
operation_id: u128::from(ulid::Ulid::generate()),
payload,
})
.map_err(|_| IggyError::InvalidFormat)?
} else {
payload
};
let reply = self
.client()
.send_binary_request(code, bytes::Bytes::from(payload))
.await?;
Ok(reply.to_vec())
}
#[cfg(feature = "kv")]
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(code = code, operation = "managed_preframed"))]
pub(crate) async fn send_raw_preframed(
&self,
code: u32,
payload: Vec<u8>,
) -> Result<Vec<u8>, IggyError> {
let reply = self
.client()
.send_binary_request(code, bytes::Bytes::from(payload))
.await?;
Ok(reply.to_vec())
}
pub(crate) fn iggy_producer(
&self,
stream: &str,
topic: &str,
) -> Result<IggyProducerBuilder, LaserError> {
Ok(self.client().producer(stream, topic)?)
}
pub(crate) fn iggy_consumer(
&self,
name: &str,
stream: &str,
topic: &str,
partition: u32,
) -> Result<IggyConsumerBuilder, LaserError> {
Ok(self.client().consumer(name, stream, topic, partition)?)
}
pub(crate) fn iggy_consumer_group(
&self,
group: &str,
stream: &str,
topic: &str,
) -> Result<IggyConsumerBuilder, LaserError> {
Ok(self.client().consumer_group(group, stream, topic)?)
}
pub(crate) async fn producer_on(
&self,
stream: &str,
topic: &str,
) -> Result<Arc<IggyProducer>, LaserError> {
let cell = self
.inner
.producers
.entry((stream.to_owned(), topic.to_owned()))
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
let producer = cell
.get_or_try_init(|| async {
let producer = self
.client()
.producer(stream, topic)?
.direct(
DirectConfig::builder()
.batch_length(PUBLISH_BATCH_LENGTH as u32)
.build(),
)
.send_retries(Some(0), None)
.build();
for attempt in 0..TRANSIENT_SEND_ATTEMPTS {
match producer.init().await {
Ok(()) => return Ok::<_, LaserError>(Arc::new(producer)),
Err(error)
if is_idempotent_create_race(&error)
&& attempt + 1 < TRANSIENT_SEND_ATTEMPTS =>
{
sleep(Duration::from_millis(50 * (attempt + 1) as u64)).await;
}
Err(error) => return Err(error.into()),
}
}
unreachable!("producer init retry returns success or the last error")
})
.await?;
Ok(producer.clone())
}
}
#[cfg(all(feature = "agent", any(feature = "query", test)))]
fn claim_presence_slot(
slot: &std::sync::Mutex<Option<crate::types::AgentId>>,
requested: crate::types::AgentId,
) -> Result<(), LaserError> {
let mut advertised = slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match advertised.as_ref() {
Some(current) if current != &requested => Err(LaserError::PresenceConflict {
advertised: current.to_string(),
requested: requested.to_string(),
}),
Some(_) => Ok(()),
None => {
*advertised = Some(requested);
Ok(())
}
}
}
pub(crate) fn publish_failure(
error: LaserError,
unsent: &[IggyMessage],
mut confirmations: Vec<SendMessagesConfirmationResponse>,
) -> LaserError {
match error {
LaserError::Iggy(IggyError::ProducerSendFailed {
cause,
failed,
committed,
stream_name,
topic_name,
}) => {
confirmations.extend(committed.iter().cloned());
let pending = failed
.iter()
.chain(unsent)
.map(clone_iggy_message)
.collect();
LaserError::Iggy(IggyError::ProducerSendFailed {
cause,
failed: Arc::new(pending),
committed: Arc::new(confirmations),
stream_name,
topic_name,
})
}
error => error,
}
}
pub(crate) fn prepare_publish_messages(messages: &mut [IggyMessage]) {
for message in messages {
if message.header.id == 0 {
message.header.id = u128::from(ulid::Ulid::generate());
}
}
}
pub(crate) fn clone_iggy_message(message: &IggyMessage) -> IggyMessage {
IggyMessage {
header: IggyMessageHeader {
checksum: message.header.checksum,
id: message.header.id,
offset: message.header.offset,
timestamp: message.header.timestamp,
origin_timestamp: message.header.origin_timestamp,
user_headers_length: message.header.user_headers_length,
payload_length: message.header.payload_length,
reserved: message.header.reserved,
},
payload: message.payload.clone(),
user_headers: message.user_headers.clone(),
}
}
pub(crate) fn is_transient_iggy_io_error(error: &IggyError) -> bool {
match error {
IggyError::Disconnected
| IggyError::TcpError
| IggyError::QuicError
| IggyError::StaleClient
| IggyError::NotConnected
| IggyError::ConnectionClosed
| IggyError::CannotEstablishConnection
| IggyError::CannotSendMessagesDueToClientDisconnection
| IggyError::TransientNotAccepted
| IggyError::TransientNotCommitted
| IggyError::CannotReadFile
| IggyError::CannotReadPartitions
| IggyError::PartitionNotFound(..) => true,
IggyError::ProducerSendFailed { cause, .. } => is_transient_iggy_io_error(cause),
_ => false,
}
}
pub(crate) fn needs_reauthentication(error: &IggyError) -> bool {
match error {
IggyError::Unauthenticated => true,
IggyError::ProducerSendFailed { cause, .. } => needs_reauthentication(cause),
_ => false,
}
}
fn is_idempotent_create_race(error: &IggyError) -> bool {
matches!(
error,
IggyError::StreamNameAlreadyExists(_) | IggyError::TopicNameAlreadyExists(_, _)
)
}
#[derive(Default)]
pub struct LaserBuilder {
connection: ConnectionConfig,
connection_conflict: Option<&'static str>,
connect_timeout: Option<Duration>,
publish_timeout: Option<Duration>,
publish_max_retries: Option<u32>,
publish_retry_backoff: Option<Duration>,
stream: Option<String>,
ops_stream: Option<String>,
control_topic: Option<String>,
dlq_topic: Option<String>,
changes_topic: Option<String>,
capabilities: Option<Capabilities>,
#[cfg(feature = "sign")]
verifier: Option<Arc<crate::sign::KeyRegistry>>,
#[cfg(feature = "agent")]
governor: Option<Arc<crate::govern::GovernorState>>,
}
#[derive(Default)]
enum ConnectionConfig {
#[default]
Unset,
ConnectionString(String),
Tcp {
address: String,
username: String,
password: String,
},
Client(IggyClient),
}
impl LaserBuilder {
pub fn connect_timeout(mut self, value: Duration) -> Self {
self.connect_timeout = Some(value);
self
}
pub fn publish_timeout(mut self, value: Duration) -> Self {
self.publish_timeout = Some(value);
self
}
pub fn publish_max_retries(mut self, value: u32) -> Self {
self.publish_max_retries = Some(value);
self
}
pub fn publish_retry_backoff(mut self, value: Duration) -> Self {
self.publish_retry_backoff = Some(value);
self
}
pub fn connection_string(mut self, value: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::Tcp { .. } | ConnectionConfig::Client(_)
) {
self.connection_conflict = Some(
"connection_string() conflicts with an address/credentials or client already set",
);
}
self.connection = ConnectionConfig::ConnectionString(value.into());
self
}
pub fn address(mut self, value: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Client(_)
) {
self.connection_conflict =
Some("address() conflicts with a connection_string or client already set");
}
match self.connection {
ConnectionConfig::Tcp {
username, password, ..
} => {
self.connection = ConnectionConfig::Tcp {
address: value.into(),
username,
password,
};
}
_ => {
self.connection = ConnectionConfig::Tcp {
address: value.into(),
username: String::new(),
password: String::new(),
};
}
}
self
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Client(_)
) {
self.connection_conflict =
Some("credentials() conflicts with a connection_string or client already set");
}
match self.connection {
ConnectionConfig::Tcp { address, .. } => {
self.connection = ConnectionConfig::Tcp {
address,
username: username.into(),
password: password.into(),
};
}
_ => {
self.connection = ConnectionConfig::Tcp {
address: String::new(),
username: username.into(),
password: password.into(),
};
}
}
self
}
pub fn client(mut self, client: IggyClient) -> Self {
if matches!(
self.connection,
ConnectionConfig::ConnectionString(_) | ConnectionConfig::Tcp { .. }
) {
self.connection_conflict = Some(
"client() conflicts with a connection_string or address/credentials already set",
);
}
self.connection = ConnectionConfig::Client(client);
self
}
pub fn stream(mut self, value: impl Into<String>) -> Self {
self.stream = Some(value.into());
self
}
pub fn capabilities(mut self, value: Capabilities) -> Self {
self.capabilities = Some(value);
self
}
#[cfg(feature = "sign")]
pub fn verifier(mut self, verifier: Arc<crate::sign::KeyRegistry>) -> Self {
self.verifier = Some(verifier);
self
}
#[cfg(feature = "agent")]
pub fn governor(
mut self,
governor: Arc<dyn crate::govern::ActionGovernor>,
mode: crate::govern::GovernorMode,
) -> Self {
self.governor = Some(Arc::new(crate::govern::GovernorState::new(governor, mode)));
self
}
#[cfg(feature = "agent")]
pub fn governor_with_retention(
mut self,
governor: Arc<dyn crate::govern::ActionGovernor>,
mode: crate::govern::GovernorMode,
retention: crate::govern::GovernorRetention,
) -> Self {
self.governor = Some(Arc::new(crate::govern::GovernorState::with_retention(
governor, mode, retention,
)));
self
}
pub fn ops_stream(mut self, value: impl Into<String>) -> Self {
self.ops_stream = Some(value.into());
self
}
pub fn control_topic(mut self, value: impl Into<String>) -> Self {
self.control_topic = Some(value.into());
self
}
pub fn dlq_topic(mut self, value: impl Into<String>) -> Self {
self.dlq_topic = Some(value.into());
self
}
pub fn changes_topic(mut self, value: impl Into<String>) -> Self {
self.changes_topic = Some(value.into());
self
}
#[allow(unused_mut)]
pub async fn build(mut self) -> Result<Laser, LaserError> {
if let Some(conflict) = self.connection_conflict {
return Err(LaserError::Config(conflict));
}
let publish_options = PublishOptions::from_env(
self.publish_timeout,
self.publish_max_retries,
self.publish_retry_backoff,
)?;
let connect_deadline =
tokio::time::Instant::now() + ConnectOptions::from_env(self.connect_timeout)?.timeout;
let stream = self.stream.filter(|value| !value.is_empty());
let (client, connection_string) = match self.connection {
ConnectionConfig::Unset => {
return Err(LaserError::Config(
"connection_string, address+credentials, or client is required",
));
}
ConnectionConfig::ConnectionString(value) => {
let normalized = normalize_connection_string(&value)?;
let client = IggyClientBuilder::from_connection_string(&normalized)?.build()?;
connect_before(&client, connect_deadline).await?;
(client, Some(normalized))
}
ConnectionConfig::Tcp {
address,
username,
password,
} => {
if address.is_empty() {
return Err(LaserError::Config("address is required"));
}
if username.is_empty() {
return Err(LaserError::Config("credentials are required"));
}
let with_tls = resolve_tls(format!("iggy+tcp://{username}:{password}@{address}"))?;
let client = IggyClientBuilder::from_connection_string(&with_tls)?.build()?;
connect_before(&client, connect_deadline).await?;
(client, Some(with_tls))
}
ConnectionConfig::Client(client) => (client, None),
};
let configured_capabilities = self.capabilities.clone().unwrap_or(Capabilities::OPEN);
let mut capabilities = configured_capabilities.clone();
let mut topology = laser_wire::topology::WireTopology::default();
match tokio::time::timeout_at(connect_deadline, probe_managed_host(&client)).await {
Ok(Ok(Some(announce))) => {
if let Some(announced) = announce.topology.clone() {
topology = announced;
}
merge_announcement(&mut capabilities, &announce);
capabilities.hello = HelloOutcome::Answered;
}
Ok(Ok(None)) => capabilities.hello = HelloOutcome::Rejected,
Ok(Err(())) | Err(_) => capabilities.hello = HelloOutcome::Failed,
}
Ok(Laser {
inner: Arc::new(LaserInner {
client: std::sync::RwLock::new(Arc::new(client)),
publish_options,
reconnect_gate: tokio::sync::Mutex::new(()),
publish_generation: std::sync::atomic::AtomicU64::new(0),
connection_string,
#[cfg(feature = "kv")]
coordination: OnceCell::new(),
#[cfg(feature = "kv")]
coordination_acquire_gate: tokio::sync::Mutex::new(()),
producers: DashMap::new(),
producer_statistics: Default::default(),
negotiated: std::sync::RwLock::new(NegotiatedState {
configured_capabilities,
capabilities,
topology,
}),
#[cfg(feature = "agent")]
registry_caches: DashMap::new(),
#[cfg(feature = "agent")]
#[cfg(all(feature = "agent", any(feature = "query", test)))]
advertised_agent: std::sync::Mutex::new(None),
#[cfg(feature = "agent")]
reply_hubs: DashMap::new(),
#[cfg(feature = "sign")]
verifier: self.verifier,
}),
capability_override: None,
ops_stream_override: self.ops_stream,
control_topic_override: self.control_topic,
dlq_topic_override: self.dlq_topic,
changes_topic_override: self.changes_topic,
stream,
#[cfg(feature = "agent")]
governor: self.governor,
})
}
}
async fn probe_managed_host(
client: &IggyClient,
) -> Result<Option<laser_wire::hello::BackendAnnounce>, ()> {
match client
.send_binary_request(laser_wire::codes::AGDX_HELLO_CODE, Bytes::new())
.await
{
Ok(reply) if reply.is_empty() => Ok(None),
Ok(reply) => decode_named::<laser_wire::hello::BackendAnnounce>(&reply)
.ok()
.filter(|announce| announce.validate().is_ok())
.map(Some)
.ok_or(()),
Err(IggyError::InvalidCommand) => Ok(None),
Err(_) => Err(()),
}
}
fn merge_announcement(
capabilities: &mut Capabilities,
announce: &laser_wire::hello::BackendAnnounce,
) {
let versions = announce.versions;
capabilities.versions = Some(versions);
capabilities.backends.clone_from(&announce.backends);
capabilities.authz |= versions.has_feature(laser_wire::hello::feature::AUTHZ);
capabilities.filters.native |=
versions.has_feature(laser_wire::hello::feature::CONSUMER_FILTERS);
capabilities.filters.group_policy_reads |=
versions.has_feature(laser_wire::hello::feature::GROUP_POLICY_READS);
if capabilities.filters.native {
capabilities
.filters
.evaluation
.clone_from(&announce.filters);
}
if announce.ready {
capabilities.managed = true;
capabilities.filters.catalog |= (capabilities.filters.native
|| capabilities.filters.group_policy_reads)
&& versions.filter > 0;
capabilities.kv.available |= versions.kv > 0;
capabilities.forks |= versions.fork > 0;
capabilities.graph |= versions.graph > 0;
capabilities.destinations.available |= versions.checkpoint > 0
&& versions.has_feature(laser_wire::hello::feature::DESTINATIONS);
capabilities.merge_features(&versions);
for backend in announce
.backends
.iter()
.filter(|backend| backend.readiness.ready)
{
if let Some(query) = &backend.query {
capabilities.query.available |= versions.query > 0;
capabilities.query.cursor_paging |= query
.paging
.contains(&laser_wire::hello::QueryPagingCapability::Cursor);
capabilities.query.cancellation |= query.cancellation;
capabilities.query.execution_status |= query.execution_status;
if query
.consistency
.contains(&laser_wire::query::Consistency::Strong)
{
capabilities.query.consistency = laser_wire::query::Consistency::Strong;
} else if query
.consistency
.contains(&laser_wire::query::Consistency::ReadYourWrites)
{
capabilities.query.consistency = capabilities
.query
.consistency
.max(laser_wire::query::Consistency::ReadYourWrites);
}
}
}
}
}
#[cfg(all(
test,
any(
feature = "filters",
feature = "fork",
feature = "graph",
feature = "kv",
feature = "projections",
feature = "query",
feature = "rbac",
feature = "runs"
)
))]
mod announcement_tests {
use super::*;
use laser_wire::destination::BackendResourceId;
use laser_wire::hello::{
BackendAnnounce, BackendDescriptor, BackendDesiredState, BackendImplementation,
BackendMode, BackendObservedState, BackendReadiness, OpVersions, QueryCapabilities,
QueryPagingCapability, feature,
};
fn backend(id: u128, ready: bool) -> BackendDescriptor {
let backend = BackendDescriptor::new(
BackendResourceId::from_u128(id),
BackendMode::Operational,
"Embedded",
BackendImplementation {
kind: "embedded".to_owned(),
version: "1.0.0".to_owned(),
},
1,
1,
);
if !ready {
return backend;
}
backend
.with_state(
BackendDesiredState::Enabled,
BackendObservedState::Ready,
BackendReadiness::ready(1),
)
.with_query(QueryCapabilities {
dialects: vec![laser_wire::query::SqlDialect::Sqlite],
time_travel: Vec::new(),
consistency: vec![laser_wire::query::Consistency::Eventual],
logical_types: vec![laser_wire::schema::LogicalTypeKind::String],
paging: vec![QueryPagingCapability::Cursor],
cancellation: true,
execution_status: true,
raw_sql: true,
})
.with_limits(laser_wire::hello::BackendLimits {
max_query_rows: 1_000,
max_query_bytes: 1_000_000,
max_scan_bytes: 10_000_000,
max_query_micros: 30_000_000,
max_concurrent_queries: 8,
max_schema_fields: 0,
max_materialization_file_bytes: 0,
})
}
#[test]
fn given_consumer_filters_without_a_plane_when_merged_then_should_serve_native_filters_only() {
let announce = BackendAnnounce::new(
OpVersions::new(0, 0, 0, 0).with_features(feature::CONSUMER_FILTERS | feature::AUTHZ),
)
.unavailable();
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.filters.native);
assert!(!capabilities.filters.catalog);
assert!(!capabilities.managed);
}
#[test]
fn given_consumer_filters_and_a_ready_catalog_when_merged_then_should_serve_both() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_filter(laser_wire::codes::FILTER_OP_VERSION)
.with_features(feature::CONSUMER_FILTERS),
)
.with_backends(vec![backend(1, true)]);
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.filters.native);
assert!(capabilities.filters.catalog);
}
#[test]
fn given_an_unavailable_backend_when_merged_then_should_not_enable_plane_surfaces() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_agent(1)
.with_graph(1)
.with_features(
feature::KV_CAS
| feature::KV_CAS_FENCED
| feature::STRONG_CONSISTENCY
| feature::AGENT_WORKFLOW
| feature::KEYWORD_SEARCH
| feature::WATCH
| feature::AUTHZ,
),
)
.with_backends(vec![backend(1, false)])
.unavailable();
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(!capabilities.managed);
assert!(!capabilities.query.available);
assert!(!capabilities.query.keyword);
assert!(!capabilities.kv.available);
assert!(!capabilities.kv.cas);
assert!(!capabilities.kv.cas_fenced);
assert!(!capabilities.graph);
assert!(!capabilities.forks);
assert!(!capabilities.agent_workflow);
assert!(!capabilities.watch);
assert!(capabilities.authz, "server-native authz remains available");
assert_eq!(capabilities.backends, announce.backends);
assert_eq!(capabilities.versions, Some(announce.versions));
}
#[test]
fn given_a_ready_backend_when_merged_then_should_enable_advertised_plane_surfaces() {
let announce = BackendAnnounce::new(
OpVersions::new(1, 1, 1, 1)
.with_graph(1)
.with_features(feature::KV_CAS | feature::AGENT_WORKFLOW),
)
.with_backends(vec![backend(1, true)]);
let mut capabilities = Capabilities::OPEN;
merge_announcement(&mut capabilities, &announce);
assert!(capabilities.managed);
assert!(capabilities.query.available);
assert!(capabilities.kv.available);
assert!(capabilities.kv.cas);
assert!(capabilities.graph);
assert!(capabilities.forks);
assert!(capabilities.agent_workflow);
assert_eq!(capabilities.backends, announce.backends);
}
}
fn normalize_connection_string(value: &str) -> Result<String, LaserError> {
let trimmed = value.trim();
let scheme_applied = if trimmed.starts_with("iggy://") || trimmed.starts_with("iggy+") {
trimmed.to_owned()
} else {
format!("iggy://{trimmed}")
};
resolve_tls(scheme_applied)
}
fn is_laserdata_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host == "laserdata.cloud"
|| host.ends_with(".laserdata.cloud")
|| host == "laserdata.com"
|| host.ends_with(".laserdata.com")
}
fn after_scheme(connection_string: &str) -> &str {
connection_string
.split_once("://")
.map_or(connection_string, |(_, rest)| rest)
}
fn authority_start(after_scheme: &str) -> usize {
let before_query = after_scheme
.find('?')
.map_or(after_scheme, |query| &after_scheme[..query]);
before_query.rfind('@').map_or(0, |at| at + 1)
}
pub(crate) fn host_of(connection_string: &str) -> &str {
let after_scheme = after_scheme(connection_string);
let host_and_port = &after_scheme[authority_start(after_scheme)..];
let authority = host_and_port
.split(['/', '?', '#'])
.next()
.unwrap_or(host_and_port);
if let Some(bracketed) = authority.strip_prefix('[')
&& let Some(closing) = bracketed.find(']')
{
return &bracketed[..closing];
}
authority
.rsplit_once(':')
.map_or(authority, |(host, _)| host)
}
fn query_of(connection_string: &str) -> Option<&str> {
let after_scheme = after_scheme(connection_string);
let authority = &after_scheme[authority_start(after_scheme)..];
authority.split_once('?').map(|(_, query)| query)
}
fn has_query_param(connection_string: &str, key: &str) -> bool {
query_of(connection_string).is_some_and(|query| {
query.split('&').any(|pair| {
let name = pair.split_once('=').map_or(pair, |(name, _)| name);
name.eq_ignore_ascii_case(key)
})
})
}
pub(crate) fn with_endpoint(connection_string: &str, endpoint: &str) -> String {
let rest = after_scheme(connection_string);
let start = connection_string.len() - rest.len() + authority_start(rest);
let end = connection_string[start..]
.find(['/', '?', '#'])
.map_or(connection_string.len(), |length| start + length);
let rewritten = format!(
"{}{endpoint}{}",
&connection_string[..start],
&connection_string[end..]
);
let tls = query_of(connection_string).is_some_and(|query| {
query
.split('&')
.any(|pair| pair.eq_ignore_ascii_case("tls=true"))
});
if !tls || has_query_param(connection_string, "tls_domain") {
return rewritten;
}
format!("{rewritten}&tls_domain={}", host_of(connection_string))
}
fn flag_value_enabled(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| flag_value_enabled(&value))
}
fn resolve_tls(connection_string: String) -> Result<String, LaserError> {
let custom_cert = std::env::var("LASER_TLS_CERT")
.ok()
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from);
resolve_tls_with(
connection_string,
env_flag_enabled("LASER_NO_TLS"),
custom_cert,
)
}
fn resolve_tls_with(
connection_string: String,
no_tls: bool,
custom_cert: Option<std::path::PathBuf>,
) -> Result<String, LaserError> {
if no_tls || has_query_param(&connection_string, "tls_ca_file") {
return Ok(connection_string);
}
if custom_cert.is_none() && !is_laserdata_host(host_of(&connection_string)) {
return Ok(connection_string);
}
let cert_path = custom_cert.map_or_else(resolve_cert_path, Ok)?;
let mut with_tls = connection_string;
if !has_query_param(&with_tls, "tls") {
let separator = if query_of(&with_tls).is_some() {
'&'
} else {
'?'
};
with_tls = format!("{with_tls}{separator}tls=true");
}
Ok(format!("{with_tls}&tls_ca_file={}", cert_path.display()))
}
static PROD_CERT: &[u8] = include_bytes!("../certs/laserdata.crt");
fn cert_cache_dir() -> Result<std::path::PathBuf, LaserError> {
#[cfg(windows)]
let base = std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from);
#[cfg(target_os = "macos")]
let base =
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join("Library/Caches"));
#[cfg(all(unix, not(target_os = "macos")))]
let base = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".cache"))
});
let base = base.ok_or(LaserError::Config(
"no per-user cache directory to store the LaserData CA in: set LASER_TLS_CERT to a CA file path",
))?;
Ok(base.join("laser-sdk"))
}
#[cfg(unix)]
fn restrict_to_owner(dir: &std::path::Path) -> Result<(), LaserError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(|error| LaserError::Invalid(format!("restrict CA cache directory: {error}")))
}
#[cfg(not(unix))]
fn restrict_to_owner(_dir: &std::path::Path) -> Result<(), LaserError> {
Ok(())
}
fn write_cert(dir: &std::path::Path, path: &std::path::Path) -> Result<(), LaserError> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static ATTEMPT: AtomicU64 = AtomicU64::new(0);
let attempt = ATTEMPT.fetch_add(1, Ordering::Relaxed);
let temp = dir.join(format!(
"laserdata.crt.{}.{attempt}.tmp",
std::process::id()
));
let _ = std::fs::remove_file(&temp);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temp)
.map_err(|error| LaserError::Invalid(format!("create CA cert: {error}")))?;
file.write_all(PROD_CERT)
.and_then(|()| file.sync_all())
.map_err(|error| LaserError::Invalid(format!("write CA cert: {error}")))?;
drop(file);
std::fs::rename(&temp, path)
.map_err(|error| LaserError::Invalid(format!("install CA cert: {error}")))
}
fn install_cert(dir: &std::path::Path) -> Result<std::path::PathBuf, LaserError> {
std::fs::create_dir_all(dir)
.map_err(|error| LaserError::Invalid(format!("create CA cache directory: {error}")))?;
restrict_to_owner(dir)?;
let path = dir.join("laserdata.crt");
if std::fs::read(&path).is_ok_and(|cached| cached == PROD_CERT) {
return Ok(path);
}
write_cert(dir, &path)?;
Ok(path)
}
fn resolve_cert_path() -> Result<std::path::PathBuf, LaserError> {
if let Ok(custom) = std::env::var("LASER_TLS_CERT")
&& !custom.is_empty()
{
return Ok(std::path::PathBuf::from(custom));
}
install_cert(&cert_cache_dir()?)
}
pub(crate) async fn ensure_stream(client: &IggyClient, stream: &str) -> Result<(), LaserError> {
if client
.get_stream(&Identifier::named(stream)?)
.await?
.is_some()
{
return Ok(());
}
if let Err(error) = client.create_stream(stream).await
&& client
.get_stream(&Identifier::named(stream)?)
.await?
.is_none()
{
return Err(error.into());
}
Ok(())
}
pub(crate) async fn delete_stream(laser: &Laser, stream: &str) -> Result<bool, LaserError> {
let client = laser.client();
let identifier = Identifier::named(stream)?;
let existed = client.get_stream(&identifier).await?.is_some();
if existed
&& let Err(error) = client.delete_stream(&identifier).await
&& client.get_stream(&identifier).await?.is_some()
{
return Err(error.into());
}
laser
.inner
.producers
.retain(|(producer_stream, _), _| producer_stream != stream);
#[cfg(feature = "agent")]
{
laser.inner.registry_caches.remove(stream);
laser
.inner
.reply_hubs
.retain(|(hub_stream, _), _| hub_stream != stream);
}
Ok(existed)
}
pub(crate) async fn ensure_topic(
client: &IggyClient,
stream: &str,
topic: &str,
partitions: u32,
) -> Result<(), LaserError> {
ensure_topic_with(client, stream, topic, partitions, IggyExpiry::NeverExpire).await
}
pub(crate) async fn ensure_topic_with(
client: &IggyClient,
stream: &str,
topic: &str,
partitions: u32,
expiry: IggyExpiry,
) -> Result<(), LaserError> {
let stream_id = Identifier::named(stream)?;
let topic_id = Identifier::named(topic)?;
if client.get_topic(&stream_id, &topic_id).await?.is_some() {
return Ok(());
}
let options = TopicCreateOptions {
partitions_count: Some(partitions),
compression_algorithm: Some(CompressionAlgorithm::default()),
message_expiry: Some(expiry),
max_topic_size: Some(MaxTopicSize::ServerDefault),
..TopicCreateOptions::default()
};
let result = client.create_topic(&stream_id, topic, &options).await;
if let Err(error) = result
&& client.get_topic(&stream_id, &topic_id).await?.is_none()
{
return Err(error.into());
}
Ok(())
}
#[cfg(test)]
mod builder_conflict_tests {
#[cfg(feature = "agent")]
use super::claim_presence_slot;
use super::{Laser, clone_iggy_message, prepare_publish_messages};
use crate::error::LaserError;
use bytes::Bytes;
use iggy::prelude::IggyMessage;
#[cfg(feature = "agent")]
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::net::TcpListener;
#[cfg(feature = "agent")]
#[test]
fn given_one_connection_when_two_agents_claim_presence_then_should_reject_the_second() {
let slot = Mutex::new(None);
claim_presence_slot(&slot, "risk".parse().expect("risk is a valid agent id"))
.expect("the first agent claims the connection");
claim_presence_slot(&slot, "risk".parse().expect("risk is a valid agent id"))
.expect("the same agent may refresh its presence");
let error = claim_presence_slot(
&slot,
"support".parse().expect("support is a valid agent id"),
)
.expect_err("a second agent must not overwrite connection presence");
assert!(matches!(
error,
LaserError::PresenceConflict { advertised, requested }
if advertised == "risk" && requested == "support"
));
}
#[test]
fn given_a_publish_batch_when_cloned_for_retry_then_should_preserve_message_identity() {
let message = IggyMessage::builder()
.payload(Bytes::from_static(b"retry-body"))
.build()
.expect("the retry fixture message builds");
let mut messages = vec![message];
prepare_publish_messages(&mut messages);
let retry = clone_iggy_message(&messages[0]);
assert_ne!(retry.header.id, 0);
assert_eq!(retry.header.id, messages[0].header.id);
assert_eq!(retry.payload, Bytes::from_static(b"retry-body"));
assert_eq!(retry.user_headers, messages[0].user_headers);
}
#[test]
fn given_a_failed_chunk_when_reporting_progress_then_should_keep_confirmations_and_unsent_tail()
{
use iggy::prelude::{IggyError, SendMessagesConfirmationResponse};
use std::sync::Arc;
let message = |id| {
IggyMessage::builder()
.id(id)
.payload(Bytes::from_static(b"body"))
.build()
.expect("message builds")
};
let confirmed = SendMessagesConfirmationResponse {
stream_id: 1,
topic_id: 2,
partition_id: 0,
base_offset: 9,
};
let error = super::publish_failure(
LaserError::Iggy(IggyError::ProducerSendFailed {
cause: Box::new(IggyError::Disconnected),
failed: Arc::new(vec![message(2)]),
committed: Arc::new(vec![]),
stream_name: "stream".to_owned(),
topic_name: "topic".to_owned(),
}),
&[message(3)],
vec![confirmed],
);
let LaserError::Iggy(IggyError::ProducerSendFailed {
failed, committed, ..
}) = error
else {
panic!("preserve the structured publish error");
};
assert_eq!(
failed
.iter()
.map(|message| message.header.id)
.collect::<Vec<_>>(),
vec![2, 3]
);
assert_eq!(committed.len(), 1);
assert_eq!(committed[0].base_offset, 9);
}
#[tokio::test]
async fn given_two_connection_modes_when_built_then_should_error_before_connecting() {
let result = Laser::builder()
.connection_string("iggy:iggy@127.0.0.1:8090")
.address("127.0.0.1:8090")
.credentials("iggy", "iggy")
.build()
.await;
assert!(matches!(result, Err(LaserError::Config(_))));
}
#[tokio::test]
async fn given_a_server_that_never_answers_login_when_building_then_should_time_out_at_the_budget()
{
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind a local listener");
let address = listener.local_addr().expect("read the bound address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accept the client");
let mut buffer = [0_u8; 1024];
while socket.read(&mut buffer).await.is_ok_and(|read| read > 0) {}
});
let started = tokio::time::Instant::now();
let result = Laser::builder()
.connection_string(format!("iggy:iggy@{address}"))
.connect_timeout(Duration::from_millis(300))
.build()
.await;
assert!(matches!(
result,
Err(LaserError::Timeout("the Iggy login reply"))
));
assert!(started.elapsed() < Duration::from_secs(5));
server.abort();
}
#[tokio::test]
async fn given_a_zero_connect_timeout_when_building_then_should_reject_the_configuration() {
let result = Laser::builder()
.connection_string("iggy:iggy@127.0.0.1:8090")
.connect_timeout(Duration::ZERO)
.build()
.await;
assert!(matches!(result, Err(LaserError::Config(_))));
}
#[cfg(feature = "kv")]
#[tokio::test]
async fn given_a_closed_laser_when_acquiring_its_first_lease_then_should_not_open_a_connection()
{
let mut laser = Laser::from_client(iggy::prelude::IggyClient::default());
std::sync::Arc::get_mut(&mut laser.inner)
.expect("the Laser is not shared yet")
.connection_string = Some("not a connection string".to_owned());
let mut capabilities = crate::capabilities::Capabilities::OPEN;
capabilities.kv.fenced_leases = true;
let laser = laser.with_capabilities(capabilities);
laser.close().await.expect("close the unconnected client");
let result = laser
.kv("leases")
.lease(b"key", "holder", Duration::from_secs(1))
.await;
assert!(matches!(
result,
Err(LaserError::Iggy(iggy::prelude::IggyError::ClientShutdown))
));
}
}
#[cfg(test)]
mod connection_string_tests {
use super::{
PROD_CERT, flag_value_enabled, has_query_param, host_of, install_cert, is_laserdata_host,
normalize_connection_string, resolve_tls, resolve_tls_with,
};
#[test]
fn given_another_node_when_aimed_at_then_should_keep_credentials_and_options() {
assert_eq!(
super::with_endpoint(
"iggy+tcp://user:p/ss@host:8090?heartbeat_interval=5s",
"10.0.0.7:8091"
),
"iggy+tcp://user:p/ss@10.0.0.7:8091?heartbeat_interval=5s",
);
}
#[test]
fn given_a_tls_connection_when_aimed_at_another_node_then_should_verify_the_original_host() {
assert_eq!(
super::with_endpoint(
"iggy+tcp://user:pass@edge.laserdata.cloud:8090?tls=true",
"10.0.0.7:8090"
),
"iggy+tcp://user:pass@10.0.0.7:8090?tls=true&tls_domain=edge.laserdata.cloud",
);
}
#[test]
fn given_a_full_tcp_connection_string_when_normalized_then_should_be_unchanged() {
assert_eq!(
normalize_connection_string("iggy+tcp://iggy:iggy@127.0.0.1:8090")
.expect("a local connection string normalizes"),
"iggy+tcp://iggy:iggy@127.0.0.1:8090",
);
}
#[test]
fn given_a_default_scheme_when_normalized_then_should_be_unchanged() {
assert_eq!(
normalize_connection_string("iggy://user:password@host:8090")
.expect("a local connection string normalizes"),
"iggy://user:password@host:8090",
);
}
#[test]
fn given_a_bare_endpoint_when_normalized_then_should_prepend_default_scheme() {
assert_eq!(
normalize_connection_string("user:password@host:8090")
.expect("a bare endpoint normalizes"),
"iggy://user:password@host:8090",
);
}
#[test]
fn given_whitespace_around_the_value_when_normalized_then_should_be_trimmed() {
assert_eq!(
normalize_connection_string(" iggy:iggy@host:8090 ")
.expect("a padded endpoint normalizes"),
"iggy://iggy:iggy@host:8090",
);
}
#[test]
fn given_laserdata_hosts_when_checked_then_should_match_both_domains() {
assert!(is_laserdata_host("laserdata.cloud"));
assert!(is_laserdata_host("starter-123.aws.laserdata.cloud"));
assert!(is_laserdata_host("LASERDATA.CLOUD"));
assert!(is_laserdata_host("laserdata.com"));
assert!(is_laserdata_host("api.laserdata.com"));
assert!(
!is_laserdata_host("laserdata.cloud.attacker.com"),
"a look-alike suffix must not match"
);
assert!(
!is_laserdata_host("laserdata.com.attacker.com"),
"a look-alike suffix must not match"
);
}
#[test]
fn given_a_connection_string_when_the_host_is_extracted_then_should_strip_scheme_userinfo_and_port()
{
assert_eq!(
host_of("iggy+tcp://user:pwd@starter-123.aws.laserdata.cloud:8090"),
"starter-123.aws.laserdata.cloud",
);
assert_eq!(
host_of("user:pwd@host.laserdata.cloud:8090"),
"host.laserdata.cloud"
);
}
#[test]
fn given_a_laserdata_cloud_host_when_resolving_tls_then_should_attach_tls_and_the_bundled_ca() {
let resolved = resolve_tls("iggy+tcp://u:p@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(resolved.contains("tls=true"), "{resolved}");
assert!(resolved.contains("tls_ca_file="), "{resolved}");
}
#[test]
fn given_a_non_laserdata_host_when_resolving_tls_then_should_leave_it_untouched() {
assert_eq!(
resolve_tls("iggy+tcp://u:p@127.0.0.1:8090".to_owned())
.expect("tls resolution should succeed"),
"iggy+tcp://u:p@127.0.0.1:8090",
);
}
#[test]
fn given_a_custom_ca_when_resolving_a_local_host_then_should_enable_tls() {
let resolved = resolve_tls_with(
"iggy+tcp://u:p@demo.localhost:8090".to_owned(),
false,
Some("/tmp/local-ca.crt".into()),
)
.expect("local TLS resolution should succeed");
assert_eq!(
resolved,
"iggy+tcp://u:p@demo.localhost:8090?tls=true&tls_ca_file=/tmp/local-ca.crt"
);
}
#[test]
fn given_an_explicit_tls_ca_file_when_resolving_tls_then_should_leave_it_untouched() {
let connection_string =
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/tmp/my-ca.crt".to_owned();
assert_eq!(
resolve_tls(connection_string.clone()).expect("tls resolution should succeed"),
connection_string,
);
}
#[test]
fn given_a_password_containing_a_slash_when_the_host_is_extracted_then_should_find_the_real_host()
{
assert_eq!(
host_of("iggy+tcp://user:pa/ss@host.laserdata.cloud:8090"),
"host.laserdata.cloud",
);
assert_eq!(
host_of("iggy+tcp://user:a/b/c@host.laserdata.cloud"),
"host.laserdata.cloud",
);
}
#[test]
fn given_a_password_containing_a_slash_when_resolving_tls_then_should_still_attach_tls() {
let resolved = resolve_tls("iggy+tcp://user:pa/ss@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(
resolved.contains("tls=true"),
"a slash in the password must not silently disable TLS: {resolved}"
);
}
#[test]
fn given_an_at_sign_inside_the_query_when_the_host_is_extracted_then_should_ignore_it() {
assert_eq!(
host_of("iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/x@y.crt"),
"h.laserdata.cloud",
);
assert_eq!(
host_of("iggy+tcp://h.laserdata.cloud:8090?note=a@b"),
"h.laserdata.cloud",
);
}
#[test]
fn given_a_bracketed_ipv6_authority_when_the_host_is_extracted_then_should_drop_the_brackets() {
assert_eq!(host_of("iggy+tcp://u:p@[::1]:8090"), "::1");
}
#[test]
fn given_a_parameter_name_inside_the_password_when_checked_then_should_not_count_as_a_query_param()
{
assert!(!has_query_param(
"iggy+tcp://u:tls_ca_file=x@h.laserdata.cloud:8090",
"tls_ca_file"
));
assert!(has_query_param(
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls_ca_file=/ca.crt",
"tls_ca_file"
));
assert!(has_query_param(
"iggy+tcp://u:p@h.laserdata.cloud:8090?tls=true&tls_ca_file=/ca.crt",
"tls"
));
}
#[test]
fn given_a_password_containing_a_parameter_name_when_resolving_tls_then_should_still_attach_the_ca()
{
let resolved = resolve_tls("iggy+tcp://u:tls_ca_file=x@h.laserdata.cloud:8090".to_owned())
.expect("tls resolution should succeed");
assert!(
resolved.contains("tls=true"),
"credential content must not suppress TLS: {resolved}"
);
assert!(resolved.ends_with(".crt"), "{resolved}");
}
#[test]
fn given_an_opt_out_flag_value_when_read_then_should_only_accept_an_affirmative() {
assert!(flag_value_enabled("1"));
assert!(flag_value_enabled("true"));
assert!(flag_value_enabled("TRUE"));
assert!(flag_value_enabled(" yes "));
assert!(flag_value_enabled("on"));
assert!(!flag_value_enabled("0"), "`0` must not disable TLS");
assert!(!flag_value_enabled("false"), "`false` must not disable TLS");
assert!(
!flag_value_enabled(""),
"an empty value must not disable TLS"
);
assert!(!flag_value_enabled("no"));
}
#[test]
fn given_no_cached_certificate_when_installed_then_should_write_the_bundled_ca() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = install_cert(dir.path()).expect("the certificate installs");
assert_eq!(
std::fs::read(&path).expect("the installed certificate reads"),
PROD_CERT,
);
}
#[test]
fn given_a_tampered_cached_certificate_when_installed_then_should_replace_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let planted = dir.path().join("laserdata.crt");
std::fs::write(&planted, b"-----BEGIN CERTIFICATE-----\nattacker\n")
.expect("the planted certificate writes");
let path = install_cert(dir.path()).expect("the certificate installs");
assert_eq!(path, planted);
assert_eq!(
std::fs::read(&path).expect("the installed certificate reads"),
PROD_CERT,
"a pre-planted trust anchor must be replaced, never trusted"
);
}
#[test]
fn given_an_already_current_certificate_when_installed_then_should_reuse_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let first = install_cert(dir.path()).expect("the certificate installs");
let second = install_cert(dir.path()).expect("the certificate installs again");
assert_eq!(first, second);
assert_eq!(
std::fs::read(&second).expect("the installed certificate reads"),
PROD_CERT,
);
}
#[cfg(unix)]
#[test]
fn given_an_installed_certificate_when_inspected_then_should_be_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("a temp dir");
let path = install_cert(dir.path()).expect("the certificate installs");
let file_mode = std::fs::metadata(&path)
.expect("the certificate has metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(file_mode, 0o600, "the cached CA must be owner-only");
let dir_mode = std::fs::metadata(dir.path())
.expect("the cache directory has metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(
dir_mode, 0o700,
"the cache directory must not be group or world writable"
);
}
}