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 (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}