liminal_server/server/connection/services.rs
1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore, Retention};
8use liminal::channel::{
9 ChannelConfig, ChannelDelivery, ChannelHandle, ChannelMode, ChannelSupervisor, Schema,
10};
11use liminal::conversation::{
12 ConversationSupervisor, CrashPolicy, EchoBehaviour, ParticipantBehaviour,
13};
14use liminal::durability::bridge::block_on;
15#[cfg(any(test, feature = "test-support"))]
16use liminal::durability::open_ephemeral;
17use liminal::durability::{
18 CountingStore, DedupCache, DedupDecision, DurableStore, EphemeralRoot, EphemeralSweepCounts,
19 HaematiteStore, ProcessingReceipt, StoreCountsHandle,
20};
21use liminal::protocol::{MessageEnvelope, ProtocolError, SchemaId as ProtocolSchemaId};
22
23use super::channel_registry::{
24 ChannelAccessError, ChannelBuildError, ChannelConfigField, ChannelDescriptor, ChannelOrigin,
25 ChannelRegistration, ChannelRegistryError, ChannelState, ChannelStatus, MAX_CHANNELS_KEY,
26 Registered, STATE_ACTIVE, STATE_QUIESCED, UNRECORDED_QUIESCE_REASON,
27};
28use super::conversation::{ConnectionConversation, LiminalConversationResource};
29use super::services_cluster::build_channel_cluster;
30use super::services_schema::{ChannelSchema, resolve_channel_schema, resolve_schema_bytes};
31use super::worker_front_door::WorkerFrontDoorServices;
32use crate::ServerError;
33use crate::config::types::{ClusterConfig, ServerConfig, ServiceProfile};
34use crate::config::validation::required_persistence_path;
35use crate::health::reissue::OperatorCredentialReissuer;
36use crate::health::unloadable::UnloadableConversationRecord;
37use crate::server::participant::{InstalledParticipantService, ProductionParticipantHandler};
38use crate::server::self_streams::events::PublishOrigin;
39use crate::server::self_streams::sink::OriginPublication;
40use crate::server::signals::ServerSignals;
41
42pub use super::services_cluster::ChannelCluster;
43
44/// Registry of custom conversation responders, keyed by conversation subject.
45///
46/// A registered [`ParticipantBehaviour`] becomes the participant for any
47/// conversation opened on its subject; subjects with no entry fall back to the
48/// built-in [`EchoBehaviour`].
49type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
50
51/// Marker for resources retained by a connection process until unsubscribe.
52pub trait SubscriptionResource: std::fmt::Debug + Send {
53 /// Releases the library subscription resource.
54 ///
55 /// # Errors
56 /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
57 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
58
59 /// Attempts to pull the next delivered envelope from the wrapped library
60 /// subscription without blocking.
61 ///
62 /// Returns `None` when the subscriber inbox is empty (or momentarily
63 /// unavailable): the connection process is the delivery pump, so a transient
64 /// empty read is simply "nothing to deliver this slice", never an error.
65 fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
66
67 /// Non-consuming availability query for the post-arm race barrier.
68 fn has_pending(&self) -> bool;
69
70 /// Whether an overflow has marked this subscription for shedding (§5). The
71 /// delivery pump sheds an overflowed subscription with a typed error frame.
72 /// Defaulted to `false`: a resource with no bounded inbox never overflows.
73 fn is_overflowed(&self) -> bool {
74 false
75 }
76
77 /// The library subscriber's pid, the id a recording publish names it by.
78 /// Defaulted to `None`: a resource with no library subscriber is never a
79 /// recipient.
80 fn subscriber_pid(&self) -> Option<u64> {
81 None
82 }
83}
84
85/// Library subscription resource owned by a single connection process.
86#[derive(Debug)]
87pub struct ConnectionSubscription {
88 id: u64,
89 /// Channel this subscription was opened on, carried for DIAGNOSTICS only —
90 /// the shed warning has to name the channel or an operator reading it learns
91 /// that something was shed without learning what. Empty when unset (the
92 /// scheduler-free unit fixtures, which construct through `new` and never log).
93 channel: String,
94 /// Client-chosen application stream the server delivers this subscription's
95 /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
96 /// the connection process from the `Subscribe` frame before the subscription is
97 /// stored; `0` only while momentarily unset during construction.
98 stream_id: u32,
99 selected_schema: ProtocolSchemaId,
100 resource: Box<dyn SubscriptionResource>,
101 issued: super::state::IssuedDeliveries,
102}
103
104impl ConnectionSubscription {
105 /// Creates an owned subscription resource for one connection process.
106 #[must_use]
107 pub fn new(
108 id: u64,
109 selected_schema: ProtocolSchemaId,
110 resource: Box<dyn SubscriptionResource>,
111 ) -> Self {
112 Self {
113 id,
114 channel: String::new(),
115 stream_id: 0,
116 selected_schema,
117 resource,
118 issued: super::state::IssuedDeliveries::new(0),
119 }
120 }
121
122 /// Records the channel this subscription was opened on (diagnostics).
123 ///
124 /// Additive setter rather than a fourth `new` parameter: `new` is public API
125 /// and its existing callers keep compiling byte-for-byte.
126 pub(super) fn set_channel(&mut self, channel: String) {
127 self.channel = channel;
128 }
129
130 /// The channel this subscription was opened on, or `""` when unset.
131 #[must_use]
132 pub(super) fn channel(&self) -> &str {
133 &self.channel
134 }
135
136 /// Returns the protocol subscription id.
137 #[must_use]
138 pub const fn id(&self) -> u64 {
139 self.id
140 }
141
142 /// Records the client-chosen delivery stream id for this subscription.
143 pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
144 self.stream_id = stream_id;
145 }
146
147 /// Returns the client-chosen application stream id deliveries ride on.
148 #[must_use]
149 pub(super) const fn stream_id(&self) -> u32 {
150 self.stream_id
151 }
152
153 pub(super) fn set_pressure_ledger_depth(&mut self, limit: usize) {
154 self.issued = super::state::IssuedDeliveries::new(limit);
155 }
156
157 pub(super) fn issued_delivery(&mut self, sequence: u64) {
158 self.issued.record(sequence);
159 }
160
161 pub(super) fn match_pressure(&mut self, reference: &str, retain: bool) -> bool {
162 self.issued.matches(reference, retain)
163 }
164
165 /// Returns the schema selected for this subscription stream.
166 #[must_use]
167 pub const fn selected_schema(&self) -> ProtocolSchemaId {
168 self.selected_schema
169 }
170
171 /// The library subscriber's pid, when the subscription has one.
172 #[must_use]
173 pub fn subscriber_pid(&self) -> Option<u64> {
174 self.resource.subscriber_pid()
175 }
176
177 /// Attempts to pull the next delivered envelope without blocking.
178 pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
179 self.resource.try_next()
180 }
181
182 pub(super) fn has_pending(&self) -> bool {
183 self.resource.has_pending()
184 }
185
186 /// Whether this subscription has been shed by an inbox overflow (§5).
187 pub(super) fn is_overflowed(&self) -> bool {
188 self.resource.is_overflowed()
189 }
190
191 pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
192 self.resource.unsubscribe()
193 }
194}
195
196/// Outcome of a server publish.
197///
198/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
199/// message was accepted by at least one live subscriber on this publish, after any
200/// dedup-on-delivery suppression).
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct PublishOutcome {
203 /// Transport-scoped message id assigned to the accepted publish.
204 ///
205 /// TRANSPORT-SCOPED, NOT A DURABLE POSITION (G1,
206 /// `docs/design/wire-durability-gaps-20260831.md`): this is a
207 /// process-local counter (`next_message_id`, reset to 1 on every
208 /// construction), so it restarts from 1 with the server and two servers
209 /// mint the same ids. It is monotonic only within one service instance's
210 /// lifetime and identifies the publish on THIS connection's wire, nothing
211 /// more. The durable position of a publish on a durable channel is the
212 /// store-assigned sequence the library now surfaces as
213 /// `ChannelDelivery::durable_position`; putting that on the wire ack is
214 /// the A1 resume lane's frame decision, not a reinterpretation of this
215 /// field.
216 pub message_id: u64,
217 /// Whether the message was genuinely delivered to a subscriber. `false` means
218 /// the publish was accepted but reached no subscriber (empty channel) or was
219 /// a duplicate suppressed by dedup-on-delivery.
220 pub delivered: bool,
221 /// Store-assigned position of the row this outcome answers for, when the
222 /// publish is known to have one. Unlike `message_id` this IS a durable
223 /// position; the connection process puts it on the wire ack only for a
224 /// connection that negotiated `PUBLISH_ACK_POSITION_VERSION`.
225 pub durable_position: Option<u64>,
226 /// Whom the publish reached, the console tap's word for it (SRV-012
227 /// R10): a subscriber, no subscriber, only subscribers that refused it,
228 /// or nobody because dedup suppressed it.
229 pub reach: Reach,
230}
231
232/// Whom a publish reached (SRV-012 R10).
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
234pub enum Reach {
235 /// At least one live subscriber took it.
236 Delivered,
237 /// The channel had no matching subscriber.
238 NoSubscriber,
239 /// Every matching subscriber refused it.
240 NoneAccepted,
241 /// A dedup-suppressed re-publish, sent to nobody.
242 Suppressed,
243}
244
245impl Reach {
246 /// The reach of a library delivery.
247 #[must_use]
248 pub const fn of(delivery: &ChannelDelivery) -> Self {
249 if delivery.is_delivered() {
250 Self::Delivered
251 } else if delivery.matched_count() == 0 {
252 Self::NoSubscriber
253 } else {
254 Self::NoneAccepted
255 }
256 }
257}
258
259/// Which channel operation is asking the roster for admission.
260///
261/// `Copy`, so the hot path allocates nothing to ask. The contrast is
262/// [`ServerError::UnsupportedOperation`]'s owned `operation: String`: that one is
263/// built on a refusal path and pays for its allocation once per refusal, while
264/// this one is consulted on every publish and every subscribe frame that reaches
265/// the connection process.
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
267pub enum ChannelOperation {
268 /// A `Publish` frame is asking.
269 Publish,
270 /// A `Subscribe` frame is asking.
271 Subscribe,
272}
273
274/// Operations that adapt wire frames to liminal library calls.
275pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
276 /// Returns the complete participant service installed on this adapter.
277 ///
278 /// `None` keeps participant capability disabled even when the adapter owns a
279 /// durable store for unrelated channel traffic. The returned token is
280 /// server-sealed and atomically carries declared semantics plus durability,
281 /// making a handler-without-store activation impossible by construction.
282 fn participant_service(&self) -> Option<InstalledParticipantService> {
283 None
284 }
285
286 /// Delegates a publish request to the liminal library.
287 ///
288 /// `connection` is the publishing connection's id (its process pid, as
289 /// `apply_frame` knows it), carried as the publish origin so the console
290 /// tap attributes the message to it (SRV-012). `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
291 /// the same key is delivered to subscribers at most once. The returned
292 /// [`PublishOutcome`] carries the genuine delivery ack.
293 ///
294 /// # Errors
295 /// Returns [`ServerError`] when the liminal publish operation fails.
296 fn publish(
297 &self,
298 connection: u64,
299 channel: &str,
300 envelope: &MessageEnvelope,
301 idempotency_key: Option<&str>,
302 ) -> Result<PublishOutcome, ServerError>;
303
304 /// Delegates a subscribe request to the liminal library.
305 ///
306 /// `install`, when `Some`, carries the connection's §5 shared inbox byte
307 /// budget, per-inbox fairness cap, and R3 wake notifier. The implementation
308 /// MUST install it on the subscription's inbox BEFORE the registration is
309 /// published to the channel actor (i.e. before any envelope can be
310 /// delivered), so no envelope is ever admitted uncharged, past the depth
311 /// cap, or without a wake. Implementations with no real inbox (test
312 /// stand-ins, capability-scoped profiles that refuse subscribe) may ignore
313 /// it.
314 ///
315 /// # Errors
316 /// Returns [`ServerError`] when the liminal subscribe operation fails.
317 fn subscribe(
318 &self,
319 channel: &str,
320 accepted_schemas: &[ProtocolSchemaId],
321 install: Option<liminal::channel::InboxInstall>,
322 ) -> Result<ConnectionSubscription, ServerError>;
323
324 /// Delegates unsubscribe to the liminal library.
325 ///
326 /// # Errors
327 /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
328 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
329
330 /// Delegates conversation open to the liminal library.
331 ///
332 /// # Errors
333 /// Returns [`ServerError`] when the liminal conversation open operation fails.
334 fn open_conversation(
335 &self,
336 conversation_id: u64,
337 subject: &str,
338 ) -> Result<ConnectionConversation, ServerError>;
339
340 /// Delegates a conversation message to the liminal library.
341 ///
342 /// # Errors
343 /// Returns [`ServerError`] when the liminal conversation message operation fails.
344 fn conversation_message(
345 &self,
346 conversation: &ConnectionConversation,
347 envelope: &MessageEnvelope,
348 op_id: Option<u64>,
349 ) -> Result<(), ServerError>;
350
351 /// Delegates conversation close to the liminal library.
352 ///
353 /// # Errors
354 /// Returns [`ServerError`] when the liminal conversation close operation fails.
355 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
356
357 /// Flushes the shared durable store after channel publications have settled.
358 ///
359 /// # Errors
360 /// Returns [`ServerError`] when the shared store flush operation fails.
361 fn flush_durable_state(&self) -> Result<(), ServerError>;
362
363 /// Whether this adapter backs ordinary channel and conversation operations.
364 ///
365 /// The default is `true` — the full-service adapter serves publish, subscribe,
366 /// and conversation frames, so full mode is byte-for-byte unchanged. The
367 /// capability-scoped worker front door overrides this to `false`, letting
368 /// [`super::apply`] reject the channel/conversation frames it short-circuits on
369 /// empty connection state (`Unsubscribe`, `ConversationMessage`,
370 /// `ConversationClose`) with a typed error frame instead of silently swallowing
371 /// an operation for a resource that could never have been created in this
372 /// profile. Frames that always reach a service method (`Publish`, `Subscribe`,
373 /// `ConversationOpen`) are rejected by the front door's own method bodies and do
374 /// not consult this flag.
375 fn supports_channel_operations(&self) -> bool {
376 true
377 }
378
379 /// Whether `channel` admits `operation` right now.
380 ///
381 /// Consulted by the connection process BEFORE the operation is delegated, so
382 /// a roster refusal is typed at the moment of the decision, by the component
383 /// that made it, from the value it decided on. The alternative — classifying
384 /// an opaque failure afterwards by re-reading the roster in the error arm —
385 /// cannot tell "the roster refused this" from "something else failed while
386 /// the roster happened to change", and would put a confident wrong cause on
387 /// the wire.
388 ///
389 /// It does NOT replace the adapter's own inner check. Admission here is the
390 /// caller's guard; a service method is public and callable without a frame,
391 /// so it keeps its own.
392 ///
393 /// The default ADMITS. An adapter with no roster has nothing to say here and
394 /// its refusals travel as service errors exactly as they do today; only the
395 /// roster-owning adapter overrides this. A default body is also what keeps
396 /// this addition inside "minor": a method added without one breaks every
397 /// downstream implementor of a public trait.
398 ///
399 /// # Errors
400 /// Returns [`ChannelAccessError`] when the roster refuses the operation.
401 fn admit_channel(
402 &self,
403 operation: ChannelOperation,
404 channel: &str,
405 ) -> Result<(), ChannelAccessError> {
406 let _ = (operation, channel);
407 Ok(())
408 }
409}
410
411/// Default adapter from server wire frames to liminal channel/conversation APIs.
412#[derive(Debug)]
413pub struct LiminalConnectionServices {
414 /// The channel roster, keyed by channel name.
415 ///
416 /// Behind an [`RwLock`] over `Arc` values so a reader clones one pointer out
417 /// under the guard and works with it after the guard is released: no roster
418 /// read is ever held across a library call, and an entry handed out can
419 /// outlive a concurrent mutation of the map itself.
420 channels: RwLock<HashMap<String, Arc<ConfiguredChannel>>>,
421 /// The operator-declared bound on RUNTIME-registered channels
422 /// (`limits.max_channels`), carried verbatim from config.
423 ///
424 /// `None` is "the operator declared no bound", which refuses every runtime
425 /// registration rather than admitting an unbounded one — the roster's
426 /// aggregate size is otherwise unbounded the moment a registration API
427 /// exists. Boot-configured channels are never counted against it: they are
428 /// the bound the operator already wrote, in the file they wrote it in.
429 max_channels: Option<usize>,
430 cluster: ChannelCluster,
431 durable_store: Arc<dyn DurableStore>,
432 /// Reads the counters of every store these services built: the durable
433 /// store and, when it is a separate store, the ephemeral dedup store.
434 store_counts: StoreCountsHandle,
435 ephemeral_sweep: Option<EphemeralSweepCounts>,
436 signals: ServerSignals,
437 /// Complete participant service, installed only when semantic lifecycle
438 /// handling and its durable aggregate store are both ready.
439 participant_service: Option<InstalledParticipantService>,
440 /// The production handler's refused-load record, captured at construction.
441 ///
442 /// [`InstalledParticipantService`] holds its handler behind
443 /// `dyn ParticipantSemanticHandler`, so the concrete production handler is
444 /// no longer reachable once it is installed; the record is taken here, in
445 /// the one place the concrete handler exists, and carried out to the
446 /// startup path that publishes it onto the health endpoint. `None` is "no
447 /// participant is configured", which the operator surface reports as such
448 /// rather than as an empty refusal set.
449 unloadable_conversations: Option<UnloadableConversationRecord>,
450 /// The production handler as the A7 operator credential-re-issue authority
451 /// (§0.18), captured at construction for exactly the reason above: the
452 /// concrete handler is unreachable once erased behind
453 /// `dyn ParticipantSemanticHandler`, so the operator surface's handle on it
454 /// has to be taken in the one place it still exists. `None` is "no
455 /// participant is configured", which the route reports as such.
456 credential_reissuer: Option<Arc<dyn OperatorCredentialReissuer>>,
457 /// Dedup-on-delivery cache for publishes to **durable** channels. Keyed by
458 /// the per-message idempotency key carried on the publish frame; a duplicate
459 /// key is suppressed before fan-out so a subscriber receives it at most once.
460 ///
461 /// The namespace is stable across restarts by design (A1 §5): on a durable
462 /// channel the MESSAGE survives a crash too, so a surviving receipt is
463 /// truthful.
464 dedup: DedupCache,
465 /// Dedup-on-delivery cache for publishes to **ephemeral** channels
466 /// (A1 §5, graft §0.2 — the panel's sharpest crash hole).
467 ///
468 /// Identical machinery, different namespace: this one carries a per-boot
469 /// incarnation id, so a receipt and the message it vouches for share a
470 /// lifetime. Without it, `ephemeral + keyed publish -> Defer (buffered in
471 /// memory) -> complete_receipt (persisted) -> crash before the consumer
472 /// pops` loses the message while the receipt survives to suppress the
473 /// producer's retry — silent permanent loss of something the producer was
474 /// told was admitted. With it, the post-crash retry re-claims and
475 /// re-delivers, which is exactly ephemeral's at-least-once contract.
476 ///
477 /// This changes no dedup API. It changes only the namespace string the
478 /// server composes.
479 dedup_ephemeral: DedupCache,
480 /// Durable-channel idempotency keys whose publish outcome is not yet
481 /// known, held until their row is looked up by its key. A cache of the
482 /// durable in-flight claims, rebuilt from them when the services start.
483 held_keys: unresolved::HeldKeys,
484 conversation_supervisor: Arc<ConversationSupervisor>,
485 /// Registered custom conversation responders, keyed by conversation subject.
486 ///
487 /// When a conversation is opened (`open_conversation`), the subject is looked
488 /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
489 /// participant; with no registration the conversation falls back to the
490 /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
491 /// This is the seam aion #13 plugs a remote worker responder into. Interior
492 /// mutability is required because the services are shared behind `&self`.
493 responders: Mutex<ResponderRegistry>,
494 next_message_id: AtomicU64,
495 next_subscription_id: AtomicU64,
496 /// SRV-012: the console's held topology and tap.
497 console: Arc<crate::console::ConsoleHub>,
498}
499
500impl LiminalConnectionServices {
501 /// Builds library-backed services from validated server configuration.
502 ///
503 /// Durable-mode channels are backed by a shared haematite event store so
504 /// their publishes are persisted and survive the graceful-shutdown flush;
505 /// ephemeral channels carry no store.
506 ///
507 /// Full-only: a config selecting the worker-front-door profile is rejected at
508 /// entry (before any store is built), so this constructor can never build full
509 /// services for a profile that forbids them. Profile-aware callers go through
510 /// [`build_connection_services`] instead.
511 ///
512 /// # Errors
513 /// Returns [`ServerError`] when the config selects a non-full profile or a
514 /// configured channel cannot be initialized.
515 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
516 require_full_profile(config)?;
517 let path = required_persistence_path(config)?;
518 let history = StoreHistory::of(path);
519 let subsystems = RootedProductionSubsystems::new(path)?;
520 let store = subsystems.durable_store(path)?;
521 Self::from_config_with_store_via(config, store, history, &subsystems)
522 }
523
524 /// Builds services over a caller-provided durable store.
525 ///
526 /// Used by tests that need to inspect persisted state through the same store
527 /// handle the durable channels write to.
528 ///
529 /// Full-only: rejects a worker-front-door profile at entry, exactly like
530 /// [`Self::from_config`].
531 ///
532 /// # Errors
533 /// Returns [`ServerError`] when the config selects a non-full profile or a
534 /// configured channel cannot be initialized.
535 pub fn from_config_with_store(
536 config: &ServerConfig,
537 durable_store: Arc<dyn DurableStore>,
538 ) -> Result<Self, ServerError> {
539 require_full_profile(config)?;
540 #[cfg(any(test, feature = "test-support"))]
541 if config.persistence_path.is_none() {
542 return Self::from_config_with_store_via(
543 config,
544 durable_store,
545 StoreHistory::Recorded,
546 &ProductionSubsystems,
547 );
548 }
549 let subsystems = RootedProductionSubsystems::new(required_persistence_path(config)?)?;
550 Self::from_config_with_store_via(config, durable_store, StoreHistory::Recorded, &subsystems)
551 }
552
553 /// [`Self::from_config_with_store`] with the subsystem factory injected.
554 ///
555 /// The channel supervisor and conversation supervisor are constructed ONLY
556 /// through `subsystems` — there is no direct constructor call in this body —
557 /// so a factory that records as a side effect of constructing cannot have its
558 /// recording omitted (§9 D2 seam census, record-by-construction). No profile
559 /// check here: the caller (the public wrapper or the profile dispatch in
560 /// [`build_connection_services`]) has already established the full profile.
561 /// `history` says whether `durable_store` can already hold in-flight
562 /// claims for the services to look up before they judge a publish.
563 fn from_config_with_store_via(
564 config: &ServerConfig,
565 durable_store: Arc<dyn DurableStore>,
566 history: StoreHistory,
567 subsystems: &dyn SubsystemFactory,
568 ) -> Result<Self, ServerError> {
569 // Build ONE shared channel supervisor for the whole server. When a
570 // [cluster] section is present it is distribution-enabled, so every
571 // channel actor and subscriber shares the clustered scheduler the cluster
572 // attaches its process-group transport to (SRV-005, Constraint B).
573 let cluster = subsystems.channel_cluster(config.cluster.as_ref())?;
574 let mut channels = HashMap::new();
575 for channel in &config.channels {
576 // Resolve the channel's real JSON Schema (loaded from `schema_ref`
577 // during config validation) or the permissive empty schema when the
578 // channel declared none. The protocol schema id advertised at
579 // subscribe time is derived from the SAME schema bytes so an SDK
580 // deriving ids from schema bytes converges on it.
581 let resolved = resolve_channel_schema(channel);
582 let configured = build_configured_channel(
583 &channel.name,
584 resolved,
585 channel.durable,
586 ChannelOrigin::BootConfigured,
587 &durable_store,
588 cluster.supervisor(),
589 )
590 .map_err(|error| ServerError::ConfigValidation {
591 message: error.boot_message(&channel.name),
592 })?;
593 channels.insert(channel.name.clone(), Arc::new(configured));
594 }
595 let conversation_supervisor = subsystems.conversation_supervisor()?;
596 let dedup = history.delivery_dedup(Arc::clone(&durable_store));
597 // A1 §5: the ephemeral namespace is scoped to THIS incarnation, so an
598 // ephemeral receipt cannot outlive the in-memory message it vouches for.
599 // No earlier process used it, so it starts empty.
600 let ephemeral_store = ephemeral_dedup_store(&durable_store, history, subsystems);
601 let store_counts = combined_store_counts(&durable_store, &ephemeral_store);
602 let dedup_ephemeral = DedupCache::new_empty(ephemeral_store, ephemeral_dedup_namespace());
603 // Production participant activation (LP gap closure, Part B): the
604 // deployment's [participant] section installs the ONE production
605 // semantic handler, sealed together with the same durable store the
606 // conversation logs live in, under the configured wire-frame limit.
607 // No section, no service — the capability bit stays off and the
608 // connection path is byte-identical to the pre-activation build.
609 let signals = ServerSignals::default();
610 let installed_participant = config
611 .participant
612 .as_ref()
613 .map(|participant| {
614 let handler =
615 ProductionParticipantHandler::new(Arc::clone(&durable_store), *participant)
616 .map_err(|error| ServerError::ParticipantStartupRestore {
617 message: error.to_string(),
618 })?;
619 // Taken BEFORE the handler is erased behind
620 // `dyn ParticipantSemanticHandler`: boot has already recorded
621 // every conversation it refused by the time `new` returns, so
622 // the record captured here is complete from the first scrape.
623 handler.install_signals(signals.clone()).map_err(|error| {
624 ServerError::ParticipantStartupRestore {
625 message: error.to_string(),
626 }
627 })?;
628 let unloadable_conversations = handler.unloadable_record();
629 let handler = Arc::new(handler);
630 // A7 (§0.18): taken here for the same reason, and from the same
631 // one live handler, so the operator surface and the participant
632 // wire cannot end up talking to two different owners.
633 let credential_reissuer: Arc<dyn OperatorCredentialReissuer> =
634 Arc::<ProductionParticipantHandler>::clone(&handler);
635 let service = InstalledParticipantService::new(
636 handler,
637 Arc::clone(&durable_store),
638 participant.wire_frame_limit,
639 )
640 .map_err(|error| ServerError::ConfigValidation {
641 message: format!(
642 "participant.wire_frame_limit: {} is below the protocol's minimum \
643 complete participant frame ({error:?})",
644 participant.wire_frame_limit
645 ),
646 })?;
647 Ok::<_, ServerError>((service, (unloadable_conversations, credential_reissuer)))
648 })
649 .transpose()?;
650 let device = config
651 .cluster
652 .as_ref()
653 .map_or("local", |cluster| cluster.node_name.as_str());
654 let console = Arc::new(console_hub(
655 device,
656 &channels,
657 config.persistence_path.is_some(),
658 )?);
659 signals.install_console(Arc::clone(&console))?;
660 cluster.observer().set_console(Arc::clone(&console))?;
661 let (participant_service, operator_surfaces) = installed_participant.unzip();
662 let (unloadable_conversations, credential_reissuer) = operator_surfaces.unzip();
663 let services = Self {
664 channels: RwLock::new(channels),
665 max_channels: config.limits.max_channels,
666 cluster,
667 durable_store,
668 store_counts,
669 ephemeral_sweep: subsystems.ephemeral_sweep_counts(),
670 signals,
671 participant_service,
672 unloadable_conversations,
673 credential_reissuer,
674 dedup,
675 dedup_ephemeral,
676 held_keys: unresolved::HeldKeys::default(),
677 conversation_supervisor,
678 responders: Mutex::new(HashMap::new()),
679 next_message_id: AtomicU64::new(1),
680 next_subscription_id: AtomicU64::new(1),
681 console,
682 };
683 // Every durable in-flight claim is looked up by its key before the
684 // services judge any publish.
685 services.resolve_claims_at_start(history)?;
686 Ok(services)
687 }
688
689 /// Builds services with no configured channels.
690 ///
691 /// # Errors
692 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
693 #[cfg(any(test, feature = "test-support"))]
694 pub fn empty() -> Result<Self, ServerError> {
695 let conversation_supervisor = ProductionSubsystems.conversation_supervisor()?;
696 let store =
697 open_ephemeral(DEFAULT_SHARD_COUNT).map_err(|error| ServerError::ConfigValidation {
698 message: format!("failed to open test durable store: {error}"),
699 })?;
700 let durable_store: Arc<dyn DurableStore> = Arc::new(CountingStore::new(store));
701 let store_counts = combined_store_counts(&durable_store, &durable_store);
702 let dedup = StoreHistory::Fresh.delivery_dedup(Arc::clone(&durable_store));
703 // A1 §5: the ephemeral namespace is scoped to THIS incarnation, so an
704 // ephemeral receipt cannot outlive the in-memory message it vouches for.
705 let dedup_ephemeral =
706 DedupCache::new_empty(Arc::clone(&durable_store), ephemeral_dedup_namespace());
707 let signals = ServerSignals::default();
708 let console = Arc::new(console_hub("local", &HashMap::new(), false)?);
709 signals.install_console(Arc::clone(&console))?;
710 let cluster = build_channel_cluster(None)?;
711 cluster.observer().set_console(Arc::clone(&console))?;
712 Ok(Self {
713 channels: RwLock::new(HashMap::new()),
714 // No config, so no declared bound: this builder serves tests and
715 // callers with no channels at all, and a registration against it
716 // refuses `CapNotConfigured` exactly as an undeclared deployment's
717 // would.
718 max_channels: None,
719 cluster,
720 durable_store,
721 store_counts,
722 ephemeral_sweep: None,
723 signals,
724 participant_service: None,
725 unloadable_conversations: None,
726 credential_reissuer: None,
727 dedup,
728 dedup_ephemeral,
729 held_keys: unresolved::HeldKeys::default(),
730 conversation_supervisor,
731 responders: Mutex::new(HashMap::new()),
732 next_message_id: AtomicU64::new(1),
733 next_subscription_id: AtomicU64::new(1),
734 console,
735 })
736 }
737
738 /// The retained counts from the root's one startup sweep.
739 #[must_use]
740 pub const fn ephemeral_sweep_counts(&self) -> Option<EphemeralSweepCounts> {
741 self.ephemeral_sweep
742 }
743
744 /// The shared channel supervisor + cluster resolver backing this service.
745 ///
746 /// The server runtime uses this to attach the cluster to the channel
747 /// supervisor's clustered scheduler (SRV-005).
748 #[must_use]
749 pub const fn channel_cluster(&self) -> &ChannelCluster {
750 &self.cluster
751 }
752
753 /// Returns the shared durable store backing this service's durable channels.
754 #[must_use]
755 pub fn durable_store(&self) -> Arc<dyn DurableStore> {
756 Arc::clone(&self.durable_store)
757 }
758
759 /// The console's held topology and tap (SRV-012).
760 #[must_use]
761 pub fn console(&self) -> &crate::console::ConsoleHub {
762 &self.console
763 }
764
765 /// Applies a topology change; a poisoned model is counted by the hub
766 /// (`topology_emit_failures`) and logged, never recovered.
767 fn console_changed(&self, change: crate::console::TopologyChange) {
768 self.signals.topology(change);
769 }
770
771 /// Conversation subjects with a registered responder, sorted. Read from
772 /// the in-memory registry; touches no store.
773 ///
774 /// # Errors
775 /// Returns [`ServerError`] when the responder registry lock is poisoned.
776 pub fn responder_subjects(&self) -> Result<Vec<String>, ServerError> {
777 let responders = self.lock_responders()?;
778 let mut subjects: Vec<String> = responders.keys().cloned().collect();
779 drop(responders);
780 subjects.sort();
781 Ok(subjects)
782 }
783
784 /// Reads the store work these services have done since they were built,
785 /// across every store they built.
786 ///
787 /// Empty (every snapshot zero) when the durable store was supplied by the
788 /// caller and does not count.
789 #[must_use]
790 pub fn store_counts(&self) -> StoreCountsHandle {
791 self.store_counts.clone()
792 }
793
794 /// The production participant handler's refused-load record, when a
795 /// participant is configured.
796 ///
797 /// The server's startup path publishes this onto the health endpoint so
798 /// `GET /unloadable-conversations` answers from the same record the
799 /// handler writes. `None` means no participant is configured at all, which
800 /// the surface reports as a distinct state from "nothing was refused".
801 #[must_use]
802 pub fn unloadable_conversation_record(&self) -> Option<UnloadableConversationRecord> {
803 self.unloadable_conversations.clone()
804 }
805
806 /// The A7 operator credential-re-issue authority, when a participant is
807 /// configured (§0.18).
808 ///
809 /// The startup path publishes this onto the health endpoint so
810 /// `POST /operator/credential-reissue` reaches the SAME serialized
811 /// participant-state point every wire operation does. `None` means no
812 /// participant is configured, which the route reports as a distinct state
813 /// from an unknown identity.
814 #[must_use]
815 pub fn credential_reissuer(&self) -> Option<Arc<dyn OperatorCredentialReissuer>> {
816 self.credential_reissuer.clone()
817 }
818
819 /// Installs a complete participant bundle in full-service supervisor tests.
820 ///
821 /// Production full services intentionally stay disabled until a concrete
822 /// lifecycle handler exists. This consuming test builder exercises the real
823 /// supervisor activation path without allowing an already-shared adapter to
824 /// change capability posture.
825 #[cfg(test)]
826 #[must_use]
827 pub(crate) fn with_participant_service(
828 mut self,
829 participant_service: InstalledParticipantService,
830 ) -> Self {
831 self.participant_service = Some(participant_service);
832 self
833 }
834
835 /// Returns the conversation supervisor backing supervised conversations.
836 ///
837 /// Tests use this to reach the underlying beamr scheduler so they can spawn
838 /// or terminate participant processes and exercise crash detection.
839 #[must_use]
840 pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
841 Arc::clone(&self.conversation_supervisor)
842 }
843
844 /// Registers a custom conversation responder for a routing `subject`.
845 ///
846 /// When a conversation is later opened with this exact `subject`, its
847 /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
848 /// The responder is spawned and supervised identically to the echo
849 /// participant — a real linked beamr process with the same crash-detection
850 /// semantics — so this exposes the responder seam without changing how
851 /// participants run. Registering a subject that already has a responder
852 /// replaces it; the previous behaviour is returned.
853 ///
854 /// This is the liminal-side seam aion #13 plugs a remote worker into: it
855 /// registers a responder that forwards each request to the worker and routes
856 /// the worker's reply back through the conversation. Subjects with no
857 /// registration keep echoing, so existing callers are unaffected.
858 ///
859 /// # Errors
860 /// Returns [`ServerError`] when the responder registry lock is poisoned.
861 pub fn register_responder(
862 &self,
863 subject: impl Into<String>,
864 behaviour: Arc<dyn ParticipantBehaviour>,
865 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
866 let subject = subject.into();
867 let mut responders = self.lock_responders()?;
868 let previous = responders.insert(subject.clone(), behaviour);
869 drop(responders);
870 self.console_changed(crate::console::TopologyChange::Service {
871 subject,
872 present: true,
873 });
874 Ok(previous)
875 }
876
877 /// Removes the custom responder registered for `subject`, if any.
878 ///
879 /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
880 /// next [`Self::open_conversation`]. Returns the removed behaviour when one
881 /// was registered.
882 ///
883 /// # Errors
884 /// Returns [`ServerError`] when the responder registry lock is poisoned.
885 pub fn unregister_responder(
886 &self,
887 subject: &str,
888 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
889 let mut responders = self.lock_responders()?;
890 let removed = responders.remove(subject);
891 drop(responders);
892 if removed.is_some() {
893 self.console_changed(crate::console::TopologyChange::Service {
894 subject: subject.to_owned(),
895 present: false,
896 });
897 }
898 Ok(removed)
899 }
900
901 /// Resolves the responder behaviour for `subject`: the registered custom
902 /// responder when present, otherwise the built-in [`EchoBehaviour`].
903 ///
904 /// This is the single routing decision behind the seam — registered-or-echo —
905 /// so the fallback is identical to the original hard-wired echo path.
906 fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
907 let responders = self.lock_responders()?;
908 Ok(responders.get(subject).map_or_else(
909 || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
910 Arc::clone,
911 ))
912 }
913
914 /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
915 /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
916 fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
917 self.responders
918 .lock()
919 .map_err(|_poisoned| ServerError::ListenerAccept {
920 message: "responder registry lock poisoned".to_owned(),
921 })
922 }
923
924 /// Takes the channel roster's read guard, mapping a poisoned lock to a
925 /// [`ServerError`] rather than panicking (the workspace denies
926 /// `unwrap`/`expect`/`panic`), exactly as [`Self::lock_responders`] does for
927 /// the responder registry.
928 ///
929 /// Every caller clones the `Arc` it needs out of the returned guard and drops
930 /// the guard before doing anything else: the roster lock is never held across
931 /// a call into the liminal library.
932 fn read_channels(
933 &self,
934 ) -> Result<std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>, ServerError>
935 {
936 self.channels
937 .read()
938 .map_err(|_poisoned| ServerError::ListenerAccept {
939 message: "channel roster lock poisoned".to_owned(),
940 })
941 }
942
943 /// Subscribes to a configured channel and returns the raw library
944 /// subscription handle so a test can drain the subscriber inbox directly and
945 /// observe exactly which messages reached a subscriber.
946 #[cfg(test)]
947 pub(in crate::server) fn channel_handle_for_test(
948 &self,
949 channel: &str,
950 ) -> Result<ChannelHandle, ServerError> {
951 self.read_channels()?
952 .get(channel)
953 .map(|configured| configured.handle.clone())
954 .ok_or_else(|| ServerError::ListenerAccept {
955 message: format!("channel '{channel}' is not configured"),
956 })
957 }
958
959 #[cfg(test)]
960 pub(crate) fn subscribe_handle_for_test(
961 &self,
962 channel: &str,
963 ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
964 self.subscribe_handle_for_test_with_install(channel, None)
965 }
966
967 /// [`Self::subscribe_handle_for_test`] carrying an explicit install, so a
968 /// test can put a subscriber under a REAL §5 regime (byte budget, fairness
969 /// cap) and observe what a publish the bus dropped does to the dedup
970 /// receipt.
971 #[cfg(test)]
972 pub(crate) fn subscribe_handle_for_test_with_install(
973 &self,
974 channel: &str,
975 install: Option<liminal::channel::InboxInstall>,
976 ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
977 let channels = self.read_channels()?;
978 let configured =
979 channels
980 .get(channel)
981 .map(Arc::clone)
982 .ok_or_else(|| ServerError::ListenerAccept {
983 message: format!("channel '{channel}' is not configured"),
984 })?;
985 drop(channels);
986 install
987 .map_or_else(
988 || configured.handle.subscribe(),
989 |install| configured.handle.subscribe_with_install(install),
990 )
991 .map_err(|error| ServerError::ListenerAccept {
992 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
993 })
994 }
995
996 /// Claims the delivery right for an idempotency key.
997 ///
998 /// Returns `Ok(true)` when this is the first publish for the key (the caller
999 /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
1000 /// duplicate the caller must suppress). The dedup cache is driven synchronously
1001 /// over the in-memory haematite store via the durable bridge.
1002 fn claim_delivery(cache: &DedupCache, key: &str) -> Result<bool, ServerError> {
1003 let decision = block_on(cache.claim_or_get(key, dedup_timestamp_millis()))
1004 .map_err(|error| ServerError::ListenerAccept {
1005 message: format!("dedup bridge failed for key '{key}': {error}"),
1006 })?
1007 .map_err(|error| ServerError::ListenerAccept {
1008 message: format!("dedup claim failed for key '{key}': {error}"),
1009 })?;
1010 Ok(matches!(decision, DedupDecision::Claimed))
1011 }
1012
1013 /// The dedup namespace a publish to `channel` belongs in (A1 §5).
1014 ///
1015 /// Durability decides, because durability is what determines whether a
1016 /// receipt can outlive the message it vouches for. A durable channel's
1017 /// message survives a crash, so its receipt may; an ephemeral channel's
1018 /// does not, so its receipt must not.
1019 const fn dedup_for(&self, channel: &ConfiguredChannel) -> &DedupCache {
1020 match channel.handle.config().mode {
1021 ChannelMode::Durable => &self.dedup,
1022 ChannelMode::Ephemeral => &self.dedup_ephemeral,
1023 }
1024 }
1025
1026 /// Releases a dangling in-flight dedup claim after a failed delivery.
1027 ///
1028 /// Best-effort: a release failure cannot mask the original publish error, so
1029 /// this returns nothing and logs at `error` level instead of surfacing. It is
1030 /// never silent — the leak (a permanently suppressed key) must be observable.
1031 /// `release_claim` itself never clobbers a stored receipt, so calling it on the
1032 /// failure path is safe even if a concurrent completion raced ahead.
1033 fn release_claim(cache: &DedupCache, key: &str) {
1034 match block_on(cache.release_claim(key)) {
1035 Ok(Ok(())) => {}
1036 Ok(Err(error)) => {
1037 tracing::error!(
1038 idempotency_key = key,
1039 %error,
1040 "failed to release dedup claim after publish failure; key may stay suppressed"
1041 );
1042 }
1043 Err(error) => {
1044 tracing::error!(
1045 idempotency_key = key,
1046 %error,
1047 "dedup release bridge failed after publish failure; key may stay suppressed"
1048 );
1049 }
1050 }
1051 }
1052
1053 /// The next connection-level message id.
1054 fn next_message_id(&self) -> u64 {
1055 self.next_message_id.fetch_add(1, Ordering::Relaxed)
1056 }
1057
1058 /// The outcome of a publish the channel took, recorded as accepted with
1059 /// its genuine subscriber deliveries. The delivered count (0 for an empty
1060 /// channel) is the same signal the delivery ack is derived from.
1061 fn delivered(&self, delivery: &ChannelDelivery) -> PublishOutcome {
1062 crate::metrics::publish_accepted();
1063 let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
1064 crate::metrics::deliveries_recorded(delivered_count);
1065 PublishOutcome {
1066 message_id: self.next_message_id(),
1067 delivered: delivery.is_delivered(),
1068 durable_position: delivery.durable_position(),
1069 reach: Reach::of(delivery),
1070 }
1071 }
1072}
1073
1074#[path = "deferred_store.rs"]
1075mod deferred_store;
1076#[path = "unresolved.rs"]
1077mod unresolved;
1078#[path = "unresolved_start.rs"]
1079mod unresolved_start;
1080#[cfg(test)]
1081#[path = "unresolved_start_tests.rs"]
1082mod unresolved_start_tests;
1083
1084use deferred_store::{DeferredStore, ThrowawayStoreOpener};
1085use unresolved_start::StoreHistory;
1086
1087/// The runtime channel-registration surface.
1088///
1089/// # Reachability: a test seam today, not a production capability
1090///
1091/// Production channels come exclusively from boot configuration; no production
1092/// code path calls `register_channel` (caller census at `c381f01`, G2 in
1093/// `docs/design/wire-durability-gaps-20260831.md` — the only callers are
1094/// `services_registry_tests.rs` and `apply_admission_tests.rs`). The method is
1095/// kept because its semantics — cap gating, typed identity refusals,
1096/// idempotent re-registration — are the contract a future runtime admission
1097/// path must satisfy, and the tests below pin that contract now. Wiring an
1098/// actual runtime admission route (an operator endpoint, a wire frame) is a
1099/// product decision that has NOT been taken; nothing here implies it. Reading
1100/// this method as an existing operator capability is the false-affordance
1101/// mistake this paragraph exists to prevent.
1102///
1103/// Inherent methods, not trait methods, and deliberately so: this is
1104/// authority-moving vocabulary that belongs to the ONE adapter owning a channel
1105/// roster. Putting `register`/`quiesce` on the public [`ConnectionServices`]
1106/// trait would break every external implementor and would hand
1107/// register/quiesce words to a profile that serves no channels at all.
1108///
1109/// The same seam as the existing runtime-mutation API on this type
1110/// (`register_responder`/`unregister_responder`): `&self`, interior mutability,
1111/// typed `Result`.
1112impl LiminalConnectionServices {
1113 /// Registers `spec` on the live roster.
1114 ///
1115 /// Test seam today: see the reachability note on this `impl` block — no
1116 /// production path reaches this method.
1117 ///
1118 /// Idempotent when an entry of that name already has an IDENTICAL
1119 /// configuration — mode, protocol schema id, and schema document, all three
1120 /// — and refuses typed, naming the first differing field, otherwise. An
1121 /// identical registration against a boot-configured entry answers
1122 /// [`Registered::AlreadyIdentical`] and leaves its origin alone: flipping it
1123 /// would make the entry lie about its restart fate and would move it into
1124 /// the counted population without a channel having been created.
1125 ///
1126 /// The cap is consulted first, in two steps with different reach. An absent
1127 /// `limits.max_channels` refuses EVERY call, identical or not, before the
1128 /// roster is read at all: a deployment that has declared no bound has not
1129 /// said what it admits, and unbounded-by-default is not a bound. A REACHED
1130 /// cap refuses only a call that would create an entry — it gates the insert,
1131 /// which is the population it bounds. An identical re-registration inserts
1132 /// nothing and so is answered `AlreadyIdentical` even at a full roster;
1133 /// refusing it would break idempotency at exactly the boundary a projector
1134 /// re-projecting its record crosses.
1135 ///
1136 /// # Errors
1137 /// Returns [`ChannelRegistryError`] when no cap is configured, the cap is
1138 /// reached, the name exists with a different configuration, the schema bytes
1139 /// do not parse or compile, durable initialization over the shared store
1140 /// fails, or the roster lock is poisoned.
1141 pub fn register_channel(
1142 &self,
1143 spec: &ChannelRegistration,
1144 ) -> Result<Registered, ChannelRegistryError> {
1145 // The cap is consulted before the roster is touched: an undeclared bound
1146 // refuses every runtime registration, identical or not, because the
1147 // deployment has not said what it admits.
1148 let Some(limit) = self.max_channels else {
1149 return Err(ChannelRegistryError::CapNotConfigured {
1150 cap: MAX_CHANNELS_KEY,
1151 });
1152 };
1153 // Schema resolution is pure (a JSON parse and a digest), so it runs with
1154 // no lock held and its result serves BOTH the identity comparison and
1155 // the construction below.
1156 let resolved = resolve_schema_bytes(spec.schema_bytes.as_deref()).map_err(|error| {
1157 ChannelRegistryError::SchemaRejected {
1158 name: spec.name.clone(),
1159 message: error.to_string(),
1160 }
1161 })?;
1162
1163 // Fast paths, under a READ lock: an already-identical registration
1164 // builds nothing, and a full roster refuses before paying for a
1165 // construction it would discard. Neither is the authority — the write
1166 // lock below re-decides both, because the roster can move in between.
1167 let (existing, registered_count) = {
1168 let channels = self.read_roster()?;
1169 let existing = channels.get(&spec.name).map(Arc::clone);
1170 let registered_count = runtime_registered_count(&channels);
1171 drop(channels);
1172 (existing, registered_count)
1173 };
1174 if let Some(existing) = existing {
1175 return compare_registration(&existing, spec, &resolved);
1176 }
1177 if registered_count >= limit {
1178 return Err(ChannelRegistryError::CapReached {
1179 cap: MAX_CHANNELS_KEY,
1180 limit,
1181 });
1182 }
1183
1184 // Construction runs with NO lock held. A durable channel recovers its
1185 // per-partition sequence counters from the store here, which is O(stream
1186 // length) in store reads; holding the roster lock across it would put a
1187 // slow store walk on every connection's publish and subscribe path.
1188 let configured = build_configured_channel(
1189 &spec.name,
1190 resolved,
1191 spec.durable,
1192 ChannelOrigin::RuntimeRegistered,
1193 &self.durable_store,
1194 self.cluster.supervisor(),
1195 )
1196 .map_err(|error| error.into_registry_error(&spec.name))?;
1197
1198 // The authoritative decision: identity, cap, and insert under ONE write
1199 // lock, so the count that admitted the entry and the insert that added
1200 // it cannot be separated by a concurrent registration.
1201 let mut channels = self.write_roster()?;
1202 let raced = channels.get(&spec.name).map(Arc::clone);
1203 let registered_count = runtime_registered_count(&channels);
1204 if raced.is_none() && registered_count < limit {
1205 channels.insert(spec.name.clone(), Arc::new(configured));
1206 drop(channels);
1207 self.console_changed(crate::console::TopologyChange::Channel {
1208 name: spec.name.clone(),
1209 life: line_life(spec.durable),
1210 state: crate::console::records::LineState::Live,
1211 });
1212 return Ok(Registered::Created);
1213 }
1214 drop(channels);
1215 if let Some(raced) = raced {
1216 // A racer registered this name while the channel above was being
1217 // built. The built entry is dropped unused: it owns no actor (the
1218 // actor is spawned lazily on first use) and its durable
1219 // construction only READ the store, so discarding it changes
1220 // nothing an observer could see.
1221 return compare_registration(&raced, spec, &schema_of(&configured));
1222 }
1223 Err(ChannelRegistryError::CapReached {
1224 cap: MAX_CHANNELS_KEY,
1225 limit,
1226 })
1227 }
1228
1229 /// Moves `name` from active to quiesced with a named `reason`. ONE-WAY.
1230 ///
1231 /// New publishes and new subscribes are refused afterwards, carrying the
1232 /// reason. Existing subscriptions are UNTOUCHED: nothing revokes a
1233 /// subscription handle, the actor's subscriber list is not walked, no EXIT
1234 /// is sent, and the channel actor keeps running. Quiesce is a roster-level
1235 /// admission decision, not an actor command.
1236 ///
1237 /// Re-quiescing under the IDENTICAL reason is `Ok(())`; a DIFFERENT reason
1238 /// refuses, carrying the reason already on record.
1239 ///
1240 /// The return does NOT mean "no new subscriber can appear". A subscribe that
1241 /// has already passed admission completes and gets its stream — the
1242 /// linearisation point is the admission read, not the subscribe's
1243 /// completion. A consumer that needs "nobody is attached" must observe
1244 /// attachment directly.
1245 ///
1246 /// # Errors
1247 /// Returns [`ChannelRegistryError`] when the name is not registered, is
1248 /// already quiesced under a different reason, or the roster lock is
1249 /// poisoned.
1250 pub fn quiesce_channel(
1251 &self,
1252 name: &str,
1253 reason: impl Into<String>,
1254 ) -> Result<(), ChannelRegistryError> {
1255 let reason = reason.into();
1256 // A READ lock: the state machine lives on the ENTRY, not in the map, so
1257 // the operation the design most wants to be safe never blocks a reader.
1258 let configured = {
1259 let channels = self.read_roster()?;
1260 channels.get(name).map(Arc::clone).ok_or_else(|| {
1261 ChannelRegistryError::NotRegistered {
1262 name: name.to_owned(),
1263 }
1264 })?
1265 };
1266 configured.quiesce(name, &reason)?;
1267 self.console_changed(crate::console::TopologyChange::Channel {
1268 name: name.to_owned(),
1269 life: line_life(configured.durable),
1270 state: crate::console::records::LineState::Quiesced,
1271 });
1272 Ok(())
1273 }
1274
1275 /// Cheap typed probe: one roster read plus one atomic load.
1276 ///
1277 /// Touches no actor and therefore CANNOT spawn one. Every handle accessor
1278 /// that could answer a question about a channel's activity routes through
1279 /// the lazy-spawn path, so a probe built on one would materialise the actor
1280 /// of the idle channel it was asked about — turning a read into a side
1281 /// effect. This reads the roster entry's own recorded fields and nothing
1282 /// else.
1283 ///
1284 /// # Errors
1285 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
1286 pub fn channel_status(&self, name: &str) -> Result<ChannelStatus, ChannelRegistryError> {
1287 let configured = {
1288 let channels = self.read_roster()?;
1289 channels.get(name).map(Arc::clone)
1290 };
1291 Ok(
1292 configured.map_or(ChannelStatus::NotRegistered, |configured| {
1293 status_of(&configured)
1294 }),
1295 )
1296 }
1297
1298 /// Every roster entry with its status, sorted by name, from ONE roster
1299 /// read (SRV-012 R7: the console's topology snapshot).
1300 ///
1301 /// # Errors
1302 /// Returns [`ChannelRegistryError::RosterUnavailable`] when the roster
1303 /// lock is poisoned.
1304 pub fn channel_census(&self) -> Result<Vec<(String, ChannelStatus)>, ChannelRegistryError> {
1305 let mut census: Vec<(String, ChannelStatus)> = self
1306 .read_roster()?
1307 .iter()
1308 .map(|(name, configured)| (name.clone(), status_of(configured)))
1309 .collect();
1310 census.sort_by(|left, right| left.0.cmp(&right.0));
1311 Ok(census)
1312 }
1313
1314 /// The whole roster: one minimal descriptor per entry, sorted by name.
1315 ///
1316 /// The census companion to [`Self::channel_status`]. A by-name probe answers
1317 /// about a name the caller already suspects; only an enumeration can reveal
1318 /// a name the caller does not know to ask about, and a verification sweep
1319 /// with no population denominator is an instrument shape this estate
1320 /// forbids. Touches no actor, under the same constraint as the probe.
1321 ///
1322 /// # Errors
1323 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
1324 pub fn registered_channels(&self) -> Result<Vec<ChannelDescriptor>, ChannelRegistryError> {
1325 let entries: Vec<(String, Arc<ConfiguredChannel>)> = {
1326 let channels = self.read_roster()?;
1327 channels
1328 .iter()
1329 .map(|(name, configured)| (name.clone(), Arc::clone(configured)))
1330 .collect()
1331 };
1332 let mut descriptors: Vec<ChannelDescriptor> = entries
1333 .into_iter()
1334 .map(|(name, configured)| ChannelDescriptor {
1335 name,
1336 origin: configured.origin,
1337 state: configured.state(),
1338 })
1339 .collect();
1340 descriptors.sort_by(|left, right| left.name.cmp(&right.name));
1341 Ok(descriptors)
1342 }
1343
1344 /// The roster admission funnel: the ONE place a channel operation's
1345 /// permission is decided.
1346 ///
1347 /// Returns the admitted entry, so the caller works from the value the
1348 /// decision was made on rather than reading the roster a second time and
1349 /// risking a different answer. This read is the LINEARISATION POINT for the
1350 /// quiesce race: everything the caller does with the returned entry happens
1351 /// outside the lock and is not re-checked, which is exactly why a quiesce
1352 /// that commits after this returns does not stop the operation it admitted.
1353 ///
1354 /// # Errors
1355 /// Returns [`ChannelAccessError`] when the channel is absent, quiesced, or
1356 /// the roster lock is poisoned.
1357 fn admit_channel(&self, channel: &str) -> Result<Arc<ConfiguredChannel>, ChannelAccessError> {
1358 let configured = {
1359 let channels = self.channels.read().map_err(|_poisoned| {
1360 ChannelAccessError::RosterUnavailable {
1361 message: ROSTER_POISONED.to_owned(),
1362 }
1363 })?;
1364 channels.get(channel).map(Arc::clone)
1365 };
1366 let configured = configured.ok_or_else(|| ChannelAccessError::NotRegistered {
1367 name: channel.to_owned(),
1368 })?;
1369 if configured.state.load(Ordering::Acquire) == STATE_QUIESCED {
1370 return Err(ChannelAccessError::Quiesced {
1371 name: channel.to_owned(),
1372 reason: configured.recorded_quiesce_reason(),
1373 });
1374 }
1375 Ok(configured)
1376 }
1377
1378 /// Takes the roster's READ guard for the registration surface, mapping a
1379 /// poisoned lock to a typed [`ChannelRegistryError`].
1380 ///
1381 /// Separate from [`Self::read_channels`] because the two surfaces answer
1382 /// different callers with different error types; recovering one from the
1383 /// other would mean inspecting a message.
1384 fn read_roster(
1385 &self,
1386 ) -> Result<
1387 std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
1388 ChannelRegistryError,
1389 > {
1390 #[cfg(test)]
1391 ROSTER_READS.with(|reads| reads.set(reads.get() + 1));
1392 self.channels
1393 .read()
1394 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
1395 message: ROSTER_POISONED.to_owned(),
1396 })
1397 }
1398
1399 /// Takes the roster's WRITE guard — held only for the check-and-insert that
1400 /// must be atomic, never across a call into the liminal library.
1401 fn write_roster(
1402 &self,
1403 ) -> Result<
1404 std::sync::RwLockWriteGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
1405 ChannelRegistryError,
1406 > {
1407 self.channels
1408 .write()
1409 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
1410 message: ROSTER_POISONED.to_owned(),
1411 })
1412 }
1413
1414 /// Gives back the tap slot a subscribe to `channel` reserved at
1415 /// admission, taking its tap channel off the roster when no tap stands
1416 /// behind it (SRV-012 R6).
1417 fn give_back_tap(&self, channel: &str) {
1418 self.console.release_tap(channel, |subject| {
1419 crate::console::ServerPublisher::retire_console_channel(self, subject)
1420 });
1421 }
1422
1423 /// The trait's subscribe, from the console guard to the subscription;
1424 /// [`ConnectionServices::subscribe`] gives back the tap reservation when
1425 /// this fails.
1426 fn open_subscription(
1427 &self,
1428 channel: &str,
1429 accepted_schemas: &[ProtocolSchemaId],
1430 install: Option<liminal::channel::InboxInstall>,
1431 ) -> Result<ConnectionSubscription, ServerError> {
1432 // The frame path refused this already at admission; this guard is for
1433 // a caller holding the trait directly.
1434 refuse_console_tap_target(channel).map_err(|error| access_to_server_error(&error))?;
1435 // As in `publish`: the admission funnel clones the entry's `Arc` out
1436 // under the read guard and the guard is released before schema
1437 // negotiation or the actor round-trip below. That release is what makes
1438 // this the linearisation point — a quiesce landing after it does not
1439 // stop the subscription this call is already building.
1440 let configured = self
1441 .admit_channel(channel)
1442 .map_err(|error| access_to_server_error(&error))?;
1443 let selected_schema = if accepted_schemas.is_empty() {
1444 configured.protocol_schema
1445 } else {
1446 liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
1447 .map_err(|error| server_error_from_protocol(&error))?
1448 };
1449 // `subscribe_with_install` installs the §5 budget/fairness cap and the R3
1450 // wake notifier on the inbox at construction — strictly before the
1451 // registration is published to the channel actor — so there is no window
1452 // in which a publish can land uncharged or without a wake.
1453 let subscription = install
1454 .map_or_else(
1455 || configured.handle.subscribe(),
1456 |install| configured.handle.subscribe_with_install(install),
1457 )
1458 .map_err(|error| ServerError::ListenerAccept {
1459 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
1460 })?;
1461 let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
1462 let mut connection_subscription = ConnectionSubscription::new(
1463 id,
1464 selected_schema,
1465 Box::new(LiminalSubscriptionResource { subscription }),
1466 );
1467 connection_subscription.set_channel(channel.to_owned());
1468 // SRV-012 R6: the console tap attaches and detaches through the
1469 // supervisor's observer, which hears every subscribe and every
1470 // departure once (ServerObserver).
1471 Ok(connection_subscription)
1472 }
1473
1474 /// Refuses a subscribe that would start one more live tap than
1475 /// [`crate::console::MAX_LIVE_TAPS`] (SRV-012 R6); a subscriber to a tap
1476 /// already live is not a new tap.
1477 ///
1478 /// A poisoned tap table does not refuse the subscribe (R8: a console
1479 /// bookkeeping failure never fails the client operation it watches); the
1480 /// attach that follows meets the poison and counts it.
1481 ///
1482 /// # Errors
1483 /// Returns [`ChannelAccessError::ConsoleTapLimit`] naming the channel.
1484 fn refuse_console_tap_limit(&self, channel: &str) -> Result<(), ChannelAccessError> {
1485 match self.console.reserve_tap(channel) {
1486 Ok(false) => Err(ChannelAccessError::ConsoleTapLimit {
1487 name: channel.to_owned(),
1488 limit: crate::console::MAX_LIVE_TAPS,
1489 }),
1490 Ok(true) | Err(_) => Ok(()),
1491 }
1492 }
1493}
1494
1495impl crate::console::ServerPublisher for LiminalConnectionServices {
1496 /// The server's own record on the one publish path, with a server origin:
1497 /// attributed to `liminal.<device>`, and never counted as a connection's
1498 /// work by the cost tick.
1499 fn publish_console(
1500 &self,
1501 subject: &str,
1502 payload: &[u8],
1503 ) -> Result<(), crate::console::ConsoleError> {
1504 let envelope = MessageEnvelope::new(
1505 ProtocolSchemaId::new([0_u8; ProtocolSchemaId::WIRE_LEN]),
1506 liminal::protocol::CausalContext::independent(),
1507 payload.to_vec(),
1508 );
1509 self.publish_with_origin(
1510 subject,
1511 &envelope,
1512 None,
1513 PublishOrigin::Server {
1514 device: self.console.device(),
1515 },
1516 )
1517 .map(|_published| ())
1518 .map_err(|source| crate::console::ConsoleError::Server {
1519 subject: subject.to_owned(),
1520 source,
1521 })
1522 }
1523
1524 /// Takes a `console.tap.<target>` channel off the roster (SRV-012 R6).
1525 /// Only a console subject is ever removed here.
1526 fn retire_console_channel(&self, subject: &str) -> Result<(), crate::console::ConsoleError> {
1527 if !super::channel_registry::is_console_subject(subject) {
1528 return Ok(());
1529 }
1530 let mut channels =
1531 self.write_roster()
1532 .map_err(|error| crate::console::ConsoleError::Server {
1533 subject: subject.to_owned(),
1534 source: ServerError::ListenerAccept {
1535 message: error.to_string(),
1536 },
1537 })?;
1538 channels.remove(subject);
1539 drop(channels);
1540 Ok(())
1541 }
1542}
1543
1544/// The console hub for `device`, its model built from the roster as it
1545/// stands (responders, connections and peers arrive as changes).
1546///
1547/// # Errors
1548/// Returns [`ServerError::ListenerAccept`] when the device is blank or the
1549/// wall clock cannot be read.
1550fn console_hub(
1551 device: &str,
1552 channels: &HashMap<String, Arc<ConfiguredChannel>>,
1553 depot: bool,
1554) -> Result<crate::console::ConsoleHub, ServerError> {
1555 let this = crate::console::ThisDevice {
1556 id: device.to_owned(),
1557 liminal: env!("CARGO_PKG_VERSION").to_owned(),
1558 started: Instant::now(),
1559 depot,
1560 };
1561 let lines = channels.iter().map(|(name, configured)| {
1562 let state = match configured.state() {
1563 ChannelState::Active => crate::console::records::LineState::Live,
1564 ChannelState::Quiesced { .. } => crate::console::records::LineState::Quiesced,
1565 };
1566 (name.clone(), line_life(configured.durable), state)
1567 });
1568 let model = crate::console::TopologyModel::new(this, [], lines, [], []);
1569 crate::console::wall::WallBase::now()
1570 .and_then(|wall| crate::console::ConsoleHub::new(device, model, wall))
1571 .map_err(|error| ServerError::ListenerAccept {
1572 message: format!("console hub: {error}"),
1573 })
1574}
1575
1576/// A roster entry's status as [`LiminalConnectionServices::channel_status`]
1577/// answers it.
1578fn status_of(configured: &ConfiguredChannel) -> ChannelStatus {
1579 let mode = configured.handle.config().mode;
1580 match configured.state() {
1581 ChannelState::Active => ChannelStatus::Active {
1582 origin: configured.origin,
1583 mode,
1584 schema: configured.protocol_schema,
1585 },
1586 ChannelState::Quiesced { reason } => ChannelStatus::Quiesced {
1587 reason,
1588 origin: configured.origin,
1589 mode,
1590 },
1591 }
1592}
1593
1594#[cfg(test)]
1595thread_local! {
1596 /// Roster read locks taken on this thread. Test-only.
1597 static ROSTER_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1598}
1599
1600/// Roster read locks taken on the calling thread so far. Test-only.
1601#[cfg(test)]
1602pub(crate) fn roster_reads() -> u64 {
1603 ROSTER_READS.with(std::cell::Cell::get)
1604}
1605
1606/// A channel's line life: durable channels keep, ephemeral ones pass.
1607const fn line_life(durable: bool) -> crate::console::records::LineLife {
1608 if durable {
1609 crate::console::records::LineLife::Kept
1610 } else {
1611 crate::console::records::LineLife::Passing
1612 }
1613}
1614
1615/// The diagnostic carried when the roster lock is poisoned. One string, shared
1616/// by every surface that reports it, so the wording cannot drift between them.
1617const ROSTER_POISONED: &str = "channel roster lock poisoned";
1618
1619/// How many roster entries the registration cap counts.
1620///
1621/// Runtime-registered entries ONLY. Boot-configured channels are the operator's
1622/// own authored bound — they are in the file the operator wrote — and counting
1623/// them would make one number mean two different things depending on how the
1624/// deployment was configured. The origin never flips, so this population is
1625/// well defined over time.
1626fn runtime_registered_count(channels: &HashMap<String, Arc<ConfiguredChannel>>) -> usize {
1627 channels
1628 .values()
1629 .filter(|configured| configured.origin == ChannelOrigin::RuntimeRegistered)
1630 .count()
1631}
1632
1633/// The three-field identity comparison behind idempotent-if-identical
1634/// registration.
1635///
1636/// All three must match; the FIRST mismatch is reported by name. The name itself
1637/// is not compared — it is the roster key, a precondition of the comparison
1638/// rather than a member of it — and neither are the supervisor or the durable
1639/// store, which are one server-wide instance each and cannot differ between two
1640/// registrations in one process.
1641///
1642/// The schema is compared as BOTH its protocol id and its parsed document, on
1643/// purpose. The id is a 64-bit non-cryptographic digest, so the document guards
1644/// against a collision accepting a different schema as identical; and the two
1645/// fail in opposite directions, because two byte sequences that parse to the
1646/// same document but differ in whitespace produce different ids — which must
1647/// refuse, since the id is what every future subscriber negotiates.
1648///
1649/// The channel's own `Schema` is not compared: it is not `PartialEq`, and its
1650/// identifier is a fresh value per construction, so comparing it would refuse
1651/// every idempotent re-registration.
1652fn compare_registration(
1653 existing: &ConfiguredChannel,
1654 spec: &ChannelRegistration,
1655 resolved: &ChannelSchema,
1656) -> Result<Registered, ChannelRegistryError> {
1657 let requested_mode = if spec.durable {
1658 ChannelMode::Durable
1659 } else {
1660 ChannelMode::Ephemeral
1661 };
1662 let config = existing.handle.config();
1663 let mismatch = if config.mode == requested_mode {
1664 if existing.protocol_schema == resolved.protocol_id {
1665 if *config.schema.definition() == resolved.document {
1666 None
1667 } else {
1668 Some(ChannelConfigField::SchemaDocument)
1669 }
1670 } else {
1671 Some(ChannelConfigField::SchemaId)
1672 }
1673 } else {
1674 Some(ChannelConfigField::Mode)
1675 };
1676 mismatch.map_or(Ok(Registered::AlreadyIdentical), |field| {
1677 Err(ChannelRegistryError::AlreadyRegistered {
1678 name: spec.name.clone(),
1679 field,
1680 })
1681 })
1682}
1683
1684/// Recovers the resolved schema of an already-built entry, so the write-lock
1685/// re-check compares the SAME three fields against the same values the fast path
1686/// would have.
1687fn schema_of(configured: &ConfiguredChannel) -> ChannelSchema {
1688 ChannelSchema {
1689 document: configured.handle.config().schema.definition().clone(),
1690 protocol_id: configured.protocol_schema,
1691 }
1692}
1693
1694/// Builds one roster entry: the channel's validation engine, its library handle
1695/// (durable or ephemeral), and the protocol schema id advertised at subscribe
1696/// time.
1697///
1698/// This is the SOLE place a [`ConfiguredChannel`] is constructed. Keeping
1699/// construction in one function is what makes "every channel on this server is
1700/// the same kind of object" structural rather than a promise: there is no second
1701/// body a channel could be built by, so no channel can drift into a different
1702/// shape. The boot loop in [`LiminalConnectionServices::from_config_with_store_via`]
1703/// is its caller.
1704///
1705/// `resolved` is consumed because its JSON Schema document is moved into the
1706/// channel's [`Schema`]; the protocol id is carried onto the entry unchanged.
1707/// `origin` is stamped here and never written again.
1708///
1709/// The failure is typed ([`ChannelBuildError`]) rather than a [`ServerError`] so
1710/// BOTH callers can render it without inspecting a message: the boot loop maps
1711/// it back to the exact `ConfigValidation` strings it has always produced, and
1712/// `register_channel` maps it to the registry's own typed variants.
1713///
1714/// # Errors
1715/// Returns [`ChannelBuildError`] when the JSON Schema document does not compile
1716/// or durable initialization over the shared store fails.
1717fn build_configured_channel(
1718 name: &str,
1719 resolved: ChannelSchema,
1720 durable: bool,
1721 origin: ChannelOrigin,
1722 durable_store: &Arc<dyn DurableStore>,
1723 supervisor: &ChannelSupervisor,
1724) -> Result<ConfiguredChannel, ChannelBuildError> {
1725 let schema =
1726 Schema::new(resolved.document).map_err(|error| ChannelBuildError::SchemaRejected {
1727 message: error.to_string(),
1728 })?;
1729 let channel_config = if durable {
1730 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Durable)
1731 } else {
1732 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Ephemeral)
1733 };
1734 let handle = if durable {
1735 ChannelHandle::new_durable_with_supervisor(
1736 channel_config,
1737 Arc::clone(durable_store),
1738 supervisor.clone(),
1739 )
1740 .map_err(|error| ChannelBuildError::DurableInitFailed {
1741 message: error.to_string(),
1742 })?
1743 } else {
1744 ChannelHandle::with_supervisor(channel_config, supervisor.clone())
1745 };
1746 Ok(ConfiguredChannel {
1747 handle,
1748 protocol_schema: resolved.protocol_id,
1749 durable,
1750 origin,
1751 state: AtomicU8::new(STATE_ACTIVE),
1752 quiesce_reason: OnceLock::new(),
1753 })
1754}
1755
1756/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
1757///
1758/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
1759/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
1760/// claim semantics, so this avoids surfacing a clock fault on the publish path.
1761fn dedup_timestamp_millis() -> u64 {
1762 use std::time::{SystemTime, UNIX_EPOCH};
1763 SystemTime::now()
1764 .duration_since(UNIX_EPOCH)
1765 .ok()
1766 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1767 .unwrap_or(0)
1768}
1769
1770/// Default shard count for an on-disk durable store.
1771///
1772/// Haematite routes keys across this many single-threaded shard actors; a small
1773/// power of two gives parallelism across cursors/streams without spawning an
1774/// actor per core. The value is fixed (haematite has no silent default) and not
1775/// yet surfaced in server config.
1776const DEFAULT_SHARD_COUNT: usize = 8;
1777
1778/// Shard count of the throwaway store an incarnation's ephemeral dedup
1779/// namespace lives in: it holds one namespace, and dies with the process.
1780const THROWAWAY_SHARD_COUNT: usize = 1;
1781
1782/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
1783/// keys from colliding with any other haematite streams in the shared store.
1784///
1785/// This is the **durable** namespace (A1 §5): stable across restarts, because a
1786/// durable channel's message outlives a restart too.
1787const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
1788
1789/// The **ephemeral** dedup namespace for this server incarnation (A1 §5,
1790/// graft §0.2).
1791///
1792/// Scoped to the process incarnation so an ephemeral channel's receipt shares a
1793/// lifetime with the in-memory message it vouches for. Composed from the
1794/// process id and the boot instant, which is enough to make two incarnations
1795/// disagree; nothing reads the components back, so no meaning is attached to
1796/// them beyond distinctness.
1797///
1798/// The namespace lives in [`ephemeral_dedup_store`], never in a store that
1799/// outlives the process, so no incarnation's records are left behind for a
1800/// later one to read past.
1801fn ephemeral_dedup_namespace() -> String {
1802 use std::time::{SystemTime, UNIX_EPOCH};
1803 let boot_nanos = SystemTime::now()
1804 .duration_since(UNIX_EPOCH)
1805 .map_or(0, |since| since.as_nanos());
1806 format!(
1807 "{DELIVERY_DEDUP_NAMESPACE}:incarnation:{}-{boot_nanos}",
1808 std::process::id()
1809 )
1810}
1811
1812/// One reader over the counters of `durable_store` and, when it is a separate
1813/// store, `ephemeral_store`; a store that is the same `Arc` is read once.
1814fn combined_store_counts(
1815 durable_store: &Arc<dyn DurableStore>,
1816 ephemeral_store: &Arc<dyn DurableStore>,
1817) -> StoreCountsHandle {
1818 let durable = durable_store.store_counts().unwrap_or_default();
1819 if Arc::ptr_eq(durable_store, ephemeral_store) {
1820 return durable;
1821 }
1822 durable.with(&ephemeral_store.store_counts().unwrap_or_default())
1823}
1824
1825/// The store an incarnation's ephemeral dedup namespace is written to: the
1826/// services' own store when it was created empty for this process and dies
1827/// with it, otherwise a store of its own that does, opened by the namespace's
1828/// first write rather than at start.
1829///
1830/// Written into a persistent store, every incarnation's namespace would stay
1831/// there for good, under the durable namespace's prefix, with nothing that
1832/// ever reads it again to prune it.
1833fn ephemeral_dedup_store(
1834 durable_store: &Arc<dyn DurableStore>,
1835 history: StoreHistory,
1836 subsystems: &dyn SubsystemFactory,
1837) -> Arc<dyn DurableStore> {
1838 match (history, durable_store) {
1839 #[cfg(any(test, feature = "test-support"))]
1840 (StoreHistory::Fresh, store) => Arc::clone(store),
1841 (StoreHistory::Created | StoreHistory::Recorded, _) => Arc::new(CountingStore::new(
1842 DeferredStore::new(subsystems.throwaway_store()),
1843 )),
1844 }
1845}
1846
1847/// Beamr-scheduler-owning subsystems the full-service construction path builds
1848/// beyond the connection supervisor's own scheduler. The §9 D2 seam census counts
1849/// these: the worker-front-door profile must construct NONE of them. Test-gated:
1850/// this is the recording vocabulary of the gate's instrument, not production state.
1851#[cfg(test)]
1852#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1853pub(super) enum SchedulerSubsystem {
1854 /// The shared channel supervisor (its own beamr scheduler).
1855 ChannelSupervisor,
1856 /// The conversation supervisor (its own beamr scheduler).
1857 ConversationSupervisor,
1858 /// The haematite store's database (its shard-actor scheduler).
1859 HaematiteStore,
1860}
1861
1862/// Constructor seam for every scheduler-owning subsystem (`SchedulerSubsystem`)
1863/// the profile-aware construction path can create.
1864///
1865/// These methods are the ONLY route through which [`build_connection_services`]
1866/// and the [`LiminalConnectionServices`] config constructors reach
1867/// `build_channel_cluster`, `ConversationSupervisor::new`, and the durable-store
1868/// constructors — no direct constructor call exists in those bodies. The §9 D2
1869/// gate therefore injects a factory that records as a side effect of
1870/// constructing (the D3 store-seam ownership move applied to schedulers): a
1871/// recording cannot be omitted without also failing to construct the subsystem,
1872/// which closes the "hand-placed census call beside the constructor" gap where a
1873/// future subsystem could be built without its courtesy call.
1874pub(super) trait SubsystemFactory {
1875 /// Constructs the shared channel supervisor + cluster resolver (a beamr
1876 /// scheduler).
1877 ///
1878 /// # Errors
1879 /// Returns [`ServerError`] when the channel supervisor scheduler cannot start.
1880 fn channel_cluster(
1881 &self,
1882 cluster_config: Option<&ClusterConfig>,
1883 ) -> Result<ChannelCluster, ServerError>;
1884
1885 /// Constructs the conversation supervisor (a beamr scheduler).
1886 ///
1887 /// # Errors
1888 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot
1889 /// start.
1890 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError>;
1891
1892 /// Constructs the persistent durable store under the explicit directory.
1893 ///
1894 /// # Errors
1895 /// Returns [`ServerError`] when the store cannot be opened.
1896 fn durable_store(&self, persistence_path: &Path) -> Result<Arc<dyn DurableStore>, ServerError>;
1897
1898 /// The opener of the throwaway store an incarnation's ephemeral dedup
1899 /// namespace lives in when the durable store outlives the process. The
1900 /// store is opened by the namespace's first write, never by this call.
1901 fn throwaway_store(&self) -> ThrowawayStoreOpener;
1902
1903 fn ephemeral_sweep_counts(&self) -> Option<EphemeralSweepCounts> {
1904 None
1905 }
1906}
1907
1908/// Test construction factory for callers that own their fixture directories.
1909#[cfg(any(test, feature = "test-support"))]
1910pub(super) struct ProductionSubsystems;
1911
1912#[cfg(any(test, feature = "test-support"))]
1913impl SubsystemFactory for ProductionSubsystems {
1914 fn channel_cluster(
1915 &self,
1916 cluster_config: Option<&ClusterConfig>,
1917 ) -> Result<ChannelCluster, ServerError> {
1918 build_channel_cluster(cluster_config)
1919 }
1920
1921 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1922 Ok(Arc::new(ConversationSupervisor::new().map_err(
1923 |error| ServerError::ConfigValidation {
1924 message: format!("failed to start conversation supervisor: {error}"),
1925 },
1926 )?))
1927 }
1928
1929 fn durable_store(&self, persistence_path: &Path) -> Result<Arc<dyn DurableStore>, ServerError> {
1930 build_durable_store(persistence_path)
1931 }
1932
1933 fn throwaway_store(&self) -> ThrowawayStoreOpener {
1934 Box::new(|| open_ephemeral(THROWAWAY_SHARD_COUNT))
1935 }
1936}
1937
1938struct RootedProductionSubsystems {
1939 root: Arc<EphemeralRoot>,
1940}
1941
1942impl RootedProductionSubsystems {
1943 fn new(path: &Path) -> Result<Self, ServerError> {
1944 let root = EphemeralRoot::open(path).map_err(|error| ServerError::ConfigValidation {
1945 message: error.to_string(),
1946 })?;
1947 Ok(Self { root })
1948 }
1949}
1950
1951impl SubsystemFactory for RootedProductionSubsystems {
1952 fn channel_cluster(
1953 &self,
1954 config: Option<&ClusterConfig>,
1955 ) -> Result<ChannelCluster, ServerError> {
1956 build_channel_cluster(config)
1957 }
1958
1959 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1960 Ok(Arc::new(ConversationSupervisor::new().map_err(
1961 |error| ServerError::ConfigValidation {
1962 message: format!("failed to start conversation supervisor: {error}"),
1963 },
1964 )?))
1965 }
1966
1967 fn durable_store(&self, path: &Path) -> Result<Arc<dyn DurableStore>, ServerError> {
1968 build_durable_store(path)
1969 }
1970
1971 fn throwaway_store(&self) -> ThrowawayStoreOpener {
1972 let root = Arc::clone(&self.root);
1973 Box::new(move || root.open_store(THROWAWAY_SHARD_COUNT))
1974 }
1975
1976 fn ephemeral_sweep_counts(&self) -> Option<EphemeralSweepCounts> {
1977 Some(self.root.sweep_counts())
1978 }
1979}
1980
1981/// Test-only recording implementation of [`SubsystemFactory`] for the §9 D2
1982/// construction gate. Lives here (not in a test module's private scope) so the
1983/// supervisor-level gate test reuses the same instrument.
1984#[cfg(test)]
1985#[allow(clippy::expect_used)]
1986pub(super) mod subsystem_census {
1987 use std::path::{Path, PathBuf};
1988 use std::sync::{Arc, Mutex};
1989
1990 use liminal::conversation::ConversationSupervisor;
1991 use liminal::durability::{DurableStore, open_ephemeral_rooted};
1992
1993 use super::{
1994 ChannelCluster, ProductionSubsystems, SchedulerSubsystem, SubsystemFactory,
1995 THROWAWAY_SHARD_COUNT, ThrowawayStoreOpener, build_durable_store,
1996 };
1997 use crate::ServerError;
1998 use crate::config::types::ClusterConfig;
1999
2000 /// Records each [`SchedulerSubsystem`] AS A SIDE EFFECT of constructing it,
2001 /// then hands back the production-constructed subsystem (with ephemeral
2002 /// stores rooted in an isolated directory, so the fs half of the gate is a
2003 /// real negative assertion). Because [`SubsystemFactory`] is the only route
2004 /// the profile-aware construction path has to these constructors, a
2005 /// recording cannot be omitted without also failing to construct — the
2006 /// record-by-construction guarantee.
2007 pub struct RecordingSubsystems {
2008 census: Mutex<Vec<SchedulerSubsystem>>,
2009 ephemeral_root: PathBuf,
2010 }
2011
2012 impl RecordingSubsystems {
2013 /// A recording factory whose ephemeral stores live under `ephemeral_root`.
2014 pub fn rooted(ephemeral_root: &Path) -> Self {
2015 Self {
2016 census: Mutex::new(Vec::new()),
2017 ephemeral_root: ephemeral_root.to_path_buf(),
2018 }
2019 }
2020
2021 /// The recorded construction census, sorted for order-independent
2022 /// comparison.
2023 pub fn recorded(&self) -> Vec<SchedulerSubsystem> {
2024 let mut recorded = self
2025 .census
2026 .lock()
2027 .expect("subsystem census lock is never poisoned in tests")
2028 .clone();
2029 recorded.sort();
2030 recorded
2031 }
2032
2033 fn record(&self, subsystem: SchedulerSubsystem) {
2034 self.census
2035 .lock()
2036 .expect("subsystem census lock is never poisoned in tests")
2037 .push(subsystem);
2038 }
2039 }
2040
2041 impl SubsystemFactory for RecordingSubsystems {
2042 fn channel_cluster(
2043 &self,
2044 cluster_config: Option<&ClusterConfig>,
2045 ) -> Result<ChannelCluster, ServerError> {
2046 let cluster = ProductionSubsystems.channel_cluster(cluster_config)?;
2047 self.record(SchedulerSubsystem::ChannelSupervisor);
2048 Ok(cluster)
2049 }
2050
2051 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
2052 let supervisor = ProductionSubsystems.conversation_supervisor()?;
2053 self.record(SchedulerSubsystem::ConversationSupervisor);
2054 Ok(supervisor)
2055 }
2056
2057 fn durable_store(
2058 &self,
2059 persistence_path: &Path,
2060 ) -> Result<Arc<dyn DurableStore>, ServerError> {
2061 let store = build_durable_store(persistence_path)?;
2062 self.record(SchedulerSubsystem::HaematiteStore);
2063 Ok(store)
2064 }
2065
2066 fn throwaway_store(&self) -> ThrowawayStoreOpener {
2067 let root = self.ephemeral_root.clone();
2068 Box::new(move || open_ephemeral_rooted(&root, THROWAWAY_SHARD_COUNT))
2069 }
2070 }
2071}
2072
2073/// Full-only constructor guard: rejects a config whose profile is not `Full`.
2074///
2075/// [`LiminalConnectionServices`]' config-based constructors call this at entry so
2076/// the full service stack can never be built for a worker-front-door config —
2077/// profile enforcement holds on every public construction path, not only the
2078/// file-loading pipeline.
2079fn require_full_profile(config: &ServerConfig) -> Result<(), ServerError> {
2080 match config.services.profile()? {
2081 ServiceProfile::Full => Ok(()),
2082 ServiceProfile::WorkerFrontDoor => Err(ServerError::ConfigValidation {
2083 message: format!(
2084 "services.profile: \"{}\" cannot construct the full LiminalConnectionServices; \
2085 build profile-selected services via build_connection_services",
2086 ServiceProfile::WORKER_FRONT_DOOR
2087 ),
2088 }),
2089 }
2090}
2091
2092/// Builds the connection-services adapter selected by `config`'s service profile.
2093///
2094/// `Full` builds [`LiminalConnectionServices`] (channels, conversations, durable
2095/// store, dedup cache) exactly as today. `WorkerFrontDoor` builds
2096/// [`WorkerFrontDoorServices`], which constructs none of that machinery. This is the
2097/// single profile-dispatch authority: [`super::supervisor::ConnectionSupervisor`]'s
2098/// config constructor and the standalone runtime's worker arm both route through it
2099/// (the runtime's full arm stays on the explicit
2100/// [`LiminalConnectionServices::from_config`] path because it also needs the shared
2101/// channel cluster, which the trait object does not expose — that path is guarded
2102/// full-only at entry).
2103///
2104/// # Errors
2105/// Returns [`ServerError`] when the selected adapter cannot be constructed, the
2106/// configured profile value is not recognised, or the worker-front-door profile is
2107/// combined with full-only config fields.
2108pub fn build_connection_services(
2109 config: &ServerConfig,
2110) -> Result<Arc<dyn ConnectionServices>, ServerError> {
2111 match config.services.profile()? {
2112 ServiceProfile::Full => {
2113 let subsystems = RootedProductionSubsystems::new(required_persistence_path(config)?)?;
2114 build_connection_services_via(config, &subsystems)
2115 }
2116 ServiceProfile::WorkerFrontDoor => build_worker_front_door(config),
2117 }
2118}
2119
2120/// [`build_connection_services`] with the subsystem factory injected — the §9 D2
2121/// gate seam, both halves at once.
2122///
2123/// Every scheduler-owning subsystem the `Full` branch creates is constructed
2124/// through `subsystems` and nowhere else, so a recording factory observes exactly
2125/// what was built (thread half, record-by-construction); the gate's factory also
2126/// roots its ephemeral stores in an isolated directory, so "the root stays empty
2127/// on the worker branch" is a real negative assertion (fs half, the D3 pattern).
2128/// The `WorkerFrontDoor` branch never touches the factory — a regression that gave
2129/// the front door any subsystem would both record in the census and land a store
2130/// directory in the injected root.
2131///
2132/// The worker branch re-runs the cross-field checks here, not only in file-loading
2133/// validation, so a directly-constructed config cannot smuggle full-only machinery
2134/// past the profile.
2135pub(super) fn build_connection_services_via(
2136 config: &ServerConfig,
2137 subsystems: &dyn SubsystemFactory,
2138) -> Result<Arc<dyn ConnectionServices>, ServerError> {
2139 match config.services.profile()? {
2140 ServiceProfile::Full => {
2141 let path = required_persistence_path(config)?;
2142 let history = StoreHistory::of(path);
2143 let store = subsystems.durable_store(path)?;
2144 Ok(Arc::new(
2145 LiminalConnectionServices::from_config_with_store_via(
2146 config, store, history, subsystems,
2147 )?,
2148 ))
2149 }
2150 ServiceProfile::WorkerFrontDoor => build_worker_front_door(config),
2151 }
2152}
2153
2154fn build_worker_front_door(
2155 config: &ServerConfig,
2156) -> Result<Arc<dyn ConnectionServices>, ServerError> {
2157 let errors = crate::config::validation::worker_front_door_field_errors(config);
2158 if !errors.is_empty() {
2159 return Err(ServerError::ConfigValidation {
2160 message: errors.join("; "),
2161 });
2162 }
2163 Ok(Arc::new(WorkerFrontDoorServices::new()))
2164}
2165
2166/// Opens the persistent durable store under the configured directory.
2167pub(crate) fn open_persistent_store(
2168 persistence_path: &Path,
2169) -> Result<Arc<dyn DurableStore>, ServerError> {
2170 build_durable_store(persistence_path)
2171}
2172
2173fn build_durable_store(path: &Path) -> Result<Arc<dyn DurableStore>, ServerError> {
2174 let data_dir = durable_data_dir(path);
2175 let database = open_or_create_database(&data_dir)?;
2176 let event_store = EventStore::new(database);
2177 Ok(Arc::new(CountingStore::new(HaematiteStore::new(Arc::new(
2178 event_store,
2179 )))))
2180}
2181
2182/// The directory the persistent database under `persistence_path` lives in.
2183pub(super) fn durable_data_dir(persistence_path: &Path) -> PathBuf {
2184 persistence_path.join("durability")
2185}
2186
2187/// Whether a haematite database already exists at `data_dir`: its
2188/// `config.json` is written when it is created.
2189pub(super) fn database_exists(data_dir: &Path) -> bool {
2190 database_config_file(data_dir).exists()
2191}
2192
2193/// What creating a haematite database leaves in its directory. A shard's
2194/// directory appears beside them the first time anything opens the shard, so
2195/// a database holding nothing else has never been read or written.
2196const UNTOUCHED_DATABASE_ENTRIES: [&str; 2] = ["config.json", "writer.lock"];
2197
2198/// Whether the database at `data_dir` holds only what its creation wrote.
2199/// Anything this cannot read, or does not recognise, answers `false`: the
2200/// database is then taken to hold what an earlier server wrote.
2201pub(super) fn database_untouched(data_dir: &Path) -> bool {
2202 std::fs::read_dir(data_dir).is_ok_and(|entries| {
2203 entries.into_iter().all(|entry| {
2204 entry.is_ok_and(|entry| {
2205 entry
2206 .file_name()
2207 .to_str()
2208 .is_some_and(|name| UNTOUCHED_DATABASE_ENTRIES.contains(&name))
2209 })
2210 })
2211 })
2212}
2213
2214fn database_config_file(data_dir: &Path) -> PathBuf {
2215 data_dir.join("config.json")
2216}
2217
2218/// Every commit writes new copies of the nodes it changes; the durable store
2219/// deletes the copies its committed root no longer reaches while it runs, so it
2220/// holds its live records and not every version ever written. A store created
2221/// under `KeepAll` is moved onto this retention by its next open.
2222const DURABLE_RETENTION: Retention = Retention::reclaim(0);
2223
2224/// Opens an existing haematite database at `data_dir`, or creates one.
2225fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
2226 let config_file = database_config_file(data_dir);
2227 let result = if config_file.exists() {
2228 adopt_missing_config_keys(data_dir, &config_file)?;
2229 Database::open_with_retention(data_dir, DURABLE_RETENTION)
2230 } else {
2231 Database::create(DatabaseConfig {
2232 data_dir: data_dir.to_path_buf(),
2233 shard_count: DEFAULT_SHARD_COUNT,
2234 distributed: None,
2235 executor_threads: None,
2236 // haematite 0.8.3 requires this field and refuses `None` at
2237 // validation; `Unlimited` is the crate's explicit spelling of the
2238 // pre-budget (0.8.1) behaviour — the behaviour-preserving choice
2239 // for a dependency hop. A real byte ceiling is a deployment
2240 // decision for its own measured lane. A store whose `config.json`
2241 // predates the field is migrated by the open arm above
2242 // (`adopt_missing_config_keys`) — same value, written once, on
2243 // upgrade.
2244 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
2245 retention: DURABLE_RETENTION,
2246 })
2247 };
2248 let database = result.map_err(|error| ServerError::ConfigValidation {
2249 message: format!(
2250 "failed to open durable store at {}: {error}",
2251 data_dir.display()
2252 ),
2253 })?;
2254 liminal::durability::record_store_opened();
2255 Ok(database)
2256}
2257
2258/// The stored keys this crate's create arm always writes, with the value it
2259/// writes, for a `config.json` written before haematite had the key.
2260///
2261/// - `node_cache_budget`: haematite 0.8.3 refuses a store without it
2262/// (`MissingNodeCacheBudget`); `"unlimited"` is the pre-budget behaviour.
2263/// - `retention`: `"keep_all"` is the 0.14.1 behaviour, written in so the
2264/// store's stored retention is explicit before the open. The open itself is
2265/// [`Database::open_with_retention`] under [`DURABLE_RETENTION`], which then
2266/// records reclaim in place of it, so an adopted store reclaims from that
2267/// same open onward (spine reclaim, Hermes's ruling of 6 October 2026:
2268/// `past_roots` 0).
2269const ADOPTED_CONFIG_KEYS: [(&str, &str); 2] = [
2270 ("node_cache_budget", "unlimited"),
2271 ("retention", "keep_all"),
2272];
2273
2274/// Writes the values of [`ADOPTED_CONFIG_KEYS`] into a `config.json` that
2275/// lacks any of them, so a store created before haematite had the key opens on
2276/// upgrade with no operator hand. The open that follows then moves its
2277/// retention onto [`DURABLE_RETENTION`].
2278///
2279/// This crate IS the caller that owns these decisions, and it already makes
2280/// them on every create, so answering them here, once, on upgrade, changes
2281/// nothing about how the store runs and takes the file off the operator's
2282/// desk (the store migrates itself or it doesn't ship).
2283///
2284/// Touches the file in exactly one case: a readable JSON object missing one of
2285/// those keys. Anything else — unreadable bytes, a non-object, an explicit
2286/// value however wrong — is left byte-identical so [`Database::open`] surfaces
2287/// haematite's own sharpest refusal instead of this function guessing over
2288/// it. The write is atomic and durable (tmp file beside the target, fsync,
2289/// rename, directory fsync): a crash mid-migration leaves the original bytes,
2290/// and the next boot migrates again.
2291fn adopt_missing_config_keys(data_dir: &Path, config_file: &Path) -> Result<(), ServerError> {
2292 let Ok(bytes) = std::fs::read(config_file) else {
2293 return Ok(());
2294 };
2295 let Ok(mut config) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
2296 return Ok(());
2297 };
2298 let Some(object) = config.as_object_mut() else {
2299 return Ok(());
2300 };
2301 let mut adopted = Vec::new();
2302 for (key, value) in ADOPTED_CONFIG_KEYS {
2303 if !object.contains_key(key) {
2304 object.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
2305 adopted.push(key);
2306 }
2307 }
2308 if adopted.is_empty() {
2309 return Ok(());
2310 }
2311 let keys = adopted.join(", ");
2312 let migrated =
2313 serde_json::to_vec_pretty(&config).map_err(|error| ServerError::ConfigValidation {
2314 message: format!(
2315 "durable store config at {} predates `{keys}` and its migration could not be \
2316 serialized: {error}",
2317 config_file.display()
2318 ),
2319 })?;
2320 let write_error = |step: &str, error: std::io::Error| ServerError::ConfigValidation {
2321 message: format!(
2322 "durable store config at {} predates `{keys}` and migrating it failed at {step}: \
2323 {error}",
2324 config_file.display()
2325 ),
2326 };
2327 let tmp_file = data_dir.join("config.json.migrate-tmp");
2328 let mut tmp = std::fs::File::create(&tmp_file)
2329 .map_err(|error| write_error("creating the temporary file", error))?;
2330 std::io::Write::write_all(&mut tmp, &migrated)
2331 .map_err(|error| write_error("writing the temporary file", error))?;
2332 tmp.sync_all()
2333 .map_err(|error| write_error("syncing the temporary file", error))?;
2334 drop(tmp);
2335 std::fs::rename(&tmp_file, config_file)
2336 .map_err(|error| write_error("renaming over config.json", error))?;
2337 std::fs::File::open(data_dir)
2338 .and_then(|dir| dir.sync_all())
2339 .map_err(|error| write_error("syncing the data directory", error))?;
2340 tracing::warn!(
2341 config = %config_file.display(),
2342 keys = %keys,
2343 "durable store config predates these keys -- wrote the create-arm values (the \
2344 behaviour-preserving ones) so the store opens with no operator action"
2345 );
2346 Ok(())
2347}
2348
2349pub(super) fn reserved_namespace_refusal(channel: &str) -> Option<ServerError> {
2350 if channel.starts_with("liminal.") {
2351 crate::metrics::self_stream_reserved_publication();
2352 Some(ServerError::ReservedNamespace {
2353 channel: channel.to_owned(),
2354 })
2355 } else {
2356 None
2357 }
2358}
2359
2360impl LiminalConnectionServices {
2361 pub(in crate::server) fn cost_snapshot(&self) -> liminal::durability::StoreCounts {
2362 self.store_counts.snapshot()
2363 }
2364
2365 pub(in crate::server) const fn signals(&self) -> &ServerSignals {
2366 &self.signals
2367 }
2368
2369 pub(in crate::server) fn self_stream_head(&self, name: &str) -> Result<u64, ServerError> {
2370 let channels = self.read_channels()?;
2371 let channel = channels
2372 .get(name)
2373 .ok_or_else(|| ServerError::ListenerAccept {
2374 message: format!("self stream '{name}' is absent after append"),
2375 })?;
2376 let heads =
2377 channel
2378 .handle
2379 .durable_positions()
2380 .map_err(|error| ServerError::ListenerAccept {
2381 message: format!("self stream '{name}' head is unavailable: {error}"),
2382 })?;
2383 drop(channels);
2384 match heads.as_slice() {
2385 [head] => Ok(*head),
2386 _ => Err(ServerError::ListenerAccept {
2387 message: format!("self stream '{name}' must have exactly one durable partition"),
2388 }),
2389 }
2390 }
2391
2392 pub(in crate::server) fn publish_self_stream_batch(
2393 &self,
2394 payloads: &[Vec<u8>],
2395 publisher: &str,
2396 ) -> Result<u64, ServerError> {
2397 if publisher.trim().is_empty() || payloads.is_empty() {
2398 return Err(ServerError::ConfigValidation {
2399 message: "self-stream batch requires a publisher and rows".to_owned(),
2400 });
2401 }
2402 let configured = self
2403 .admit_channel("liminal.events")
2404 .map_err(|error| access_to_server_error(&error))?;
2405 let deliveries = configured
2406 .handle
2407 .publish_batch_with_delivery(
2408 payloads,
2409 &liminal::envelope::PublisherId::new(publisher),
2410 None,
2411 )
2412 .map_err(|error| match error {
2413 liminal::error::LiminalError::PublishUnresolved { append } => {
2414 ServerError::PublishUnresolved {
2415 channel: "liminal.events".to_owned(),
2416 idempotency_key: None,
2417 sequence: Some(append.sequence()),
2418 message: append.to_string(),
2419 }
2420 }
2421 error => ServerError::ListenerAccept {
2422 message: format!("self-stream receipt batch failed: {error}"),
2423 },
2424 })?;
2425 if deliveries.len() != payloads.len()
2426 || deliveries
2427 .iter()
2428 .any(|delivery| delivery.durable_position().is_none())
2429 {
2430 return Err(ServerError::ListenerAccept {
2431 message: "self-stream receipt batch was refused before append".to_owned(),
2432 });
2433 }
2434 for pair in deliveries.windows(2) {
2435 if pair[0]
2436 .durable_position()
2437 .and_then(|position| position.checked_add(1))
2438 != pair[1].durable_position()
2439 {
2440 return Err(ServerError::ListenerAccept {
2441 message: "self-stream receipt batch positions are not contiguous".to_owned(),
2442 });
2443 }
2444 }
2445 for delivery in &deliveries {
2446 self.delivered(delivery);
2447 }
2448 deliveries
2449 .last()
2450 .and_then(ChannelDelivery::durable_position)
2451 .and_then(|position| position.checked_add(1))
2452 .ok_or_else(|| ServerError::ListenerAccept {
2453 message: "self-stream receipt batch head is unavailable".to_owned(),
2454 })
2455 }
2456
2457 pub(in crate::server) fn checkpoint_self_stream_heads(
2458 &self,
2459 heads: [u64; 2],
2460 ) -> Result<(), ServerError> {
2461 for (name, head) in ["liminal.events:0", "liminal.cost:0"]
2462 .into_iter()
2463 .zip(heads)
2464 {
2465 block_on(liminal::durability::head_checkpoint::advance_head(
2466 self.durable_store.as_ref(),
2467 name,
2468 head,
2469 ))
2470 .map_err(|error| ServerError::ShutdownFlush {
2471 message: format!("self-stream head checkpoint bridge for '{name}' failed: {error}"),
2472 })?
2473 .map_err(|error| ServerError::ShutdownFlush {
2474 message: format!("self-stream head checkpoint for '{name}' failed: {error}"),
2475 })?;
2476 }
2477 Ok(())
2478 }
2479
2480 pub(in crate::server) fn install_self_stream_channels(&self) -> Result<(), ServerError> {
2481 let names = ["liminal.cost", "liminal.events"];
2482 {
2483 let channels = self.read_channels()?;
2484 for name in names {
2485 if channels.contains_key(name) {
2486 return Err(ServerError::ConfigValidation {
2487 message: format!("self-stream channel '{name}' is already configured"),
2488 });
2489 }
2490 }
2491 }
2492 let mut built = Vec::with_capacity(names.len());
2493 for name in names {
2494 let schema =
2495 resolve_schema_bytes(None).map_err(|error| ServerError::ConfigValidation {
2496 message: format!("self-stream channel '{name}' schema failed: {error}"),
2497 })?;
2498 let channel = build_configured_channel(
2499 name,
2500 schema,
2501 true,
2502 ChannelOrigin::BootConfigured,
2503 &self.durable_store,
2504 self.cluster.supervisor(),
2505 )
2506 .map_err(|error| ServerError::ConfigValidation {
2507 message: error.boot_message(name),
2508 })?;
2509 built.push((name.to_owned(), Arc::new(channel)));
2510 }
2511 let mut channels =
2512 self.channels
2513 .write()
2514 .map_err(|error| ServerError::ConfigValidation {
2515 message: format!("self-stream channel roster lock poisoned: {error}"),
2516 })?;
2517 for (name, _) in &built {
2518 if channels.contains_key(name) {
2519 return Err(ServerError::ConfigValidation {
2520 message: format!("self-stream channel '{name}' was concurrently configured"),
2521 });
2522 }
2523 }
2524 channels.extend(built);
2525 drop(channels);
2526 // SRV-012 R7: every roster channel but `console.*` is a line, and
2527 // these join after the hub's model was built from the roster.
2528 for name in names {
2529 self.console_changed(crate::console::TopologyChange::Channel {
2530 name: name.to_owned(),
2531 life: line_life(true),
2532 state: crate::console::records::LineState::Live,
2533 });
2534 }
2535 Ok(())
2536 }
2537
2538 /// SRV-012 R4: registers `console.topology` as an ephemeral,
2539 /// server-owned channel (never a store write). A channel of that name
2540 /// already configured (ephemeral, since config refuses a durable one) is
2541 /// kept as it is.
2542 pub(in crate::server) fn install_console_channels(&self) -> Result<(), ServerError> {
2543 self.register_console_channel(crate::console::TOPOLOGY_SUBJECT)
2544 .map_err(|error| ServerError::ConfigValidation {
2545 message: format!("console channel registration failed: {error}"),
2546 })
2547 }
2548
2549 /// Puts the ephemeral console channel `name` on the roster unless it is
2550 /// there.
2551 fn register_console_channel(&self, name: &str) -> Result<(), ChannelRegistryError> {
2552 if self.read_roster()?.contains_key(name) {
2553 return Ok(());
2554 }
2555 let schema = resolve_schema_bytes(None).map_err(|error| {
2556 ChannelRegistryError::RosterUnavailable {
2557 message: format!("console channel '{name}' schema failed: {error}"),
2558 }
2559 })?;
2560 let channel = build_configured_channel(
2561 name,
2562 schema,
2563 false,
2564 ChannelOrigin::BootConfigured,
2565 &self.durable_store,
2566 self.cluster.supervisor(),
2567 )
2568 .map_err(|error| error.into_registry_error(name))?;
2569 self.write_roster()?
2570 .entry(name.to_owned())
2571 .or_insert_with(|| Arc::new(channel));
2572 Ok(())
2573 }
2574
2575 /// Publishes on an ordinary channel for an application embedding the server.
2576 ///
2577 /// The live and durable publisher identity is liminal.<host>. Reserved self
2578 /// streams and console channels retain the same refusal as connection publishes.
2579 /// An idempotency key has the same delivery semantics as a connection publish.
2580 ///
2581 /// # Errors
2582 ///
2583 /// Returns a configuration error for an empty or whitespace-only host name,
2584 /// a namespace refusal for a reserved channel, or the existing admission,
2585 /// durability, and publication error.
2586 pub fn publish_embedded(
2587 &self,
2588 host: &str,
2589 channel: &str,
2590 envelope: &MessageEnvelope,
2591 idempotency_key: Option<&str>,
2592 ) -> Result<PublishOutcome, ServerError> {
2593 self.publish_with_origin(
2594 channel,
2595 envelope,
2596 idempotency_key,
2597 PublishOrigin::Host { name: host },
2598 )
2599 .map(|published| published.outcome)
2600 }
2601
2602 pub(in crate::server) fn publish_with_origin(
2603 &self,
2604 channel: &str,
2605 envelope: &MessageEnvelope,
2606 idempotency_key: Option<&str>,
2607 origin: PublishOrigin<'_>,
2608 ) -> Result<OriginPublication, ServerError> {
2609 validate_publication_origin(channel, idempotency_key, origin)?;
2610 // SRV-012 to_stations: only while a console tap is live does the
2611 // fan-out record whom the message reached, in a buffer the tap lends
2612 // and takes back; otherwise the publish is the plain one.
2613 let mut recipients = self.signals.lend_recipients();
2614 // Lane brief ruling 6: a tapped message is first seen as the server
2615 // receives it, before admission; the clock is read only while a tap
2616 // lent a record.
2617 let received_at = recipients.is_some().then(Instant::now);
2618 let result = (|| {
2619 // The roster read holds its guard only long enough to clone the entry's
2620 // `Arc` out; everything below — the dedup bridge and the publish into the
2621 // channel actor — runs with no roster lock held.
2622 //
2623 // This inner admission STAYS even once the connection process consults
2624 // the roster ahead of delegating: this method is public and callable
2625 // without going through a frame at all, and a guard that only exists in
2626 // the caller is not a guard.
2627 let configured = self
2628 .admit_channel(channel)
2629 .map_err(|error| access_to_server_error(&error))?;
2630
2631 // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
2632 // subscribers AT MOST ONCE across re-publishes of the same key. Only a
2633 // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
2634 // decision is a duplicate and is suppressed (no second delivery), which is
2635 // the at-most-once guarantee the aion outbox relies on.
2636 // A1 §5: the namespace is chosen by the channel's DURABILITY, so an
2637 // ephemeral receipt cannot outlive the message it vouches for.
2638 // A keyed publish to a durable channel claims its key WITH the channel
2639 // as evidence and holds the claim across an unresolved append
2640 // (`unresolved.rs`); the ephemeral namespace keeps the claim below.
2641 if let (Some(key), ChannelMode::Durable) =
2642 (idempotency_key, configured.handle.config().mode)
2643 {
2644 return self
2645 .publish_durable_keyed(
2646 &configured,
2647 channel,
2648 (envelope, origin),
2649 key,
2650 recipients.as_mut(),
2651 )
2652 .map(OriginPublication::connection);
2653 }
2654 let dedup = self.dedup_for(&configured);
2655 if let Some(key) = idempotency_key {
2656 if !Self::claim_delivery(dedup, key)? {
2657 // A dedup-suppressed re-publish is still an accepted publish (it is
2658 // assigned a message id), but it reaches no subscriber, so it counts
2659 // toward publishes and not deliveries.
2660 crate::metrics::publish_accepted();
2661 return Ok(OriginPublication::connection(PublishOutcome {
2662 message_id: self.next_message_id(),
2663 delivered: false,
2664 durable_position: None,
2665 reach: Reach::Suppressed,
2666 }));
2667 }
2668 }
2669
2670 let delivery = fan_out(&configured, envelope, origin, recipients.as_mut());
2671 let delivery = match delivery {
2672 Ok(delivery) => delivery,
2673 // An unkeyed durable publish whose append outcome is not known is
2674 // answered as unresolved, never as a definite failure a producer
2675 // would retry into a second row.
2676 Err(liminal::error::LiminalError::PublishUnresolved { append }) => {
2677 return Err(ServerError::PublishUnresolved {
2678 channel: channel.to_owned(),
2679 idempotency_key: None,
2680 sequence: Some(append.sequence()),
2681 message: append.to_string(),
2682 });
2683 }
2684 Err(error) => {
2685 // The claim above appended an `InFlight` entry but the delivery
2686 // failed before `complete_receipt` could run. Release the claim so
2687 // the key is re-claimable; otherwise every re-publish would see
2688 // `InFlight` and be suppressed forever. Best-effort: surface the
2689 // ORIGINAL publish error regardless, but never swallow a release
2690 // failure silently (it leaves the leak intact).
2691 if let Some(key) = idempotency_key {
2692 Self::release_claim(dedup, key);
2693 }
2694 return Err(ServerError::ListenerAccept {
2695 message: format!("liminal publish failed for channel '{channel}': {error}"),
2696 });
2697 }
2698 };
2699
2700 // A1 §5, the two-way dedup mapping. Every publish outcome maps to
2701 // EXACTLY ONE of complete/release — the same shape as the C1 contract:
2702 //
2703 // * **Defer (or Accept) => `complete_receipt`.** The message is
2704 // admitted and bus-held, so the key is done: a producer retrying it
2705 // hits `Completed` and there is no duplicate delivery and no InFlight
2706 // leak. This is the path that makes "re-publishing with the same key
2707 // is safe and is suppressed while the bus owns the message" true.
2708 // * **Reject => `release_claim`.** Aggregation makes a producer-visible
2709 // Reject mean delivered-to-zero, so the correct dedup state is "as if
2710 // never published": tombstone it so a deliberate retry can re-claim.
2711 // `release_claim` never clobbers a receipt, so the at-most-once guard
2712 // survives a concurrent completion racing ahead.
2713 //
2714 // Mixed accept/reject reports Defer and so completes — consistent with
2715 // "at least one delivery happened".
2716 if let Some(key) = idempotency_key {
2717 if delivery.is_admitted() {
2718 block_on(dedup.complete_receipt(key, ProcessingReceipt::new(Vec::new())))
2719 .map_err(|error| ServerError::ListenerAccept {
2720 message: format!(
2721 "dedup receipt bridge failed for key '{key}': {error}"
2722 ),
2723 })?
2724 .map_err(|error| ServerError::ListenerAccept {
2725 message: format!("dedup receipt write failed for key '{key}': {error}"),
2726 })?;
2727 } else {
2728 Self::release_claim(dedup, key);
2729 }
2730 }
2731
2732 let self_row_at = delivery.durable_position().filter(|_| origin.is_server());
2733 Ok(OriginPublication {
2734 outcome: self.delivered(&delivery),
2735 self_row_appended: self_row_at.is_some(),
2736 self_row_at,
2737 })
2738 })();
2739 self.signals.published(
2740 channel,
2741 origin,
2742 envelope,
2743 &result,
2744 recipients.as_ref().zip(received_at),
2745 );
2746 if let Some(buffer) = recipients {
2747 self.signals.give_back(buffer);
2748 }
2749 result
2750 }
2751}
2752
2753/// The library publish of an unkeyed message, recording what it saw in
2754/// `recipients` when the tap lent a record.
2755fn fan_out(
2756 configured: &ConfiguredChannel,
2757 envelope: &MessageEnvelope,
2758 origin: PublishOrigin<'_>,
2759 recipients: Option<&mut liminal::channel::PublishRecord>,
2760) -> Result<liminal::channel::ChannelDelivery, liminal::error::LiminalError> {
2761 let publisher = || publisher_for_origin(origin);
2762 recipients.map_or_else(
2763 || {
2764 configured
2765 .handle
2766 .publish_with_delivery(&envelope.payload, publisher(), None)
2767 },
2768 |recipients| {
2769 configured.handle.publish_recording(
2770 &envelope.payload,
2771 publisher(),
2772 None,
2773 None,
2774 recipients,
2775 )
2776 },
2777 )
2778}
2779
2780fn validate_publication_origin(
2781 channel: &str,
2782 idempotency_key: Option<&str>,
2783 origin: PublishOrigin<'_>,
2784) -> Result<(), ServerError> {
2785 if !origin.is_server() {
2786 refuse_console_namespace(channel).map_err(|error| access_to_server_error(&error))?;
2787 if let Some(error) = reserved_namespace_refusal(channel) {
2788 return Err(error);
2789 }
2790 }
2791 if let PublishOrigin::Host { name } = origin
2792 && name.trim().is_empty()
2793 {
2794 return Err(ServerError::ConfigValidation {
2795 message: "embedded publication requires a nonempty host name".to_owned(),
2796 });
2797 }
2798 if let PublishOrigin::Server { device } = origin {
2799 if device.trim().is_empty() || idempotency_key.is_some() {
2800 return Err(ServerError::ConfigValidation {
2801 message: "server-origin publication requires a device and no idempotency key"
2802 .to_owned(),
2803 });
2804 }
2805 }
2806 Ok(())
2807}
2808
2809fn publisher_for_origin(origin: PublishOrigin<'_>) -> liminal::envelope::PublisherId {
2810 match origin {
2811 PublishOrigin::Connection { .. } => liminal::envelope::PublisherId::default(),
2812 PublishOrigin::Host { name } => {
2813 liminal::envelope::PublisherId::new(format!("liminal.{name}"))
2814 }
2815 PublishOrigin::Server { device } => {
2816 liminal::envelope::PublisherId::new(format!("liminal.{device}"))
2817 }
2818 }
2819}
2820
2821impl ConnectionServices for LiminalConnectionServices {
2822 fn participant_service(&self) -> Option<InstalledParticipantService> {
2823 self.participant_service.clone()
2824 }
2825
2826 /// The roster's admission decision, exposed across the trait boundary as the
2827 /// permission alone.
2828 ///
2829 /// Two methods share the name `admit_channel` on this type: this trait
2830 /// method, and the private inherent funnel it delegates to. They are the
2831 /// same decision at two different boundaries — the funnel returns the ENTRY
2832 /// the decision was made on, because its in-crate callers go on to publish
2833 /// or subscribe through it, and this one returns `()`, because a caller
2834 /// outside the crate is asking whether it MAY, not for the thing itself.
2835 /// Handing the entry out here would make [`ConfiguredChannel`] public
2836 /// surface and hand a frame-level caller a channel handle it has no business
2837 /// holding. Rust resolves an inherent method ahead of a trait method of the
2838 /// same name, so the funnel's existing callers — and the `Self::` call below
2839 /// — reach the funnel, not this method; the trait form is reached only
2840 /// through a `dyn ConnectionServices`, which is exactly the caller it exists
2841 /// for. `an_admitted_operation_the_service_then_refuses_stays_undifferentiated`
2842 /// is the instrument that would catch this delegation turning into a
2843 /// recursion.
2844 ///
2845 /// `operation` is where the answers part: absence and quiesce refuse
2846 /// publishes and subscribes alike, but the console namespace refuses only
2847 /// a connection's publish (a console reads `console.*` as an ordinary
2848 /// subscriber, and only the server may write there), and a tap target
2849 /// refuses only a subscribe. Both are checked before the roster, so a
2850 /// client is told the same thing whether or not the subject is registered
2851 /// yet.
2852 fn admit_channel(
2853 &self,
2854 operation: ChannelOperation,
2855 channel: &str,
2856 ) -> Result<(), ChannelAccessError> {
2857 match operation {
2858 ChannelOperation::Publish => refuse_console_namespace(channel)?,
2859 ChannelOperation::Subscribe => {
2860 refuse_console_tap_target(channel)?;
2861 self.refuse_console_tap_limit(channel)?;
2862 // SRV-012 R4/R6: a tap channel exists while it has a
2863 // subscriber, and only while the console streams run.
2864 let admitted = if self.signals.console_enabled()
2865 && super::channel_registry::console_tap_target(channel).is_some()
2866 {
2867 self.register_console_channel(channel).map_err(|error| {
2868 ChannelAccessError::RosterUnavailable {
2869 message: error.to_string(),
2870 }
2871 })
2872 } else {
2873 Ok(())
2874 }
2875 .and_then(|()| Self::admit_channel(self, channel).map(|_| ()));
2876 if admitted.is_err() {
2877 self.give_back_tap(channel);
2878 }
2879 return admitted;
2880 }
2881 }
2882 Self::admit_channel(self, channel)?;
2883 Ok(())
2884 }
2885
2886 fn publish(
2887 &self,
2888 connection: u64,
2889 channel: &str,
2890 envelope: &MessageEnvelope,
2891 idempotency_key: Option<&str>,
2892 ) -> Result<PublishOutcome, ServerError> {
2893 // The frame path refused this already at admission. This guard is for
2894 // a caller holding the trait directly: a guard that only exists in the
2895 // caller is not a guard.
2896 refuse_console_namespace(channel).map_err(|error| access_to_server_error(&error))?;
2897 self.publish_with_origin(
2898 channel,
2899 envelope,
2900 idempotency_key,
2901 PublishOrigin::Connection { id: connection },
2902 )
2903 .map(|published| published.outcome)
2904 }
2905
2906 fn subscribe(
2907 &self,
2908 channel: &str,
2909 accepted_schemas: &[ProtocolSchemaId],
2910 install: Option<liminal::channel::InboxInstall>,
2911 ) -> Result<ConnectionSubscription, ServerError> {
2912 let subscribed = self.open_subscription(channel, accepted_schemas, install);
2913 if subscribed.is_err() {
2914 // SRV-012 R6: a tap slot reserved at admission for a subscribe
2915 // that did not happen is given back, and a tap channel it
2916 // registered with no tap behind it leaves the roster.
2917 self.give_back_tap(channel);
2918 }
2919 subscribed
2920 }
2921
2922 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
2923 subscription.unsubscribe()
2924 }
2925
2926 fn open_conversation(
2927 &self,
2928 conversation_id: u64,
2929 subject: &str,
2930 ) -> Result<ConnectionConversation, ServerError> {
2931 // Spawn a REAL participant process (a beamr `NativeHandler` running the
2932 // resolved responder behaviour) on the conversation supervisor's
2933 // scheduler, and a supervised conversation actor linked to it. The actor
2934 // FORWARDS each conversation message to the participant, which genuinely
2935 // processes it and delivers a reply back. The actor traps the
2936 // participant's EXIT (a beamr process link), so killing it fires a
2937 // structural, microsecond-scale crash signal.
2938 //
2939 // The responder is chosen by `subject`: a custom responder registered via
2940 // `register_responder` for this subject, or the built-in `EchoBehaviour`
2941 // when none is registered. Either way it runs as the SAME supervised,
2942 // linked participant process — the seam changes WHO responds, not HOW the
2943 // participant is spawned or supervised.
2944 let behaviour = self.responder_for(subject)?;
2945 let (actor, participant) = self
2946 .conversation_supervisor
2947 .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
2948 .map_err(|error| ServerError::ListenerAccept {
2949 message: format!(
2950 "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
2951 ),
2952 })?;
2953
2954 // Drive boot to completion so the beamr link to the participant exists
2955 // before any message is forwarded (link-before-forward), mirroring the
2956 // ROUTING-004 dispatch pattern.
2957 actor.pid().map_err(|error| ServerError::ListenerAccept {
2958 message: format!(
2959 "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
2960 ),
2961 })?;
2962
2963 // Register the structural EXIT notifier BEFORE returning, so a crash that
2964 // fires the instant a message reaches the participant is never missed.
2965 // The notifier is woken by the actor's trapped-EXIT handler (event
2966 // driven), and a crash that already landed is replayed immediately.
2967 let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
2968 actor
2969 .notify_on_participant_exit(participant, exit_tx)
2970 .map_err(|error| ServerError::ListenerAccept {
2971 message: format!(
2972 "failed to arm crash detection for conversation {conversation_id}: {error}"
2973 ),
2974 })?;
2975
2976 Ok(ConnectionConversation::new(Box::new(
2977 LiminalConversationResource::new(actor, participant, exit_rx),
2978 )))
2979 }
2980
2981 fn conversation_message(
2982 &self,
2983 conversation: &ConnectionConversation,
2984 envelope: &MessageEnvelope,
2985 op_id: Option<u64>,
2986 ) -> Result<(), ServerError> {
2987 conversation.message(envelope, op_id)
2988 }
2989
2990 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
2991 conversation.close()
2992 }
2993
2994 fn flush_durable_state(&self) -> Result<(), ServerError> {
2995 // A flush observer publishes to a durable channel, so no channel append lock can be held.
2996 // Separate ephemeral dedup state dies with this incarnation; shared dedup state is covered here.
2997 self.signals.with_flush_barrier(|| {
2998 block_on(self.durable_store.flush())
2999 .map_err(|error| ServerError::ShutdownFlush {
3000 message: format!("shared durable store flush bridge failed: {error}"),
3001 })?
3002 .map_err(|error| ServerError::ShutdownFlush {
3003 message: format!("shared durable store flush failed: {error}"),
3004 })?;
3005 Ok(())
3006 })?;
3007 Ok(())
3008 }
3009}
3010
3011/// One roster entry: a channel's library handle, the protocol schema id
3012/// advertised for it, where it came from, and its admission state.
3013#[derive(Debug)]
3014pub(super) struct ConfiguredChannel {
3015 handle: ChannelHandle,
3016 protocol_schema: ProtocolSchemaId,
3017 /// Durable (stored) or ephemeral, as built; the console's line life.
3018 durable: bool,
3019 /// Boot-configured or runtime-registered. Written once at construction and
3020 /// NEVER flipped: it is the only field that predicts what a restart does to
3021 /// this entry, and it defines the population the registration cap counts.
3022 origin: ChannelOrigin,
3023 /// [`STATE_ACTIVE`] or [`STATE_QUIESCED`]. Written at most once, by one
3024 /// `compare_exchange`, so the transition is one-way and exactly one caller
3025 /// can perform it.
3026 state: AtomicU8,
3027 /// The quiesce cause, set STRICTLY BEFORE `state` flips so any reader that
3028 /// observes [`STATE_QUIESCED`] can read it. Written once (the `OnceLock`
3029 /// enforces that structurally), which is why a re-quiesce under a different
3030 /// reason must refuse rather than silently keep or replace one of them.
3031 quiesce_reason: OnceLock<String>,
3032}
3033
3034impl ConfiguredChannel {
3035 /// The entry's admission state as a value.
3036 ///
3037 /// One `Acquire` load and, when quiesced, one read of the already-written
3038 /// reason. Touches no actor: the whole point of recording state on the entry
3039 /// is that observing it cannot spawn the thing being observed.
3040 fn state(&self) -> ChannelState {
3041 if self.state.load(Ordering::Acquire) == STATE_QUIESCED {
3042 ChannelState::Quiesced {
3043 reason: self.recorded_quiesce_reason(),
3044 }
3045 } else {
3046 ChannelState::Active
3047 }
3048 }
3049
3050 /// The recorded quiesce reason, for a caller that has already observed
3051 /// [`STATE_QUIESCED`] with an `Acquire` load.
3052 fn recorded_quiesce_reason(&self) -> String {
3053 self.quiesce_reason
3054 .get()
3055 .map_or_else(|| UNRECORDED_QUIESCE_REASON.to_owned(), Clone::clone)
3056 }
3057
3058 /// Moves this entry from active to quiesced, recording `reason` first.
3059 ///
3060 /// The reason is written to the `OnceLock` BEFORE the `Release`
3061 /// `compare_exchange` that flips the state, so no reader can observe
3062 /// `QUIESCED` without being able to read why. Re-quiescing under the
3063 /// IDENTICAL reason is `Ok(())` — the caller's intent already holds — and a
3064 /// DIFFERENT reason refuses, because the recorded one cannot be replaced
3065 /// without losing a cause and cannot be kept without lying to the caller.
3066 fn quiesce(&self, name: &str, reason: &str) -> Result<(), ChannelRegistryError> {
3067 // Whoever wins the `OnceLock` writes the cause on record; every other
3068 // caller — a re-quiesce or a concurrent racer — is judged against THAT
3069 // reason, never against whether it happened to perform the flip itself.
3070 // Reporting success for having won the CAS would tell a racer whose
3071 // reason lost that its reason took effect.
3072 let recorded = match self.quiesce_reason.set(reason.to_owned()) {
3073 Ok(()) => reason.to_owned(),
3074 Err(_rejected) => self.recorded_quiesce_reason(),
3075 };
3076 // The reason is now readable, so the flip may become visible. The CAS
3077 // result is deliberately unused: the state is `QUIESCED` afterwards
3078 // whether this call or a racer performed the transition, and the answer
3079 // to the caller is governed by the recorded reason above.
3080 let _flipped_here = self.state.compare_exchange(
3081 STATE_ACTIVE,
3082 STATE_QUIESCED,
3083 Ordering::Release,
3084 Ordering::Acquire,
3085 );
3086 if recorded == reason {
3087 return Ok(());
3088 }
3089 Err(ChannelRegistryError::AlreadyQuiesced {
3090 name: name.to_owned(),
3091 reason: recorded,
3092 })
3093 }
3094}
3095
3096#[derive(Debug)]
3097struct LiminalSubscriptionResource {
3098 subscription: liminal::channel::SubscriptionHandle,
3099}
3100
3101impl SubscriptionResource for LiminalSubscriptionResource {
3102 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
3103 drop(self.subscription);
3104 Ok(())
3105 }
3106
3107 fn is_overflowed(&self) -> bool {
3108 self.subscription.is_overflowed()
3109 }
3110
3111 fn subscriber_pid(&self) -> Option<u64> {
3112 Some(self.subscription.subscriber_pid())
3113 }
3114
3115 fn has_pending(&self) -> bool {
3116 self.subscription.has_pending()
3117 }
3118
3119 fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
3120 match self.subscription.try_next() {
3121 Ok(envelope) => envelope,
3122 Err(error) => {
3123 // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
3124 // stays poisoned, so every future `try_next` also returns `Err` and this
3125 // subscription goes silent for the rest of its life — no further
3126 // deliveries, not "held for the next slice". Poisoning requires a panic
3127 // while the lock is held, which the workspace lints forbid
3128 // (no unwrap/expect/panic), so this is an accepted low-probability
3129 // failure rather than a recoverable one. We keep the connection alive (a
3130 // single permanently-silent subscription is less harmful than tearing
3131 // down every other subscription and stream the connection multiplexes)
3132 // but log loudly so the silence is diagnosable. The log cannot storm:
3133 // it can only fire after the one panic that poisoned the lock.
3134 tracing::error!(
3135 %error,
3136 "subscription inbox lock is poisoned; this subscription is now \
3137 permanently silent and will deliver no further messages"
3138 );
3139 None
3140 }
3141 }
3142 }
3143}
3144
3145/// Renders an admission refusal as the service error the trait's callers expect.
3146///
3147/// The two refusals that already existed keep their EXACT bytes: an absent
3148/// channel is still `channel '<name>' is not configured` and a poisoned roster
3149/// is still `channel roster lock poisoned`, so nothing that reads these messages
3150/// today sees a change. Discrimination lives in the reason code
3151/// ([`ChannelAccessError::reason_code`]), which the connection process carries to
3152/// the wire — this rendering is the degraded path for a caller that reaches the
3153/// service directly and has no code to carry.
3154///
3155/// `Quiesced` has no predecessor string to preserve; it carries the refusal's own
3156/// message, reason included.
3157///
3158/// The connection process renders its admission refusals through this SAME
3159/// function rather than through [`ChannelAccessError`]'s own `Display`. Two
3160/// consequences, both wanted. The bytes on the wire for an absent channel stay
3161/// exactly what they have always been, which is the whole of the semver promise
3162/// this lane makes. And an operation refused AT admission and one refused after
3163/// admission by the service produce the identical message, differing only in the
3164/// reason code — so the code really is the only discriminator, instead of the
3165/// message quietly becoming a second one.
3166pub(super) fn access_to_server_error(error: &ChannelAccessError) -> ServerError {
3167 let message = match error {
3168 ChannelAccessError::NotRegistered { name } => {
3169 format!("channel '{name}' is not configured")
3170 }
3171 ChannelAccessError::RosterUnavailable { message } => message.clone(),
3172 ChannelAccessError::Quiesced { .. }
3173 | ChannelAccessError::ConsoleNamespace { .. }
3174 | ChannelAccessError::ConsoleTapTarget { .. }
3175 | ChannelAccessError::ConsoleTapLimit { .. } => error.to_string(),
3176 };
3177 ServerError::ListenerAccept { message }
3178}
3179
3180/// Refuses a subscribe to `console.tap.<target>` whose target is empty or
3181/// under `console.`: the tap never taps the console's own records.
3182///
3183/// # Errors
3184/// Returns [`ChannelAccessError::ConsoleTapTarget`] naming the channel.
3185fn refuse_console_tap_target(channel: &str) -> Result<(), ChannelAccessError> {
3186 use super::channel_registry::{console_tap_target, is_console_subject};
3187 match console_tap_target(channel) {
3188 Some(target) if target.is_empty() || is_console_subject(target) => {
3189 Err(ChannelAccessError::ConsoleTapTarget {
3190 name: channel.to_owned(),
3191 })
3192 }
3193 _ => Ok(()),
3194 }
3195}
3196
3197/// Refuses a connection's publish under `console.`, the server's own namespace.
3198///
3199/// # Errors
3200/// Returns [`ChannelAccessError::ConsoleNamespace`] naming the channel.
3201fn refuse_console_namespace(channel: &str) -> Result<(), ChannelAccessError> {
3202 if super::channel_registry::is_console_subject(channel) {
3203 return Err(ChannelAccessError::ConsoleNamespace {
3204 name: channel.to_owned(),
3205 });
3206 }
3207 Ok(())
3208}
3209
3210pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
3211 ServerError::ListenerAccept {
3212 message: format!("protocol operation failed: {error}"),
3213 }
3214}
3215
3216/// The three pinned registration tests. A CHILD module of `services` because
3217/// test 2 must hold the exact entry the admission funnel handed out.
3218#[cfg(test)]
3219#[path = "services_registry_tests.rs"]
3220mod services_registry_tests;
3221
3222#[cfg(test)]
3223#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
3224mod durable_store_tests {
3225 use liminal::durability::open_ephemeral_rooted;
3226
3227 use super::subsystem_census::RecordingSubsystems;
3228 use super::{
3229 ConnectionServices, DEFAULT_SHARD_COUNT, LiminalConnectionServices, SchedulerSubsystem,
3230 build_connection_services, build_connection_services_via, build_durable_store,
3231 };
3232 use crate::ServerError;
3233 use crate::config::types::{LimitsConfig, ServerConfig, ServicesConfig};
3234
3235 /// Counts directory entries under `root`, for the empty/one-dir assertions
3236 /// on an injected ephemeral root.
3237 fn entry_count(root: &std::path::Path) -> usize {
3238 std::fs::read_dir(root)
3239 .expect("ephemeral root is readable")
3240 .count()
3241 }
3242
3243 /// A minimal channel-free config with the given service `profile`. No channels,
3244 /// routing, persistence, or cluster — the shape both profiles accept (the full
3245 /// profile simply builds an empty channel set; the worker-front-door profile
3246 /// requires exactly this shape).
3247 fn config_with_profile(profile: &str) -> ServerConfig {
3248 ServerConfig {
3249 listen_address: "127.0.0.1:0".parse().expect("valid socket addr"),
3250 health_listen_address: "127.0.0.1:1".parse().expect("valid socket addr"),
3251 drain_timeout_ms: 30_000,
3252 channels: Vec::new(),
3253 routing_rules: Vec::new(),
3254 persistence_path: None,
3255 cluster: None,
3256 auth: None,
3257 services: ServicesConfig {
3258 profile: profile.to_owned(),
3259 ..ServicesConfig::default()
3260 },
3261 limits: LimitsConfig::default(),
3262 participant: None,
3263 websocket: None,
3264 }
3265 }
3266
3267 /// §9 D2 front-door construction gate (fs half): building the worker-front-door
3268 /// services creates NO haematite store and NO temp dir, while the full profile
3269 /// over an equally-rooted factory DOES create exactly one store directory.
3270 ///
3271 /// The injected root is the only place an ephemeral store directory can appear
3272 /// (the recording factory roots its stores there), so "the root stays empty on
3273 /// the front-door branch" is a real negative assertion: a regression that gave
3274 /// the front door a store would land its directory here and fail this test. The
3275 /// full-profile arm is the positive control proving the seam genuinely
3276 /// constructs a store when the profile asks for one.
3277 #[test]
3278 fn worker_front_door_builds_no_store_and_no_temp_dir() {
3279 let front_door_root = tempfile::tempdir().expect("test can create an ephemeral root");
3280 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
3281
3282 let front_door_subsystems = RecordingSubsystems::rooted(front_door_root.path());
3283 let front_door: std::sync::Arc<dyn ConnectionServices> = build_connection_services_via(
3284 &config_with_profile("worker-front-door"),
3285 &front_door_subsystems,
3286 )
3287 .expect("worker-front-door services build");
3288 assert!(
3289 !front_door.supports_channel_operations(),
3290 "the worker front door serves no channel operations"
3291 );
3292 assert_eq!(
3293 entry_count(front_door_root.path()),
3294 0,
3295 "the worker front door creates no ephemeral store directory (no haematite, no temp dir)"
3296 );
3297
3298 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
3299 let mut config = config_with_profile("full");
3300 config.persistence_path = Some(full_root.path().to_path_buf());
3301 let full =
3302 build_connection_services_via(&config, &full_subsystems).expect("full services build");
3303 assert!(
3304 full.supports_channel_operations(),
3305 "full mode serves channel operations"
3306 );
3307 assert_eq!(
3308 entry_count(full_root.path()),
3309 1,
3310 "full mode builds its persistent database in the explicit directory"
3311 );
3312
3313 drop(front_door);
3314 drop(full);
3315 }
3316
3317 /// §9 D2 front-door construction gate (thread half — record-by-construction
3318 /// census): the worker profile constructs NO channel-supervisor,
3319 /// conversation-supervisor, or haematite scheduler, while the SAME instrument
3320 /// over the full profile records all three — the positive control proving the
3321 /// census detects the extra schedulers, so an empty census on the worker branch
3322 /// is a real observation, not a decoration.
3323 ///
3324 /// The instrument's boundary: recording happens INSIDE the [`SubsystemFactory`]
3325 /// methods that are the profile-aware path's only route to these constructors,
3326 /// so a recording cannot be silently omitted — a future subsystem added to this
3327 /// path either goes through the factory (and is recorded by construction) or
3328 /// bypasses it, which is a code-review-visible structural violation of the
3329 /// factory seam, not a silently-missing side call. The connection supervisor's
3330 /// own scheduler is the shared baseline of both profiles and is asserted at the
3331 /// supervisor level (`supervisor::tests`); an OS-level thread census upgrades
3332 /// this when the beamr composition lane's scheduler-inventory API (currently on
3333 /// their branch, not yet consumable from liminal) lands.
3334 #[test]
3335 fn worker_profile_census_is_empty_and_full_profile_records_all_schedulers() {
3336 let worker_root = tempfile::tempdir().expect("test can create an ephemeral root");
3337 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
3338
3339 let worker_subsystems = RecordingSubsystems::rooted(worker_root.path());
3340 let front_door = build_connection_services_via(
3341 &config_with_profile("worker-front-door"),
3342 &worker_subsystems,
3343 )
3344 .expect("worker-front-door services build");
3345 assert_eq!(
3346 worker_subsystems.recorded(),
3347 Vec::<SchedulerSubsystem>::new(),
3348 "the worker front door constructs no scheduler-owning subsystem"
3349 );
3350
3351 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
3352 let mut config = config_with_profile("full");
3353 config.persistence_path = Some(full_root.path().to_path_buf());
3354 let full =
3355 build_connection_services_via(&config, &full_subsystems).expect("full services build");
3356 assert_eq!(
3357 full_subsystems.recorded(),
3358 vec![
3359 SchedulerSubsystem::ChannelSupervisor,
3360 SchedulerSubsystem::ConversationSupervisor,
3361 SchedulerSubsystem::HaematiteStore,
3362 ],
3363 "the full profile constructs every scheduler-owning subsystem, once each — \
3364 the positive control proving the census instrument detects them"
3365 );
3366
3367 drop(front_door);
3368 drop(full);
3369 }
3370
3371 /// MAJOR-1 regression: the full-only constructors reject a worker-front-door
3372 /// config with a typed `ConfigValidation` error AT ENTRY — no full service can
3373 /// be created through any public config-based constructor under that profile.
3374 #[test]
3375 fn full_only_constructors_reject_worker_profile() {
3376 let config = config_with_profile("worker-front-door");
3377
3378 let from_config = LiminalConnectionServices::from_config(&config);
3379 assert!(
3380 matches!(from_config, Err(ServerError::ConfigValidation { .. })),
3381 "from_config must reject a worker-front-door profile with ConfigValidation, got {from_config:?}"
3382 );
3383
3384 let root = tempfile::tempdir().expect("test can create an ephemeral root");
3385 let store = open_ephemeral_rooted(root.path(), DEFAULT_SHARD_COUNT)
3386 .expect("test store for the rejection check builds");
3387 let from_config_with_store =
3388 LiminalConnectionServices::from_config_with_store(&config, std::sync::Arc::new(store));
3389 assert!(
3390 matches!(
3391 from_config_with_store,
3392 Err(ServerError::ConfigValidation { .. })
3393 ),
3394 "from_config_with_store must reject a worker-front-door profile with ConfigValidation"
3395 );
3396 }
3397
3398 /// MAJOR-1 regression: the profile-aware factory itself re-runs the
3399 /// worker-front-door cross-field checks, so a directly-constructed config (one
3400 /// that never passed file-loading validation) combining the worker profile with
3401 /// full-only machinery is refused with the same typed `ConfigValidation` errors.
3402 #[test]
3403 fn build_connection_services_rejects_worker_profile_with_full_only_fields() {
3404 let mut config = config_with_profile("worker-front-door");
3405 config.channels = vec![crate::config::types::ChannelDef {
3406 name: "orders".to_owned(),
3407 schema_ref: None,
3408 durable: false,
3409 loaded_schema: None,
3410 }];
3411 config.persistence_path = Some(std::path::PathBuf::from("/tmp"));
3412
3413 let result = build_connection_services(&config);
3414 let Err(ServerError::ConfigValidation { message }) = result else {
3415 panic!("expected ConfigValidation for worker profile with full-only fields");
3416 };
3417 assert!(message.contains("builds no channels"), "got: {message}");
3418 assert!(
3419 message.contains("builds no durable store"),
3420 "got: {message}"
3421 );
3422 }
3423
3424 /// The required-path constructor creates the persistent database in its
3425 /// configured directory without populating the separate throwaway root.
3426 #[test]
3427 fn persistent_store_uses_configured_path_and_creates_no_temp_dir() {
3428 let home = tempfile::tempdir().expect("test can create a temp dir");
3429 let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
3430
3431 let store = build_durable_store(home.path()).expect("persistent store builds");
3432
3433 assert!(
3434 home.path().join("durability").join("config.json").exists(),
3435 "the persistent database is created under the configured path"
3436 );
3437 assert_eq!(
3438 entry_count(ephemeral_root.path()),
3439 0,
3440 "the persistent branch creates no ephemeral guard directory"
3441 );
3442
3443 drop(store);
3444 }
3445
3446 #[test]
3447 fn missing_path_refuses_before_any_store_is_opened() -> Result<(), Box<dyn std::error::Error>> {
3448 let root = tempfile::tempdir()?;
3449 let factory = RecordingSubsystems::rooted(root.path());
3450 let result = build_connection_services_via(&config_with_profile("full"), &factory);
3451 assert!(
3452 matches!(result, Err(ServerError::ConfigValidation { message }) if message.contains("no store is opened"))
3453 );
3454 assert_eq!(entry_count(root.path()), 0);
3455 assert!(factory.recorded().is_empty());
3456 Ok(())
3457 }
3458
3459 /// The store migrates itself: a `config.json` written before haematite
3460 /// required `node_cache_budget` opens on the new server with no operator
3461 /// hand, and the file afterwards carries the key — spelled exactly as the
3462 /// create arm spells it. The pre-field store is fabricated by creating a
3463 /// current one and deleting the key, which is byte-faithful to what a
3464 /// 0.8.1-era store looks like to the parser: the key absent, everything
3465 /// else in place.
3466 #[test]
3467 fn a_store_whose_config_predates_the_budget_field_opens_with_no_operator_hand() {
3468 let home = tempfile::tempdir().expect("test can create a temp dir");
3469 let store = build_durable_store(home.path()).expect("persistent store builds");
3470 drop(store);
3471
3472 let config_file = home.path().join("durability").join("config.json");
3473 let bytes = std::fs::read(&config_file).expect("config.json is readable");
3474 let mut config: serde_json::Value =
3475 serde_json::from_slice(&bytes).expect("config.json parses");
3476 config
3477 .as_object_mut()
3478 .expect("config.json is an object")
3479 .remove("node_cache_budget")
3480 .expect("the created config carries the budget to remove");
3481 std::fs::write(
3482 &config_file,
3483 serde_json::to_vec_pretty(&config).expect("serializes"),
3484 )
3485 .expect("test can age the config");
3486
3487 let reopened = build_durable_store(home.path())
3488 .expect("a pre-budget store opens with no operator action");
3489 drop(reopened);
3490
3491 let after = std::fs::read(&config_file).expect("config.json is readable after");
3492 let migrated: serde_json::Value =
3493 serde_json::from_slice(&after).expect("migrated config parses");
3494 assert_eq!(
3495 migrated.get("node_cache_budget"),
3496 Some(&serde_json::Value::String("unlimited".to_owned())),
3497 "the migrated file carries the budget the create arm chooses"
3498 );
3499 }
3500
3501 /// The migration touches only the one migratable case. A corrupt
3502 /// `config.json` still refuses — through haematite's own parse refusal,
3503 /// which is sharper than anything this crate could say — and the bytes are
3504 /// left exactly as found, so the operator diagnoses the file that failed,
3505 /// not one the migration half-rewrote.
3506 #[test]
3507 fn a_corrupt_config_still_refuses_and_is_left_untouched() {
3508 let home = tempfile::tempdir().expect("test can create a temp dir");
3509 let data_dir = home.path().join("durability");
3510 std::fs::create_dir_all(&data_dir).expect("test can create the data dir");
3511 let config_file = data_dir.join("config.json");
3512 let garbage = b"{ not json at all";
3513 std::fs::write(&config_file, garbage).expect("test can write garbage");
3514
3515 let refused = build_durable_store(home.path());
3516 assert!(refused.is_err(), "a corrupt config refuses to open");
3517 assert_eq!(
3518 std::fs::read(&config_file)
3519 .expect("config.json is still readable")
3520 .as_slice(),
3521 garbage,
3522 "the refusing file is left byte-identical for the operator to read"
3523 );
3524 }
3525
3526 /// A config already carrying the budget is not rewritten: reopening leaves
3527 /// the file byte-identical, so the migration cannot churn mtimes, reorder
3528 /// keys, or otherwise touch a store that needs nothing.
3529 #[test]
3530 fn a_config_already_carrying_the_budget_is_left_byte_identical() {
3531 let home = tempfile::tempdir().expect("test can create a temp dir");
3532 let store = build_durable_store(home.path()).expect("persistent store builds");
3533 drop(store);
3534
3535 let config_file = home.path().join("durability").join("config.json");
3536 let before = std::fs::read(&config_file).expect("config.json is readable");
3537 let reopened = build_durable_store(home.path()).expect("a current store reopens");
3538 drop(reopened);
3539 assert_eq!(
3540 std::fs::read(&config_file).expect("config.json is readable after"),
3541 before,
3542 "a config that already carries the budget is left byte-identical"
3543 );
3544 }
3545
3546 /// A durable store created under `KeepAll` is moved onto reclaim by the
3547 /// server's next open: its `config.json` names `reclaim` afterwards, and the
3548 /// copies its old commits left are gone once reclaim has run.
3549 #[test]
3550 fn a_keep_all_durable_store_reclaims_after_the_next_open() {
3551 let dir = tempfile::tempdir().expect("test can create a store dir");
3552 let data_dir = dir.path().join("durability");
3553 let database = haematite::Database::create(haematite::DatabaseConfig {
3554 data_dir: data_dir.clone(),
3555 shard_count: 1,
3556 distributed: None,
3557 executor_threads: None,
3558 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
3559 retention: haematite::Retention::KeepAll,
3560 })
3561 .expect("a keep-all store is created");
3562 for round in 0_u32..200 {
3563 let mut value = round.to_be_bytes().to_vec();
3564 value.resize(1_024, 0x5a);
3565 database.put(b"key".to_vec(), value).expect("put");
3566 database.commit().expect("commit");
3567 }
3568 drop(database);
3569 let grown =
3570 haematite::db::vacuum::stats(&haematite::db::vacuum::Options::new(data_dir.clone()))
3571 .expect("stats of the grown store")
3572 .totals
3573 .enumerated_bytes;
3574
3575 let reopened = super::open_or_create_database(&data_dir).expect("the server opens it");
3576 reopened.quiesce_reclaim().expect("reclaim runs to rest");
3577 drop(reopened);
3578
3579 let config: serde_json::Value = serde_json::from_slice(
3580 &std::fs::read(data_dir.join("config.json")).expect("config is readable"),
3581 )
3582 .expect("config is JSON");
3583 assert_eq!(
3584 config["retention"],
3585 serde_json::to_value(super::DURABLE_RETENTION).expect("the retention serializes")
3586 );
3587 let report = haematite::db::vacuum::stats(&haematite::db::vacuum::Options::new(data_dir))
3588 .expect("stats of the reclaimed store");
3589 assert!(
3590 report.totals.enumerated_bytes <= report.totals.marked_bytes.saturating_mul(3),
3591 "the reopened store holds about its live set: enumerated {} B, marked {} B",
3592 report.totals.enumerated_bytes,
3593 report.totals.marked_bytes
3594 );
3595 assert!(
3596 report.totals.enumerated_bytes.saturating_mul(10) < grown,
3597 "the old copies are gone: {} B now against {grown} B grown",
3598 report.totals.enumerated_bytes
3599 );
3600 }
3601
3602 /// `config.json` exactly as a store grown by an engine that never reclaimed
3603 /// carries it: `reclaim` with `past_roots` 0 is already written, so the
3604 /// open has no retention to adopt (it does move the format to version 3),
3605 /// and every superseded copy is still on disk.
3606 /// `DATA_DIR` stands for the one field the fixture rewrites.
3607 const STORED_RECLAIM_CONFIG: &str = r#"{
3608 "chunking_policy": {
3609 "chunking": "bytes-v2",
3610 "internal_target_bytes": 49152,
3611 "leaf_target_bytes": 65536
3612 },
3613 "data_dir": "DATA_DIR",
3614 "distributed": null,
3615 "executor_threads": null,
3616 "format_version": 2,
3617 "node_cache_budget": "unlimited",
3618 "retention": {
3619 "reclaim": {
3620 "past_roots": 0
3621 }
3622 },
3623 "shard_count": 8
3624}"#;
3625
3626 /// A store whose `config.json` already names reclaim, but whose history was
3627 /// grown with nothing reclaimed, is opened by the server and gives its old
3628 /// copies back with no write and no quiesce. A store with no reclaim
3629 /// record owes a startup pass, and a shard runs it on its first turn. The
3630 /// seed holds both shapes the pass must free: one row overwritten many
3631 /// times, and rows written and then deleted, as a prune leaves them.
3632 #[test]
3633 fn a_stored_reclaim_store_grown_without_reclaim_gives_its_old_copies_back_on_open() {
3634 let dir = tempfile::tempdir().expect("test can create a store dir");
3635 let data_dir = dir.path().join("durability");
3636 let database = haematite::Database::create(haematite::DatabaseConfig {
3637 data_dir: data_dir.clone(),
3638 shard_count: 8,
3639 distributed: None,
3640 executor_threads: None,
3641 node_cache_budget: Some(haematite::NodeCacheBudget::Unlimited),
3642 retention: haematite::Retention::KeepAll,
3643 })
3644 .expect("a keep-all store is created");
3645 for round in 0_u32..200 {
3646 let mut value = round.to_be_bytes().to_vec();
3647 value.resize(1_024, 0x5a);
3648 database.put(b"key".to_vec(), value).expect("put");
3649 database.commit().expect("commit");
3650 }
3651 for row in 0_u32..200 {
3652 let key = [b"pruned-".as_slice(), &row.to_be_bytes()].concat();
3653 database.put(key, vec![0xa5; 1_024]).expect("put");
3654 database.commit().expect("commit");
3655 }
3656 for row in 0_u32..200 {
3657 let key = [b"pruned-".as_slice(), &row.to_be_bytes()].concat();
3658 database.delete(key).expect("delete");
3659 }
3660 database.commit().expect("commit");
3661 drop(database);
3662 let grown_report =
3663 haematite::db::vacuum::stats(&haematite::db::vacuum::Options::new(data_dir.clone()))
3664 .expect("stats of the grown store");
3665 let grown = grown_report.totals;
3666 assert!(
3667 grown.unmarked_nodes > 0,
3668 "the seed leaves copies no root reaches"
3669 );
3670 let mut owed: std::collections::BTreeSet<usize> = grown_report
3671 .shards
3672 .iter()
3673 .filter(|shard| shard.nodes.unmarked_nodes > 0)
3674 .map(|shard| shard.shard_id)
3675 .collect();
3676
3677 let config_file = data_dir.join("config.json");
3678 let seeded = STORED_RECLAIM_CONFIG.replace(
3679 "DATA_DIR",
3680 data_dir.to_str().expect("the test dir is UTF-8"),
3681 );
3682 std::fs::write(&config_file, &seeded).expect("config.json is written");
3683
3684 let reopened = super::open_or_create_database(&data_dir).expect("the server opens it");
3685 let watch = reopened
3686 .watch_reclaim_passes(64)
3687 .expect("every shard accepts a pass watch");
3688 let mut unmarked_by_shard = std::collections::BTreeMap::new();
3689 while !owed.is_empty() {
3690 let report = watch
3691 .recv_timeout(std::time::Duration::from_secs(30))
3692 .expect("each pass the open owes ends without a write or a quiesce");
3693 assert_eq!(
3694 report.outcome,
3695 haematite::ReclaimPassOutcome::Passed,
3696 "the pass the open owed on shard {} finished",
3697 report.shard_id
3698 );
3699 if owed.remove(&report.shard_id) {
3700 unmarked_by_shard.insert(report.shard_id, report.unmarked_bytes);
3701 }
3702 }
3703 drop(reopened);
3704
3705 let after = haematite::db::vacuum::stats(&haematite::db::vacuum::Options::new(data_dir))
3706 .expect("stats of the reclaimed store")
3707 .totals;
3708 eprintln!(
3709 "grown {} B, after {} B, {} unreachable nodes seeded, unmarked bytes by shard {unmarked_by_shard:?}",
3710 grown.enumerated_bytes, after.enumerated_bytes, grown.unmarked_nodes
3711 );
3712 assert!(
3713 after.enumerated_bytes.saturating_mul(10) < grown.enumerated_bytes,
3714 "the old copies are gone: {} B now against {} B grown",
3715 after.enumerated_bytes,
3716 grown.enumerated_bytes
3717 );
3718 let config: serde_json::Value = serde_json::from_slice(
3719 &std::fs::read(&config_file).expect("config.json is readable after"),
3720 )
3721 .expect("config is JSON");
3722 assert_eq!(
3723 config["retention"],
3724 serde_json::to_value(super::DURABLE_RETENTION).expect("the retention serializes"),
3725 "config.json names reclaim after the open"
3726 );
3727 }
3728}