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