Skip to main content

liminal/channel/
types.rs

1//! Public channel surface: [`ChannelConfig`], [`ChannelMode`], and the
2//! cloneable [`ChannelHandle`] that drives a REAL supervised beamr channel
3//! actor (LIM-002).
4//!
5//! The handle is a thin, synchronous-looking facade over the process-backed
6//! actor: every operation enqueues a typed command onto the actor's mailbox and
7//! blocks on a per-command reply (the haematite `ShardHandle` pattern). It owns
8//! no subscriber state and performs no fan-out itself — the actor process does.
9//!
10//! `ChannelHandle::new` stays infallible (existing call-sites depend on it): the
11//! actor is spawned lazily on first use and the spawn result is memoised, so a
12//! scheduler failure surfaces as a `LiminalError` from the first operation
13//! rather than as a panic.
14
15use std::sync::atomic::AtomicU32;
16use std::sync::{Arc, Mutex, OnceLock};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use serde_json::Value;
20
21use crate::causal::CausalContext;
22use crate::channel::actor::{ChannelActorCore, predicate_from};
23use crate::channel::observer::ClusterObserver;
24use crate::channel::schema::{Schema, SchemaId, SchemaValidationError};
25use crate::channel::subscription::{SubscriptionHandle, SubscriptionPredicate};
26use crate::channel::supervisor::{ChannelSupervisor, shared_supervisor};
27use crate::durability::bridge::block_on;
28use crate::durability::{DurableChannel, DurableStore, MessageEnvelope, recover_durable_channel};
29use crate::envelope::{Envelope, PublisherId};
30use crate::error::LiminalError;
31
32/// Single-partition count used to back a flat runtime channel with durable storage.
33const RUNTIME_DURABLE_PARTITIONS: usize = 1;
34
35/// Genuine delivery ack returned by [`ChannelHandle::publish_with_delivery`].
36///
37/// Distinct from backpressure: it reports whether a published message was
38/// actually received by a subscriber, not whether it was admitted to the bus.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct ChannelDelivery {
41    delivered_count: usize,
42}
43
44impl ChannelDelivery {
45    /// Number of local subscribers the message was genuinely delivered to.
46    #[must_use]
47    pub const fn delivered_count(&self) -> usize {
48        self.delivered_count
49    }
50
51    /// Whether the message was accepted by at least one subscriber.
52    #[must_use]
53    pub const fn is_delivered(&self) -> bool {
54        self.delivered_count > 0
55    }
56}
57
58/// Defines whether a channel is memory-only or durable across restarts.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum ChannelMode {
61    /// In-memory channel mode with no persistence overhead.
62    Ephemeral,
63    /// Durable channel mode reserved for future haematite-backed storage.
64    Durable,
65}
66
67/// Compatibility alias for the channel-owned schema definition.
68pub type SchemaRef = Schema;
69
70/// Required configuration for creating a typed channel.
71#[derive(Clone, Debug)]
72pub struct ChannelConfig {
73    /// Explicit channel name.
74    pub name: String,
75    /// Explicit schema for validating published payloads.
76    pub schema: Schema,
77    /// Explicit durability mode for the channel.
78    pub mode: ChannelMode,
79}
80
81impl ChannelConfig {
82    /// Creates channel configuration from its required fields.
83    #[must_use]
84    pub const fn new(name: String, schema: Schema, mode: ChannelMode) -> Self {
85        Self { name, schema, mode }
86    }
87}
88
89/// A lazily-spawned, supervised channel actor shared by every clone of a handle.
90///
91/// `supervisor` is stored as a `Result` so [`ChannelHandle::new`] can stay
92/// infallible: a scheduler-start failure is captured here and surfaced as a
93/// `LiminalError` the first time the actor is actually used.
94struct ChannelActorState {
95    supervisor: Result<ChannelSupervisor, String>,
96    core: OnceLock<Result<Arc<ChannelActorCore>, String>>,
97    restarts: AtomicU32,
98}
99
100impl ChannelActorState {
101    const fn new(supervisor: Result<ChannelSupervisor, String>) -> Self {
102        Self {
103            supervisor,
104            core: OnceLock::new(),
105            restarts: AtomicU32::new(0),
106        }
107    }
108
109    fn supervisor(&self) -> Result<&ChannelSupervisor, LiminalError> {
110        self.supervisor
111            .as_ref()
112            .map_err(|message| LiminalError::PublishFailed {
113                message: format!("channel supervisor unavailable: {message}"),
114            })
115    }
116
117    /// The installed cluster observer, if this channel runs on a clustered
118    /// supervisor (SRV-005). Returns `None` for non-clustered channels.
119    fn observer(&self) -> Option<Arc<dyn ClusterObserver>> {
120        self.supervisor
121            .as_ref()
122            .ok()
123            .and_then(|supervisor| supervisor.observer().cloned())
124    }
125
126    /// Returns the live actor core, spawning it (and any restart) on demand.
127    fn core(&self, schema: &Schema) -> Result<Arc<ChannelActorCore>, LiminalError> {
128        let supervisor = self.supervisor()?;
129        let stored = self.core.get_or_init(|| {
130            supervisor
131                .spawn_channel(schema.clone())
132                .map_err(|error| error.to_string())
133        });
134        let core = stored
135            .as_ref()
136            .map_err(|message| LiminalError::PublishFailed {
137                message: format!("channel actor unavailable: {message}"),
138            })?;
139        // Restart on a dead pid (R4) before returning the core for use.
140        supervisor.ensure_running(core, &self.restarts)?;
141        Ok(Arc::clone(core))
142    }
143}
144
145impl std::fmt::Debug for ChannelActorState {
146    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        formatter
148            .debug_struct("ChannelActorState")
149            .field("supervisor", &self.supervisor)
150            .finish_non_exhaustive()
151    }
152}
153
154/// Cloneable handle for interacting with a channel actor process.
155#[derive(Clone, Debug)]
156pub struct ChannelHandle {
157    config: ChannelConfig,
158    actor: Arc<ChannelActorState>,
159    durable: Option<Arc<Mutex<DurableChannel>>>,
160}
161
162impl ChannelHandle {
163    /// Creates an ephemeral handle backed by a real supervised channel actor on
164    /// the shared default supervisor.
165    ///
166    /// The actor process is spawned lazily on first use; a scheduler failure is
167    /// surfaced as a [`LiminalError`] from the first operation, not a panic.
168    #[must_use]
169    pub fn new(config: ChannelConfig) -> Self {
170        let supervisor = shared_supervisor().map_err(|error| error.to_string());
171        Self {
172            config,
173            actor: Arc::new(ChannelActorState::new(supervisor)),
174            durable: None,
175        }
176    }
177
178    /// Creates an ephemeral handle bound to an explicit `supervisor` (isolation
179    /// for the registry and tests).
180    #[must_use]
181    pub fn with_supervisor(config: ChannelConfig, supervisor: ChannelSupervisor) -> Self {
182        Self {
183            config,
184            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
185            durable: None,
186        }
187    }
188
189    /// Creates a durable handle that persists every accepted publish to `store`
190    /// before fanning it out to subscribers.
191    ///
192    /// Construction reads the store: every partition's next-sequence counter is
193    /// recovered from the existing stream head, so a handle rebuilt over a
194    /// previously used store resumes appending where the log left off instead
195    /// of conflicting at sequence zero. A fresh store recovers to zero, which is
196    /// identical to cold start. This makes construction O(stream length) in
197    /// store reads and adds store read errors to the failure modes below.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
202    /// initialized over `store`, including when recovering the per-partition
203    /// sequence counters from the store fails.
204    pub fn new_durable(
205        config: ChannelConfig,
206        store: Arc<dyn DurableStore>,
207    ) -> Result<Self, LiminalError> {
208        let durable = recover_durable(&config.name, store)?;
209        let supervisor = shared_supervisor()?;
210        Ok(Self {
211            config,
212            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
213            durable: Some(Arc::new(Mutex::new(durable))),
214        })
215    }
216
217    /// Creates a durable handle bound to an explicit `supervisor`.
218    ///
219    /// Used by the standalone server so every channel — durable or ephemeral —
220    /// shares ONE (optionally clustered) supervisor and thus one scheduler, which
221    /// is the precondition for cross-node delivery (SRV-005): a subscriber pid
222    /// joined to a channel's distributed process group must live on the same
223    /// scheduler that owns the distribution links.
224    ///
225    /// Like [`Self::new_durable`], construction recovers each partition's
226    /// next-sequence counter from the store, so restarting over an existing
227    /// persistence path resumes the log instead of conflicting at sequence zero.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
232    /// initialized over `store`, including when recovering the per-partition
233    /// sequence counters from the store fails.
234    pub fn new_durable_with_supervisor(
235        config: ChannelConfig,
236        store: Arc<dyn DurableStore>,
237        supervisor: ChannelSupervisor,
238    ) -> Result<Self, LiminalError> {
239        let durable = recover_durable(&config.name, store)?;
240        Ok(Self {
241            config,
242            actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
243            durable: Some(Arc::new(Mutex::new(durable))),
244        })
245    }
246
247    /// Returns the channel configuration used to create this handle.
248    #[must_use]
249    pub const fn config(&self) -> &ChannelConfig {
250        &self.config
251    }
252
253    /// Publishes a payload to the channel with the default publisher identity.
254    ///
255    /// # Errors
256    ///
257    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
258    pub fn publish<Payload>(&self, payload: Payload) -> Result<(), LiminalError>
259    where
260        Payload: AsRef<[u8]>,
261    {
262        self.publish_with_context(payload, PublisherId::default(), None)
263    }
264
265    /// Publishes a payload with an explicit publisher identity.
266    ///
267    /// # Errors
268    ///
269    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
270    pub fn publish_from<Payload>(
271        &self,
272        publisher_id: impl Into<PublisherId>,
273        payload: Payload,
274    ) -> Result<(), LiminalError>
275    where
276        Payload: AsRef<[u8]>,
277    {
278        self.publish_with_context(payload, publisher_id.into(), None)
279    }
280
281    /// Publishes a payload with explicit publisher and causal metadata.
282    ///
283    /// # Errors
284    ///
285    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
286    pub fn publish_with_context<Payload>(
287        &self,
288        payload: Payload,
289        publisher_id: PublisherId,
290        causal_context: Option<CausalContext>,
291    ) -> Result<(), LiminalError>
292    where
293        Payload: AsRef<[u8]>,
294    {
295        self.publish_with_delivery(payload, publisher_id, causal_context)
296            .map(|_delivery| ())
297    }
298
299    /// Publishes a payload and reports a genuine delivery ack.
300    ///
301    /// Returns a [`ChannelDelivery`] whose `delivered_count` is the number of
302    /// local subscribers the message was actually delivered to. A caller that
303    /// needs to know the message was ACCEPTED by a subscriber (not merely
304    /// buffered/published) inspects [`ChannelDelivery::is_delivered`]. This is the
305    /// channel-library half of the 13-L1 delivery-ack signal; the publish-without-
306    /// delivery methods stay unchanged for existing callers.
307    ///
308    /// # Errors
309    ///
310    /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
311    pub fn publish_with_delivery<Payload>(
312        &self,
313        payload: Payload,
314        publisher_id: PublisherId,
315        causal_context: Option<CausalContext>,
316    ) -> Result<ChannelDelivery, LiminalError>
317    where
318        Payload: AsRef<[u8]>,
319    {
320        // Durable channels persist the message to the store BEFORE acknowledging
321        // the publish (and before fanning out): a published message that was not
322        // durably recorded would be lost on shutdown, which CN7 forbids.
323        if let Some(durable) = self.durable.as_ref() {
324            self.persist_durable(durable, payload.as_ref(), &publisher_id)?;
325        }
326        let core = self.core()?;
327        let outcome = core.publish(payload.as_ref().to_vec(), publisher_id, causal_context)?;
328        // SRV-005: hand the normalised envelope to the cluster observer so it can
329        // fan the message out to remote subscribers. Local fan-out already
330        // happened inside `core.publish`; this is purely the cross-node leg and
331        // is a no-op when no observer is installed (non-clustered channels).
332        if let Some(observer) = self.actor.observer() {
333            observer.on_publish(&self.config.name, &outcome.envelope);
334        }
335        Ok(ChannelDelivery {
336            delivered_count: outcome.delivered_count,
337        })
338    }
339
340    fn persist_durable(
341        &self,
342        durable: &Arc<Mutex<DurableChannel>>,
343        payload: &[u8],
344        publisher_id: &PublisherId,
345    ) -> Result<(), LiminalError> {
346        let envelope = MessageEnvelope {
347            payload: payload.to_vec(),
348            causal_context: None,
349            timestamp: now_millis(),
350            publisher_id: publisher_id.as_str().to_owned(),
351            idempotency_key: None,
352        };
353        let publish_result = {
354            let mut channel = durable
355                .lock()
356                .map_err(|error| LiminalError::PublishFailed {
357                    message: format!("durable channel state unavailable: {error}"),
358                })?;
359            block_on(channel.publish(&envelope))
360        };
361        publish_result
362            .map_err(|error| LiminalError::PublishFailed {
363                message: format!(
364                    "durable publish bridge for channel '{}' failed: {error}",
365                    self.config.name
366                ),
367            })?
368            .map_err(|error| LiminalError::PublishFailed {
369                message: format!(
370                    "durable publish to channel '{}' failed: {error}",
371                    self.config.name
372                ),
373            })?;
374        Ok(())
375    }
376
377    /// Returns the schema version currently owned by the channel actor.
378    ///
379    /// # Errors
380    ///
381    /// Returns a [`LiminalError`] when the channel actor cannot be read.
382    pub fn current_schema_id(&self) -> Result<SchemaId, LiminalError> {
383        self.core()?.schema_id()
384    }
385
386    /// Evolves the channel schema by adding a defaulted field without disconnecting subscribers.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`SchemaValidationError`] when the schema cannot be evolved.
391    pub fn evolve_schema_add_field(
392        &self,
393        name: impl Into<String>,
394        field_schema: Value,
395        default: Value,
396    ) -> Result<SchemaId, SchemaValidationError> {
397        let core = self
398            .core()
399            .map_err(|error| SchemaValidationError::InvalidSchema {
400                message: error.to_string(),
401            })?;
402        core.evolve(name.into(), field_schema, default)
403    }
404
405    /// Subscribes to the channel, receiving every published message.
406    ///
407    /// # Errors
408    ///
409    /// Returns a [`LiminalError`] when a subscription cannot be created.
410    pub fn subscribe(&self) -> Result<SubscriptionHandle, LiminalError> {
411        self.subscribe_inner(None)
412    }
413
414    /// Subscribes with a delivery predicate: only messages for which `predicate`
415    /// returns `true` are delivered to this subscriber. The predicate is owned
416    /// and evaluated by the actor process (R3).
417    ///
418    /// # Clustering
419    ///
420    /// The predicate filters **local-node publishes only**. Under clustering
421    /// (SRV-005), messages published on a remote node are delivered to this
422    /// subscriber *ungated* — the predicate is a non-serializable closure and is
423    /// not propagated across the wire, so remote nodes cannot evaluate it. If you
424    /// need filtering to hold for cross-node traffic, filter again on receipt
425    /// rather than relying on this predicate alone.
426    ///
427    /// # Errors
428    ///
429    /// Returns a [`LiminalError`] when a subscription cannot be created.
430    pub fn subscribe_filtered<F>(&self, predicate: F) -> Result<SubscriptionHandle, LiminalError>
431    where
432        F: Fn(&Envelope) -> bool + Send + Sync + 'static,
433    {
434        self.subscribe_inner(Some(predicate_from(predicate)))
435    }
436
437    fn subscribe_inner(
438        &self,
439        predicate: Option<SubscriptionPredicate>,
440    ) -> Result<SubscriptionHandle, LiminalError> {
441        let core = self.core()?;
442        let (handle, registration) = SubscriptionHandle::spawn(core.scheduler(), predicate)?;
443        let pid = registration.pid();
444        core.subscribe(registration)?;
445        // SRV-005: tell the cluster a local subscriber joined this channel so it
446        // can advertise the subscription to peers via its process group.
447        if let Some(observer) = self.actor.observer() {
448            observer.on_subscribe(&self.config.name, pid);
449        }
450        Ok(handle)
451    }
452
453    /// Unsubscribes the subscriber owning `subscription` by its process pid.
454    ///
455    /// # Errors
456    ///
457    /// Returns a [`LiminalError`] when the unsubscribe command fails.
458    pub fn unsubscribe(&self, subscription: &SubscriptionHandle) -> Result<(), LiminalError> {
459        let pid = subscription.pid();
460        self.core()?.unsubscribe(pid)?;
461        // SRV-005: tell the cluster the local subscriber left so it can withdraw
462        // the subscription from its process group.
463        if let Some(observer) = self.actor.observer() {
464            observer.on_unsubscribe(&self.config.name, pid);
465        }
466        Ok(())
467    }
468
469    /// Flushes buffered durable channel state to the backing store before shutdown.
470    ///
471    /// # Errors
472    ///
473    /// Returns a [`LiminalError`] when the channel actor cannot be inspected or
474    /// when the durable store flush fails.
475    pub fn flush(&self) -> Result<(), LiminalError> {
476        // Confirm the actor is reachable (and restart it if needed) before flush.
477        drop(self.core()?);
478        let Some(durable) = self.durable.as_ref() else {
479            return Ok(());
480        };
481        let flush_result = {
482            let channel = durable
483                .lock()
484                .map_err(|error| LiminalError::PublishFailed {
485                    message: format!("durable channel state unavailable: {error}"),
486                })?;
487            block_on(channel.flush_store())
488        };
489        flush_result
490            .map_err(|error| LiminalError::PublishFailed {
491                message: format!(
492                    "durable flush bridge for channel '{}' failed: {error}",
493                    self.config.name
494                ),
495            })?
496            .map_err(|error| LiminalError::PublishFailed {
497                message: format!(
498                    "durable flush for channel '{}' failed: {error}",
499                    self.config.name
500                ),
501            })?;
502        Ok(())
503    }
504
505    /// Returns the number of currently-active subscribers on the channel actor.
506    ///
507    /// # Errors
508    ///
509    /// Returns a [`LiminalError`] when the actor cannot service the query.
510    pub fn subscriber_count(&self) -> Result<usize, LiminalError> {
511        Ok(self.core()?.list_subscribers()?.len())
512    }
513
514    /// Closes the channel gracefully, stopping the actor process.
515    ///
516    /// # Errors
517    ///
518    /// Returns a [`LiminalError`] when the channel cannot be shut down.
519    pub fn close(&self) -> Result<(), LiminalError> {
520        self.core()?.close()
521    }
522
523    fn core(&self) -> Result<Arc<ChannelActorCore>, LiminalError> {
524        self.actor.core(&self.config.schema)
525    }
526
527    /// The channel actor's current beamr pid, ensuring it is running first.
528    /// Test-only: lets restart tests crash the exact actor process.
529    #[cfg(test)]
530    pub(crate) fn actor_pid(&self) -> Result<u64, LiminalError> {
531        let core = self.core()?;
532        core.current_pid()?
533            .ok_or_else(|| LiminalError::DeliveryFailed {
534                message: "channel actor has no live pid".to_owned(),
535            })
536    }
537
538    /// The scheduler the channel actor and its subscribers run on (test-only).
539    #[cfg(test)]
540    pub(crate) fn scheduler(&self) -> Result<Arc<beamr::scheduler::Scheduler>, LiminalError> {
541        Ok(Arc::clone(self.core()?.scheduler()))
542    }
543}
544
545/// Reconstructs a durable channel over `store`, deriving each partition's next
546/// sequence from the persisted log so a restart over an existing stream appends at
547/// the tail rather than colliding with the occupied head (H2 / ledger G1). On a
548/// fresh store, recovery yields zeroed sequences — identical to a first-boot
549/// channel — so this is the single durable-construction path for both cold start
550/// and restart.
551///
552/// The async recovery is driven to completion through the synchronous
553/// [`block_on`] bridge, exactly as the durable publish/flush paths do: the
554/// haematite store completes on its first poll, so no executor is needed.
555fn recover_durable(
556    channel_name: &str,
557    store: Arc<dyn DurableStore>,
558) -> Result<DurableChannel, LiminalError> {
559    block_on(recover_durable_channel(
560        channel_name.to_owned(),
561        RUNTIME_DURABLE_PARTITIONS,
562        store,
563    ))
564    .map_err(|error| LiminalError::PublishFailed {
565        message: format!("durable recovery bridge for channel '{channel_name}' failed: {error}"),
566    })?
567    .map_err(|error| LiminalError::PublishFailed {
568        message: format!("failed to recover durable channel '{channel_name}': {error}"),
569    })
570}
571
572/// Returns the current epoch milliseconds, saturating to zero before the epoch.
573fn now_millis() -> u64 {
574    SystemTime::now()
575        .duration_since(UNIX_EPOCH)
576        .map_or(0, |duration| {
577            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
578        })
579}