Skip to main content

laminar_db/subscription/portal/
mod.rs

1//! Per-subscriber cursor over the shared subscription log.
2
3use std::collections::VecDeque;
4use std::sync::Arc;
5
6use arrow_array::RecordBatch;
7use arrow_schema::SchemaRef;
8use datafusion::physical_expr::PhysicalExpr;
9use futures::FutureExt;
10use laminar_core::checkpoint::{OutputPartitionId, PartitionSequence, StreamGeneration};
11
12#[cfg(feature = "cluster")]
13use super::cluster::{ClusterReaderFrame, ClusterReaderRead, ClusterSubscriptionReader};
14use super::registry::{ChargedUpdate, MvUpdate, SubscriptionRead, SubscriptionReader};
15
16#[derive(Debug)]
17enum PortalReader {
18    Local(SubscriptionReader),
19    #[cfg(feature = "cluster")]
20    Cluster(ClusterSubscriptionReader),
21}
22
23impl PortalReader {
24    async fn next(&mut self) -> PortalRead {
25        match self {
26            Self::Local(reader) => PortalRead::Local(reader.next().await),
27            #[cfg(feature = "cluster")]
28            Self::Cluster(reader) => PortalRead::Cluster(reader.next().await),
29        }
30    }
31
32    fn try_next(&mut self) -> Option<PortalRead> {
33        match self {
34            Self::Local(reader) => reader.next().now_or_never().map(PortalRead::Local),
35            #[cfg(feature = "cluster")]
36            Self::Cluster(reader) => reader.try_next().map(PortalRead::Cluster),
37        }
38    }
39}
40
41enum PortalRead {
42    Local(SubscriptionRead),
43    #[cfg(feature = "cluster")]
44    Cluster(ClusterReaderRead),
45}
46
47/// Keeps the process-wide subscription charge alive with an emitted batch.
48#[doc(hidden)]
49#[derive(Clone)]
50pub struct SubscriptionFrameLease {
51    _local_owner: Option<ChargedUpdate>,
52    #[cfg(feature = "cluster")]
53    _cluster_owner: Option<Arc<tokio::sync::OwnedSemaphorePermit>>,
54}
55
56impl std::fmt::Debug for SubscriptionFrameLease {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter.write_str("SubscriptionFrameLease")
59    }
60}
61
62/// Optional durable identity carried alongside a compatibility [`PortalFrame`].
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ClusterSubscriptionFrameMetadata {
65    /// Partition-local identity of one committed data frame.
66    Data {
67        /// Durable stream incarnation.
68        stream_generation: StreamGeneration,
69        /// Stable vnode output partition.
70        partition: OutputPartitionId,
71        /// Monotonic sequence within this generation and partition.
72        partition_sequence: PartitionSequence,
73        /// Whole-cluster checkpoint that first exposed this frame.
74        committed_epoch: u64,
75    },
76    /// Whole-cluster committed progress after every partition interval was delivered.
77    Progress {
78        /// Durable stream incarnation.
79        stream_generation: StreamGeneration,
80        /// Committed checkpoint epoch.
81        epoch: u64,
82        /// Committed checkpoint identifier.
83        checkpoint_id: u64,
84    },
85    /// Rows of a materialized view read from the committed checkpoint at `epoch`.
86    ViewRows {
87        /// Committed checkpoint epoch.
88        epoch: u64,
89        /// Whether the rows are part of the view's rows at `epoch`, which the subscription
90        /// starts with, rather than part of its change since the previous committed checkpoint.
91        snapshot: bool,
92    },
93    /// Every change of a materialized view through this committed checkpoint was delivered.
94    ViewProgress {
95        /// Committed checkpoint epoch.
96        epoch: u64,
97        /// Committed checkpoint identifier.
98        checkpoint_id: u64,
99    },
100}
101
102/// Backward-compatible frame plus optional cluster delivery metadata.
103#[derive(Debug, Clone)]
104pub struct SubscriptionEnvelope {
105    /// Existing local/standalone-compatible frame.
106    pub frame: PortalFrame,
107    /// Present only for committed cluster frames.
108    pub cluster: Option<ClusterSubscriptionFrameMetadata>,
109    /// Stable terminal error code when `frame` is [`PortalFrame::Error`].
110    pub error_code: Option<&'static str>,
111}
112
113impl SubscriptionEnvelope {
114    fn local(frame: PortalFrame) -> Self {
115        Self {
116            frame,
117            cluster: None,
118            error_code: None,
119        }
120    }
121}
122
123/// One frame emitted toward the wire.
124#[derive(Debug, Clone)]
125pub enum PortalFrame {
126    /// Rows produced in a cycle, or part of a materialized view's rows when the subscription
127    /// attached.
128    Batch {
129        /// Arrow rows in the shared-log entry.
130        batch: RecordBatch,
131        /// Portal-local delivery sequence. In cluster mode this is neither durable nor global;
132        /// use [`ClusterSubscriptionFrameMetadata::Data`] for partition identity. A local view
133        /// snapshot frame carries the sequence of the first change after it.
134        sequence: u64,
135        /// Internal process-memory ownership token.
136        #[doc(hidden)]
137        lease: SubscriptionFrameLease,
138    },
139    /// Progress frontier for a durably committed checkpoint.
140    Barrier {
141        /// Portal-local delivery sequence; not a cluster-wide ordering position.
142        sequence: u64,
143        /// Engine checkpoint epoch.
144        epoch: u64,
145        /// Engine checkpoint id.
146        checkpoint_id: u64,
147        /// Local-log cut, or the gateway-local delivery cut in cluster mode. The durable cluster
148        /// position is the checkpoint's partition-frontier vector, addressed by `epoch`.
149        through_sequence: u64,
150    },
151    /// Consumer fell behind by exactly `skipped` shared-log entries. This is
152    /// terminal because continuing would hide rows or checkpoint markers.
153    Lagged(u64),
154    /// The subscription cannot continue without returning an invalid result.
155    Error {
156        /// Human-readable failure detail.
157        message: String,
158    },
159}
160
161/// One `SUBSCRIBE` consumer.
162#[derive(Debug)]
163pub struct SubscriptionPortal {
164    name: String,
165    schema: SchemaRef,
166    /// A local view subscription's rows at attach, delivered before any change.
167    snapshot: VecDeque<RecordBatch>,
168    reader: Option<PortalReader>,
169    closed: bool,
170    filter: Option<Arc<dyn PhysicalExpr>>,
171}
172
173impl SubscriptionPortal {
174    pub(crate) fn open(
175        name: impl Into<String>,
176        schema: SchemaRef,
177        reader: SubscriptionReader,
178    ) -> Self {
179        Self::new_inner(name, schema, reader, None)
180    }
181
182    pub(crate) fn open_with_filter(
183        name: impl Into<String>,
184        schema: SchemaRef,
185        reader: SubscriptionReader,
186        filter: Arc<dyn PhysicalExpr>,
187    ) -> Self {
188        Self::new_inner(name, schema, reader, Some(filter))
189    }
190
191    /// A subscription to a materialized view: `snapshot` holds its rows at attach, and `reader`
192    /// is positioned at the first change after them.
193    pub(crate) fn open_view(
194        name: impl Into<String>,
195        schema: SchemaRef,
196        snapshot: RecordBatch,
197        reader: SubscriptionReader,
198        filter: Option<Arc<dyn PhysicalExpr>>,
199    ) -> Self {
200        let mut portal = Self::new_inner(name, schema, reader, filter);
201        if snapshot.num_rows() > 0 {
202            portal.snapshot.push_back(snapshot);
203        }
204        portal
205    }
206
207    fn new_inner(
208        name: impl Into<String>,
209        schema: SchemaRef,
210        reader: SubscriptionReader,
211        filter: Option<Arc<dyn PhysicalExpr>>,
212    ) -> Self {
213        Self {
214            name: name.into(),
215            schema,
216            snapshot: VecDeque::new(),
217            reader: Some(PortalReader::Local(reader)),
218            closed: false,
219            filter,
220        }
221    }
222
223    #[cfg(feature = "cluster")]
224    pub(crate) fn open_cluster(
225        name: impl Into<String>,
226        schema: SchemaRef,
227        reader: ClusterSubscriptionReader,
228        filter: Option<Arc<dyn PhysicalExpr>>,
229    ) -> Self {
230        Self {
231            name: name.into(),
232            schema,
233            snapshot: VecDeque::new(),
234            reader: Some(PortalReader::Cluster(reader)),
235            closed: false,
236            filter,
237        }
238    }
239
240    /// Schema of the subscribed object.
241    #[must_use]
242    pub fn schema(&self) -> SchemaRef {
243        Arc::clone(&self.schema)
244    }
245
246    /// Next frame, or `None` after a terminal frame or explicit close.
247    pub async fn next_frame(&mut self) -> Option<PortalFrame> {
248        self.next_envelope().await.map(|envelope| envelope.frame)
249    }
250
251    /// Next frame with optional partition/checkpoint metadata for cluster-aware clients.
252    pub async fn next_envelope(&mut self) -> Option<SubscriptionEnvelope> {
253        if self.closed {
254            return None;
255        }
256        if let Some(envelope) = self.next_snapshot_envelope() {
257            return Some(envelope);
258        }
259
260        loop {
261            let read = self.reader.as_mut()?.next().await;
262            if let Some(frame) = self.process_read(read) {
263                return Some(frame);
264            }
265        }
266    }
267
268    /// Return the next immediately available frame without waiting.
269    pub fn try_next_frame(&mut self) -> Option<PortalFrame> {
270        self.try_next_envelope().map(|envelope| envelope.frame)
271    }
272
273    /// Return the next immediately available cluster-aware envelope without waiting.
274    pub fn try_next_envelope(&mut self) -> Option<SubscriptionEnvelope> {
275        if self.closed {
276            return None;
277        }
278        if let Some(envelope) = self.next_snapshot_envelope() {
279            return Some(envelope);
280        }
281
282        loop {
283            let read = self.reader.as_mut()?.try_next()?;
284            if let Some(frame) = self.process_read(read) {
285                return Some(frame);
286            }
287        }
288    }
289
290    fn next_snapshot_envelope(&mut self) -> Option<SubscriptionEnvelope> {
291        while let Some(batch) = self.snapshot.pop_front() {
292            let Some(PortalReader::Local(reader)) = &self.reader else {
293                return None;
294            };
295            let frame = PortalFrame::Batch {
296                batch,
297                sequence: reader.cursor(),
298                lease: SubscriptionFrameLease {
299                    _local_owner: None,
300                    #[cfg(feature = "cluster")]
301                    _cluster_owner: None,
302                },
303            };
304            if let Some(envelope) = self.process_envelope(SubscriptionEnvelope::local(frame)) {
305                return Some(envelope);
306            }
307        }
308        None
309    }
310
311    fn process_read(&mut self, read: PortalRead) -> Option<SubscriptionEnvelope> {
312        match read {
313            PortalRead::Local(read) => self.process_local_read(read),
314            #[cfg(feature = "cluster")]
315            PortalRead::Cluster(read) => self.process_cluster_read(read),
316        }
317    }
318
319    fn process_local_read(&mut self, read: SubscriptionRead) -> Option<SubscriptionEnvelope> {
320        let frame = match read {
321            SubscriptionRead::Update { sequence, update } => translate(sequence, update),
322            SubscriptionRead::Lagged(skipped) => {
323                tracing::warn!(
324                    subscription = %self.name,
325                    skipped,
326                    "subscription cursor was evicted; closing"
327                );
328                self.close();
329                return Some(SubscriptionEnvelope::local(PortalFrame::Lagged(skipped)));
330            }
331            SubscriptionRead::Terminal(message) => {
332                tracing::warn!(
333                    subscription = %self.name,
334                    %message,
335                    "subscription log terminated; closing"
336                );
337                self.close();
338                return Some(SubscriptionEnvelope::local(PortalFrame::Error { message }));
339            }
340        };
341
342        self.process_envelope(SubscriptionEnvelope::local(frame))
343    }
344
345    #[cfg(feature = "cluster")]
346    fn process_cluster_read(&mut self, read: ClusterReaderRead) -> Option<SubscriptionEnvelope> {
347        let envelope = match read {
348            ClusterReaderRead::Frame(ClusterReaderFrame::Batch {
349                batch,
350                delivery_sequence,
351                stream_generation,
352                partition,
353                partition_sequence,
354                committed_epoch,
355                permit,
356            }) => SubscriptionEnvelope {
357                frame: PortalFrame::Batch {
358                    batch,
359                    sequence: delivery_sequence,
360                    lease: SubscriptionFrameLease {
361                        _local_owner: None,
362                        _cluster_owner: Some(permit),
363                    },
364                },
365                cluster: Some(ClusterSubscriptionFrameMetadata::Data {
366                    stream_generation,
367                    partition,
368                    partition_sequence,
369                    committed_epoch,
370                }),
371                error_code: None,
372            },
373            ClusterReaderRead::Frame(ClusterReaderFrame::Progress {
374                delivery_sequence,
375                through_sequence,
376                stream_generation,
377                epoch,
378                checkpoint_id,
379            }) => SubscriptionEnvelope {
380                frame: PortalFrame::Barrier {
381                    sequence: delivery_sequence,
382                    epoch,
383                    checkpoint_id,
384                    through_sequence,
385                },
386                cluster: Some(ClusterSubscriptionFrameMetadata::Progress {
387                    stream_generation,
388                    epoch,
389                    checkpoint_id,
390                }),
391                error_code: None,
392            },
393            ClusterReaderRead::Frame(ClusterReaderFrame::ViewRows {
394                batch,
395                delivery_sequence,
396                epoch,
397                snapshot,
398                permit,
399            }) => SubscriptionEnvelope {
400                frame: PortalFrame::Batch {
401                    batch,
402                    sequence: delivery_sequence,
403                    lease: SubscriptionFrameLease {
404                        _local_owner: None,
405                        _cluster_owner: Some(permit),
406                    },
407                },
408                cluster: Some(ClusterSubscriptionFrameMetadata::ViewRows { epoch, snapshot }),
409                error_code: None,
410            },
411            ClusterReaderRead::Frame(ClusterReaderFrame::ViewProgress {
412                delivery_sequence,
413                epoch,
414                checkpoint_id,
415            }) => SubscriptionEnvelope {
416                frame: PortalFrame::Barrier {
417                    sequence: delivery_sequence,
418                    epoch,
419                    checkpoint_id,
420                    through_sequence: delivery_sequence,
421                },
422                cluster: Some(ClusterSubscriptionFrameMetadata::ViewProgress {
423                    epoch,
424                    checkpoint_id,
425                }),
426                error_code: None,
427            },
428            ClusterReaderRead::Terminal(error) => {
429                let code = error.code();
430                tracing::warn!(
431                    subscription = %self.name,
432                    code,
433                    error = %error,
434                    "committed cluster subscription terminated"
435                );
436                self.close();
437                return Some(SubscriptionEnvelope {
438                    frame: PortalFrame::Error {
439                        message: format!("[{code}] {error}"),
440                    },
441                    cluster: None,
442                    error_code: Some(code),
443                });
444            }
445        };
446        self.process_envelope(envelope)
447    }
448
449    fn process_envelope(&mut self, envelope: SubscriptionEnvelope) -> Option<SubscriptionEnvelope> {
450        let SubscriptionEnvelope {
451            frame,
452            cluster,
453            error_code,
454        } = envelope;
455
456        let PortalFrame::Batch {
457            batch,
458            sequence,
459            lease,
460        } = frame
461        else {
462            if matches!(&frame, PortalFrame::Error { .. }) {
463                self.close();
464            }
465            return Some(SubscriptionEnvelope {
466                frame,
467                cluster,
468                error_code,
469            });
470        };
471        let Some(filter) = self.filter.as_ref() else {
472            return Some(SubscriptionEnvelope {
473                frame: PortalFrame::Batch {
474                    batch,
475                    sequence,
476                    lease,
477                },
478                cluster,
479                error_code,
480            });
481        };
482        match crate::filter_compile::apply(&batch, filter.as_ref()) {
483            Ok(Some(filtered)) => Some(SubscriptionEnvelope {
484                frame: PortalFrame::Batch {
485                    batch: filtered,
486                    sequence,
487                    lease,
488                },
489                cluster,
490                error_code,
491            }),
492            Ok(None) => None,
493            Err(error) => {
494                tracing::warn!(
495                    subscription = %self.name,
496                    %error,
497                    "subscription filter failed; closing"
498                );
499                let message = error.to_string();
500                self.close();
501                Some(SubscriptionEnvelope {
502                    frame: PortalFrame::Error { message },
503                    cluster: None,
504                    error_code: None,
505                })
506            }
507        }
508    }
509
510    /// Stop reading and release the subscriber registration. Idempotent.
511    pub fn close(&mut self) {
512        self.closed = true;
513        self.snapshot.clear();
514        self.reader = None;
515    }
516
517    /// True after `close()` has been called.
518    #[must_use]
519    pub fn is_closed(&self) -> bool {
520        self.closed
521    }
522}
523
524impl Drop for SubscriptionPortal {
525    fn drop(&mut self) {
526        self.close();
527    }
528}
529
530fn translate(sequence: u64, update: ChargedUpdate) -> PortalFrame {
531    match update.as_ref() {
532        MvUpdate::Batch(batch) => {
533            let batch = batch.clone();
534            PortalFrame::Batch {
535                batch,
536                sequence,
537                lease: SubscriptionFrameLease {
538                    _local_owner: Some(update),
539                    #[cfg(feature = "cluster")]
540                    _cluster_owner: None,
541                },
542            }
543        }
544        MvUpdate::Barrier {
545            epoch,
546            checkpoint_id,
547            through_sequence,
548        } => PortalFrame::Barrier {
549            sequence,
550            epoch: *epoch,
551            checkpoint_id: *checkpoint_id,
552            through_sequence: *through_sequence,
553        },
554        MvUpdate::Error(message) => PortalFrame::Error {
555            message: message.clone(),
556        },
557    }
558}
559
560#[cfg(test)]
561mod tests;