Skip to main content

liminal/channel/
durable_publish.rs

1//! Durable admission persists a publish and enqueues its fan-out under one append
2//! lock, and fans out a row whose uncertain append resolved as committed ahead of
3//! the publish that resolved it.
4use std::collections::VecDeque;
5use std::sync::{Arc, Mutex};
6
7use super::actor::{ChannelActorCore, PendingPublish};
8use super::record::{PublishRecord, recorded_now};
9use super::types::{ChannelDelivery, ChannelHandle, now_millis};
10use crate::causal::CausalContext;
11use crate::durability::bridge::block_on;
12use crate::durability::{AppendResolution, DurableChannel, MessageEnvelope, UnresolvedAppend};
13use crate::envelope::PublisherId;
14use crate::error::LiminalError;
15
16/// A durable channel's storage state, with the runtime metadata of the row it
17/// still owes the live subscribers kept beside the channel's pending record.
18pub(super) struct RuntimeDurable {
19    pub(super) channel: DurableChannel,
20    fanout: Option<PendingFanout>,
21    batch_fanout: VecDeque<PendingFanout>,
22}
23
24/// The fan-out of a row whose publish returned before the row reached the
25/// actor: the payload, publisher and causal context of the publish that
26/// produced it, and the row's position once the store has established it.
27struct PendingFanout {
28    delivery_id: Option<crate::protocol::DeliveredId>,
29    payload: Vec<u8>,
30    publisher: PublisherId,
31    causal: Option<CausalContext>,
32    position: Option<u64>,
33}
34
35/// Why the row owed from an earlier publish could not be settled.
36enum RecoveryFailure {
37    /// The pending append could not be resolved; its record is unchanged.
38    Unresolved(String),
39    /// The pending append resolved as committed and its fan-out could not be
40    /// enqueued; the fan-out stays owed.
41    Fanout(LiminalError),
42}
43
44/// A row the store committed, carried past the append lock so a live-delivery
45/// failure still names the row instead of reporting a failure that would
46/// license a second append.
47pub(super) struct CommittedRow {
48    append: UnresolvedAppend,
49}
50
51impl CommittedRow {
52    /// The committed row as an unresolved publish: stored, with its live
53    /// delivery not confirmed.
54    pub(super) fn unresolved(self, detail: impl std::fmt::Display) -> LiminalError {
55        let mut append = self.append;
56        append.set_cause(format!("row committed; live delivery failed: {detail}"));
57        LiminalError::PublishUnresolved { append }
58    }
59}
60
61impl std::fmt::Debug for RuntimeDurable {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("RuntimeDurable")
64            .field("channel", &self.channel)
65            .field("pending_fanout", &self.fanout.is_some())
66            .field("pending_batch_fanout", &self.batch_fanout.len())
67            .finish()
68    }
69}
70
71impl RuntimeDurable {
72    pub(super) const fn new(channel: DurableChannel) -> Self {
73        Self {
74            channel,
75            fanout: None,
76            batch_fanout: VecDeque::new(),
77        }
78    }
79
80    /// Resolves the channel's pending append and enqueues the fan-out a
81    /// committed row still owes, returning its rendezvous. `None` means
82    /// nothing is owed and the next append may proceed.
83    fn recover_fanout(
84        &mut self,
85        core: &ChannelActorCore,
86    ) -> Result<Option<PendingPublish>, RecoveryFailure> {
87        if self.channel.pending_batch_evidence().is_some() {
88            match block_on(self.channel.resolve_pending_batch()) {
89                Ok(Ok(Some(AppendResolution::Absent))) => self.batch_fanout.clear(),
90                Ok(Ok(_)) => {}
91                Ok(Err(error)) => return Err(RecoveryFailure::Unresolved(error.to_string())),
92                Err(error) => return Err(RecoveryFailure::Unresolved(error.to_string())),
93            }
94        }
95        if let Some(fanout) = self.batch_fanout.front() {
96            let pending = core
97                .enqueue_identified(
98                    fanout.payload.clone(),
99                    fanout.publisher.clone(),
100                    fanout.causal.clone(),
101                    fanout.position,
102                    None,
103                    fanout.delivery_id,
104                )
105                .map_err(RecoveryFailure::Fanout)?;
106            self.batch_fanout.pop_front();
107            return Ok(Some(pending));
108        }
109        if self.channel.pending_append().is_some() {
110            let resolution = match block_on(self.channel.resolve_pending()) {
111                Ok(Ok(resolution)) => resolution,
112                Ok(Err(error)) => return Err(RecoveryFailure::Unresolved(error.to_string())),
113                Err(error) => return Err(RecoveryFailure::Unresolved(error.to_string())),
114            };
115            match resolution {
116                Some(AppendResolution::Committed { position }) => {
117                    if let Some(fanout) = self.fanout.as_mut() {
118                        fanout.position = Some(position);
119                    }
120                }
121                Some(AppendResolution::Absent) | None => self.fanout = None,
122            }
123        }
124        let Some(fanout) = self.fanout.as_ref() else {
125            return Ok(None);
126        };
127        let Some(position) = fanout.position else {
128            // No pending record and no position: the row this metadata waited
129            // for can no longer be established, so nothing is owed for it.
130            self.fanout = None;
131            return Ok(None);
132        };
133        let pending = core
134            .enqueue_identified(
135                fanout.payload.clone(),
136                fanout.publisher.clone(),
137                fanout.causal.clone(),
138                Some(position),
139                None,
140                fanout.delivery_id,
141            )
142            .map_err(RecoveryFailure::Fanout)?;
143        self.fanout = None;
144        Ok(Some(pending))
145    }
146
147    /// Maps a refused durable publish. An append the channel still holds as
148    /// pending is unresolved, and its fan-out metadata is kept beside the
149    /// record; every other refusal is definite.
150    fn refused(
151        &mut self,
152        message: String,
153        payload: &[u8],
154        publisher: PublisherId,
155        causal: Option<CausalContext>,
156        delivery_id: Option<crate::protocol::DeliveredId>,
157    ) -> LiminalError {
158        let Some(append) = self.channel.pending_append() else {
159            return LiminalError::PublishFailed { message };
160        };
161        self.fanout = Some(PendingFanout {
162            delivery_id,
163            payload: payload.to_vec(),
164            publisher,
165            causal,
166            position: None,
167        });
168        LiminalError::PublishUnresolved { append }
169    }
170}
171
172/// One publish on its way to the durable store and the fan-out.
173pub(super) struct Outgoing<'a> {
174    pub(super) delivery_id: Option<crate::protocol::DeliveredId>,
175    pub(super) payload: &'a [u8],
176    pub(super) publisher_id: PublisherId,
177    pub(super) causal_context: Option<CausalContext>,
178    pub(super) idempotency_key: Option<&'a str>,
179    /// The caller's record, for the fan-out to fill. Its recipient buffer is
180    /// lent to the actor only as the publish is enqueued, so a failure before
181    /// then leaves it with the caller.
182    pub(super) record: Option<&'a mut PublishRecord>,
183}
184
185impl ChannelHandle {
186    pub(super) fn persist_batch_and_enqueue(
187        &self,
188        core: &Arc<ChannelActorCore>,
189        durable: &Arc<Mutex<RuntimeDurable>>,
190        payloads: &[Vec<u8>],
191        publisher: &PublisherId,
192        causal: Option<&CausalContext>,
193    ) -> Result<Vec<(PendingPublish, UnresolvedAppend)>, LiminalError> {
194        loop {
195            let mut state = durable
196                .lock()
197                .map_err(|error| LiminalError::PublishFailed {
198                    message: format!("durable batch state unavailable: {error}"),
199                })?;
200            match state.recover_fanout(core) {
201                Ok(Some(pending)) => {
202                    drop(state);
203                    self.finish_recovered(core, pending)?;
204                    continue;
205                }
206                Ok(None) => {}
207                Err(RecoveryFailure::Unresolved(message)) => {
208                    return Err(LiminalError::PublishFailed { message });
209                }
210                Err(RecoveryFailure::Fanout(error)) => return Err(error),
211            }
212            return state.append_batch(core, payloads, publisher, causal);
213        }
214    }
215
216    /// Publishes as [`ChannelHandle::publish_with_delivery`] does, and stores
217    /// the producer's idempotency key in the persisted row so the row can be
218    /// found by its key.
219    ///
220    /// # Errors
221    ///
222    /// Returns the errors of [`ChannelHandle::publish_with_delivery`]. A
223    /// durable append whose outcome is not known is
224    /// [`LiminalError::PublishUnresolved`], never a definite failure.
225    pub fn publish_with_idempotency_key<Payload: AsRef<[u8]>>(
226        &self,
227        payload: Payload,
228        publisher_id: PublisherId,
229        causal_context: Option<CausalContext>,
230        idempotency_key: &str,
231    ) -> Result<ChannelDelivery, LiminalError> {
232        self.publish_with_key(
233            payload,
234            publisher_id,
235            causal_context,
236            Some(idempotency_key),
237            None,
238        )
239    }
240
241    /// Persists one publish to the durable store and hands its fan-out to the
242    /// actor's command queue **without releasing the append lock in between**
243    /// (A1 §4; round-2 finding). Returns the assigned append sequence — the
244    /// durable position G1 says must not be discarded — the pending
245    /// rendezvous for the fan-out, and the committed row.
246    ///
247    /// **Why the two must not be separated.** `DurableChannel::publish` assigns
248    /// sequences under this mutex, so durable positions are totally ordered.
249    /// The actor's command queue is a `VecDeque` drained in push order, so the
250    /// fan-out order is the enqueue order. Take the position under the lock and
251    /// enqueue after releasing it, and the two orders are independent: two
252    /// publishers can invert, the higher position's live push reaching an inbox
253    /// first. That is not a cosmetic reordering. §4's replay cursor is one
254    /// `u64` meaning "the contiguous prefix this subscriber has been offered",
255    /// and an inversion makes that sentence false — the cursor steps past a
256    /// message that has not been offered, and backsteps onto one that has.
257    /// Enqueuing here, inside the same critical section, is what makes the two
258    /// orders the same order.
259    ///
260    /// **A row owed from an earlier publish goes first.** An append whose
261    /// outcome the store could not report is resolved by one read under the
262    /// same lock before this publish appends. A committed row is enqueued at
263    /// its position ahead of this one and awaited with the lock released, so
264    /// the live order stays the position order.
265    ///
266    /// **What it costs.** The section grows by one `VecDeque` push under the
267    /// command mutex plus one non-blocking scheduler wake — both O(1), neither
268    /// blocking. The fan-out ITSELF is not held here (that is
269    /// [`ChannelActorCore::await_publish`], called with this lock released), so
270    /// the next publisher's append still overlaps this publisher's fan-out
271    /// exactly as it did before: the pipeline is unchanged and no subscriber's
272    /// notifier can ever stall an append.
273    pub(super) fn persist_and_enqueue(
274        &self,
275        core: &Arc<ChannelActorCore>,
276        durable: &Arc<Mutex<RuntimeDurable>>,
277        outgoing: Outgoing<'_>,
278    ) -> Result<(u64, PendingPublish, CommittedRow), LiminalError> {
279        let Outgoing {
280            delivery_id,
281            payload,
282            publisher_id,
283            causal_context,
284            idempotency_key,
285            record,
286        } = outgoing;
287        loop {
288            let mut state = durable
289                .lock()
290                .map_err(|error| LiminalError::PublishFailed {
291                    message: format!("durable channel state unavailable: {error}"),
292                })?;
293            match state.recover_fanout(core) {
294                Ok(Some(recovered)) => {
295                    drop(state);
296                    self.finish_recovered(core, recovered)?;
297                    continue;
298                }
299                Ok(None) => {}
300                Err(RecoveryFailure::Unresolved(detail)) => {
301                    return Err(LiminalError::PublishFailed {
302                        message: format!(
303                            "durable publish to channel '{}' failed: {detail}",
304                            self.config.name
305                        ),
306                    });
307                }
308                Err(RecoveryFailure::Fanout(error)) => return Err(error),
309            }
310            let envelope = MessageEnvelope {
311                payload: payload.to_vec(),
312                causal_context: causal_context.as_ref().map(|context| {
313                    crate::durability::CausalContext {
314                        parent_id: context.parent.map(|parent| parent.as_uuid().to_string()),
315                        vector_clock_entry: None,
316                    }
317                }),
318                timestamp: now_millis(),
319                publisher_id: publisher_id.as_str().to_owned(),
320                idempotency_key: idempotency_key.map(str::to_owned),
321            };
322            let append = match block_on(state.channel.publish_recorded(&envelope)) {
323                Ok(Ok(append)) => append,
324                Ok(Err(error)) => {
325                    let message = format!(
326                        "durable publish to channel '{}' failed: {error}",
327                        self.config.name
328                    );
329                    return Err(state.refused(
330                        message,
331                        payload,
332                        publisher_id,
333                        causal_context,
334                        delivery_id,
335                    ));
336                }
337                Err(error) => {
338                    let message = format!(
339                        "durable publish bridge for channel '{}' failed: {error}",
340                        self.config.name
341                    );
342                    return Err(state.refused(
343                        message,
344                        payload,
345                        publisher_id,
346                        causal_context,
347                        delivery_id,
348                    ));
349                }
350            };
351            let position = append.sequence();
352            let row = CommittedRow { append };
353            let pending = match core.enqueue_identified(
354                payload.to_vec(),
355                publisher_id.clone(),
356                causal_context.clone(),
357                Some(position),
358                // The append has completed; the fan-out comes after it.
359                record.map(|record| {
360                    record.appended_at = Some(recorded_now());
361                    &mut record.recipients
362                }),
363                delivery_id,
364            ) {
365                Ok(pending) => pending,
366                Err(error) => {
367                    // The row is stored and owes its fan-out; the next admission
368                    // enqueues it at its position.
369                    state.fanout = Some(PendingFanout {
370                        delivery_id,
371                        payload: payload.to_vec(),
372                        publisher: publisher_id,
373                        causal: causal_context,
374                        position: Some(position),
375                    });
376                    return Err(row.unresolved(error));
377                }
378            };
379            drop(state);
380            return Ok((position, pending, row));
381        }
382    }
383
384    /// Where each partition of the durable channel stands: the sequence its
385    /// next row takes. A claim records these before its publish appends, so
386    /// a later lookup of its key starts here.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`LiminalError::PublishFailed`] when the channel is not durable
391    /// or its state is unavailable.
392    pub fn durable_positions(&self) -> Result<Vec<u64>, LiminalError> {
393        let durable = self
394            .durable
395            .as_ref()
396            .ok_or_else(|| LiminalError::PublishFailed {
397                message: format!("channel '{}' has no durable stream", self.config.name),
398            })?;
399        let state = durable
400            .lock()
401            .map_err(|error| LiminalError::PublishFailed {
402                message: format!("durable channel state unavailable: {error}"),
403            })?;
404        Ok(state.channel.next_sequences().to_vec())
405    }
406
407    /// Resolves the channel's pending append, fanning out a committed row,
408    /// then looks the row carrying `idempotency_key` up in the channel's
409    /// stream from the positions in `from`, page by page, with the append
410    /// lock released. Writes no row.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`LiminalError::PublishUnresolved`] when the pending append
415    /// cannot be read back, and [`LiminalError::ResolutionUnreadable`] naming
416    /// the channel and the key when the stream cannot be read.
417    pub fn resolve_idempotency_key(
418        &self,
419        idempotency_key: &str,
420        from: &[u64],
421    ) -> Result<AppendResolution, LiminalError> {
422        let unreadable = |message: String| LiminalError::ResolutionUnreadable {
423            channel: self.config.name.clone(),
424            idempotency_key: idempotency_key.to_owned(),
425            message,
426        };
427        let durable = self
428            .durable
429            .as_ref()
430            .ok_or_else(|| unreadable("channel has no durable stream".to_owned()))?;
431        let core = self.core()?;
432        loop {
433            let mut state = durable
434                .lock()
435                .map_err(|error| LiminalError::PublishFailed {
436                    message: format!("durable channel state unavailable: {error}"),
437                })?;
438            match state.recover_fanout(&core) {
439                Ok(Some(recovered)) => {
440                    drop(state);
441                    self.finish_recovered(&core, recovered)?;
442                    continue;
443                }
444                Ok(None) => {}
445                Err(RecoveryFailure::Unresolved(detail)) => {
446                    return Err(state.channel.pending_append().map_or_else(
447                        || LiminalError::PublishFailed {
448                            message: format!(
449                                "durable channel '{}' could not resolve its pending append: {detail}",
450                                self.config.name
451                            ),
452                        },
453                        |append| LiminalError::PublishUnresolved { append },
454                    ));
455                }
456                Err(RecoveryFailure::Fanout(error)) => return Err(error),
457            }
458            // The pending append is settled and a held key is never appended
459            // again, so the lookup reads with the append lock released.
460            let lookup = state.channel.key_lookup();
461            drop(state);
462            return block_on(lookup.find(idempotency_key, from))
463                .map_err(|error| unreadable(error.to_string()))?
464                .map_err(|error| unreadable(error.to_string()));
465        }
466    }
467}
468
469impl RuntimeDurable {
470    fn append_batch(
471        &mut self,
472        core: &ChannelActorCore,
473        payloads: &[Vec<u8>],
474        publisher: &PublisherId,
475        causal: Option<&CausalContext>,
476    ) -> Result<Vec<(PendingPublish, UnresolvedAppend)>, LiminalError> {
477        let start =
478            self.channel
479                .next_expected_sequence(0)
480                .ok_or_else(|| LiminalError::PublishFailed {
481                    message: "durable batch has no partition head".to_owned(),
482                })?;
483        let envelopes: Vec<_> = payloads
484            .iter()
485            .map(|payload| MessageEnvelope {
486                payload: payload.clone(),
487                causal_context: causal.map(|context| crate::durability::CausalContext {
488                    parent_id: context.parent.map(|parent| parent.as_uuid().to_string()),
489                    vector_clock_entry: None,
490                }),
491                timestamp: now_millis(),
492                publisher_id: publisher.as_str().to_owned(),
493                idempotency_key: None,
494            })
495            .collect();
496        let proofs = envelopes
497            .iter()
498            .enumerate()
499            .map(|(index, envelope)| {
500                let offset = u64::try_from(index).map_err(|error| LiminalError::PublishFailed {
501                    message: format!("durable batch index overflow: {error}"),
502                })?;
503                let position =
504                    start
505                        .checked_add(offset)
506                        .ok_or_else(|| LiminalError::PublishFailed {
507                            message: "durable batch position overflow".to_owned(),
508                        })?;
509                let encoded =
510                    envelope
511                        .serialize()
512                        .map_err(|error| LiminalError::PublishFailed {
513                            message: format!("durable batch envelope encoding failed: {error}"),
514                        })?;
515                Ok(UnresolvedAppend::new(
516                    self.channel.channel_id().to_owned(),
517                    0,
518                    position,
519                    encoded,
520                    None,
521                ))
522            })
523            .collect::<Result<Vec<_>, LiminalError>>()?;
524        self.batch_fanout = payloads
525            .iter()
526            .zip(&proofs)
527            .map(|(payload, proof)| PendingFanout {
528                delivery_id: None,
529                payload: payload.clone(),
530                publisher: publisher.clone(),
531                causal: causal.cloned(),
532                position: Some(proof.sequence()),
533            })
534            .collect();
535        match block_on(self.channel.publish_batch(&envelopes)) {
536            Ok(Ok(_)) => {}
537            outcome => {
538                if let Some(append) = self.channel.pending_batch_evidence() {
539                    return Err(LiminalError::PublishUnresolved { append });
540                }
541                self.batch_fanout.clear();
542                return Err(LiminalError::PublishFailed {
543                    message: format!("durable batch failed: {outcome:?}"),
544                });
545            }
546        }
547        let mut queued = Vec::with_capacity(proofs.len());
548        for mut proof in proofs {
549            let fanout = self
550                .batch_fanout
551                .front()
552                .ok_or_else(|| LiminalError::PublishFailed {
553                    message: "committed durable batch lost its fanout".to_owned(),
554                })?;
555            match core.enqueue_publish(
556                fanout.payload.clone(),
557                fanout.publisher.clone(),
558                fanout.causal.clone(),
559                fanout.position,
560                None,
561            ) {
562                Ok(pending) => {
563                    self.batch_fanout.pop_front();
564                    queued.push((pending, proof));
565                }
566                Err(error) => {
567                    proof.set_cause(format!("batch committed; live enqueue failed: {error}"));
568                    return Err(LiminalError::PublishUnresolved { append: proof });
569                }
570            }
571        }
572        Ok(queued)
573    }
574}