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}