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: causal_context.as_ref().map(|context| {
306                    crate::durability::CausalContext {
307                        parent_id: context.parent.map(|parent| parent.as_uuid().to_string()),
308                        vector_clock_entry: None,
309                    }
310                }),
311                timestamp: now_millis(),
312                publisher_id: publisher_id.as_str().to_owned(),
313                idempotency_key: idempotency_key.map(str::to_owned),
314            };
315            let append = match block_on(state.channel.publish_recorded(&envelope)) {
316                Ok(Ok(append)) => append,
317                Ok(Err(error)) => {
318                    let message = format!(
319                        "durable publish to channel '{}' failed: {error}",
320                        self.config.name
321                    );
322                    return Err(state.refused(message, payload, publisher_id, causal_context));
323                }
324                Err(error) => {
325                    let message = format!(
326                        "durable publish bridge for channel '{}' failed: {error}",
327                        self.config.name
328                    );
329                    return Err(state.refused(message, payload, publisher_id, causal_context));
330                }
331            };
332            let position = append.sequence();
333            let row = CommittedRow { append };
334            let pending = match core.enqueue_publish(
335                payload.to_vec(),
336                publisher_id.clone(),
337                causal_context.clone(),
338                Some(position),
339                // The append has completed; the fan-out comes after it.
340                record.map(|record| {
341                    record.appended_at = Some(recorded_now());
342                    &mut record.recipients
343                }),
344            ) {
345                Ok(pending) => pending,
346                Err(error) => {
347                    // The row is stored and owes its fan-out; the next admission
348                    // enqueues it at its position.
349                    state.fanout = Some(PendingFanout {
350                        payload: payload.to_vec(),
351                        publisher: publisher_id,
352                        causal: causal_context,
353                        position: Some(position),
354                    });
355                    return Err(row.unresolved(error));
356                }
357            };
358            drop(state);
359            return Ok((position, pending, row));
360        }
361    }
362
363    /// Where each partition of the durable channel stands: the sequence its
364    /// next row takes. A claim records these before its publish appends, so
365    /// a later lookup of its key starts here.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`LiminalError::PublishFailed`] when the channel is not durable
370    /// or its state is unavailable.
371    pub fn durable_positions(&self) -> Result<Vec<u64>, LiminalError> {
372        let durable = self
373            .durable
374            .as_ref()
375            .ok_or_else(|| LiminalError::PublishFailed {
376                message: format!("channel '{}' has no durable stream", self.config.name),
377            })?;
378        let state = durable
379            .lock()
380            .map_err(|error| LiminalError::PublishFailed {
381                message: format!("durable channel state unavailable: {error}"),
382            })?;
383        Ok(state.channel.next_sequences().to_vec())
384    }
385
386    /// Resolves the channel's pending append, fanning out a committed row,
387    /// then looks the row carrying `idempotency_key` up in the channel's
388    /// stream from the positions in `from`, page by page, with the append
389    /// lock released. Writes no row.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`LiminalError::PublishUnresolved`] when the pending append
394    /// cannot be read back, and [`LiminalError::ResolutionUnreadable`] naming
395    /// the channel and the key when the stream cannot be read.
396    pub fn resolve_idempotency_key(
397        &self,
398        idempotency_key: &str,
399        from: &[u64],
400    ) -> Result<AppendResolution, LiminalError> {
401        let unreadable = |message: String| LiminalError::ResolutionUnreadable {
402            channel: self.config.name.clone(),
403            idempotency_key: idempotency_key.to_owned(),
404            message,
405        };
406        let durable = self
407            .durable
408            .as_ref()
409            .ok_or_else(|| unreadable("channel has no durable stream".to_owned()))?;
410        let core = self.core()?;
411        loop {
412            let mut state = durable
413                .lock()
414                .map_err(|error| LiminalError::PublishFailed {
415                    message: format!("durable channel state unavailable: {error}"),
416                })?;
417            match state.recover_fanout(&core) {
418                Ok(Some(recovered)) => {
419                    drop(state);
420                    self.finish_recovered(&core, recovered)?;
421                    continue;
422                }
423                Ok(None) => {}
424                Err(RecoveryFailure::Unresolved(detail)) => {
425                    return Err(state.channel.pending_append().map_or_else(
426                        || LiminalError::PublishFailed {
427                            message: format!(
428                                "durable channel '{}' could not resolve its pending append: {detail}",
429                                self.config.name
430                            ),
431                        },
432                        |append| LiminalError::PublishUnresolved { append },
433                    ));
434                }
435                Err(RecoveryFailure::Fanout(error)) => return Err(error),
436            }
437            // The pending append is settled and a held key is never appended
438            // again, so the lookup reads with the append lock released.
439            let lookup = state.channel.key_lookup();
440            drop(state);
441            return block_on(lookup.find(idempotency_key, from))
442                .map_err(|error| unreadable(error.to_string()))?
443                .map_err(|error| unreadable(error.to_string()));
444        }
445    }
446}
447
448impl RuntimeDurable {
449    fn append_batch(
450        &mut self,
451        core: &ChannelActorCore,
452        payloads: &[Vec<u8>],
453        publisher: &PublisherId,
454        causal: Option<&CausalContext>,
455    ) -> Result<Vec<(PendingPublish, UnresolvedAppend)>, LiminalError> {
456        let start =
457            self.channel
458                .next_expected_sequence(0)
459                .ok_or_else(|| LiminalError::PublishFailed {
460                    message: "durable batch has no partition head".to_owned(),
461                })?;
462        let envelopes: Vec<_> = payloads
463            .iter()
464            .map(|payload| MessageEnvelope {
465                payload: payload.clone(),
466                causal_context: causal.map(|context| crate::durability::CausalContext {
467                    parent_id: context.parent.map(|parent| parent.as_uuid().to_string()),
468                    vector_clock_entry: None,
469                }),
470                timestamp: now_millis(),
471                publisher_id: publisher.as_str().to_owned(),
472                idempotency_key: None,
473            })
474            .collect();
475        let proofs = envelopes
476            .iter()
477            .enumerate()
478            .map(|(index, envelope)| {
479                let offset = u64::try_from(index).map_err(|error| LiminalError::PublishFailed {
480                    message: format!("durable batch index overflow: {error}"),
481                })?;
482                let position =
483                    start
484                        .checked_add(offset)
485                        .ok_or_else(|| LiminalError::PublishFailed {
486                            message: "durable batch position overflow".to_owned(),
487                        })?;
488                let encoded =
489                    envelope
490                        .serialize()
491                        .map_err(|error| LiminalError::PublishFailed {
492                            message: format!("durable batch envelope encoding failed: {error}"),
493                        })?;
494                Ok(UnresolvedAppend::new(
495                    self.channel.channel_id().to_owned(),
496                    0,
497                    position,
498                    encoded,
499                    None,
500                ))
501            })
502            .collect::<Result<Vec<_>, LiminalError>>()?;
503        self.batch_fanout = payloads
504            .iter()
505            .zip(&proofs)
506            .map(|(payload, proof)| PendingFanout {
507                payload: payload.clone(),
508                publisher: publisher.clone(),
509                causal: causal.cloned(),
510                position: Some(proof.sequence()),
511            })
512            .collect();
513        match block_on(self.channel.publish_batch(&envelopes)) {
514            Ok(Ok(_)) => {}
515            outcome => {
516                if let Some(append) = self.channel.pending_batch_evidence() {
517                    return Err(LiminalError::PublishUnresolved { append });
518                }
519                self.batch_fanout.clear();
520                return Err(LiminalError::PublishFailed {
521                    message: format!("durable batch failed: {outcome:?}"),
522                });
523            }
524        }
525        let mut queued = Vec::with_capacity(proofs.len());
526        for mut proof in proofs {
527            let fanout = self
528                .batch_fanout
529                .front()
530                .ok_or_else(|| LiminalError::PublishFailed {
531                    message: "committed durable batch lost its fanout".to_owned(),
532                })?;
533            match core.enqueue_publish(
534                fanout.payload.clone(),
535                fanout.publisher.clone(),
536                fanout.causal.clone(),
537                fanout.position,
538                None,
539            ) {
540                Ok(pending) => {
541                    self.batch_fanout.pop_front();
542                    queued.push((pending, proof));
543                }
544                Err(error) => {
545                    proof.set_cause(format!("batch committed; live enqueue failed: {error}"));
546                    return Err(LiminalError::PublishUnresolved { append: proof });
547                }
548            }
549        }
550        Ok(queued)
551    }
552}