Skip to main content

liminal_server/config/types/
limits.rs

1/// Operational bounds (§5, scout Q4 — rule-2 items).
2///
3/// Each field is a hard per-scope cap with a typed refusal and a
4/// certifying-pair-signed default (the numbers below are §5's). The struct is
5/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
6/// zero value as a typed config error (a zero cap would gate nothing — the exact
7/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
8/// free functions so an absent key resolves to the signed number, not zero.
9#[derive(Debug, Clone, Copy, serde::Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct LimitsConfig {
12    /// Total live connections the listener admits before refusing (§5: 256 — a
13    /// worker-bus, an order of magnitude above any observed fleet).
14    #[serde(default = "default_max_connections")]
15    pub max_connections: usize,
16    /// Subscriptions one connection may hold (§5: 32).
17    #[serde(default = "default_max_subscriptions_per_connection")]
18    pub max_subscriptions_per_connection: usize,
19    /// Open conversations one connection may hold (§5: 32).
20    #[serde(default = "default_max_conversations_per_connection")]
21    pub max_conversations_per_connection: usize,
22    /// In-flight server→client correlated pushes per connection (§5: 32).
23    #[serde(default = "default_max_pending_pushes_per_connection")]
24    pub max_pending_pushes_per_connection: usize,
25    /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
26    /// distinct from server-push slots).
27    #[serde(default = "default_max_pending_conversation_replies_per_connection")]
28    pub max_pending_conversation_replies_per_connection: usize,
29    /// Per-conversation sub-cap that confines tombstone ambiguity to its own
30    /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
31    /// the connection table; tombstones against THIS alone.
32    #[serde(default = "default_max_pending_replies_per_conversation")]
33    pub max_pending_replies_per_conversation: usize,
34    /// One shared inbox-byte budget per connection, spent across ALL its
35    /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
36    /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
37    /// enqueue and released at dequeue.
38    #[serde(default = "default_max_connection_inbox_bytes")]
39    pub max_connection_inbox_bytes: usize,
40    /// One outbound byte buffer per connection, holding every server-originated
41    /// frame (acks, errors, `Push`, `Deliver`, `Disconnect`, `Pong`) until the
42    /// connection's scheduler slice drains it to the socket.
43    ///
44    /// This is a DELIVERY bound, not a fairness one: a single frame larger than
45    /// the whole buffer can never be queued, so this value is the largest frame
46    /// the server can send on one connection. Raising it raises that ceiling at
47    /// the cost of per-connection memory; lowering it lowers the ceiling for
48    /// every connection at once.
49    #[serde(default = "default_max_connection_outbound_bytes")]
50    pub max_connection_outbound_bytes: usize,
51    /// Per-inbox envelope-count secondary fairness trip — stops one subscription
52    /// starving its siblings inside the shared byte budget. See
53    /// [`LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`] for why the default
54    /// is what it is; the short version is that this cap is the CRUDE bound and
55    /// [`LimitsConfig::max_connection_inbox_bytes`] is the real one.
56    #[serde(default = "default_max_subscription_inbox_depth")]
57    pub max_subscription_inbox_depth: usize,
58    /// Per-slice cap on `Deliver` frames one connection may enqueue across all of
59    /// its subscriptions, before the scheduler moves on to its peers.
60    ///
61    /// Operator-visible because P0 #55 proved it decides whether a subscriber
62    /// behind a burst survives — but see
63    /// [`LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET`] for why the DEFAULT is not
64    /// raised. Raising it trades one starvation for another, and the measurement
65    /// that showed it moves the outcome could not see the starvation it causes.
66    #[serde(default = "default_delivery_slice_budget")]
67    pub delivery_slice_budget: usize,
68    /// Runtime-registered channels this deployment admits.
69    ///
70    /// # Why this cap departs the uniform pattern
71    ///
72    /// Every other field here carries `#[serde(default = "…")]` naming a §5
73    /// constant, because each of those numbers is a signed §5 bound. **There is
74    /// no signed §5 bound for channel count**, and inventing one is barred: a
75    /// number nobody certified, presented in the same shape as eight numbers
76    /// somebody did, is a forged citation. So the type is `Option<usize>` and
77    /// the serde default is the ABSENCE itself (`None`), never a value —
78    /// `#[serde(default)]` here resolves a missing key to "no bound declared",
79    /// which is a different statement from any number.
80    ///
81    /// Nor may this be a `usize` with a large default: unbounded-by-default is
82    /// not a bound, it is the gap wearing a number.
83    ///
84    /// `None` refuses every runtime channel registration with a typed error
85    /// naming this key, so a deployment that wants runtime registration declares
86    /// its own bound and a deployment that never registers needs no config
87    /// change at all. The cap bounds RUNTIME-registered channels only:
88    /// `[[channels]]` entries are the bound the operator already wrote.
89    ///
90    /// `Some(0)` is a validation error like every other zero cap here — see
91    /// [`LimitsConfig::collect_errors`].
92    #[serde(default)]
93    pub max_channels: Option<usize>,
94    /// How long a client's correlated conversation request waits for its reply
95    /// before the server answers it timed out, in milliseconds (default 5,000).
96    /// A zero would time every request out on its first slice.
97    #[serde(default = "default_reply_timeout_ms")]
98    pub reply_timeout_ms: u64,
99}
100
101impl LimitsConfig {
102    /// §5 default: total live connections before the listener refuses.
103    pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
104    /// §5 default: subscriptions per connection.
105    pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
106    /// §5 default: open conversations per connection.
107    pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
108    /// §5 default: in-flight server pushes per connection.
109    pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
110    /// §5 default: pending-reply table entries per connection.
111    pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
112    /// §5 default: per-conversation pending-reply sub-cap.
113    pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
114    /// §5 default: shared per-connection inbox byte budget (4 MiB).
115    pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
116    /// Default per-connection OUTBOUND byte budget (4 MiB) — the twin the inbound
117    /// budget was always described as mirroring, now declared in its own right.
118    ///
119    /// It is the honest single-frame delivery bound: a frame up to this size is
120    /// delivered intact across as many scheduler slices as the peer's read rate
121    /// needs, and a frame LARGER than the whole buffer can never be queued at all.
122    /// The number is unchanged from the buffer's former private constant; what
123    /// changes is that an operator can now see it and set it, instead of meeting
124    /// it only as a connection that stopped carrying something.
125    pub const DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES: usize = 4 * 1024 * 1024;
126    /// Default per-inbox envelope-count fairness trip.
127    ///
128    /// # Why 4096 and not the §5-era 256 (P0 #55)
129    ///
130    /// A subscription inbox is bounded TWICE: by
131    /// [`Self::DEFAULT_MAX_CONNECTION_INBOX_BYTES`] (4 MiB, shared across all of
132    /// one connection's inboxes) and by this envelope COUNT. Memory is the real
133    /// resource, so the byte budget is the bound that should bind and this one is
134    /// meant to be a fairness backstop behind it.
135    ///
136    /// Which one binds first is decided by record size. For a connection holding a
137    /// single subscription, the crossover — the record size at which the two caps
138    /// trip together — is `4 MiB / depth`:
139    ///
140    /// | depth | crossover record size  | binds first for a 1 KiB record |
141    /// |-------|------------------------|--------------------------------|
142    /// | 256   | 4194304/256 = 16 KiB   | the COUNT (at 256 KiB, 6% of 4 MiB) |
143    /// | 4096  | 4194304/4096 = 1 KiB   | the BYTES (as intended)        |
144    ///
145    /// At 256 the count cap therefore binds for every record smaller than 16 KiB —
146    /// which is nearly all real traffic — and it binds at a small fraction of the
147    /// memory the connection is actually permitted: at a 164-byte record, 256
148    /// envelopes is 41 KiB, ~1% of the 4 MiB budget. A replay burst is thousands of
149    /// SMALL records, which is precisely the shape that hits the crude bound while
150    /// the real bound sits untouched. That is the P0: a live subscriber shed for
151    /// exceeding a fairness trip it reached at 1% of its memory allowance.
152    ///
153    /// At 4096 the crossover falls to 1 KiB, so bytes bind for realistic records
154    /// and this cap returns to backstop duty.
155    ///
156    /// Honest bound on that arithmetic: the byte budget is per CONNECTION and this
157    /// cap is per SUBSCRIPTION, so with `S` subscriptions sharing one connection
158    /// the effective crossover is `4 MiB / S / depth`. The table above is the `S=1`
159    /// case. A connection holding many subscriptions of small records can still
160    /// reach the count cap first — which is exactly the sibling-starvation case
161    /// this cap exists to catch.
162    pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 4096;
163    /// Default per-slice `Deliver` budget for one connection — the source of truth
164    /// for [`crate::config::LimitsConfig::delivery_slice_budget`] and for the
165    /// delivery pump's own `DELIVERY_SLICE_BUDGET`.
166    ///
167    /// # Why this is NOT raised (P0 #55)
168    ///
169    /// This is the cross-connection FAIRNESS bound: it caps how long one connection
170    /// may hold a shared scheduler thread before its peers get a turn. Raising it
171    /// makes a burst subscriber's own drain finish in fewer scheduling round trips,
172    /// and the P0 #55 2x2 measured exactly that — 0/192 boots lost a subscriber at
173    /// 256 against roughly half of them at 32.
174    ///
175    /// That experiment does not license raising the default. It ran two or three
176    /// connections, so the peer starvation a raise would CAUSE was unobservable BY
177    /// CONSTRUCTION: with no queue of waiting peers there is nothing for a longer
178    /// slice to delay. What was measured is that this knob moves the outcome, not
179    /// that moving it is safe.
180    ///
181    /// So the number stays 32 and becomes an operator DECISION instead: a
182    /// deployment that knows its connection count and its burst shape can raise it
183    /// deliberately. The default does not make that trade on anyone's behalf.
184    /// The P0's actual fix is [`Self::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`].
185    pub const DEFAULT_DELIVERY_SLICE_BUDGET: usize = 32;
186    /// Default wait for a correlated conversation reply: 5 s, the bound a
187    /// client has always been answered within.
188    pub const DEFAULT_REPLY_TIMEOUT_MS: u64 = 5_000;
189
190    /// Validates the caps: every value must be non-zero (a zero cap gates nothing
191    /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
192    /// `errors` (one per offending field) so an operator sees every bad cap at
193    /// once, matching the rest of config validation.
194    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
195        let checks: [(&str, usize); 10] = [
196            ("max_connections", self.max_connections),
197            (
198                "max_subscriptions_per_connection",
199                self.max_subscriptions_per_connection,
200            ),
201            (
202                "max_conversations_per_connection",
203                self.max_conversations_per_connection,
204            ),
205            (
206                "max_pending_pushes_per_connection",
207                self.max_pending_pushes_per_connection,
208            ),
209            (
210                "max_pending_conversation_replies_per_connection",
211                self.max_pending_conversation_replies_per_connection,
212            ),
213            (
214                "max_pending_replies_per_conversation",
215                self.max_pending_replies_per_conversation,
216            ),
217            (
218                "max_connection_inbox_bytes",
219                self.max_connection_inbox_bytes,
220            ),
221            // A zero here would leave a connection unable to queue ANY frame at
222            // all — every push and every delivery refused on its first byte.
223            (
224                "max_connection_outbound_bytes",
225                self.max_connection_outbound_bytes,
226            ),
227            (
228                "max_subscription_inbox_depth",
229                self.max_subscription_inbox_depth,
230            ),
231            // A zero here would make the delivery pump enqueue nothing on any
232            // slice — every subscription silently stalled forever, which is the
233            // unlimited-by-silence failure wearing the opposite sign.
234            ("delivery_slice_budget", self.delivery_slice_budget),
235        ];
236        for (field, value) in checks {
237            if value == 0 {
238                errors.push(zero_cap_error(field));
239            }
240        }
241        // `max_channels` is checked BESIDE the array rather than inside it,
242        // because it is the one optional cap and the array's element type has no
243        // way to say "absent". Forcing it in would need a stand-in value for
244        // `None` — a number standing where a declaration is not — and any
245        // stand-in would either invent a bound or make an undeclared cap look
246        // declared. So the shape stays honest and the RULE stays uniform: a
247        // declared zero is refused with the same message as the other eight.
248        //
249        // The parenthetical about unlimited-by-silence is the other eight caps'
250        // ground; for this one a zero would refuse every registration rather
251        // than admit every registration. The message is kept identical anyway:
252        // an operator reading nine cap errors should learn one rule — zero is
253        // not a legal cap value anywhere in this section — not two.
254        if self.max_channels == Some(0) {
255            errors.push(zero_cap_error("max_channels"));
256        }
257        // A duration, not a count, so it sits beside the array too; zero is
258        // refused by the same rule.
259        if self.reply_timeout_ms == 0 {
260            errors.push(zero_cap_error("reply_timeout_ms"));
261        }
262    }
263}
264
265/// The typed refusal for a zero-valued cap, shared by every field in `[limits]`
266/// so the wording cannot drift between them.
267fn zero_cap_error(field: &str) -> String {
268    format!(
269        "limits.{field}: must be greater than zero (a zero cap would be \
270         unlimited-by-silence, which §5 forbids)"
271    )
272}
273
274impl Default for LimitsConfig {
275    fn default() -> Self {
276        Self {
277            max_connections: default_max_connections(),
278            max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
279            max_conversations_per_connection: default_max_conversations_per_connection(),
280            max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
281            max_pending_conversation_replies_per_connection:
282                default_max_pending_conversation_replies_per_connection(),
283            max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
284            max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
285            max_connection_outbound_bytes: default_max_connection_outbound_bytes(),
286            max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
287            delivery_slice_budget: default_delivery_slice_budget(),
288            // No signed bound exists for this cap, so the default is the
289            // absence of a declaration — never a number.
290            max_channels: None,
291            reply_timeout_ms: default_reply_timeout_ms(),
292        }
293    }
294}
295
296const fn default_max_connections() -> usize {
297    LimitsConfig::DEFAULT_MAX_CONNECTIONS
298}
299const fn default_max_subscriptions_per_connection() -> usize {
300    LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
301}
302const fn default_max_conversations_per_connection() -> usize {
303    LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
304}
305const fn default_max_pending_pushes_per_connection() -> usize {
306    LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
307}
308const fn default_max_pending_conversation_replies_per_connection() -> usize {
309    LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
310}
311const fn default_max_pending_replies_per_conversation() -> usize {
312    LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
313}
314const fn default_max_connection_inbox_bytes() -> usize {
315    LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
316}
317const fn default_max_connection_outbound_bytes() -> usize {
318    LimitsConfig::DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES
319}
320const fn default_max_subscription_inbox_depth() -> usize {
321    LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
322}
323const fn default_delivery_slice_budget() -> usize {
324    LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET
325}
326
327const fn default_reply_timeout_ms() -> u64 {
328    LimitsConfig::DEFAULT_REPLY_TIMEOUT_MS
329}