use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProducerIdentity {
pub id: i64,
pub epoch: i16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TxnState {
#[default]
Uninitialised,
Ready,
InTransaction,
Ending,
Fatal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SequenceRange {
pub base: i32,
pub count: i32,
}
impl SequenceRange {
#[must_use]
pub fn end(self) -> i32 {
self.base + self.count
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ProducerError {
#[error("the producer is not initialised")]
Uninitialised,
#[error("no transaction is open")]
NoTransaction,
#[error("a transaction is already open")]
TransactionAlreadyOpen,
#[error("{topic}-{partition} was not added to the transaction")]
NotEnrolled { topic: String, partition: i32 },
#[error("the producer is fenced or out of sequence and must be re-initialised")]
Fatal,
}
type PartitionKey = (String, i32);
#[derive(Debug, Default)]
pub struct ProducerState {
identity: Option<ProducerIdentity>,
txn: TxnState,
sequences: HashMap<PartitionKey, i32>,
enrolled: HashSet<PartitionKey>,
transactional: bool,
}
impl ProducerState {
#[must_use]
pub fn transactional() -> Self {
Self {
transactional: true,
..Self::default()
}
}
#[must_use]
pub fn idempotent() -> Self {
Self {
transactional: false,
..Self::default()
}
}
#[must_use]
pub fn state(&self) -> TxnState {
self.txn
}
#[must_use]
pub fn identity(&self) -> Option<ProducerIdentity> {
self.identity
}
pub fn on_init_producer_id(&mut self, identity: ProducerIdentity) {
let epoch_changed = self.identity.map(|i| i.epoch) != Some(identity.epoch)
|| self.identity.map(|i| i.id) != Some(identity.id);
self.identity = Some(identity);
if epoch_changed {
self.sequences.clear();
}
self.enrolled.clear();
self.txn = TxnState::Ready;
}
pub fn begin_transaction(&mut self) -> Result<(), ProducerError> {
self.check_usable()?;
match self.txn {
TxnState::Ready => {
self.enrolled.clear();
self.txn = TxnState::InTransaction;
Ok(())
}
TxnState::InTransaction | TxnState::Ending => {
Err(ProducerError::TransactionAlreadyOpen)
}
TxnState::Uninitialised => Err(ProducerError::Uninitialised),
TxnState::Fatal => Err(ProducerError::Fatal),
}
}
#[must_use]
pub fn needs_enrollment(&self, topic: &str, partition: i32) -> bool {
self.transactional
&& self.txn == TxnState::InTransaction
&& !self.enrolled.contains(&(topic.to_owned(), partition))
}
pub fn on_enrolled(&mut self, topic: &str, partition: i32) {
self.enrolled.insert((topic.to_owned(), partition));
}
pub fn allocate(
&mut self,
topic: &str,
partition: i32,
count: i32,
) -> Result<SequenceRange, ProducerError> {
self.check_usable()?;
if self.transactional {
if self.txn != TxnState::InTransaction {
return Err(ProducerError::NoTransaction);
}
if !self.enrolled.contains(&(topic.to_owned(), partition)) {
return Err(ProducerError::NotEnrolled {
topic: topic.to_owned(),
partition,
});
}
}
let next = self
.sequences
.entry((topic.to_owned(), partition))
.or_insert(0);
let base = *next;
*next += count;
Ok(SequenceRange { base, count })
}
#[must_use]
pub fn next_sequence(&self, topic: &str, partition: i32) -> i32 {
self.sequences
.get(&(topic.to_owned(), partition))
.copied()
.unwrap_or(0)
}
pub fn end_transaction(&mut self) -> Result<(), ProducerError> {
self.check_usable()?;
if self.txn != TxnState::InTransaction {
return Err(ProducerError::NoTransaction);
}
self.txn = TxnState::Ending;
Ok(())
}
pub fn on_end_transaction(&mut self) {
if self.txn == TxnState::Ending {
self.enrolled.clear();
self.txn = TxnState::Ready;
}
}
pub fn fence(&mut self) {
self.txn = TxnState::Fatal;
}
fn check_usable(&self) -> Result<(), ProducerError> {
match self.txn {
TxnState::Fatal => Err(ProducerError::Fatal),
TxnState::Uninitialised if self.identity.is_none() => Err(ProducerError::Uninitialised),
_ => Ok(()),
}
}
}
#[must_use]
pub fn looks_deduplicated(base_offset: i64, previous_high: Option<i64>) -> bool {
match previous_high {
Some(high) => base_offset <= high,
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ready_transactional() -> ProducerState {
let mut p = ProducerState::transactional();
p.on_init_producer_id(ProducerIdentity { id: 7, epoch: 0 });
p
}
fn enrolled_in_transaction() -> ProducerState {
let mut p = ready_transactional();
p.begin_transaction().unwrap();
p.on_enrolled("t", 0);
p
}
#[test]
fn sequences_continue_across_transactions() {
let mut p = enrolled_in_transaction();
let first = p.allocate("t", 0, 3).unwrap();
assert_eq!(first, SequenceRange { base: 0, count: 3 });
p.end_transaction().unwrap();
p.on_end_transaction();
p.begin_transaction().unwrap();
p.on_enrolled("t", 0);
let second = p.allocate("t", 0, 2).unwrap();
assert_eq!(
second,
SequenceRange { base: 3, count: 2 },
"a new transaction restarted the sequence; the broker will \
deduplicate it and commit an empty transaction"
);
}
#[test]
fn a_retry_reuses_its_range_and_does_not_advance() {
let mut p = enrolled_in_transaction();
let range = p.allocate("t", 0, 5).unwrap();
let next_before = p.next_sequence("t", 0);
assert_eq!(range.base, 0);
assert_eq!(next_before, 5);
assert_eq!(p.next_sequence("t", 0), 5, "a retry must not advance");
}
#[test]
fn allocation_advances_even_without_acknowledgement() {
let mut p = enrolled_in_transaction();
p.allocate("t", 0, 4).unwrap();
assert_eq!(p.next_sequence("t", 0), 4);
let next = p.allocate("t", 0, 1).unwrap();
assert_eq!(next.base, 4);
}
#[test]
fn a_new_epoch_resets_sequences() {
let mut p = enrolled_in_transaction();
p.allocate("t", 0, 3).unwrap();
assert_eq!(p.next_sequence("t", 0), 3);
p.on_init_producer_id(ProducerIdentity { id: 7, epoch: 1 });
assert_eq!(p.next_sequence("t", 0), 0);
}
#[test]
fn re_initialising_the_same_identity_keeps_sequences() {
let mut p = enrolled_in_transaction();
p.allocate("t", 0, 3).unwrap();
p.on_init_producer_id(ProducerIdentity { id: 7, epoch: 0 });
assert_eq!(p.next_sequence("t", 0), 3);
}
#[test]
fn sequences_are_per_partition() {
let mut p = ready_transactional();
p.begin_transaction().unwrap();
p.on_enrolled("t", 0);
p.on_enrolled("t", 1);
p.allocate("t", 0, 3).unwrap();
let other = p.allocate("t", 1, 1).unwrap();
assert_eq!(other.base, 0);
}
#[test]
fn a_fenced_producer_refuses_everything() {
let mut p = enrolled_in_transaction();
p.fence();
assert_eq!(p.begin_transaction(), Err(ProducerError::Fatal));
assert_eq!(p.allocate("t", 0, 1), Err(ProducerError::Fatal));
assert_eq!(p.end_transaction(), Err(ProducerError::Fatal));
}
#[test]
fn re_initialising_clears_the_fatal_state() {
let mut p = enrolled_in_transaction();
p.fence();
p.on_init_producer_id(ProducerIdentity { id: 7, epoch: 1 });
assert_eq!(p.state(), TxnState::Ready);
assert!(p.begin_transaction().is_ok());
}
#[test]
fn producing_to_an_unenrolled_partition_is_refused() {
let mut p = ready_transactional();
p.begin_transaction().unwrap();
assert_eq!(
p.allocate("t", 0, 1),
Err(ProducerError::NotEnrolled {
topic: "t".to_owned(),
partition: 0
})
);
}
#[test]
fn enrollment_is_once_per_transaction() {
let mut p = ready_transactional();
p.begin_transaction().unwrap();
assert!(p.needs_enrollment("t", 0));
p.on_enrolled("t", 0);
assert!(!p.needs_enrollment("t", 0));
p.end_transaction().unwrap();
p.on_end_transaction();
p.begin_transaction().unwrap();
assert!(
p.needs_enrollment("t", 0),
"a new transaction must enroll its partitions again"
);
}
#[test]
fn producing_outside_a_transaction_is_refused() {
let mut p = ready_transactional();
assert_eq!(p.allocate("t", 0, 1), Err(ProducerError::NoTransaction));
}
#[test]
fn nesting_transactions_is_refused() {
let mut p = ready_transactional();
p.begin_transaction().unwrap();
assert_eq!(
p.begin_transaction(),
Err(ProducerError::TransactionAlreadyOpen)
);
}
#[test]
fn an_uninitialised_producer_refuses_to_begin() {
let mut p = ProducerState::transactional();
assert_eq!(p.begin_transaction(), Err(ProducerError::Uninitialised));
}
#[test]
fn an_idempotent_producer_needs_no_transaction() {
let mut p = ProducerState::idempotent();
p.on_init_producer_id(ProducerIdentity { id: 1, epoch: 0 });
assert!(!p.needs_enrollment("t", 0));
assert_eq!(p.allocate("t", 0, 2).unwrap().base, 0);
assert_eq!(p.allocate("t", 0, 2).unwrap().base, 2);
}
#[test]
fn a_repeated_base_offset_reads_as_deduplication() {
assert!(!looks_deduplicated(0, None));
assert!(!looks_deduplicated(10, Some(7)));
assert!(looks_deduplicated(7, Some(7)));
assert!(looks_deduplicated(3, Some(7)));
}
#[test]
fn sequences_are_contiguous_under_arbitrary_interleaving() {
let mut p = ready_transactional();
let partitions = [0, 1, 2];
let mut expected: HashMap<i32, i32> = partitions.iter().map(|p| (*p, 0)).collect();
let mut counter = 0u32;
for txn in 0..5 {
p.begin_transaction().unwrap();
for step in 0..7 {
counter = counter.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let partition = partitions[(counter >> 16) as usize % partitions.len()];
let count = i32::try_from((counter >> 8) % 4 + 1).unwrap();
if p.needs_enrollment("t", partition) {
p.on_enrolled("t", partition);
}
let range = p.allocate("t", partition, count).unwrap();
let want = expected.get_mut(&partition).unwrap();
assert_eq!(
range.base, *want,
"txn {txn} step {step}: partition {partition} sequence jumped"
);
*want += count;
}
p.end_transaction().unwrap();
p.on_end_transaction();
}
for (partition, want) in expected {
assert_eq!(p.next_sequence("t", partition), want);
}
}
}