mod codec;
#[cfg(test)]
use std::{cell::RefCell, marker::PhantomData};
use std::{collections::VecDeque, sync::Arc};
use liminal::durability::settle::{append_settled, flush_settled};
use liminal::durability::{DurabilityError, DurableStore};
use liminal_protocol::wire::{
BindingEpoch, MarkerAck, ParticipantDelivery, ParticipantId, ParticipantRecord,
};
use super::facts::{FactsError, now_unix_nanos};
use super::operation_batch::OperationBatch;
use super::restore_checkpoint_locator::checkpoint_conversation_route;
use super::routed_stream::{RoutedStreamError, RoutedStreamRefusal};
pub(super) use codec::{decode_row, encode_row};
pub(super) const OUTBOX_STREAM_PREFIX: &str = "liminal:participant-production-unit2:";
pub(super) const UNIT2_OUTBOX_RESTORE_BATCH_ROWS: usize = 64;
pub(super) const OUTBOX_SCHEMA_VERSION: u8 = 1;
type CanonicalOutboxRow = (u64, OutboxRow, Vec<u8>);
#[derive(Debug, thiserror::Error)]
pub(super) enum OutboxLogError {
#[error(transparent)]
Durability(#[from] DurabilityError),
#[error("routed stream refused: {0}")]
RoutedStream(#[source] RoutedStreamRefusal),
#[error("Unit 2 extension row timestamp: {0}")]
Clock(#[from] FactsError),
#[error("Unit 2 extension row is missing its schema version")]
MissingSchemaVersion,
#[error("unsupported Unit 2 extension schema version {0}")]
SchemaVersion(u8),
#[error("mixed Unit 2 extension schema versions: expected {expected}, found {actual}")]
MixedSchemaVersions {
expected: u8,
actual: u8,
},
#[error("Unit 2 extension row ended before {field}")]
UnexpectedEnd {
field: &'static str,
},
#[error("unknown Unit 2 extension {domain} tag {value}")]
UnknownTag {
domain: &'static str,
value: u8,
},
#[error("invalid Unit 2 extension {field} selector {value}")]
InvalidSelector {
field: &'static str,
value: u8,
},
#[error("Unit 2 extension row carries zero capability generation")]
ZeroGeneration,
#[error("Unit 2 extension {field} length {length} exceeds u32")]
LengthOverflow {
field: &'static str,
length: usize,
},
#[error("Unit 2 extension participant push is not canonically encodable: {0:?}")]
PushCodec(liminal_protocol::wire::CodecError),
#[error("Unit 2 extension row has {remaining} trailing bytes")]
TrailingBytes {
remaining: usize,
},
#[error("Unit 2 extension stream expected sequence {expected}, found {actual}")]
Sequence {
expected: u64,
actual: u64,
},
#[error("Unit 2 extension append expected {expected}, got {actual}")]
AssignedSequence {
expected: u64,
actual: u64,
},
}
impl From<RoutedStreamError> for OutboxLogError {
fn from(error: RoutedStreamError) -> Self {
match error {
RoutedStreamError::Store(original) => Self::Durability(original),
RoutedStreamError::Refusal(refusal) => Self::RoutedStream(refusal),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ProducedSourceKind {
Enrolled,
Attached,
Detached,
Died,
MarkerDrained,
RecordAdmission,
Left,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ProjectedRecord {
pub(super) delivery_seq: u64,
pub(super) body: ParticipantRecord,
pub(super) recipients: Vec<ParticipantId>,
pub(super) sender: Option<ParticipantId>,
pub(super) encoded_push_bytes: u64,
}
impl ProjectedRecord {
pub(super) fn try_new(
conversation_id: u64,
delivery_seq: u64,
body: ParticipantRecord,
recipients: Vec<ParticipantId>,
sender: Option<ParticipantId>,
) -> Result<Self, OutboxLogError> {
let encoded_push_bytes = codec::canonical_push_bytes(conversation_id, delivery_seq, &body)?;
Ok(Self {
delivery_seq,
body,
recipients,
sender,
encoded_push_bytes,
})
}
pub(super) const fn delivery_seq(&self) -> u64 {
self.delivery_seq
}
pub(super) const fn body(&self) -> &ParticipantRecord {
&self.body
}
pub(super) fn recipients(&self) -> &[ParticipantId] {
&self.recipients
}
pub(super) const fn sender(&self) -> Option<ParticipantId> {
self.sender
}
pub(super) const fn encoded_push_bytes(&self) -> u64 {
self.encoded_push_bytes
}
pub(super) fn into_delivery(self, conversation_id: u64) -> ParticipantDelivery {
ParticipantDelivery {
conversation_id,
delivery_seq: self.delivery_seq,
record: self.body,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ProducedBatch {
pub(super) source_log_sequence: u64,
pub(super) source_kind: ProducedSourceKind,
pub(super) ordered_records: Vec<ProjectedRecord>,
}
impl ProducedBatch {
pub(super) const fn source_log_sequence(&self) -> u64 {
self.source_log_sequence
}
pub(super) const fn source_kind(&self) -> ProducedSourceKind {
self.source_kind
}
pub(super) fn ordered_records(&self) -> &[ProjectedRecord] {
&self.ordered_records
}
pub(super) const fn new(
source_log_sequence: u64,
source_kind: ProducedSourceKind,
ordered_records: Vec<ProjectedRecord>,
) -> Self {
Self {
source_log_sequence,
source_kind,
ordered_records,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct StoredMarkerAckCommitted {
pub(super) request: MarkerAck,
pub(super) receiving_binding_epoch: BindingEpoch,
pub(super) offered_marker_delivery_seq: u64,
pub(super) delivered_binding_epoch: BindingEpoch,
pub(super) from_cursor: u64,
pub(super) resulting_cursor: u64,
pub(super) base_log_head: u64,
pub(super) extension_sequence: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum OutboxRow {
Produced(ProducedBatch),
AckAdvanced {
source_log_sequence: u64,
participant_id: ParticipantId,
through_seq: u64,
},
MarkerAckCommitted(StoredMarkerAckCommitted),
}
impl OutboxRow {
pub(super) const fn base_log_head(&self) -> Option<u64> {
match self {
Self::Produced(batch) => batch.source_log_sequence.checked_add(1),
Self::AckAdvanced {
source_log_sequence,
..
} => source_log_sequence.checked_add(1),
Self::MarkerAckCommitted(row) => Some(row.base_log_head),
}
}
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct RestorePageAccounting {
pub(super) cursors_started: usize,
pub(super) current_rows: usize,
pub(super) peak_rows: usize,
pub(super) pages: usize,
pub(super) cursor_overlap_observed: bool,
pub(super) counter_overflow_observed: bool,
}
#[cfg(test)]
#[derive(Default)]
struct ActiveRestorePageAccounting {
snapshot: RestorePageAccounting,
active_cursors: usize,
}
#[cfg(test)]
std::thread_local! {
static RESTORE_PAGE_ACCOUNTING: RefCell<Option<ActiveRestorePageAccounting>> =
const { RefCell::new(None) };
}
#[cfg(test)]
pub(super) struct RestorePageAccountingGuard {
_not_send: PhantomData<*const ()>,
active: bool,
}
#[cfg(test)]
impl RestorePageAccountingGuard {
pub(super) fn start() -> Self {
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
*accounting.borrow_mut() = Some(ActiveRestorePageAccounting::default());
});
Self {
_not_send: PhantomData,
active: true,
}
}
pub(super) fn snapshot(&self) -> RestorePageAccounting {
if !self.active {
return RestorePageAccounting::default();
}
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
accounting
.borrow()
.as_ref()
.map_or_else(RestorePageAccounting::default, |active| active.snapshot)
})
}
}
#[cfg(test)]
impl Drop for RestorePageAccountingGuard {
fn drop(&mut self) {
self.active = false;
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
*accounting.borrow_mut() = None;
});
}
}
#[cfg(test)]
fn account_cursor_started() -> bool {
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
let mut accounting = accounting.borrow_mut();
let Some(active) = accounting.as_mut() else {
return false;
};
active.snapshot.cursor_overlap_observed |= active.active_cursors != 0;
let Some(cursors_started) = active.snapshot.cursors_started.checked_add(1) else {
active.snapshot.counter_overflow_observed = true;
return false;
};
let Some(active_cursors) = active.active_cursors.checked_add(1) else {
active.snapshot.counter_overflow_observed = true;
return false;
};
active.snapshot.cursors_started = cursors_started;
active.active_cursors = active_cursors;
true
})
}
#[cfg(test)]
fn account_page_loaded(rows: usize) {
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
if let Some(active) = accounting.borrow_mut().as_mut() {
let snapshot = &mut active.snapshot;
snapshot.current_rows = rows;
snapshot.peak_rows = snapshot.peak_rows.max(rows);
if let Some(next_pages) = snapshot.pages.checked_add(1) {
snapshot.pages = next_pages;
} else {
snapshot.counter_overflow_observed = true;
}
}
});
}
pub(super) struct OutboxRestoreCursor<'a> {
log: &'a OutboxLog,
routed_route: Option<String>,
rebuild_route: Option<String>,
next_expected_sequence: u64,
established_version: Option<u8>,
eof: bool,
page: VecDeque<(u64, OutboxRow)>,
canonical_rows: VecDeque<(u64, Vec<u8>)>,
#[cfg(test)]
accounted: bool,
}
impl<'a> OutboxRestoreCursor<'a> {
#[cfg(not(test))]
const fn new(log: &'a OutboxLog, next_expected_sequence: u64) -> Self {
Self {
log,
routed_route: None,
rebuild_route: None,
next_expected_sequence,
established_version: None,
eof: false,
page: VecDeque::new(),
canonical_rows: VecDeque::new(),
}
}
#[cfg(test)]
fn new(log: &'a OutboxLog, next_expected_sequence: u64) -> Self {
Self {
log,
routed_route: None,
rebuild_route: None,
next_expected_sequence,
established_version: None,
eof: false,
page: VecDeque::new(),
canonical_rows: VecDeque::new(),
accounted: account_cursor_started(),
}
}
async fn load_page(&mut self) -> Result<(), OutboxLogError> {
if self.eof || !self.page.is_empty() {
return Ok(());
}
if let Some(route) = self.routed_route.as_deref() {
let rows = self
.log
.read_routed_page(route, self.next_expected_sequence)
.await?;
return self.install_routed_page(rows);
}
let entries = self
.log
.store
.read_from(
&self.log.stream_key,
self.next_expected_sequence,
UNIT2_OUTBOX_RESTORE_BATCH_ROWS,
)
.await?;
if entries.is_empty() {
if let Some(route) = self.rebuild_route.take() {
let rows = self
.log
.read_rebuild_page(&route, self.next_expected_sequence)
.await?;
self.routed_route = Some(route);
return self.install_routed_page(rows.unwrap_or_default());
}
self.eof = true;
return Ok(());
}
let mut decoded = VecDeque::with_capacity(entries.len());
let mut canonical_rows = VecDeque::with_capacity(entries.len());
for entry in entries {
if entry.sequence != self.next_expected_sequence {
return Err(OutboxLogError::Sequence {
expected: self.next_expected_sequence,
actual: entry.sequence,
});
}
let version = entry
.payload
.first()
.copied()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
if let Some(expected) = self.established_version {
if version != expected {
return Err(OutboxLogError::MixedSchemaVersions {
expected,
actual: version,
});
}
} else {
self.established_version = Some(version);
}
if version != OUTBOX_SCHEMA_VERSION {
return Err(OutboxLogError::SchemaVersion(version));
}
decoded.push_back((self.next_expected_sequence, decode_row(&entry.payload)?));
canonical_rows.push_back((entry.sequence, entry.payload));
self.next_expected_sequence =
self.next_expected_sequence
.checked_add(1)
.ok_or(OutboxLogError::Sequence {
expected: u64::MAX,
actual: entry.sequence,
})?;
}
self.page = decoded;
self.canonical_rows = canonical_rows;
#[cfg(test)]
if self.accounted {
account_page_loaded(self.page.len());
}
Ok(())
}
fn install_routed_page(&mut self, rows: Vec<CanonicalOutboxRow>) -> Result<(), OutboxLogError> {
if rows.is_empty() {
self.eof = true;
return Ok(());
}
let mut page = VecDeque::with_capacity(rows.len());
let mut canonical_rows = VecDeque::with_capacity(rows.len());
for (sequence, row, canonical) in rows {
if sequence != self.next_expected_sequence {
return Err(OutboxLogError::Sequence {
expected: self.next_expected_sequence,
actual: sequence,
});
}
self.next_expected_sequence =
self.next_expected_sequence
.checked_add(1)
.ok_or(OutboxLogError::Sequence {
expected: u64::MAX,
actual: sequence,
})?;
page.push_back((sequence, row));
canonical_rows.push_back((sequence, canonical));
}
self.page = page;
self.canonical_rows = canonical_rows;
#[cfg(test)]
if self.accounted {
account_page_loaded(self.page.len());
}
Ok(())
}
pub(super) async fn front(&mut self) -> Result<Option<&(u64, OutboxRow)>, OutboxLogError> {
self.load_page().await?;
Ok(self.page.front())
}
pub(super) fn pop_front(&mut self) -> Option<(u64, OutboxRow)> {
let row = self.page.pop_front();
self.canonical_rows.pop_front();
#[cfg(test)]
if row.is_some() {
self.account_current_rows(self.page.len());
}
row
}
pub(super) fn pop_front_with_canonical(
&mut self,
) -> Result<CanonicalOutboxRow, OutboxLogError> {
let (sequence, _) = self
.canonical_rows
.front()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
let (row_sequence, _) = self
.page
.front()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
if sequence != row_sequence {
return Err(OutboxLogError::Sequence {
expected: *row_sequence,
actual: *sequence,
});
}
let (_, canonical) = self
.canonical_rows
.pop_front()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
let (sequence, row) = self
.page
.pop_front()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
#[cfg(test)]
self.account_current_rows(self.page.len());
Ok((sequence, row, canonical))
}
#[cfg(test)]
fn account_current_rows(&self, rows: usize) {
if !self.accounted {
return;
}
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
if let Some(active) = accounting.borrow_mut().as_mut() {
active.snapshot.current_rows = rows;
}
});
}
pub(super) async fn drain(&mut self) -> Result<(), OutboxLogError> {
while self.front().await?.is_some() {
self.pop_front();
}
Ok(())
}
pub(super) const fn confirmed_head(&self) -> Option<u64> {
if self.eof {
Some(self.next_expected_sequence)
} else {
None
}
}
}
#[cfg(test)]
impl Drop for OutboxRestoreCursor<'_> {
fn drop(&mut self) {
self.account_current_rows(0);
if self.accounted {
RESTORE_PAGE_ACCOUNTING.with(|accounting| {
if let Some(active) = accounting.borrow_mut().as_mut() {
if let Some(active_cursors) = active.active_cursors.checked_sub(1) {
active.active_cursors = active_cursors;
} else {
active.snapshot.counter_overflow_observed = true;
}
}
});
}
}
}
#[derive(Debug)]
pub(super) struct OutboxLog {
store: Arc<dyn DurableStore>,
conversation_id: u64,
stream_key: String,
}
impl OutboxLog {
pub(super) fn new(store: Arc<dyn DurableStore>, conversation_id: u64) -> Self {
Self {
store,
conversation_id,
stream_key: format!("{OUTBOX_STREAM_PREFIX}{conversation_id}"),
}
}
#[cfg(not(test))]
pub(super) const fn restore_cursor(&self) -> OutboxRestoreCursor<'_> {
OutboxRestoreCursor::new(self, 0)
}
#[cfg(test)]
pub(super) fn restore_cursor(&self) -> OutboxRestoreCursor<'_> {
OutboxRestoreCursor::new(self, 0)
}
#[cfg(not(test))]
pub(super) const fn restore_cursor_from(&self, sequence: u64) -> OutboxRestoreCursor<'_> {
OutboxRestoreCursor::new(self, sequence)
}
#[cfg(test)]
pub(super) fn restore_cursor_from(&self, sequence: u64) -> OutboxRestoreCursor<'_> {
OutboxRestoreCursor::new(self, sequence)
}
pub(super) fn restore_routed_cursor_from(
&self,
route: &str,
sequence: u64,
) -> OutboxRestoreCursor<'_> {
let mut cursor = self.restore_cursor_from(sequence);
cursor.routed_route = Some(route.to_owned());
cursor
}
pub(super) fn restore_rebuild_cursor(&self, route: &str) -> OutboxRestoreCursor<'_> {
let mut cursor = OutboxRestoreCursor::new(self, 0);
cursor.rebuild_route = Some(route.to_owned());
cursor
}
pub(super) async fn stage_into(
&self,
batch: &mut OperationBatch,
row: &OutboxRow,
expected_sequence: u64,
timestamp: u64,
) -> Result<(), OutboxLogError> {
let result: Result<(), OutboxLogError> = async {
let payload = encode_row(row)?;
batch
.stage_source_stream(
self.store.as_ref(),
&self.stream_key,
super::restore_checkpoint_locator::CheckpointSource::Extension,
liminal::durability::StoredEntry {
payload,
sequence: expected_sequence,
timestamp,
},
)
.await?;
Ok(())
}
.await;
if result.is_err() {
batch.refuse();
}
result
}
pub(super) async fn read_routed_page(
&self,
route: &str,
from_sequence: u64,
) -> Result<Vec<CanonicalOutboxRow>, OutboxLogError> {
let head = super::routed_stream::read_head(self.store.as_ref(), route, &self.stream_key)
.await?
.ok_or_else(|| {
OutboxLogError::RoutedStream(RoutedStreamRefusal::MissingHead {
stream: self.stream_key.as_bytes().to_vec(),
})
})?;
let entries = super::routed_stream::read_page(
self.store.as_ref(),
route,
&self.stream_key,
&head,
from_sequence,
UNIT2_OUTBOX_RESTORE_BATCH_ROWS,
)
.await?;
entries
.into_iter()
.map(|entry| Ok((entry.sequence, decode_row(&entry.payload)?, entry.payload)))
.collect()
}
pub(super) async fn read_rebuild_page(
&self,
route: &str,
from_sequence: u64,
) -> Result<Option<Vec<CanonicalOutboxRow>>, OutboxLogError> {
let Some(head) =
super::routed_stream::read_head(self.store.as_ref(), route, &self.stream_key).await?
else {
return Ok(None);
};
let entries = super::routed_stream::read_page(
self.store.as_ref(),
route,
&self.stream_key,
&head,
from_sequence,
UNIT2_OUTBOX_RESTORE_BATCH_ROWS,
)
.await?;
entries
.into_iter()
.map(|entry| Ok((entry.sequence, decode_row(&entry.payload)?, entry.payload)))
.collect::<Result<Vec<_>, _>>()
.map(Some)
}
pub(super) async fn stage_bootstrap_head(
&self,
batch: &mut OperationBatch,
next_sequence: u64,
) -> Result<(), OutboxLogError> {
batch
.stage_bootstrap_stream(self.store.as_ref(), &self.stream_key, next_sequence)
.await?;
Ok(())
}
pub(super) async fn append(
&self,
row: &OutboxRow,
expected_sequence: u64,
) -> Result<(), OutboxLogError> {
self.append_page(std::slice::from_ref(row), expected_sequence)
.await
}
pub(super) async fn append_flat(
&self,
row: &OutboxRow,
expected_sequence: u64,
) -> Result<(), OutboxLogError> {
let payload = encode_row(row)?;
let assigned = append_settled(
self.store.as_ref(),
&self.stream_key,
payload,
expected_sequence,
)
.await?;
if assigned != expected_sequence {
return Err(OutboxLogError::AssignedSequence {
expected: expected_sequence,
actual: assigned,
});
}
flush_settled(self.store.as_ref()).await?;
Ok(())
}
pub(super) async fn append_page(
&self,
rows: &[OutboxRow],
expected_sequence: u64,
) -> Result<(), OutboxLogError> {
if rows.is_empty() {
return Ok(());
}
let timestamp = now_unix_nanos()?;
let mut batch = OperationBatch::new(&checkpoint_conversation_route(self.conversation_id));
let mut sequence = expected_sequence;
for row in rows {
self.stage_into(&mut batch, row, sequence, timestamp)
.await?;
sequence = sequence
.checked_add(1)
.ok_or(OutboxLogError::AssignedSequence {
expected: expected_sequence,
actual: u64::MAX,
})?;
}
batch.commit(self.store.as_ref(), || {}).await?;
Ok(())
}
#[cfg(test)]
pub(super) async fn read_all(&self) -> Result<Vec<(u64, OutboxRow)>, OutboxLogError> {
let mut rows = Vec::new();
let mut sequence = 0_u64;
let mut established_version = None;
loop {
let entries = self
.store
.read_from(&self.stream_key, sequence, UNIT2_OUTBOX_RESTORE_BATCH_ROWS)
.await?;
if entries.is_empty() {
break;
}
let page_len = entries.len();
for entry in entries {
if entry.sequence != sequence {
return Err(OutboxLogError::Sequence {
expected: sequence,
actual: entry.sequence,
});
}
let version = entry
.payload
.first()
.copied()
.ok_or(OutboxLogError::MissingSchemaVersion)?;
if let Some(expected) = established_version {
if version != expected {
return Err(OutboxLogError::MixedSchemaVersions {
expected,
actual: version,
});
}
} else {
established_version = Some(version);
}
if version != OUTBOX_SCHEMA_VERSION {
return Err(OutboxLogError::SchemaVersion(version));
}
rows.push((sequence, decode_row(&entry.payload)?));
sequence = sequence.checked_add(1).ok_or(OutboxLogError::Sequence {
expected: u64::MAX,
actual: entry.sequence,
})?;
}
if page_len < UNIT2_OUTBOX_RESTORE_BATCH_ROWS {
break;
}
}
let route = checkpoint_conversation_route(self.conversation_id);
loop {
let Some(page) = self.read_rebuild_page(&route, sequence).await? else {
break;
};
if page.is_empty() {
break;
}
for (actual, row, _) in page {
if actual != sequence {
return Err(OutboxLogError::Sequence {
expected: sequence,
actual,
});
}
rows.push((sequence, row));
sequence = sequence.checked_add(1).ok_or(OutboxLogError::Sequence {
expected: u64::MAX,
actual,
})?;
}
}
Ok(rows)
}
}