blob: c51e34d2fa0c35b35a9a8c85f01b657dddd8ca47 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use std::{str::FromStr, sync::Arc};
use iggy::clients::producer_config::BackpressureMode as RustBackpressureMode;
use iggy::prelude::{
BackgroundConfig as RustBackgroundConfig, BalancedSharding, DirectConfig as RustDirectConfig,
Identifier, IggyByteSize, IggyDuration, IggyError, IggyMessage as RustIggyMessage,
IggyProducer as RustIggyProducer, OrderedSharding,
SendMessagesConfirmationResponse as RustSendMessagesConfirmationResponse, Sharding,
};
use pyo3::IntoPyObjectExt;
use pyo3::conversion::FromPyObject;
use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyDelta, PyInt, PyList, PyString};
use pyo3_async_runtimes::tokio::future_into_py;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods};
use pyo3_stub_gen::{PyStubType, TypeInfo};
use tokio::sync::{RwLock, Semaphore};
use crate::duration::{duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration};
use crate::partitioning::Partitioning;
use crate::send_message::{SendMessage, SendMessagesConfirmation, SendMessagesResponse};
const DEFAULT_BACKGROUND_NUM_SHARDS: usize = 1;
const DEFAULT_BACKGROUND_BATCH_SIZE: usize = 1024 * 1024;
const DEFAULT_BACKGROUND_BATCH_LENGTH: usize = 1_000;
const DEFAULT_BACKGROUND_MAX_BUFFER_SIZE: u64 = 32 * 1024 * 1024;
const DEFAULT_BACKGROUND_MAX_IN_FLIGHT: usize = 1;
/// Configuration for a producer that sends from the calling task.
#[derive(Clone)]
#[gen_stub_pyclass]
#[pyclass(frozen, from_py_object)]
pub struct DirectProducerConfig {
pub(crate) inner: RustDirectConfig,
}
impl Default for DirectProducerConfig {
fn default() -> Self {
Self {
inner: RustDirectConfig::builder().build(),
}
}
}
impl From<&DirectProducerConfig> for RustDirectConfig {
fn from(config: &DirectProducerConfig) -> Self {
config.inner.clone()
}
}
#[gen_stub_pymethods]
#[pymethods]
impl DirectProducerConfig {
/// Constructs direct-producer batching and pacing configuration.
#[new]
#[pyo3(signature = (*, batch_length=1000, linger_time=DefaultDuration::default()))]
fn new(batch_length: i64, linger_time: DefaultDuration) -> PyResult<Self> {
let batch_length = u32_param(batch_length, "batch_length")?;
let linger_time = linger_time.resolve(IggyDuration::from(0))?;
if linger_time.get_duration().as_micros() > u128::from(u64::MAX) {
return Err(PyValueError::new_err(format!(
"'linger_time' must not exceed {} microseconds",
u64::MAX
)));
}
Ok(Self {
inner: RustDirectConfig::builder()
.batch_length(batch_length)
.linger_time(linger_time)
.build(),
})
}
/// Maximum number of messages sent in one request.
/// A value of zero uses the internal limit of 1,000,000 messages.
#[getter]
fn batch_length(&self) -> u32 {
self.inner.batch_length
}
/// Minimum gap requested between sequential direct sends.
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn linger_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.linger_time)
}
fn __repr__(&self) -> String {
format!(
"DirectProducerConfig(batch_length={}, linger_time={})",
self.inner.batch_length,
duration_repr(self.inner.linger_time)
)
}
}
/// How a background producer distributes sends among its workers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[gen_stub_pyclass_enum]
#[pyclass(eq, from_py_object, rename_all = "UPPERCASE")]
pub enum ProducerSharding {
Ordered,
Balanced,
}
impl From<ProducerSharding> for Box<dyn Sharding + Send + Sync> {
fn from(sharding: ProducerSharding) -> Self {
match sharding {
ProducerSharding::Ordered => Box::new(OrderedSharding),
ProducerSharding::Balanced => Box::new(BalancedSharding::default()),
}
}
}
/// What a background send does when the producer buffer is full.
#[derive(Debug, Clone, PartialEq, Eq)]
#[gen_stub_pyclass]
#[pyclass(eq, frozen, from_py_object)]
pub struct BackpressureMode {
kind: BackpressureKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BackpressureKind {
Block,
BlockWithTimeout(IggyDuration),
FailImmediately,
}
impl From<&BackpressureMode> for RustBackpressureMode {
fn from(mode: &BackpressureMode) -> Self {
match mode.kind {
BackpressureKind::Block => Self::Block,
BackpressureKind::BlockWithTimeout(timeout) => Self::BlockWithTimeout(timeout),
BackpressureKind::FailImmediately => Self::FailImmediately,
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl BackpressureMode {
/// Wait indefinitely for buffer capacity.
#[staticmethod]
fn block() -> Self {
Self {
kind: BackpressureKind::Block,
}
}
/// Wait up to `timeout` for buffer capacity.
#[staticmethod]
fn block_with_timeout(
#[gen_stub(override_type(type_repr = "datetime.timedelta", imports=("datetime")))]
timeout: Py<PyDelta>,
) -> PyResult<Self> {
Ok(Self {
kind: BackpressureKind::BlockWithTimeout(py_delta_to_iggy_duration(&timeout)?),
})
}
/// Fail immediately when the producer buffer is full.
#[staticmethod]
fn fail_immediately() -> Self {
Self {
kind: BackpressureKind::FailImmediately,
}
}
/// The configured timeout, or `None` for modes without one.
#[gen_stub(override_return_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
#[getter]
fn timeout<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyDelta>>> {
match self.kind {
BackpressureKind::BlockWithTimeout(timeout) => {
iggy_duration_to_py_delta(py, timeout).map(Some)
}
BackpressureKind::Block | BackpressureKind::FailImmediately => Ok(None),
}
}
fn __repr__(&self) -> String {
match self.kind {
BackpressureKind::Block => "BackpressureMode.block()".to_owned(),
BackpressureKind::BlockWithTimeout(timeout) => format!(
"BackpressureMode.block_with_timeout({})",
duration_repr(timeout)
),
BackpressureKind::FailImmediately => "BackpressureMode.fail_immediately()".to_owned(),
}
}
}
/// Immutable configuration for a producer that queues sends on background workers.
///
/// For detailed background-producer semantics, see
/// https://iggy.apache.org/docs/sdk/rust/high-level-sdk/.
#[derive(Clone)]
#[gen_stub_pyclass]
#[pyclass(frozen, from_py_object)]
pub struct BackgroundProducerConfig {
num_shards: usize,
linger_time: IggyDuration,
batch_size: usize,
batch_length: usize,
max_buffer_size: IggyByteSize,
failure_mode: BackpressureMode,
max_in_flight: usize,
sharding: ProducerSharding,
}
impl Default for BackgroundProducerConfig {
fn default() -> Self {
Self {
num_shards: DEFAULT_BACKGROUND_NUM_SHARDS,
linger_time: IggyDuration::from(1_000),
batch_size: DEFAULT_BACKGROUND_BATCH_SIZE,
batch_length: DEFAULT_BACKGROUND_BATCH_LENGTH,
max_buffer_size: IggyByteSize::from(DEFAULT_BACKGROUND_MAX_BUFFER_SIZE),
failure_mode: BackpressureMode::block(),
max_in_flight: DEFAULT_BACKGROUND_MAX_IN_FLIGHT,
sharding: ProducerSharding::Ordered,
}
}
}
impl TryFrom<&BackgroundProducerConfig> for RustBackgroundConfig {
type Error = PyErr;
fn try_from(config: &BackgroundProducerConfig) -> PyResult<Self> {
let max_buffer_size = config.max_buffer_size.as_bytes_u64();
if max_buffer_size != 0 && max_buffer_size > Semaphore::MAX_PERMITS as u64 {
return Err(PyValueError::new_err(format!(
"'max_buffer_size' must not exceed {}",
Semaphore::MAX_PERMITS
)));
}
if config.max_in_flight != 0 && config.max_in_flight > Semaphore::MAX_PERMITS {
return Err(PyValueError::new_err(format!(
"'max_in_flight' must not exceed {}",
Semaphore::MAX_PERMITS
)));
}
Ok(RustBackgroundConfig::builder()
.num_shards(config.num_shards)
.linger_time(config.linger_time)
.batch_size(config.batch_size)
.batch_length(config.batch_length)
.max_buffer_size(config.max_buffer_size)
.failure_mode((&config.failure_mode).into())
.max_in_flight(config.max_in_flight)
.sharding(config.sharding.into())
.build())
}
}
#[gen_stub_pymethods]
#[pymethods]
impl BackgroundProducerConfig {
/// Constructs background batching, capacity, backpressure, and sharding configuration.
#[new]
#[allow(clippy::too_many_arguments)]
#[pyo3(signature = (
*,
num_shards=1,
linger_time=DefaultDuration::one_millisecond(),
batch_size=1048576,
batch_length=1000,
max_buffer_size=33554432,
failure_mode=DefaultBackpressureMode::default(),
max_in_flight=1,
sharding=DefaultProducerSharding::default(),
))]
fn new(
num_shards: i128,
linger_time: DefaultDuration,
batch_size: i128,
batch_length: i128,
max_buffer_size: i128,
failure_mode: DefaultBackpressureMode,
max_in_flight: i128,
sharding: DefaultProducerSharding,
) -> PyResult<Self> {
let linger_time = linger_time.resolve(IggyDuration::from(1_000))?;
Ok(Self {
num_shards: usize_param(num_shards, "num_shards")?,
linger_time,
batch_size: usize_param(batch_size, "batch_size")?,
batch_length: usize_param(batch_length, "batch_length")?,
max_buffer_size: IggyByteSize::from(u64_param(max_buffer_size, "max_buffer_size")?),
failure_mode: failure_mode.resolve(),
max_in_flight: usize_param(max_in_flight, "max_in_flight")?,
sharding: sharding.resolve(),
})
}
/// Number of background worker shards, each with its own queue.
/// A value of zero is treated as one shard.
#[getter]
fn num_shards(&self) -> usize {
self.num_shards
}
/// Maximum time a worker holds a non-empty buffer before flushing it.
/// A zero duration flushes as soon as the worker receives a send.
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn linger_time<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDelta>> {
iggy_duration_to_py_delta(py, self.linger_time)
}
/// Per-worker flush threshold in buffered bytes.
/// A value of zero disables this threshold.
#[getter]
fn batch_size(&self) -> usize {
self.batch_size
}
/// Per-worker flush threshold in queued sends, not individual messages.
/// A value of zero disables this threshold.
#[getter]
fn batch_length(&self) -> usize {
self.batch_length
}
/// Maximum bytes buffered or in flight across all worker shards.
/// A value of zero makes the byte budget unlimited.
#[getter]
fn max_buffer_size(&self) -> u64 {
self.max_buffer_size.as_bytes_u64()
}
/// Behavior when `max_buffer_size` is exhausted.
#[getter]
fn failure_mode(&self) -> BackpressureMode {
self.failure_mode.clone()
}
/// Maximum number of requests written concurrently across all workers.
/// A value of zero uses the runtime's maximum semaphore permit count.
#[getter]
fn max_in_flight(&self) -> usize {
self.max_in_flight
}
/// Strategy used to assign each send to a worker shard.
/// Ordered sharding preserves per-destination dispatch order, while balanced
/// sharding distributes sends round-robin and may reorder them.
#[getter]
fn sharding(&self) -> ProducerSharding {
self.sharding
}
fn __repr__(&self) -> String {
let sharding = match self.sharding {
ProducerSharding::Ordered => "ProducerSharding.ORDERED",
ProducerSharding::Balanced => "ProducerSharding.BALANCED",
};
format!(
"BackgroundProducerConfig(num_shards={}, linger_time={}, batch_size={}, batch_length={}, max_buffer_size={}, failure_mode={}, max_in_flight={}, sharding={sharding})",
self.num_shards,
duration_repr(self.linger_time),
self.batch_size,
self.batch_length,
self.max_buffer_size.as_bytes_u64(),
self.failure_mode.__repr__(),
self.max_in_flight,
)
}
}
/// A direct producer error that preserves partial-send recovery state.
#[gen_stub_pyclass]
#[pyclass(frozen, extends=PyRuntimeError)]
pub struct ProducerSendError {
cause: String,
failed: Arc<Vec<RustIggyMessage>>,
committed: Arc<Vec<RustSendMessagesConfirmationResponse>>,
}
#[gen_stub_pymethods]
#[pymethods]
impl ProducerSendError {
/// The underlying Iggy error message.
#[getter]
fn cause(&self) -> &str {
&self.cause
}
/// Messages without a usable confirmation after the failure.
/// An encryptor can leave these messages encrypted, so do not submit them
/// to the same producer without restoring their original payloads.
#[getter]
fn failed(&self) -> Vec<SendMessage> {
self.failed
.iter()
.map(SendMessage::clone_from_rust)
.collect()
}
/// Confirmations returned for chunks committed before the failure.
#[getter]
fn committed(&self) -> Vec<SendMessagesConfirmation> {
self.committed
.iter()
.map(SendMessagesConfirmation::from)
.collect()
}
}
impl ProducerSendError {
fn new_err(
cause: IggyError,
failed: Arc<Vec<RustIggyMessage>>,
committed: Arc<Vec<RustSendMessagesConfirmationResponse>>,
) -> PyErr {
Python::attach(|py| {
let cause = cause.to_string();
let message = format!("Producer send failed: {cause}");
let cause_error = PyRuntimeError::new_err(cause.clone());
let instance = match Bound::new(
py,
Self {
cause,
failed,
committed,
},
) {
Ok(instance) => instance,
Err(error) => return error,
};
if let Err(error) = instance.setattr("args", (message,)) {
return error;
}
let error = PyErr::from_value(instance.into_any());
error.set_cause(py, Some(cause_error));
error
})
}
}
/// Python port of the Rust high-level producer API, bound to one stream and topic.
///
/// For detailed producer semantics, see
/// https://iggy.apache.org/docs/sdk/rust/high-level-sdk/.
///
/// Direct sends complete after the server responds and contain commit confirmations.
/// Background sends complete once accepted by a worker and contain no confirmations.
/// Always use the async context manager or call `shutdown()` explicitly; dropping a
/// background producer can lose accepted buffered messages.
#[derive(Clone)]
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
pub struct IggyProducer {
inner: Arc<RwLock<Option<RustIggyProducer>>>,
}
impl IggyProducer {
pub(crate) fn new(producer: RustIggyProducer) -> Self {
Self {
inner: Arc::new(RwLock::new(Some(producer))),
}
}
}
#[gen_stub_pymethods]
#[pymethods]
impl IggyProducer {
/// Sends a batch to the producer's bound stream and topic.
/// In background mode, success means accepted into the dispatcher and the
/// returned confirmation list is empty.
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))]
fn send<'py>(
&self,
py: Python<'py>,
#[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>,
) -> PyResult<Bound<'py, PyAny>> {
let messages = extract_messages(messages)?;
let inner = self.inner.clone();
future_into_py(py, async move {
// Every send keeps a read guard for its full future. Concurrent sends
// remain possible while shutdown cannot consume an active producer.
let producer = inner.read().await;
let producer = producer
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?;
producer
.send(messages)
.await
.map(SendMessagesResponse::from)
.map_err(to_send_error)
})
}
/// Sends one message to the producer's bound stream and topic.
/// It has the same mode-dependent completion semantics as `send()`.
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))]
fn send_one<'py>(
&self,
py: Python<'py>,
message: PyRef<'_, SendMessage>,
) -> PyResult<Bound<'py, PyAny>> {
let message = clone_rust_message(&message);
let inner = self.inner.clone();
future_into_py(py, async move {
let producer = inner.read().await;
let producer = producer
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?;
producer
.send_one(message)
.await
.map(SendMessagesResponse::from)
.map_err(to_send_error)
})
}
/// Sends a batch with an optional per-call partitioning override.
/// It has the same mode-dependent completion semantics as `send()`.
#[pyo3(signature = (messages, partitioning=None))]
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))]
fn send_with_partitioning<'py>(
&self,
py: Python<'py>,
#[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>,
#[gen_stub(override_type(type_repr = "Partitioning | None"))] partitioning: Option<
&Partitioning,
>,
) -> PyResult<Bound<'py, PyAny>> {
let messages = extract_messages(messages)?;
let partitioning = partitioning.map(|value| value.inner.clone());
let inner = self.inner.clone();
future_into_py(py, async move {
let producer = inner.read().await;
let producer = producer
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?;
producer
.send_with_partitioning(messages, partitioning)
.await
.map(SendMessagesResponse::from)
.map_err(to_send_error)
})
}
/// Sends a batch to another existing stream and topic.
/// It has the same mode-dependent completion semantics as `send()` and does
/// not create the alternate destination.
#[pyo3(signature = (stream, topic, messages, partitioning=None))]
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))]
fn send_to<'py>(
&self,
py: Python<'py>,
#[gen_stub(override_type(type_repr = "builtins.str | builtins.int"))] stream: &Bound<
'_,
PyAny,
>,
#[gen_stub(override_type(type_repr = "builtins.str | builtins.int"))] topic: &Bound<
'_,
PyAny,
>,
#[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>,
#[gen_stub(override_type(type_repr = "Partitioning | None"))] partitioning: Option<
&Partitioning,
>,
) -> PyResult<Bound<'py, PyAny>> {
let stream = Arc::new(extract_send_to_identifier(stream, "stream")?);
let topic = Arc::new(extract_send_to_identifier(topic, "topic")?);
let messages = extract_messages(messages)?;
let partitioning = partitioning.map(|value| value.inner.clone());
let inner = self.inner.clone();
future_into_py(py, async move {
let producer = inner.read().await;
let producer = producer
.as_ref()
.ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?;
producer
.send_to(stream, topic, messages, partitioning)
.await
.map(SendMessagesResponse::from)
.map_err(to_send_error)
})
}
#[gen_stub(skip)]
fn _is_send_active(&self) -> bool {
self.inner.try_write().is_err()
}
/// Waits for active sends and closes the producer. Repeated calls are safe.
/// Background shutdown flushes every accepted buffered message before returning.
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[None]", imports=("collections.abc")))]
fn shutdown<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let inner = self.inner.clone();
future_into_py(py, async move {
shutdown(inner).await?;
Ok(Python::attach(|py| py.None()))
})
}
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[IggyProducer]", imports=("collections.abc")))]
fn __aenter__<'py>(this: Py<Self>, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
future_into_py(py, async move { Ok(this) })
}
#[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[builtins.bool]", imports=("collections.abc")))]
fn __aexit__<'py>(
&self,
py: Python<'py>,
_exc_type: &Bound<'_, PyAny>,
_exc_value: &Bound<'_, PyAny>,
_traceback: &Bound<'_, PyAny>,
) -> PyResult<Bound<'py, PyAny>> {
let inner = self.inner.clone();
future_into_py(py, async move {
shutdown(inner).await?;
Ok(false)
})
}
}
#[derive(Clone, FromPyObject)]
pub(crate) enum ProducerMode {
#[pyo3(transparent)]
Direct(DirectProducerConfig),
#[pyo3(transparent)]
Background(BackgroundProducerConfig),
}
impl Default for ProducerMode {
fn default() -> Self {
Self::Direct(DirectProducerConfig::default())
}
}
#[derive(FromPyObject)]
#[pyo3(transparent)]
struct DefaultBackpressureMode(BackpressureMode);
impl Default for DefaultBackpressureMode {
fn default() -> Self {
Self(BackpressureMode::block())
}
}
impl DefaultBackpressureMode {
fn resolve(self) -> BackpressureMode {
self.0
}
}
impl PyStubType for DefaultBackpressureMode {
fn type_output() -> TypeInfo {
BackpressureMode::type_output()
}
fn type_input() -> TypeInfo {
BackpressureMode::type_input()
}
}
impl<'py> IntoPyObject<'py> for DefaultBackpressureMode {
type Target = PyAny;
type Output = Bound<'py, PyAny>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
Ok(opaque_stub_default(py))
}
}
#[derive(FromPyObject)]
#[pyo3(transparent)]
struct DefaultProducerSharding(ProducerSharding);
impl Default for DefaultProducerSharding {
fn default() -> Self {
Self(ProducerSharding::Ordered)
}
}
impl DefaultProducerSharding {
fn resolve(self) -> ProducerSharding {
self.0
}
}
impl PyStubType for DefaultProducerSharding {
fn type_output() -> TypeInfo {
ProducerSharding::type_output()
}
fn type_input() -> TypeInfo {
ProducerSharding::type_input()
}
}
impl<'py> IntoPyObject<'py> for DefaultProducerSharding {
type Target = PyAny;
type Output = Bound<'py, PyAny>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
Ok(opaque_stub_default(py))
}
}
#[derive(Default)]
pub(crate) enum RetryInterval {
#[default]
Omitted,
Disabled,
Duration(Py<PyDelta>),
}
impl<'a, 'py> FromPyObject<'a, 'py> for RetryInterval {
type Error = PyErr;
fn extract(object: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
if object.is_none() {
Ok(Self::Disabled)
} else {
Ok(Self::Duration(object.extract::<Py<PyDelta>>()?))
}
}
}
impl PyStubType for RetryInterval {
fn type_output() -> TypeInfo {
<std::time::Duration>::type_output() | TypeInfo::none()
}
fn type_input() -> TypeInfo {
<std::time::Duration>::type_input() | TypeInfo::none()
}
}
impl<'py> IntoPyObject<'py> for RetryInterval {
type Target = PyAny;
type Output = Bound<'py, PyAny>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
match self {
Self::Omitted => std::time::Duration::from_secs(1).into_bound_py_any(py),
Self::Disabled => Ok(py.None().into_bound(py)),
Self::Duration(duration) => Ok(duration.into_bound(py).into_any()),
}
}
}
impl RetryInterval {
pub(crate) fn resolve(self) -> PyResult<Option<iggy::prelude::NonZeroIggyDuration>> {
match self {
Self::Omitted => Ok(Some(iggy::prelude::NonZeroIggyDuration::ONE_SECOND)),
Self::Disabled => Ok(None),
Self::Duration(duration) => py_delta_to_iggy_duration(&duration).and_then(|duration| {
iggy::prelude::NonZeroIggyDuration::try_from(duration)
.map(Some)
.map_err(|_| PyValueError::new_err("'send_retry_interval' must not be zero"))
}),
}
}
}
#[derive(Default)]
enum DefaultDuration {
#[default]
Zero,
OneMillisecond,
Value(Py<PyDelta>),
}
impl DefaultDuration {
fn one_millisecond() -> Self {
Self::OneMillisecond
}
}
impl PyStubType for DefaultDuration {
fn type_output() -> TypeInfo {
timedelta_type_info()
}
fn type_input() -> TypeInfo {
timedelta_type_info()
}
}
impl<'py> IntoPyObject<'py> for DefaultDuration {
type Target = PyDelta;
type Output = Bound<'py, PyDelta>;
type Error = PyErr;
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
match self {
Self::Zero => std::time::Duration::ZERO.into_pyobject(py),
Self::OneMillisecond => std::time::Duration::from_millis(1).into_pyobject(py),
Self::Value(duration) => Ok(duration.into_bound(py)),
}
}
}
impl<'a, 'py> FromPyObject<'a, 'py> for DefaultDuration {
type Error = PyErr;
fn extract(object: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
Ok(Self::Value(object.extract::<Py<PyDelta>>()?))
}
}
impl DefaultDuration {
fn resolve(self, default: IggyDuration) -> PyResult<IggyDuration> {
match self {
Self::Value(duration) => py_delta_to_iggy_duration(&duration),
Self::Zero | Self::OneMillisecond => Ok(default),
}
}
}
fn timedelta_type_info() -> TypeInfo {
let mut type_info = <std::time::Duration>::type_input();
type_info.source_module = None;
type_info
}
fn opaque_stub_default(py: Python<'_>) -> Bound<'_, PyAny> {
// The stub generator renders objects without a stable Python expression as
// `...`. An actual Ellipsis is rendered as `Ellipsis`, which type checkers
// reject as a default for these public configuration types.
py.None().into_bound(py).get_type().into_any()
}
async fn shutdown(inner: Arc<RwLock<Option<RustIggyProducer>>>) -> PyResult<()> {
// Exclusive access must span consuming shutdown so another close waits for
// completion and no send can observe a producer being closed underneath it.
let mut producer = inner.write().await;
if let Some(producer) = producer.take() {
producer.shutdown().await;
}
Ok(())
}
fn extract_messages(messages: &Bound<'_, PyList>) -> PyResult<Vec<RustIggyMessage>> {
messages
.iter()
.map(|item| {
let message = item.extract::<PyRef<'_, SendMessage>>()?;
Ok(clone_rust_message(&message))
})
.collect()
}
fn extract_send_to_identifier(value: &Bound<'_, PyAny>, parameter: &str) -> PyResult<Identifier> {
if let Ok(value) = value.cast::<PyString>() {
return Identifier::from_str(value.to_str()?)
.map_err(|error| PyValueError::new_err(error.to_string()));
}
if value.is_instance_of::<PyInt>() {
let value = value.extract::<u32>()?;
return Identifier::numeric(value)
.map_err(|error| PyValueError::new_err(error.to_string()));
}
Err(PyTypeError::new_err(format!(
"'{parameter}' must be a string or an integer"
)))
}
fn clone_rust_message(message: &SendMessage) -> RustIggyMessage {
message.clone().inner
}
fn to_send_error(error: IggyError) -> PyErr {
match error {
IggyError::ProducerSendFailed {
cause,
failed,
committed,
..
} => ProducerSendError::new_err(*cause, failed, committed),
error => PyRuntimeError::new_err(error.to_string()),
}
}
pub(crate) fn u32_param(value: i64, parameter: &str) -> PyResult<u32> {
u32::try_from(value).map_err(|_| {
PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u32::MAX))
})
}
fn usize_param(value: i128, parameter: &str) -> PyResult<usize> {
usize::try_from(value).map_err(|_| {
PyValueError::new_err(format!(
"'{parameter}' must be between 0 and {}",
usize::MAX
))
})
}
fn u64_param(value: i128, parameter: &str) -> PyResult<u64> {
u64::try_from(value).map_err(|_| {
PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u64::MAX))
})
}
#[cfg(test)]
mod tests {
use std::future::Future;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use iggy::clients::producer::ProducerCoreBackend;
use iggy::clients::producer_dispatcher::ProducerDispatcher;
use pyo3::exceptions::{PyOverflowError, PyRuntimeError, PyTypeError, PyValueError};
use super::*;
#[derive(Debug)]
struct ConcurrencyTrackingBackend {
active: Arc<AtomicUsize>,
maximum: Arc<AtomicUsize>,
}
impl ProducerCoreBackend for ConcurrencyTrackingBackend {
fn send_internal(
&self,
_stream: &Identifier,
_topic: &Identifier,
_messages: Vec<RustIggyMessage>,
_partitioning: Option<Arc<iggy::prelude::Partitioning>>,
) -> impl Future<Output = Result<iggy::prelude::SendMessagesResponse, IggyError>> + Send
{
let active = self.active.clone();
let maximum = self.maximum.clone();
async move {
let active_now = active.fetch_add(1, Ordering::SeqCst) + 1;
maximum.fetch_max(active_now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(50)).await;
active.fetch_sub(1, Ordering::SeqCst);
Ok(iggy::prelude::SendMessagesResponse {
confirmations: Vec::new(),
})
}
}
}
#[test]
fn direct_defaults_match_rust() {
let python = DirectProducerConfig::default();
let rust = RustDirectConfig::builder().build();
assert_eq!(python.inner.batch_length, rust.batch_length);
assert_eq!(python.inner.linger_time, rust.linger_time);
}
#[test]
fn direct_linger_rejects_values_above_u64_microseconds() {
Python::initialize();
Python::attach(|py| {
let duration = PyDelta::new(py, 999_999_999, 0, 0, false).unwrap().unbind();
let error = DirectProducerConfig::new(1_000, DefaultDuration::Value(duration))
.err()
.unwrap();
assert!(error.is_instance_of::<PyValueError>(py));
assert!(error.to_string().contains("linger_time"));
});
}
#[test]
fn producer_send_error_preserves_recovery_state() {
Python::initialize();
Python::attach(|py| {
let cause = IggyError::CannotSendMessagesDueToClientDisconnection;
let cause_message = cause.to_string();
let error = to_send_error(IggyError::ProducerSendFailed {
cause: Box::new(cause),
failed: Arc::new(vec![RustIggyMessage::from_str("failed").unwrap()]),
committed: Arc::new(vec![RustSendMessagesConfirmationResponse {
stream_id: 1,
topic_id: 2,
partition_id: 3,
base_offset: 4,
}]),
stream_name: "stream".to_owned(),
topic_name: "topic".to_owned(),
});
assert!(error.is_instance_of::<ProducerSendError>(py));
assert!(error.is_instance_of::<PyRuntimeError>(py));
assert_eq!(
error
.value(py)
.getattr("cause")
.unwrap()
.extract::<String>()
.unwrap(),
cause_message
);
let failed = error
.value(py)
.getattr("failed")
.unwrap()
.extract::<Vec<Py<SendMessage>>>()
.unwrap();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].borrow(py).inner.payload.as_ref(), b"failed");
let committed = error
.value(py)
.getattr("committed")
.unwrap()
.extract::<Vec<Py<SendMessagesConfirmation>>>()
.unwrap();
assert_eq!(committed.len(), 1);
assert_eq!(committed[0].borrow(py).inner.base_offset, 4);
let source = error.cause(py).unwrap();
assert!(source.is_instance_of::<PyRuntimeError>(py));
assert_eq!(
source.value(py).str().unwrap().to_str().unwrap(),
cause_message
);
});
}
#[test]
fn background_defaults_match_rust() {
let python = BackgroundProducerConfig::default();
let rust = RustBackgroundConfig::builder().build();
assert_eq!(python.num_shards, rust.num_shards);
assert_eq!(python.linger_time, rust.linger_time);
assert_eq!(python.batch_size, rust.batch_size);
assert_eq!(python.batch_length, rust.batch_length);
assert_eq!(python.max_buffer_size, rust.max_buffer_size);
assert!(matches!(rust.failure_mode, RustBackpressureMode::Block));
assert_eq!(python.max_in_flight, rust.max_in_flight);
assert!(matches!(python.sharding, ProducerSharding::Ordered));
assert_eq!(format!("{:?}", rust.sharding), "OrderedSharding");
}
#[test]
fn background_configuration_converts_every_field_to_rust() {
let python = BackgroundProducerConfig {
num_shards: 4,
linger_time: IggyDuration::from(2_000),
batch_size: 2_048,
batch_length: 32,
max_buffer_size: IggyByteSize::from(8_192),
failure_mode: BackpressureMode::fail_immediately(),
max_in_flight: 3,
sharding: ProducerSharding::Balanced,
};
let rust = RustBackgroundConfig::try_from(&python).unwrap();
assert_eq!(rust.num_shards, 4);
assert_eq!(rust.linger_time, IggyDuration::from(2_000));
assert_eq!(rust.batch_size, 2_048);
assert_eq!(rust.batch_length, 32);
assert_eq!(rust.max_buffer_size.as_bytes_u64(), 8_192);
assert!(matches!(
rust.failure_mode,
RustBackpressureMode::FailImmediately
));
assert_eq!(rust.max_in_flight, 3);
assert_eq!(
format!("{:?}", rust.sharding),
"BalancedSharding { counter: 0 }"
);
}
#[test]
fn backpressure_modes_convert_to_rust() {
let block = RustBackpressureMode::from(&BackpressureMode::block());
let timeout = RustBackpressureMode::from(&BackpressureMode {
kind: BackpressureKind::BlockWithTimeout(IggyDuration::from(250_000)),
});
let immediate = RustBackpressureMode::from(&BackpressureMode::fail_immediately());
assert!(matches!(block, RustBackpressureMode::Block));
assert!(matches!(
timeout,
RustBackpressureMode::BlockWithTimeout(value)
if value == IggyDuration::from(250_000)
));
assert!(matches!(immediate, RustBackpressureMode::FailImmediately));
}
#[test]
fn sharding_modes_convert_to_the_selected_rust_strategy() {
let messages = vec![RustIggyMessage::from_str("message").unwrap()];
let stream = Identifier::from_str("stream").unwrap();
let topic = Identifier::from_str("topic").unwrap();
let ordered = RustBackgroundConfig::try_from(&BackgroundProducerConfig {
num_shards: 4,
..BackgroundProducerConfig::default()
})
.unwrap();
let first = ordered.sharding.pick_shard(4, &messages, &stream, &topic);
let second = ordered.sharding.pick_shard(4, &messages, &stream, &topic);
assert_eq!(first, second);
let balanced = RustBackgroundConfig::try_from(&BackgroundProducerConfig {
num_shards: 3,
sharding: ProducerSharding::Balanced,
..BackgroundProducerConfig::default()
})
.unwrap();
let picked = (0..4)
.map(|_| balanced.sharding.pick_shard(3, &messages, &stream, &topic))
.collect::<Vec<_>>();
assert_eq!(picked, vec![0, 1, 2, 0]);
}
#[test]
fn background_conversion_rejects_values_that_would_panic_semaphore() {
let invalid_max_buffer = BackgroundProducerConfig {
max_buffer_size: IggyByteSize::from(Semaphore::MAX_PERMITS as u64 + 1),
..BackgroundProducerConfig::default()
};
let invalid_max_in_flight = BackgroundProducerConfig {
max_in_flight: Semaphore::MAX_PERMITS + 1,
..BackgroundProducerConfig::default()
};
assert!(RustBackgroundConfig::try_from(&invalid_max_buffer).is_err());
assert!(RustBackgroundConfig::try_from(&invalid_max_in_flight).is_err());
}
#[tokio::test]
async fn converted_max_in_flight_limits_concurrent_background_writes() {
let active = Arc::new(AtomicUsize::new(0));
let maximum = Arc::new(AtomicUsize::new(0));
let backend = Arc::new(ConcurrencyTrackingBackend {
active,
maximum: maximum.clone(),
});
let config = RustBackgroundConfig::try_from(&BackgroundProducerConfig {
num_shards: 4,
linger_time: IggyDuration::from(0),
batch_size: 0,
batch_length: 1,
max_buffer_size: IggyByteSize::from(0),
failure_mode: BackpressureMode::block(),
max_in_flight: 2,
sharding: ProducerSharding::Balanced,
})
.unwrap();
let dispatcher = ProducerDispatcher::new(backend, config);
let stream = Arc::new(Identifier::numeric(1).unwrap());
let topic = Arc::new(Identifier::numeric(1).unwrap());
for index in 0..4 {
dispatcher
.dispatch(
vec![RustIggyMessage::from_str(&index.to_string()).unwrap()],
stream.clone(),
topic.clone(),
None,
)
.await
.unwrap();
}
dispatcher.shutdown().await;
assert_eq!(maximum.load(Ordering::SeqCst), 2);
}
#[test]
fn direct_batch_length_rejects_values_outside_u32() {
assert!(u32_param(-1, "batch_length").is_err());
assert!(u32_param(i64::from(u32::MAX) + 1, "batch_length").is_err());
assert_eq!(
u32_param(i64::from(u32::MAX), "batch_length").unwrap(),
u32::MAX
);
}
#[test]
fn send_to_identifier_preserves_python_error_categories() {
Python::initialize();
Python::attach(|py| {
let string = PyString::new(py, "stream");
let numeric = 7_u32.into_pyobject(py).unwrap();
let negative = (-1_i64).into_pyobject(py).unwrap();
let too_large = (u64::from(u32::MAX) + 1).into_pyobject(py).unwrap();
let wrong_type = py.None().into_bound(py);
assert_eq!(
extract_send_to_identifier(string.as_any(), "stream")
.unwrap()
.get_string_value()
.unwrap(),
"stream"
);
assert_eq!(
extract_send_to_identifier(numeric.as_any(), "stream")
.unwrap()
.get_u32_value()
.unwrap(),
7
);
let negative = extract_send_to_identifier(negative.as_any(), "stream").unwrap_err();
let too_large = extract_send_to_identifier(too_large.as_any(), "stream").unwrap_err();
let wrong_type = extract_send_to_identifier(&wrong_type, "stream").unwrap_err();
assert!(negative.is_instance_of::<PyOverflowError>(py));
assert!(too_large.is_instance_of::<PyOverflowError>(py));
assert!(wrong_type.is_instance_of::<PyTypeError>(py));
});
}
#[test]
fn representations_name_public_python_constructors() {
let direct = DirectProducerConfig::default();
let background = BackgroundProducerConfig::default();
assert_eq!(
direct.__repr__(),
"DirectProducerConfig(batch_length=1000, linger_time=datetime.timedelta(seconds=0))"
);
assert_eq!(
BackpressureMode::block().__repr__(),
"BackpressureMode.block()"
);
assert_eq!(
BackpressureMode::fail_immediately().__repr__(),
"BackpressureMode.fail_immediately()"
);
assert!(
background
.__repr__()
.ends_with("sharding=ProducerSharding.ORDERED)")
);
}
#[test]
fn numeric_getters_return_configured_values() {
let background = BackgroundProducerConfig::default();
assert_eq!(background.num_shards(), 1);
assert_eq!(background.batch_size(), 1_048_576);
assert_eq!(background.batch_length(), 1_000);
assert_eq!(background.max_buffer_size(), 33_554_432);
assert_eq!(background.max_in_flight(), 1);
assert_eq!(background.sharding(), ProducerSharding::Ordered);
assert_eq!(background.failure_mode(), BackpressureMode::block());
}
#[test]
fn numeric_validation_uses_python_semantic_ranges() {
assert!(usize_param(-1, "num_shards").is_err());
assert!(u64_param(-1, "max_buffer_size").is_err());
assert_eq!(
usize_param(usize::MAX as i128, "num_shards").unwrap(),
usize::MAX
);
assert_eq!(
u64_param(u64::MAX as i128, "max_buffer_size").unwrap(),
u64::MAX
);
}
#[test]
fn direct_default_constants_match_builder() {
let rust = RustDirectConfig::builder().build();
assert_eq!(1_000, rust.batch_length);
assert_eq!(IggyDuration::from(0), rust.linger_time);
}
#[test]
fn background_default_constants_match_builder() {
let rust = RustBackgroundConfig::builder().build();
assert_eq!(DEFAULT_BACKGROUND_NUM_SHARDS, rust.num_shards);
assert_eq!(IggyDuration::from(1_000), rust.linger_time);
assert_eq!(DEFAULT_BACKGROUND_BATCH_SIZE, rust.batch_size);
assert_eq!(DEFAULT_BACKGROUND_BATCH_LENGTH, rust.batch_length);
assert_eq!(
DEFAULT_BACKGROUND_MAX_BUFFER_SIZE,
rust.max_buffer_size.as_bytes_u64()
);
assert_eq!(DEFAULT_BACKGROUND_MAX_IN_FLIGHT, rust.max_in_flight);
}
}