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