Skip to main content

liminal/durability/channel/
storage.rs

1//! Durable channel admission retains and reconciles uncertain writes, and recovers its
2//! partition sequence from a definite append conflict, before reusing a sequence.
3use std::fmt;
4use std::sync::Arc;
5
6use super::batch::PendingBatch;
7use super::{MessageEnvelope, UnresolvedAppend};
8use crate::durability::{
9    DurabilityConfig, DurabilityError, DurabilityMode, DurableStore, HEAD_CHECKPOINT_EVERY,
10    advance_head,
11};
12
13/// Caller-provided function used to route envelopes to partitions.
14#[derive(Clone)]
15pub struct PartitionKey {
16    function: Arc<dyn Fn(&MessageEnvelope) -> u64 + Send + Sync + 'static>,
17}
18
19impl PartitionKey {
20    /// Wraps a partition key closure.
21    #[must_use]
22    pub fn new<F>(function: F) -> Self
23    where
24        F: Fn(&MessageEnvelope) -> u64 + Send + Sync + 'static,
25    {
26        Self {
27            function: Arc::new(function),
28        }
29    }
30
31    fn apply(&self, envelope: &MessageEnvelope) -> u64 {
32        (self.function)(envelope)
33    }
34}
35
36impl fmt::Debug for PartitionKey {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        formatter.write_str("PartitionKey(<closure>)")
39    }
40}
41
42/// Haematite-backed durable channel state.
43#[derive(Clone)]
44pub struct DurableChannel {
45    pub(super) channel_id: String,
46    partition_count: usize,
47    partition_key: Option<PartitionKey>,
48    next_sequences: Vec<u64>,
49    pub(super) pending: Option<UnresolvedAppend>,
50    pub(super) pending_batch: Option<PendingBatch>,
51    pub(super) store: Arc<dyn DurableStore>,
52}
53
54impl DurableChannel {
55    /// Creates a new durable channel with all partition sequences initialized to zero.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`DurabilityError::ConfigError`] when `partition_count` is zero.
60    pub fn new(
61        channel_id: impl Into<String>,
62        partition_count: usize,
63        store: Arc<dyn DurableStore>,
64    ) -> Result<Self, DurabilityError> {
65        Self::from_parts(channel_id.into(), partition_count, store, None, None)
66    }
67
68    /// Creates a durable channel with a caller-provided partition key function.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`DurabilityError::ConfigError`] when `partition_count` is zero.
73    pub fn with_partition_key<F>(
74        channel_id: impl Into<String>,
75        partition_count: usize,
76        store: Arc<dyn DurableStore>,
77        partition_key: F,
78    ) -> Result<Self, DurabilityError>
79    where
80        F: Fn(&MessageEnvelope) -> u64 + Send + Sync + 'static,
81    {
82        Self::from_parts(
83            channel_id.into(),
84            partition_count,
85            store,
86            Some(PartitionKey::new(partition_key)),
87            None,
88        )
89    }
90
91    /// Creates a durable channel from explicit recovered next sequence counters.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`DurabilityError::ConfigError`] when `partition_count` is zero
96    /// or the recovered sequence vector length differs from `partition_count`.
97    pub fn from_recovered_sequences(
98        channel_id: impl Into<String>,
99        partition_count: usize,
100        store: Arc<dyn DurableStore>,
101        next_sequences: Vec<u64>,
102    ) -> Result<Self, DurabilityError> {
103        Self::from_parts(
104            channel_id.into(),
105            partition_count,
106            store,
107            None,
108            Some(next_sequences),
109        )
110    }
111
112    /// Creates a durable channel from validated durability configuration.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`DurabilityError::ConfigError`] when `config` is ephemeral.
117    pub fn from_config(
118        channel_id: impl Into<String>,
119        config: DurabilityConfig,
120        store: Arc<dyn DurableStore>,
121    ) -> Result<Self, DurabilityError> {
122        if config.mode() == DurabilityMode::Ephemeral {
123            return Err(DurabilityError::ConfigError(
124                "durable channel requires a durable durability mode".to_owned(),
125            ));
126        }
127        Self::new(channel_id, config.partition_count(), store)
128    }
129
130    /// Returns the durable channel identifier.
131    #[must_use]
132    pub fn channel_id(&self) -> &str {
133        &self.channel_id
134    }
135
136    /// Returns the number of partitions owned by this channel.
137    #[must_use]
138    pub const fn partition_count(&self) -> usize {
139        self.partition_count
140    }
141
142    /// Returns the next expected sequence for a partition, if it exists.
143    #[must_use]
144    pub fn next_expected_sequence(&self, partition_index: usize) -> Option<u64> {
145        self.next_sequences.get(partition_index).copied()
146    }
147
148    /// Returns all next expected sequence counters.
149    #[must_use]
150    pub fn next_sequences(&self) -> &[u64] {
151        &self.next_sequences
152    }
153
154    /// Computes the partition index for an envelope without touching storage.
155    #[must_use]
156    pub fn partition_for(&self, envelope: &MessageEnvelope) -> usize {
157        route_partition(self.partition_count, self.partition_key.as_ref(), envelope)
158    }
159
160    /// Formats the haematite stream key for a channel partition.
161    #[must_use]
162    pub fn stream_key_for(&self, partition_index: usize) -> String {
163        format!("{}:{partition_index}", self.channel_id)
164    }
165
166    /// Persists an envelope, retaining exact evidence before the cancellable append.
167    ///
168    /// # Errors
169    ///
170    /// Returns serialization and store errors. An earlier unresolved append is
171    /// reconciled by one read before this one is sent, and an unreadable one
172    /// refuses this publish by name. A definite sequence conflict reads the
173    /// current tail and retries this refused append once. An uncertain append
174    /// error keeps its evidence and is returned without resubmitting its payload.
175    pub async fn publish(&mut self, envelope: &MessageEnvelope) -> Result<u64, DurabilityError> {
176        self.publish_recorded(envelope)
177            .await
178            .map(|append| append.sequence())
179    }
180
181    /// Carries the exact append evidence into live delivery without encoding again.
182    pub(crate) async fn publish_recorded(
183        &mut self,
184        envelope: &MessageEnvelope,
185    ) -> Result<UnresolvedAppend, DurabilityError> {
186        let payload = envelope.serialize()?;
187        self.resolve_pending_batch().await?;
188        self.resolve_pending().await?;
189        let partition = self.partition_for(envelope);
190        let stream_key = self.stream_key_for(partition);
191        match self
192            .append_recorded(partition, &stream_key, payload, envelope)
193            .await
194        {
195            Err(DurabilityError::SequenceConflict { expected, actual }) => {
196                self.refresh_sequence(partition, &stream_key, expected, actual)
197                    .await?;
198                match self
199                    .append_recorded(partition, &stream_key, envelope.serialize()?, envelope)
200                    .await
201                {
202                    Err(DurabilityError::SequenceConflict { expected, actual }) => {
203                        self.refresh_sequence(partition, &stream_key, expected, actual)
204                            .await?;
205                        Err(DurabilityError::SequenceConflict { expected, actual })
206                    }
207                    outcome => outcome,
208                }
209            }
210            outcome => outcome,
211        }
212    }
213
214    /// Records the pending append, then sends it. Only a definite outcome
215    /// clears the record: a success, or a conflict that stored nothing.
216    async fn append_recorded(
217        &mut self,
218        partition: usize,
219        stream_key: &str,
220        payload: Vec<u8>,
221        envelope: &MessageEnvelope,
222    ) -> Result<UnresolvedAppend, DurabilityError> {
223        let expected = self.sequence_for_append(partition)?;
224        let next = expected.checked_add(1).ok_or_else(|| {
225            DurabilityError::ConfigError(format!("channel {} sequence overflow", self.channel_id))
226        })?;
227        self.pending = Some(UnresolvedAppend::new(
228            self.channel_id.clone(),
229            partition,
230            expected,
231            payload.clone(),
232            envelope.idempotency_key.clone(),
233        ));
234        match self.store.append(stream_key, payload, expected).await {
235            Ok(assigned) if assigned == expected => {
236                let append = self.pending.take().ok_or_else(|| {
237                    DurabilityError::ConfigError(format!(
238                        "channel {} lost its committed append evidence",
239                        self.channel_id
240                    ))
241                })?;
242                self.advance_partition(partition, stream_key, next).await?;
243                Ok(append)
244            }
245            Ok(assigned) => self.append_unresolved(format!(
246                "store answered sequence {assigned} for reserved sequence {expected}"
247            )),
248            Err(conflict @ DurabilityError::SequenceConflict { .. }) => {
249                self.pending = None;
250                Err(conflict)
251            }
252            Err(original) => self.append_unresolved(original.to_string()),
253        }
254    }
255
256    fn append_unresolved(&mut self, cause: String) -> Result<UnresolvedAppend, DurabilityError> {
257        let pending = self.pending.as_mut().ok_or_else(|| {
258            DurabilityError::ConfigError(format!(
259                "channel {} lost its pending append evidence",
260                self.channel_id
261            ))
262        })?;
263        pending.set_cause(cause.clone());
264        Err(DurabilityError::AppendUnresolved {
265            stream_key: format!("{}:{}", pending.channel(), pending.partition()),
266            sequence: pending.sequence(),
267            cause,
268        })
269    }
270
271    /// Moves a partition past a row the store holds, and records the head once
272    /// every [`HEAD_CHECKPOINT_EVERY`] appends.
273    pub(super) async fn advance_partition(
274        &mut self,
275        partition: usize,
276        stream_key: &str,
277        next: u64,
278    ) -> Result<(), DurabilityError> {
279        self.set_next_sequence(partition, next)?;
280        if next % HEAD_CHECKPOINT_EVERY == 0 {
281            // The row is already durable, so a failed head record must not fail the
282            // publish: the old record stays valid, the next start reads a longer tail,
283            // and the next count records the head again.
284            if let Err(error) = advance_head(self.store.as_ref(), stream_key, next).await {
285                tracing::warn!(
286                    stream = %stream_key,
287                    head = next,
288                    %error,
289                    "durable channel head could not be recorded; the next start reads the \
290                     rows since the previous record"
291                );
292            }
293        }
294        Ok(())
295    }
296
297    pub(super) async fn refresh_sequence(
298        &mut self,
299        partition_index: usize,
300        stream_key: &str,
301        expected: u64,
302        mut next_sequence: u64,
303    ) -> Result<(), DurabilityError> {
304        // The conflict reports the store's next slot. Read only rows that could
305        // have arrived after that observation, never the already settled prefix.
306        if let Some(entry) = self.store.read_at(stream_key, next_sequence).await? {
307            if entry.sequence < next_sequence {
308                return Err(DurabilityError::ConfigError(format!(
309                    "stream {stream_key} read returned sequence {} before {next_sequence}",
310                    entry.sequence
311                )));
312            }
313            next_sequence = entry.sequence.checked_add(1).ok_or_else(|| {
314                DurabilityError::ConfigError(format!(
315                    "stream {stream_key} sequence overflow during conflict recovery"
316                ))
317            })?;
318        }
319        tracing::warn!(
320            channel = %self.channel_id,
321            expected,
322            recorded = next_sequence,
323            "durable channel sequence conflict"
324        );
325        self.set_next_sequence(partition_index, next_sequence)
326    }
327
328    /// Flushes the backing store so every appended message is durably persisted.
329    ///
330    /// # Errors
331    ///
332    /// Propagates any [`DurabilityError`] returned by [`DurableStore::flush`].
333    pub async fn flush_store(&self) -> Result<(), DurabilityError> {
334        self.store.flush().await
335    }
336
337    fn from_parts(
338        channel_id: String,
339        partition_count: usize,
340        store: Arc<dyn DurableStore>,
341        partition_key: Option<PartitionKey>,
342        next_sequences: Option<Vec<u64>>,
343    ) -> Result<Self, DurabilityError> {
344        validate_partition_count(partition_count)?;
345        let next_sequences = next_sequences.unwrap_or_else(|| vec![0; partition_count]);
346        if next_sequences.len() != partition_count {
347            return Err(DurabilityError::ConfigError(
348                "recovered sequence count must match partition_count".to_owned(),
349            ));
350        }
351        Ok(Self {
352            channel_id,
353            partition_count,
354            partition_key,
355            next_sequences,
356            pending: None,
357            pending_batch: None,
358            store,
359        })
360    }
361
362    pub(super) fn sequence_for_append(
363        &self,
364        partition_index: usize,
365    ) -> Result<u64, DurabilityError> {
366        self.next_sequences
367            .get(partition_index)
368            .copied()
369            .ok_or_else(|| {
370                DurabilityError::ConfigError("partition sequence state missing".to_owned())
371            })
372    }
373
374    fn set_next_sequence(
375        &mut self,
376        partition_index: usize,
377        next_sequence: u64,
378    ) -> Result<(), DurabilityError> {
379        let Some(sequence) = self.next_sequences.get_mut(partition_index) else {
380            return Err(DurabilityError::ConfigError(
381                "partition sequence state missing".to_owned(),
382            ));
383        };
384        *sequence = next_sequence;
385        Ok(())
386    }
387}
388
389impl fmt::Debug for DurableChannel {
390    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
391        formatter
392            .debug_struct("DurableChannel")
393            .field("channel_id", &self.channel_id)
394            .field("partition_count", &self.partition_count)
395            .field("partition_key_configured", &self.partition_key.is_some())
396            .field("next_sequences", &self.next_sequences)
397            .field("pending", &self.pending)
398            .field("pending_batch", &self.pending_batch)
399            .field("store", &self.store)
400            .finish()
401    }
402}
403
404/// Ephemeral channel state with no durable store reference.
405#[derive(Clone)]
406pub struct EphemeralChannel {
407    channel_id: String,
408    partition_count: usize,
409    partition_key: Option<PartitionKey>,
410}
411
412impl EphemeralChannel {
413    /// Creates an ephemeral channel without requiring any durable store.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`DurabilityError::ConfigError`] when `partition_count` is zero.
418    pub fn new(
419        channel_id: impl Into<String>,
420        partition_count: usize,
421    ) -> Result<Self, DurabilityError> {
422        Self::from_parts(channel_id.into(), partition_count, None)
423    }
424
425    /// Creates an ephemeral channel with a caller-provided partition key function.
426    ///
427    /// # Errors
428    ///
429    /// Returns [`DurabilityError::ConfigError`] when `partition_count` is zero.
430    pub fn with_partition_key<F>(
431        channel_id: impl Into<String>,
432        partition_count: usize,
433        partition_key: F,
434    ) -> Result<Self, DurabilityError>
435    where
436        F: Fn(&MessageEnvelope) -> u64 + Send + Sync + 'static,
437    {
438        Self::from_parts(
439            channel_id.into(),
440            partition_count,
441            Some(PartitionKey::new(partition_key)),
442        )
443    }
444
445    /// Creates an ephemeral channel from validated durability configuration.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`DurabilityError::ConfigError`] when `config` is not ephemeral.
450    pub fn from_config(
451        channel_id: impl Into<String>,
452        config: DurabilityConfig,
453    ) -> Result<Self, DurabilityError> {
454        if config.mode() != DurabilityMode::Ephemeral {
455            return Err(DurabilityError::ConfigError(
456                "ephemeral channel requires Ephemeral durability mode".to_owned(),
457            ));
458        }
459        Self::new(channel_id, config.partition_count())
460    }
461
462    /// Returns the channel identifier.
463    #[must_use]
464    pub fn channel_id(&self) -> &str {
465        &self.channel_id
466    }
467
468    /// Returns the number of configured partitions.
469    #[must_use]
470    pub const fn partition_count(&self) -> usize {
471        self.partition_count
472    }
473
474    /// Computes the partition index for an envelope without touching storage.
475    #[must_use]
476    pub fn partition_for(&self, envelope: &MessageEnvelope) -> usize {
477        route_partition(self.partition_count, self.partition_key.as_ref(), envelope)
478    }
479
480    fn from_parts(
481        channel_id: String,
482        partition_count: usize,
483        partition_key: Option<PartitionKey>,
484    ) -> Result<Self, DurabilityError> {
485        validate_partition_count(partition_count)?;
486        Ok(Self {
487            channel_id,
488            partition_count,
489            partition_key,
490        })
491    }
492}
493
494impl fmt::Debug for EphemeralChannel {
495    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
496        formatter
497            .debug_struct("EphemeralChannel")
498            .field("channel_id", &self.channel_id)
499            .field("partition_count", &self.partition_count)
500            .field("partition_key_configured", &self.partition_key.is_some())
501            .finish()
502    }
503}
504
505fn route_partition(
506    partition_count: usize,
507    partition_key: Option<&PartitionKey>,
508    envelope: &MessageEnvelope,
509) -> usize {
510    if partition_count == 1 {
511        return 0;
512    }
513
514    let Some(partition_key) = partition_key else {
515        return 0;
516    };
517
518    let Ok(partition_count_u64) = u64::try_from(partition_count) else {
519        return 0;
520    };
521    let routed = partition_key.apply(envelope) % partition_count_u64;
522    usize::try_from(routed).unwrap_or_else(|_| partition_count.saturating_sub(1))
523}
524
525fn validate_partition_count(partition_count: usize) -> Result<(), DurabilityError> {
526    if partition_count == 0 {
527        return Err(DurabilityError::ConfigError(
528            "partition_count must be at least 1".to_owned(),
529        ));
530    }
531    Ok(())
532}