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