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 (CON-023 R1). The console's largest set is one
105 /// topology, a tap for each of 256 lines, three subjects for each of up to 64 agents, a plan for
106 /// each of up to 64 sites and one inbox, 514, doubled for headroom and a second console tool.
107 pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 1024;
108 /// §5 default: open conversations per connection.
109 pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
110 /// §5 default: in-flight server pushes per connection.
111 pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
112 /// §5 default: pending-reply table entries per connection.
113 pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
114 /// §5 default: per-conversation pending-reply sub-cap.
115 pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
116 /// §5 default: shared per-connection inbox byte budget (4 MiB).
117 pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
118 /// Default per-connection OUTBOUND byte budget (4 MiB) — the twin the inbound
119 /// budget was always described as mirroring, now declared in its own right.
120 ///
121 /// It is the honest single-frame delivery bound: a frame up to this size is
122 /// delivered intact across as many scheduler slices as the peer's read rate
123 /// needs, and a frame LARGER than the whole buffer can never be queued at all.
124 /// The number is unchanged from the buffer's former private constant; what
125 /// changes is that an operator can now see it and set it, instead of meeting
126 /// it only as a connection that stopped carrying something.
127 pub const DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES: usize = 4 * 1024 * 1024;
128 /// Default per-inbox envelope-count fairness trip.
129 ///
130 /// # Why 4096 and not the §5-era 256 (P0 #55)
131 ///
132 /// A subscription inbox is bounded TWICE: by
133 /// [`Self::DEFAULT_MAX_CONNECTION_INBOX_BYTES`] (4 MiB, shared across all of
134 /// one connection's inboxes) and by this envelope COUNT. Memory is the real
135 /// resource, so the byte budget is the bound that should bind and this one is
136 /// meant to be a fairness backstop behind it.
137 ///
138 /// Which one binds first is decided by record size. For a connection holding a
139 /// single subscription, the crossover — the record size at which the two caps
140 /// trip together — is `4 MiB / depth`:
141 ///
142 /// | depth | crossover record size | binds first for a 1 KiB record |
143 /// |-------|------------------------|--------------------------------|
144 /// | 256 | 4194304/256 = 16 KiB | the COUNT (at 256 KiB, 6% of 4 MiB) |
145 /// | 4096 | 4194304/4096 = 1 KiB | the BYTES (as intended) |
146 ///
147 /// At 256 the count cap therefore binds for every record smaller than 16 KiB —
148 /// which is nearly all real traffic — and it binds at a small fraction of the
149 /// memory the connection is actually permitted: at a 164-byte record, 256
150 /// envelopes is 41 KiB, ~1% of the 4 MiB budget. A replay burst is thousands of
151 /// SMALL records, which is precisely the shape that hits the crude bound while
152 /// the real bound sits untouched. That is the P0: a live subscriber shed for
153 /// exceeding a fairness trip it reached at 1% of its memory allowance.
154 ///
155 /// At 4096 the crossover falls to 1 KiB, so bytes bind for realistic records
156 /// and this cap returns to backstop duty.
157 ///
158 /// Honest bound on that arithmetic: the byte budget is per CONNECTION and this
159 /// cap is per SUBSCRIPTION, so with `S` subscriptions sharing one connection
160 /// the effective crossover is `4 MiB / S / depth`. The table above is the `S=1`
161 /// case. A connection holding many subscriptions of small records can still
162 /// reach the count cap first — which is exactly the sibling-starvation case
163 /// this cap exists to catch.
164 pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 4096;
165 /// Default per-slice `Deliver` budget for one connection — the source of truth
166 /// for [`crate::config::LimitsConfig::delivery_slice_budget`] and for the
167 /// delivery pump's own `DELIVERY_SLICE_BUDGET`.
168 ///
169 /// # Why this is NOT raised (P0 #55)
170 ///
171 /// This is the cross-connection FAIRNESS bound: it caps how long one connection
172 /// may hold a shared scheduler thread before its peers get a turn. Raising it
173 /// makes a burst subscriber's own drain finish in fewer scheduling round trips,
174 /// and the P0 #55 2x2 measured exactly that — 0/192 boots lost a subscriber at
175 /// 256 against roughly half of them at 32.
176 ///
177 /// That experiment does not license raising the default. It ran two or three
178 /// connections, so the peer starvation a raise would CAUSE was unobservable BY
179 /// CONSTRUCTION: with no queue of waiting peers there is nothing for a longer
180 /// slice to delay. What was measured is that this knob moves the outcome, not
181 /// that moving it is safe.
182 ///
183 /// So the number stays 32 and becomes an operator DECISION instead: a
184 /// deployment that knows its connection count and its burst shape can raise it
185 /// deliberately. The default does not make that trade on anyone's behalf.
186 /// The P0's actual fix is [`Self::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`].
187 pub const DEFAULT_DELIVERY_SLICE_BUDGET: usize = 32;
188 /// Default wait for a correlated conversation reply: 5 s, the bound a
189 /// client has always been answered within.
190 pub const DEFAULT_REPLY_TIMEOUT_MS: u64 = 5_000;
191
192 /// Validates the caps: every value must be non-zero (a zero cap gates nothing
193 /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
194 /// `errors` (one per offending field) so an operator sees every bad cap at
195 /// once, matching the rest of config validation.
196 pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
197 let checks: [(&str, usize); 10] = [
198 ("max_connections", self.max_connections),
199 (
200 "max_subscriptions_per_connection",
201 self.max_subscriptions_per_connection,
202 ),
203 (
204 "max_conversations_per_connection",
205 self.max_conversations_per_connection,
206 ),
207 (
208 "max_pending_pushes_per_connection",
209 self.max_pending_pushes_per_connection,
210 ),
211 (
212 "max_pending_conversation_replies_per_connection",
213 self.max_pending_conversation_replies_per_connection,
214 ),
215 (
216 "max_pending_replies_per_conversation",
217 self.max_pending_replies_per_conversation,
218 ),
219 (
220 "max_connection_inbox_bytes",
221 self.max_connection_inbox_bytes,
222 ),
223 // A zero here would leave a connection unable to queue ANY frame at
224 // all — every push and every delivery refused on its first byte.
225 (
226 "max_connection_outbound_bytes",
227 self.max_connection_outbound_bytes,
228 ),
229 (
230 "max_subscription_inbox_depth",
231 self.max_subscription_inbox_depth,
232 ),
233 // A zero here would make the delivery pump enqueue nothing on any
234 // slice — every subscription silently stalled forever, which is the
235 // unlimited-by-silence failure wearing the opposite sign.
236 ("delivery_slice_budget", self.delivery_slice_budget),
237 ];
238 for (field, value) in checks {
239 if value == 0 {
240 errors.push(zero_cap_error(field));
241 }
242 }
243 // `max_channels` is checked BESIDE the array rather than inside it,
244 // because it is the one optional cap and the array's element type has no
245 // way to say "absent". Forcing it in would need a stand-in value for
246 // `None` — a number standing where a declaration is not — and any
247 // stand-in would either invent a bound or make an undeclared cap look
248 // declared. So the shape stays honest and the RULE stays uniform: a
249 // declared zero is refused with the same message as the other eight.
250 //
251 // The parenthetical about unlimited-by-silence is the other eight caps'
252 // ground; for this one a zero would refuse every registration rather
253 // than admit every registration. The message is kept identical anyway:
254 // an operator reading nine cap errors should learn one rule — zero is
255 // not a legal cap value anywhere in this section — not two.
256 if self.max_channels == Some(0) {
257 errors.push(zero_cap_error("max_channels"));
258 }
259 // A duration, not a count, so it sits beside the array too; zero is
260 // refused by the same rule.
261 if self.reply_timeout_ms == 0 {
262 errors.push(zero_cap_error("reply_timeout_ms"));
263 }
264 }
265}
266
267/// The typed refusal for a zero-valued cap, shared by every field in `[limits]`
268/// so the wording cannot drift between them.
269fn zero_cap_error(field: &str) -> String {
270 format!(
271 "limits.{field}: must be greater than zero (a zero cap would be \
272 unlimited-by-silence, which §5 forbids)"
273 )
274}
275
276impl Default for LimitsConfig {
277 fn default() -> Self {
278 Self {
279 max_connections: default_max_connections(),
280 max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
281 max_conversations_per_connection: default_max_conversations_per_connection(),
282 max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
283 max_pending_conversation_replies_per_connection:
284 default_max_pending_conversation_replies_per_connection(),
285 max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
286 max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
287 max_connection_outbound_bytes: default_max_connection_outbound_bytes(),
288 max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
289 delivery_slice_budget: default_delivery_slice_budget(),
290 // No signed bound exists for this cap, so the default is the
291 // absence of a declaration — never a number.
292 max_channels: None,
293 reply_timeout_ms: default_reply_timeout_ms(),
294 }
295 }
296}
297
298const fn default_max_connections() -> usize {
299 LimitsConfig::DEFAULT_MAX_CONNECTIONS
300}
301const fn default_max_subscriptions_per_connection() -> usize {
302 LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
303}
304const fn default_max_conversations_per_connection() -> usize {
305 LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
306}
307const fn default_max_pending_pushes_per_connection() -> usize {
308 LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
309}
310const fn default_max_pending_conversation_replies_per_connection() -> usize {
311 LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
312}
313const fn default_max_pending_replies_per_conversation() -> usize {
314 LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
315}
316const fn default_max_connection_inbox_bytes() -> usize {
317 LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
318}
319const fn default_max_connection_outbound_bytes() -> usize {
320 LimitsConfig::DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES
321}
322const fn default_max_subscription_inbox_depth() -> usize {
323 LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
324}
325const fn default_delivery_slice_budget() -> usize {
326 LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET
327}
328
329const fn default_reply_timeout_ms() -> u64 {
330 LimitsConfig::DEFAULT_REPLY_TIMEOUT_MS
331}