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::{Duration, SystemTime, UNIX_EPOCH};
18
19use serde_json::Value;
20
21use crate::causal::CausalContext;
22use crate::channel::actor::{ChannelActorCore, PendingPublish, predicate_from};
23use crate::channel::admission::{
24 ChannelPressureConfig, defer_after_append, defer_delay, watermark_reached,
25};
26use crate::channel::durable_publish::{Outgoing, RuntimeDurable};
27use crate::channel::observer::ClusterObserver;
28use crate::channel::record::PublishRecord;
29use crate::channel::schema::{Schema, SchemaId, SchemaValidationError};
30use crate::channel::subscription::{InboxInstall, SubscriptionHandle, SubscriptionPredicate};
31use crate::channel::supervisor::{ChannelSupervisor, shared_supervisor};
32use crate::durability::bridge::block_on;
33use crate::durability::{DurableChannel, DurableStore, recover_durable_channel};
34use crate::envelope::{Envelope, PublisherId};
35use crate::error::LiminalError;
36use crate::pressure::PressureSignal;
37
38/// Single-partition count used to back a flat runtime channel with durable storage.
39const RUNTIME_DURABLE_PARTITIONS: usize = 1;
40
41/// Genuine delivery ack returned by [`ChannelHandle::publish_with_delivery`].
42///
43/// Distinct from backpressure: it reports whether a published message was
44/// actually received by a subscriber, not whether it was admitted to the bus.
45///
46/// `Copy` is preserved from published `liminal-rs 0.9.1`. A1 added two fields;
47/// the one that briefly cost `Copy` was `signal: PressureSignal`, which is four
48/// `usize`s and now derives `Copy` itself (`durable_position: Option<u64>` and
49/// `pressure_config: ChannelPressureConfig` were already `Copy`). Nothing about
50/// this type wants move semantics, and taking `Copy` away from a published type
51/// breaks callers silently — `let a = delivery; use(delivery);` simply stops
52/// compiling.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct ChannelDelivery {
55 delivery_id: Option<crate::protocol::DeliveredId>,
56 delivered_count: usize,
57 matched_count: usize,
58 durable_position: Option<u64>,
59 signal: PressureSignal,
60 pressure_config: ChannelPressureConfig,
61}
62
63impl ChannelDelivery {
64 /// The exact live identity supplied by the publication's transport.
65 #[must_use]
66 pub const fn delivery_id(&self) -> Option<crate::protocol::DeliveredId> {
67 self.delivery_id
68 }
69
70 /// Number of local subscribers the message was genuinely delivered to.
71 #[must_use]
72 pub const fn delivered_count(&self) -> usize {
73 self.delivered_count
74 }
75
76 /// Number of local subscribers whose predicate matched the message, whether
77 /// or not their inbox took it. With [`Self::delivered_count`] it tells
78 /// "nobody was subscribed" (0 matched) from "subscribers were there and
79 /// none took it" (matched, 0 delivered). Zero when the publish was refused
80 /// before any fan-out (a durable watermark Reject).
81 #[must_use]
82 pub const fn matched_count(&self) -> usize {
83 self.matched_count
84 }
85
86 /// Whether the message was accepted by at least one subscriber.
87 #[must_use]
88 pub const fn is_delivered(&self) -> bool {
89 self.delivered_count > 0
90 }
91
92 /// The store-assigned append sequence of this publish on a durable
93 /// channel, `None` on a memory channel.
94 ///
95 /// This is the durable position G1 of
96 /// `docs/design/wire-durability-gaps-20260831.md` names: haematite's
97 /// append assigns it, and before this field the caller discarded it, so
98 /// no client of a durable channel could ever learn where its publish
99 /// landed. Any resume/ack protocol needs exactly this value.
100 ///
101 /// Assigned is not flushed: the position is real the moment the append
102 /// returns, but the store flushes on graceful shutdown only (G4's flush
103 /// half), so a crash can still lose the tail. The position tells you
104 /// WHERE the publish sits, not that it survives every failure mode.
105 #[must_use]
106 pub const fn durable_position(&self) -> Option<u64> {
107 self.durable_position
108 }
109
110 /// The A1 backpressure signal for this publish
111 /// (`docs/design/A1-DEFER-SEMANTICS.md` §6: "`ChannelDelivery` gains a
112 /// `pressure()` accessor (additive)").
113 ///
114 /// Orthogonal to [`Self::is_delivered`]. Delivery answers "did a subscriber
115 /// receive it"; pressure answers "should you slow down". A publish to a
116 /// channel with no subscribers is `Accept` and delivered to nobody; a
117 /// publish every consumer buffered is `Defer` and delivered to everybody.
118 #[must_use]
119 pub const fn pressure(&self) -> &PressureSignal {
120 &self.signal
121 }
122
123 /// Whether the bus took custody of the message (A1 §7): `Accept` or
124 /// `Defer`.
125 ///
126 /// The accessor an aion worker's fire-and-forget completion policy reads,
127 /// deliberately distinct from [`Self::is_delivered`] ("a subscriber
128 /// genuinely received it"). A Deferred message is admitted — the bus holds
129 /// it and the consumer's next pop is its redelivery — so a producer that
130 /// re-publishes it with a fresh key double-delivers. `false` means Reject:
131 /// shed, delivered to nobody, and the producer owns the retry.
132 #[must_use]
133 pub const fn is_admitted(&self) -> bool {
134 !matches!(self.signal, PressureSignal::Reject { .. })
135 }
136
137 /// The producer's advisory pacing hint (A1 §3):
138 /// `base + (max − base) × buffer_fill_fraction`, from this channel's
139 /// configured delay policy. `None` on Accept — an accepted publish is not
140 /// paced.
141 ///
142 /// The bus never blocks a producer on this and never lies about it:
143 /// ignoring the hint leads to Reject on an ephemeral channel, and to
144 /// growing hints and eventually the pre-append watermark Reject on a
145 /// durable one.
146 #[must_use]
147 pub fn defer_delay(&self) -> Option<Duration> {
148 defer_delay(&self.signal, self.pressure_config)
149 }
150}
151
152/// Defines whether a channel is memory-only or durable across restarts.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum ChannelMode {
155 /// In-memory channel mode with no persistence overhead.
156 Ephemeral,
157 /// Durable channel mode reserved for future haematite-backed storage.
158 Durable,
159}
160
161/// Compatibility alias for the channel-owned schema definition.
162pub type SchemaRef = Schema;
163
164/// Required configuration for creating a typed channel.
165///
166/// **SEMVER — this type takes a declared break in the next publish wave.** A1
167/// added the `pressure` field, and adding a field to a struct with public
168/// fields breaks every out-of-crate struct-literal construction
169/// (`ChannelConfig { name, schema, mode }` no longer compiles). There is no
170/// version of "carry a per-channel pressure policy on the config" that avoids
171/// it, so it is declared rather than hidden — see
172/// `docs/design/A1-DEFER-WIRING-RECORD-2026-09-01.md` §6.
173///
174/// `#[non_exhaustive]` is added in the SAME wave, deliberately: the break is
175/// already being paid, and it buys the property that no future field costs one
176/// again. Out-of-crate construction goes through [`Self::new`] plus the
177/// `with_*` builders, which is what every construction site in this workspace
178/// already does. Reading a field is unaffected.
179#[derive(Clone, Debug)]
180#[non_exhaustive]
181pub struct ChannelConfig {
182 /// Explicit channel name.
183 pub name: String,
184 /// Explicit schema for validating published payloads.
185 pub schema: Schema,
186 /// Explicit durability mode for the channel.
187 pub mode: ChannelMode,
188 /// A1 pressure policy for this channel: the Defer delay-hint band and the
189 /// durable pre-append hard watermark. Defaulted by [`Self::new`], so no
190 /// existing construction site changes; overridden with
191 /// [`Self::with_pressure`].
192 pub pressure: ChannelPressureConfig,
193}
194
195impl ChannelConfig {
196 /// Creates channel configuration from its required fields, with the
197 /// default A1 pressure policy.
198 #[must_use]
199 pub const fn new(name: String, schema: Schema, mode: ChannelMode) -> Self {
200 Self {
201 name,
202 schema,
203 mode,
204 pressure: ChannelPressureConfig::DEFAULT,
205 }
206 }
207
208 /// Replaces this channel's A1 pressure policy.
209 #[must_use]
210 pub const fn with_pressure(mut self, pressure: ChannelPressureConfig) -> Self {
211 self.pressure = pressure;
212 self
213 }
214}
215
216/// A lazily-spawned, supervised channel actor shared by every clone of a handle.
217///
218/// `supervisor` is stored as a `Result` so [`ChannelHandle::new`] can stay
219/// infallible: a scheduler-start failure is captured here and surfaced as a
220/// `LiminalError` the first time the actor is actually used.
221struct ChannelActorState {
222 /// The channel's name, which the actor reports a departed subscriber under.
223 name: String,
224 supervisor: Result<ChannelSupervisor, String>,
225 core: OnceLock<Result<Arc<ChannelActorCore>, String>>,
226 restarts: AtomicU32,
227}
228
229impl ChannelActorState {
230 const fn new(name: String, supervisor: Result<ChannelSupervisor, String>) -> Self {
231 Self {
232 name,
233 supervisor,
234 core: OnceLock::new(),
235 restarts: AtomicU32::new(0),
236 }
237 }
238
239 fn supervisor(&self) -> Result<&ChannelSupervisor, LiminalError> {
240 self.supervisor
241 .as_ref()
242 .map_err(|message| LiminalError::PublishFailed {
243 message: format!("channel supervisor unavailable: {message}"),
244 })
245 }
246
247 /// The installed cluster observer, if this channel runs on a clustered
248 /// supervisor (SRV-005). Returns `None` for non-clustered channels.
249 fn observer(&self) -> Option<Arc<dyn ClusterObserver>> {
250 self.supervisor
251 .as_ref()
252 .ok()
253 .and_then(|supervisor| supervisor.observer().cloned())
254 }
255
256 /// Returns the live actor core, spawning it (and any restart) on demand.
257 fn core(&self, schema: &Schema) -> Result<Arc<ChannelActorCore>, LiminalError> {
258 let supervisor = self.supervisor()?;
259 let stored = self.core.get_or_init(|| {
260 supervisor
261 .spawn_channel(&self.name, schema.clone())
262 .map_err(|error| error.to_string())
263 });
264 let core = stored
265 .as_ref()
266 .map_err(|message| LiminalError::PublishFailed {
267 message: format!("channel actor unavailable: {message}"),
268 })?;
269 // Restart on a dead pid (R4) before returning the core for use.
270 supervisor.ensure_running(core, &self.restarts)?;
271 Ok(Arc::clone(core))
272 }
273}
274
275impl std::fmt::Debug for ChannelActorState {
276 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 formatter
278 .debug_struct("ChannelActorState")
279 .field("supervisor", &self.supervisor)
280 .finish_non_exhaustive()
281 }
282}
283
284/// Cloneable handle for interacting with a channel actor process.
285#[derive(Clone, Debug)]
286pub struct ChannelHandle {
287 delivery_identity: Arc<OnceLock<Arc<dyn super::PublicationIdentity>>>,
288 pub(super) config: ChannelConfig,
289 actor: Arc<ChannelActorState>,
290 pub(super) durable: Option<Arc<Mutex<RuntimeDurable>>>,
291 /// The same store `durable` was built over, kept beside it so A1 §4's
292 /// host-side refill can read the log WITHOUT taking the `DurableChannel`
293 /// mutex the publish path holds. A refill runs on a consumer's thread while
294 /// producers are publishing; making it contend for the append lock would
295 /// let a slow reader stall every writer, which is the opposite of what
296 /// backpressure is for. `Some` exactly when `durable` is.
297 durable_store: Option<Arc<dyn DurableStore>>,
298}
299
300impl ChannelHandle {
301 /// Installs a transport allocator before publishing or exposing this handle.
302 ///
303 /// # Errors
304 /// An allocator was already installed on this shared channel handle.
305 pub fn install_publication_identity(
306 &self,
307 source: Arc<dyn super::PublicationIdentity>,
308 ) -> Result<(), LiminalError> {
309 self.delivery_identity
310 .set(source)
311 .map_err(|_| LiminalError::PublishFailed {
312 message: "publication identity already installed".into(),
313 })
314 }
315
316 /// Creates an ephemeral handle backed by a real supervised channel actor on
317 /// the shared default supervisor.
318 ///
319 /// The actor process is spawned lazily on first use; a scheduler failure is
320 /// surfaced as a [`LiminalError`] from the first operation, not a panic.
321 #[must_use]
322 pub fn new(config: ChannelConfig) -> Self {
323 let supervisor = shared_supervisor().map_err(|error| error.to_string());
324 let name = config.name.clone();
325 Self {
326 delivery_identity: Arc::new(OnceLock::new()),
327 config,
328 actor: Arc::new(ChannelActorState::new(name, supervisor)),
329 durable: None,
330 durable_store: None,
331 }
332 }
333
334 /// Creates an ephemeral handle bound to an explicit `supervisor` (isolation
335 /// for the registry and tests).
336 #[must_use]
337 pub fn with_supervisor(config: ChannelConfig, supervisor: ChannelSupervisor) -> Self {
338 let name = config.name.clone();
339 Self {
340 delivery_identity: Arc::new(OnceLock::new()),
341 config,
342 actor: Arc::new(ChannelActorState::new(name, Ok(supervisor))),
343 durable: None,
344 durable_store: None,
345 }
346 }
347
348 /// Creates a durable handle that persists every accepted publish to `store`
349 /// before fanning it out to subscribers.
350 ///
351 /// Construction reads the store: every partition's next-sequence counter is
352 /// recovered from the existing stream head, so a handle rebuilt over a
353 /// previously used store resumes appending where the log left off instead
354 /// of conflicting at sequence zero. A fresh store recovers to zero, which is
355 /// identical to cold start. This makes construction O(stream length) in
356 /// store reads and adds store read errors to the failure modes below.
357 ///
358 /// # Errors
359 ///
360 /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
361 /// initialized over `store`, including when recovering the per-partition
362 /// sequence counters from the store fails.
363 pub fn new_durable(
364 config: ChannelConfig,
365 store: Arc<dyn DurableStore>,
366 ) -> Result<Self, LiminalError> {
367 let durable = recover_durable(&config.name, Arc::clone(&store))?;
368 let supervisor = shared_supervisor()?;
369 let name = config.name.clone();
370 Ok(Self {
371 delivery_identity: Arc::new(OnceLock::new()),
372 config,
373 actor: Arc::new(ChannelActorState::new(name, Ok(supervisor))),
374 durable: Some(Arc::new(Mutex::new(RuntimeDurable::new(durable)))),
375 durable_store: Some(store),
376 })
377 }
378
379 /// Creates a durable handle bound to an explicit `supervisor`.
380 ///
381 /// Used by the standalone server so every channel — durable or ephemeral —
382 /// shares ONE (optionally clustered) supervisor and thus one scheduler, which
383 /// is the precondition for cross-node delivery (SRV-005): a subscriber pid
384 /// joined to a channel's distributed process group must live on the same
385 /// scheduler that owns the distribution links.
386 ///
387 /// Like [`Self::new_durable`], construction recovers each partition's
388 /// next-sequence counter from the store, so restarting over an existing
389 /// persistence path resumes the log instead of conflicting at sequence zero.
390 ///
391 /// # Errors
392 ///
393 /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
394 /// initialized over `store`, including when recovering the per-partition
395 /// sequence counters from the store fails.
396 pub fn new_durable_with_supervisor(
397 config: ChannelConfig,
398 store: Arc<dyn DurableStore>,
399 supervisor: ChannelSupervisor,
400 ) -> Result<Self, LiminalError> {
401 let durable = recover_durable(&config.name, Arc::clone(&store))?;
402 let name = config.name.clone();
403 Ok(Self {
404 delivery_identity: Arc::new(OnceLock::new()),
405 config,
406 actor: Arc::new(ChannelActorState::new(name, Ok(supervisor))),
407 durable: Some(Arc::new(Mutex::new(RuntimeDurable::new(durable)))),
408 durable_store: Some(store),
409 })
410 }
411
412 /// Returns the channel configuration used to create this handle.
413 #[must_use]
414 pub const fn config(&self) -> &ChannelConfig {
415 &self.config
416 }
417
418 /// Publishes a payload to the channel with the default publisher identity.
419 ///
420 /// # Errors
421 ///
422 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
423 pub fn publish<Payload>(&self, payload: Payload) -> Result<(), LiminalError>
424 where
425 Payload: AsRef<[u8]>,
426 {
427 self.publish_with_context(payload, PublisherId::default(), None)
428 }
429
430 /// Publishes a payload with an explicit publisher identity.
431 ///
432 /// # Errors
433 ///
434 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
435 pub fn publish_from<Payload>(
436 &self,
437 publisher_id: impl Into<PublisherId>,
438 payload: Payload,
439 ) -> Result<(), LiminalError>
440 where
441 Payload: AsRef<[u8]>,
442 {
443 self.publish_with_context(payload, publisher_id.into(), None)
444 }
445
446 /// Publishes a payload with explicit publisher and causal metadata.
447 ///
448 /// # Errors
449 ///
450 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
451 pub fn publish_with_context<Payload>(
452 &self,
453 payload: Payload,
454 publisher_id: PublisherId,
455 causal_context: Option<CausalContext>,
456 ) -> Result<(), LiminalError>
457 where
458 Payload: AsRef<[u8]>,
459 {
460 self.publish_with_delivery(payload, publisher_id, causal_context)
461 .map(|_delivery| ())
462 }
463
464 /// Publishes a payload and reports a genuine delivery ack.
465 ///
466 /// Returns a [`ChannelDelivery`] whose `delivered_count` is the number of
467 /// local subscribers the message was actually delivered to. A caller that
468 /// needs to know the message was ACCEPTED by a subscriber (not merely
469 /// buffered/published) inspects [`ChannelDelivery::is_delivered`]. This is the
470 /// channel-library half of the 13-L1 delivery-ack signal; the publish-without-
471 /// delivery methods stay unchanged for existing callers.
472 ///
473 /// # Errors
474 ///
475 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
476 pub fn publish_with_delivery<Payload>(
477 &self,
478 payload: Payload,
479 publisher_id: PublisherId,
480 causal_context: Option<CausalContext>,
481 ) -> Result<ChannelDelivery, LiminalError>
482 where
483 Payload: AsRef<[u8]>,
484 {
485 self.publish_with_key(payload, publisher_id, causal_context, None, None)
486 }
487
488 /// Publishes as [`ChannelHandle::publish_with_delivery`] does (and, with
489 /// an `idempotency_key`, as
490 /// [`ChannelHandle::publish_with_idempotency_key`] does), and fills
491 /// `record` with what it saw: the subscriber pid of every local
492 /// subscriber whose inbox queued the message, with the instant it did
493 /// (exactly [`ChannelDelivery::delivered_count`] of them), and, on a
494 /// durable channel, the instant its append completed. A subscriber whose
495 /// predicate matched and whose inbox refused the message is counted in
496 /// [`ChannelDelivery::matched_count`] and is not a recipient.
497 ///
498 /// `record` is cleared first and its allocation is reused, so a caller
499 /// that keeps one record allocates only while it grows to the channel's
500 /// widest fan-out. A publish that fails, or is refused at the watermark,
501 /// names no recipient and hands its allocation back, with one exception:
502 /// a publish whose channel actor took the command and then never answered
503 /// (it stopped, or the wait for it ended) leaves no allocation, because
504 /// the one it lent went with that actor. A durable publish whose append
505 /// committed before it failed (it is then
506 /// [`LiminalError::PublishUnresolved`]) still says when the append
507 /// completed: the row is stored. Any other failure leaves the record
508 /// empty.
509 ///
510 /// # Errors
511 ///
512 /// Returns the errors of [`ChannelHandle::publish_with_delivery`].
513 pub fn publish_recording<Payload>(
514 &self,
515 payload: Payload,
516 publisher_id: PublisherId,
517 causal_context: Option<CausalContext>,
518 idempotency_key: Option<&str>,
519 record: &mut PublishRecord,
520 ) -> Result<ChannelDelivery, LiminalError>
521 where
522 Payload: AsRef<[u8]>,
523 {
524 record.clear();
525 self.publish_with_key(
526 payload,
527 publisher_id,
528 causal_context,
529 idempotency_key,
530 Some(record),
531 )
532 }
533
534 pub(super) fn publish_with_key<Payload>(
535 &self,
536 payload: Payload,
537 publisher_id: PublisherId,
538 causal_context: Option<CausalContext>,
539 idempotency_key: Option<&str>,
540 mut record: Option<&mut PublishRecord>,
541 ) -> Result<ChannelDelivery, LiminalError>
542 where
543 Payload: AsRef<[u8]>,
544 {
545 let core = self.core()?;
546 let delivery_id = self
547 .delivery_identity
548 .get()
549 .map(|source| source.next())
550 .transpose()?;
551 // G4 (docs/design/wire-durability-gaps-20260831.md): the schema/closed
552 // gate runs BEFORE the durable bridge, so a payload the channel would
553 // refuse never burns a store sequence. The actor's validation inside
554 // `publish` below stays authoritative; a concurrent schema evolve
555 // narrows the remaining pre-validated-then-refused window to that race.
556 core.pre_publish_gate(payload.as_ref())?;
557 // A1 §4 (graft §0.3), the PRE-APPEND HARD WATERMARK. A durable channel
558 // owes the producer Defer-not-Reject after an append, which means the
559 // only honest Reject it can ever emit is one taken before anything is
560 // persisted. Without this check a producer that ignores delay hints
561 // grows the log without bound, because every post-append outcome is by
562 // construction an admission.
563 //
564 // Cheap and coarse on purpose: a channel-aggregate occupancy read with
565 // no predicate evaluation, so it may refuse a message whose target
566 // subscriber is fast. The design documents that imprecision and defers
567 // the precise per-cursor bound to v2, once live consumer cursors exist.
568 if let Some(mut rejection) = self.watermark_rejection(&core)? {
569 rejection.delivery_id = delivery_id;
570 return Ok(rejection);
571 }
572 // Durable channels persist the message to the store BEFORE acknowledging
573 // the publish (and before fanning out): a published message that was not
574 // durably recorded would be lost on shutdown, which CN7 forbids. (The
575 // store is flushed on graceful shutdown only — an unflushed append can
576 // still be lost to a crash; that bound is G4's flush half, named in the
577 // gaps docket, not silently absorbed here.)
578 //
579 // AND THE FAN-OUT IS ENQUEUED UNDER THE SAME LOCK (round 2). See
580 // `persist_and_enqueue` for why: the position and the queue slot are
581 // taken together, so the actor's FIFO command queue receives durable
582 // publishes in durable-position order. Everything §4 does with the
583 // replay cursor — a single `u64` meaning "the contiguous prefix this
584 // subscriber has been offered" — is false without it.
585 let (durable_position, pending, committed) = match self.durable.as_ref() {
586 Some(durable) => {
587 let (position, pending, committed) = self.persist_and_enqueue(
588 &core,
589 durable,
590 Outgoing {
591 delivery_id,
592 payload: payload.as_ref(),
593 publisher_id,
594 causal_context,
595 idempotency_key,
596 record: record.as_deref_mut(),
597 },
598 )?;
599 (Some(position), pending, Some(committed))
600 }
601 None => (
602 None,
603 core.enqueue_identified(
604 payload.as_ref().to_vec(),
605 publisher_id,
606 causal_context,
607 None,
608 record.as_deref_mut().map(|record| &mut record.recipients),
609 delivery_id,
610 )?,
611 None,
612 ),
613 };
614 // The wait happens with the append lock released: the ordering is
615 // already fixed by the enqueue above, and holding it across the actor
616 // rendezvous would put a slow subscriber's notifier in front of the
617 // next publisher's append for no ordering gain at all. A durable row is
618 // stored by now, so a failed rendezvous is an unresolved publish, never
619 // a definite failure that would license a second append.
620 let mut outcome = core
621 .await_publish(pending)
622 .map_err(|error| match committed {
623 Some(row) => row.unresolved(error),
624 None => error,
625 })?;
626 // SRV-005: hand the normalised envelope to the cluster observer so it can
627 // fan the message out to remote subscribers. Local fan-out already
628 // happened inside `core.publish`; this is purely the cross-node leg and
629 // is a no-op when no observer is installed (non-clustered channels).
630 if let Some(observer) = self.actor.observer() {
631 observer.on_publish(&self.config.name, &outcome.envelope);
632 }
633 // A1 §4: "after append, durable channels Defer, never Reject." The
634 // message is at its sequence position and every replay consumer sees it
635 // exactly once regardless of what the live buffers did — the live shed
636 // marked its subscriber lagging and auto-catch-up covers the gap.
637 // Emitting Reject here would say "delivered to nobody, retry", which is
638 // false of a message that is in the log AND on its way to a consumer.
639 //
640 // WITH ONE EXCEPTION, AND IT IS A MEASURED ONE (round 3). That
641 // justification holds for the A1 §4 PACING shed, which arms
642 // auto-catch-up (`admit_inner` sets `state.lagging` on a durable
643 // Reject). It is FALSE for the two §5 memory-safety doors: neither
644 // `FairnessTripped` nor `BudgetExceeded` sets `lagging` or bumps
645 // `shed_generation`, so no refill is ever due, and on the connection
646 // path the subscription is torn down outright before one could run —
647 // the server's delivery pump sheds an `is_overflowed()` subscription
648 // with a typed `SubscribeError` and releases it, and `try_next`, the
649 // ONLY driver of `refill_if_lagging`, is never called on it again.
650 // MEASURED, M1, gate-logs/a1-defer/m1-measurement-section5-recovery.log:
651 // after the shedding pressure was fully freed and the consumer polled
652 // on, the pacing door recovered 3/3 of its shed rows and the two §5
653 // doors recovered 0/4 and 0/13.
654 //
655 // So when EVERY matching subscriber dropped the envelope through §5,
656 // the rewrite is suppressed and the producer reads the Reject the
657 // aggregate actually resolved. That answer is about LIVE CUSTODY, not
658 // about the log: the row IS appended and a deliberate `replay_from`
659 // read still finds it, but no subscription will ever be offered it
660 // again (a fresh subscription seeds its replay cursor at the CURRENT
661 // head), so telling the producer "admitted" would be the §7 custody lie
662 // F2 closed on ephemeral channels, arriving by a different door.
663 //
664 // A §5 drop BESIDE a subscriber that took it, or beside one the pacing
665 // door shed, is not this case: `every_match_dropped` is false and the
666 // Defer stands, because a consumer will still get the message.
667 //
668 // SENTINEL-SHAPES: this makes the two channel modes give the same shape
669 // of answer to the same situation — an everybody-dropped publish is a
670 // Reject on ephemeral and on durable alike.
671 let signal = if durable_position.is_some() && !outcome.every_match_dropped {
672 defer_after_append(outcome.signal)
673 } else {
674 outcome.signal
675 };
676 if let (Some(record), Some(filled)) = (record, outcome.recipients.take()) {
677 record.recipients = filled;
678 }
679 Ok(ChannelDelivery {
680 delivery_id,
681 delivered_count: outcome.delivered_count,
682 matched_count: outcome.matched_count,
683 durable_position,
684 signal,
685 pressure_config: self.config.pressure,
686 })
687 }
688
689 /// Awaits the fan-out of a row whose uncertain append resolved as
690 /// committed, and hands it to the cluster observer as any publish is.
691 pub(super) fn finish_recovered(
692 &self,
693 core: &ChannelActorCore,
694 pending: PendingPublish,
695 ) -> Result<(), LiminalError> {
696 let outcome = core.await_publish(pending)?;
697 if let Some(observer) = self.actor.observer() {
698 observer.on_publish(&self.config.name, &outcome.envelope);
699 }
700 Ok(())
701 }
702
703 /// Appends and flushes a durable batch once, then delivers each row in its stored order.
704 ///
705 /// An empty batch does no work. The single-record publication path is unchanged.
706 /// Flush observers must hand publication off without re-entering this channel's append lock.
707 ///
708 /// # Errors
709 /// Returns an error for an ephemeral channel, an empty publisher, invalid payloads or failed storage.
710 /// A committed row whose delivery is unconfirmed returns [`LiminalError::PublishUnresolved`].
711 pub fn publish_batch_with_delivery(
712 &self,
713 payloads: &[Vec<u8>],
714 publisher: &PublisherId,
715 causal: Option<&CausalContext>,
716 ) -> Result<Vec<ChannelDelivery>, LiminalError> {
717 if payloads.is_empty() {
718 return Ok(Vec::new());
719 }
720 if publisher.as_str().trim().is_empty() {
721 return Err(LiminalError::PublishFailed {
722 message: "durable batch publisher is empty".to_owned(),
723 });
724 }
725 let durable = self
726 .durable
727 .as_ref()
728 .ok_or_else(|| LiminalError::PublishFailed {
729 message: "batch publication requires a durable channel".to_owned(),
730 })?;
731 let core = self.core()?;
732 for payload in payloads {
733 core.pre_publish_gate(payload)?;
734 }
735 if let Some(rejection) = self.watermark_rejection(&core)? {
736 return Ok(vec![rejection; payloads.len()]);
737 }
738 let queued = self.persist_batch_and_enqueue(&core, durable, payloads, publisher, causal)?;
739 let mut deliveries = Vec::with_capacity(queued.len());
740 for (pending, mut proof) in queued {
741 let position = proof.sequence();
742 let outcome = core.await_publish(pending).map_err(|error| {
743 proof.set_cause(format!("batch committed; live delivery failed: {error}"));
744 LiminalError::PublishUnresolved { append: proof }
745 })?;
746 if let Some(observer) = self.actor.observer() {
747 observer.on_publish(&self.config.name, &outcome.envelope);
748 }
749 let signal = if outcome.every_match_dropped {
750 outcome.signal
751 } else {
752 defer_after_append(outcome.signal)
753 };
754 deliveries.push(ChannelDelivery {
755 delivery_id: outcome.envelope.delivery_id,
756 delivered_count: outcome.delivered_count,
757 matched_count: outcome.matched_count,
758 durable_position: Some(position),
759 signal,
760 pressure_config: self.config.pressure,
761 });
762 }
763 Ok(deliveries)
764 }
765
766 /// The pre-append hard watermark verdict for a durable channel (A1 §4).
767 ///
768 /// `Some(delivery)` is an honest Reject: **nothing was appended**, nothing
769 /// was fanned out, `durable_position` is `None`, and `delivered_count` is
770 /// zero — so the caller's `Reject ⇒ delivered to nobody` reading holds and
771 /// the server's dedup mapping releases the claim exactly as if the publish
772 /// had never happened (§5). `None` means proceed.
773 ///
774 /// Always `None` on a memory channel: there is no log to bound, and an
775 /// ephemeral channel's Reject is the per-message shed the fan-out produces.
776 fn watermark_rejection(
777 &self,
778 core: &Arc<ChannelActorCore>,
779 ) -> Result<Option<ChannelDelivery>, LiminalError> {
780 if self.durable.is_none() {
781 return Ok(None);
782 }
783 let (total_queued, total_bound) = core.live_buffer_occupancy()?;
784 if !watermark_reached(
785 total_queued,
786 total_bound,
787 self.config.pressure.durable_reject_watermark_percent,
788 ) {
789 return Ok(None);
790 }
791 Ok(Some(ChannelDelivery {
792 delivery_id: None,
793 delivered_count: 0,
794 matched_count: 0,
795 durable_position: None,
796 signal: PressureSignal::reject(total_queued, total_bound, total_queued, total_bound),
797 pressure_config: self.config.pressure,
798 }))
799 }
800
801 /// The durable partition stream key and its current head — the point a new
802 /// subscription joins the log at (A1 §4).
803 ///
804 /// Liminal backs a runtime channel with a single partition
805 /// ([`RUNTIME_DURABLE_PARTITIONS`]), so partition 0 is the whole log.
806 fn durable_join_point(
807 durable: &Arc<Mutex<RuntimeDurable>>,
808 ) -> Result<(String, u64), LiminalError> {
809 let channel = durable
810 .lock()
811 .map_err(|error| LiminalError::PublishFailed {
812 message: format!("durable channel state unavailable: {error}"),
813 })?;
814 let stream_key = channel.channel.stream_key_for(0);
815 let head = channel.channel.next_expected_sequence(0).unwrap_or(0);
816 drop(channel);
817 Ok((stream_key, head))
818 }
819
820 /// The store this durable channel was built over.
821 fn durable_store(&self) -> Result<Arc<dyn DurableStore>, LiminalError> {
822 self.durable_store
823 .as_ref()
824 .map(Arc::clone)
825 .ok_or_else(|| LiminalError::PublishFailed {
826 message: format!(
827 "channel '{}' has no durable store to replay from",
828 self.config.name
829 ),
830 })
831 }
832
833 /// Returns the schema version currently owned by the channel actor.
834 ///
835 /// # Errors
836 ///
837 /// Returns a [`LiminalError`] when the channel actor cannot be read.
838 pub fn current_schema_id(&self) -> Result<SchemaId, LiminalError> {
839 self.core()?.schema_id()
840 }
841
842 /// Evolves the channel schema by adding a defaulted field without disconnecting subscribers.
843 ///
844 /// # Errors
845 ///
846 /// Returns [`SchemaValidationError`] when the schema cannot be evolved.
847 pub fn evolve_schema_add_field(
848 &self,
849 name: impl Into<String>,
850 field_schema: Value,
851 default: Value,
852 ) -> Result<SchemaId, SchemaValidationError> {
853 let core = self
854 .core()
855 .map_err(|error| SchemaValidationError::InvalidSchema {
856 message: error.to_string(),
857 })?;
858 core.evolve(name.into(), field_schema, default)
859 }
860
861 /// Subscribes to the channel, receiving every published message.
862 ///
863 /// # Errors
864 ///
865 /// Returns a [`LiminalError`] when a subscription cannot be created.
866 pub fn subscribe(&self) -> Result<SubscriptionHandle, LiminalError> {
867 self.subscribe_inner(None, None, None)
868 }
869
870 /// Subscribes with a server-connection [`InboxInstall`]: the §5 shared byte
871 /// budget, per-inbox fairness cap, and R3 wake notifier are installed on the
872 /// inbox AT CONSTRUCTION — strictly before the registration is published to
873 /// the channel actor — so no envelope can be admitted uncharged, past the
874 /// depth cap, or without a wake (the pre-install window is structurally
875 /// closed, not merely narrowed).
876 ///
877 /// # Errors
878 ///
879 /// Returns a [`LiminalError`] when a subscription cannot be created.
880 pub fn subscribe_with_install(
881 &self,
882 install: InboxInstall,
883 ) -> Result<SubscriptionHandle, LiminalError> {
884 self.subscribe_inner(None, Some(install), None)
885 }
886
887 /// Subscribes with a delivery predicate: only messages for which `predicate`
888 /// returns `true` are delivered to this subscriber. The predicate is owned
889 /// and evaluated by the actor process (R3).
890 ///
891 /// # Clustering
892 ///
893 /// The predicate filters **local-node publishes only**. Under clustering
894 /// (SRV-005), messages published on a remote node are delivered to this
895 /// subscriber *ungated* — the predicate is a non-serializable closure and is
896 /// not propagated across the wire, so remote nodes cannot evaluate it. If you
897 /// need filtering to hold for cross-node traffic, filter again on receipt
898 /// rather than relying on this predicate alone.
899 ///
900 /// # Errors
901 ///
902 /// Returns a [`LiminalError`] when a subscription cannot be created.
903 pub fn subscribe_filtered<F>(&self, predicate: F) -> Result<SubscriptionHandle, LiminalError>
904 where
905 F: Fn(&Envelope) -> bool + Send + Sync + 'static,
906 {
907 self.subscribe_inner(Some(predicate_from(predicate)), None, None)
908 }
909
910 /// Opens a stable durable consumer at its subscription-owned checkpoint.
911 /// Ephemeral channels retain the same bounded live subscription.
912 ///
913 /// # Errors
914 /// Subscription construction, cursor recovery, or replay failed.
915 pub fn subscribe_consumer(
916 &self,
917 consumer: &str,
918 install: InboxInstall,
919 ) -> Result<SubscriptionHandle, LiminalError> {
920 self.subscribe_inner(None, Some(install), Some(consumer))
921 }
922
923 fn subscribe_inner(
924 &self,
925 predicate: Option<SubscriptionPredicate>,
926 install: Option<InboxInstall>,
927 consumer: Option<&str>,
928 ) -> Result<SubscriptionHandle, LiminalError> {
929 let core = self.core()?;
930 let (handle, registration) =
931 SubscriptionHandle::spawn(&core.scheduler()?, predicate.clone(), install)?;
932 // A1 §4 auto-catch-up: attach the durable log this subscription can
933 // converge from, and seed its replay cursor at the head it is JOINING
934 // AT — both strictly before the registration reaches the actor, so the
935 // first publish this subscription can possibly see is measured against
936 // the right origin. A cursor left at zero would replay the entire
937 // history on the first shed.
938 if let Some(durable) = self.durable.as_ref() {
939 let (stream_key, head) = Self::durable_join_point(durable)?;
940 handle.seed_replay_cursor(head);
941 handle.attach_durable_refill(
942 self.durable_store()?,
943 stream_key,
944 self.config.schema.id(),
945 predicate,
946 );
947 if let Some(consumer) = consumer {
948 handle.resume_consumer(consumer)?;
949 }
950 }
951 let pid = registration.pid();
952 core.subscribe(registration)?;
953 if consumer.is_some() {
954 handle.refill_if_lagging()?;
955 }
956 // SRV-005: tell the cluster a local subscriber joined this channel so it
957 // can advertise the subscription to peers via its process group.
958 if let Some(observer) = self.actor.observer() {
959 observer.on_subscribe(&self.config.name, pid);
960 }
961 Ok(handle)
962 }
963
964 /// Unsubscribes the subscriber owning `subscription` by its process pid.
965 ///
966 /// # Errors
967 ///
968 /// Returns a [`LiminalError`] when the unsubscribe command fails.
969 pub fn unsubscribe(&self, subscription: &SubscriptionHandle) -> Result<(), LiminalError> {
970 let pid = subscription.pid();
971 self.core()?.unsubscribe(pid)?;
972 // SRV-005: tell the cluster the local subscriber left so it can withdraw
973 // the subscription from its process group.
974 if let Some(observer) = self.actor.observer() {
975 observer.on_unsubscribe(&self.config.name, pid);
976 }
977 Ok(())
978 }
979
980 /// Flushes buffered durable channel state to the backing store before shutdown.
981 ///
982 /// # Errors
983 ///
984 /// Returns a [`LiminalError`] when the channel actor cannot be inspected or
985 /// when the durable store flush fails.
986 pub fn flush(&self) -> Result<(), LiminalError> {
987 // Confirm the actor is reachable (and restart it if needed) before flush.
988 drop(self.core()?);
989 let Some(durable) = self.durable.as_ref() else {
990 return Ok(());
991 };
992 let flush_result = {
993 let channel = durable
994 .lock()
995 .map_err(|error| LiminalError::PublishFailed {
996 message: format!("durable channel state unavailable: {error}"),
997 })?;
998 block_on(channel.channel.flush_store())
999 };
1000 flush_result
1001 .map_err(|error| LiminalError::PublishFailed {
1002 message: format!(
1003 "durable flush bridge for channel '{}' failed: {error}",
1004 self.config.name
1005 ),
1006 })?
1007 .map_err(|error| LiminalError::PublishFailed {
1008 message: format!(
1009 "durable flush for channel '{}' failed: {error}",
1010 self.config.name
1011 ),
1012 })?;
1013 Ok(())
1014 }
1015
1016 /// Returns the number of currently-active subscribers on the channel actor.
1017 ///
1018 /// # Errors
1019 ///
1020 /// Returns a [`LiminalError`] when the actor cannot service the query.
1021 pub fn subscriber_count(&self) -> Result<usize, LiminalError> {
1022 Ok(self.core()?.list_subscribers()?.len())
1023 }
1024
1025 /// Closes the channel gracefully, stopping the actor process.
1026 ///
1027 /// # Errors
1028 ///
1029 /// Returns a [`LiminalError`] when the channel cannot be shut down.
1030 pub fn close(&self) -> Result<(), LiminalError> {
1031 self.core()?.close()
1032 }
1033
1034 /// Whether this channel's actor has been spawned — **without spawning it**.
1035 ///
1036 /// This is the one accessor on the handle that observes the lazy-spawn
1037 /// state instead of consuming it. Every other accessor
1038 /// ([`subscriber_count`](Self::subscriber_count),
1039 /// [`close`](Self::close)) routes through the private `core()`, which
1040 /// spawns the actor on demand, so reading "is there an actor?" through any
1041 /// of them destroys the very property being read. A handle that spawns
1042 /// lazily has a genuine, observable idle/live state, and a consumer that
1043 /// needs to know it — a registry reporting which of its channels are inert,
1044 /// a cost accountant bounding what mere configuration costs — otherwise has
1045 /// to choose between not knowing and changing the answer by asking.
1046 ///
1047 /// It reads the memoised spawn slot and nothing else: no supervisor call,
1048 /// no schema clone, no `ensure_running`. That last exclusion is the point
1049 /// of the restriction — `ensure_running` RESTARTS a dead actor, so an
1050 /// implementation routed through it would repair the state rather than
1051 /// report it.
1052 ///
1053 /// # What `false` and `true` mean
1054 ///
1055 /// The slot holds `Result<Arc<ChannelActorCore>, String>`: a spawn that
1056 /// failed is memoised as `Err` and, because the slot is one-shot, is never
1057 /// retried — every later operation on this handle fails with that stored
1058 /// message. So a failed spawn ATTEMPT leaves no actor and can never acquire
1059 /// one, and this method answers `false` for it, exactly as it does for a
1060 /// channel nobody has touched. `false` therefore means "no actor core
1061 /// exists" and deliberately does not distinguish "never attempted" from
1062 /// "attempted and permanently failed" — neither has an actor.
1063 ///
1064 /// `true` means the actor core exists, not that its process is currently
1065 /// alive: an actor whose pid has died stays `true` until the next real
1066 /// operation restarts it through the supervisor. Liveness is a stronger
1067 /// question than this method asks, and answering it would require running
1068 /// the repair path this method exists to avoid.
1069 #[must_use]
1070 pub fn is_actor_spawned(&self) -> bool {
1071 self.actor.core.get().is_some_and(Result::is_ok)
1072 }
1073
1074 pub(super) fn core(&self) -> Result<Arc<ChannelActorCore>, LiminalError> {
1075 self.actor.core(&self.config.schema)
1076 }
1077
1078 /// The channel actor's current beamr pid, ensuring it is running first.
1079 /// Test-only: lets restart tests crash the exact actor process.
1080 #[cfg(test)]
1081 pub(crate) fn actor_pid(&self) -> Result<u64, LiminalError> {
1082 let core = self.core()?;
1083 core.current_pid()?
1084 .ok_or_else(|| LiminalError::DeliveryFailed {
1085 message: "channel actor has no live pid".to_owned(),
1086 })
1087 }
1088
1089 /// The scheduler the channel actor and its subscribers run on (test-only).
1090 #[cfg(test)]
1091 pub(crate) fn scheduler(&self) -> Result<Arc<beamr::scheduler::Scheduler>, LiminalError> {
1092 self.core()?.scheduler()
1093 }
1094}
1095
1096/// Reconstructs a durable channel over `store`, deriving each partition's next
1097/// sequence from the persisted log so a restart over an existing stream appends at
1098/// the tail rather than colliding with the occupied head (H2 / ledger G1). On a
1099/// fresh store, recovery yields zeroed sequences — identical to a first-boot
1100/// channel — so this is the single durable-construction path for both cold start
1101/// and restart.
1102///
1103/// The async recovery is driven to completion through the synchronous
1104/// [`block_on`] bridge, exactly as the durable publish/flush paths do: the
1105/// haematite store completes on its first poll, so no executor is needed.
1106fn recover_durable(
1107 channel_name: &str,
1108 store: Arc<dyn DurableStore>,
1109) -> Result<DurableChannel, LiminalError> {
1110 block_on(recover_durable_channel(
1111 channel_name.to_owned(),
1112 RUNTIME_DURABLE_PARTITIONS,
1113 store,
1114 ))
1115 .map_err(|error| LiminalError::PublishFailed {
1116 message: format!("durable recovery bridge for channel '{channel_name}' failed: {error}"),
1117 })?
1118 .map_err(|error| LiminalError::PublishFailed {
1119 message: format!("failed to recover durable channel '{channel_name}': {error}"),
1120 })
1121}
1122
1123/// Returns the current epoch milliseconds, saturating to zero before the epoch.
1124pub(super) fn now_millis() -> u64 {
1125 SystemTime::now()
1126 .duration_since(UNIX_EPOCH)
1127 .map_or(0, |duration| {
1128 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1129 })
1130}