aion_server/worker/backpressure.rs
1//! Per-tenant keyed backpressure at the outbox claim (Control-Plane Phase 2,
2//! P2-Q2).
3//!
4//! # What this is
5//!
6//! The non-replayed [`OutboxDispatcher`](crate::worker::OutboxDispatcher) normally
7//! claims one unscoped batch of pending rows per sweep. With backpressure attached
8//! it instead claims **per-namespace, round-robin, headroom-capped**: a tenant at
9//! its concurrency ceiling has its excess Pending rows held (left durable, NOT
10//! dropped, reconsidered next sweep), and a bursty tenant cannot starve a quiet one.
11//!
12//! # Fairness is per-NAMESPACE, not per-route
13//!
14//! The batch budget is allocated PER NAMESPACE first: every active namespace (one
15//! with claimable pending work) gets a guaranteed slice — `batch_size ÷ active`
16//! (rounded up, ≥1), capped by that namespace's headroom — BEFORE any single tenant
17//! can consume the whole batch. A bursty tenant spread across many task_queues can
18//! therefore never exhaust the sweep budget on its own routes and starve a quiet
19//! single-route tenant: each namespace's slice is reserved up front, and only within
20//! a namespace is that slice distributed round-robin across its own routes. Any
21//! budget left after every namespace has had its guaranteed slice is offered in a
22//! second pass to namespaces with more pending work — fairness first, utilization
23//! second.
24//!
25//! # The three load-bearing semantics
26//!
27//! 1. **CLAIMED-only headroom.** The ceiling caps *concurrent executing* activities
28//! — `Claimed` rows — never `Pending + Claimed`. Counting the Pending backlog
29//! would wedge a tenant against its own backlog (it could never claim the rows
30//! that make up the count). So `headroom = per_node_ceiling − claimed`, fed by
31//! [`OutboxStore::count_claimed_outbox_rows`], never `count_inflight_*`
32//! (CP-Phase-2 §3.1 as corrected).
33//! 2. **Proportional per-node ceiling.** The tenant's quota is a *cluster-wide*
34//! contract; each node enforces `ceil(quota × owned_shard_fraction)` where the
35//! fraction is `|owned shards| / shard_count`. Rows scatter by `dispatch_key`
36//! hash uniformly across shards and a node claims only rows on shards it owns,
37//! so the per-node ceilings sum to ≈quota cluster-wide with NO central counter
38//! (CP-Phase-2 §3.6).
39//! 3. **Exactly-once preserved.** Backpressure only shapes the `limit` and `scope`
40//! of the existing atomic [`OutboxStore::claim_outbox_rows_scoped`]; a smaller
41//! limit is already first-class (the backoff/visibility machinery defers claims
42//! routinely). It touches no dedup (`dispatch_key` UNIQUE / INSERT OR IGNORE) and
43//! no ack/settle path — a held row stays exactly `Pending`.
44
45use std::collections::BTreeMap;
46
47use aion_store::{ClaimScope, OutboxRow, OutboxStore};
48use std::sync::Arc;
49use tracing::warn;
50
51use crate::worker::QuotaCache;
52
53/// This node's owned-shard fraction of the cluster's virtual shard space.
54///
55/// `owned / total` is the proportional slice of every tenant's cluster-wide quota
56/// this node enforces (CP-Phase-2 §3.6). A single-node / own-all deployment has
57/// `owned == total` (fraction 1), so each per-node ceiling equals the full quota
58/// and behaviour is byte-identical to no per-node split.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub struct OwnedShardFraction {
61 owned: u32,
62 total: u32,
63}
64
65impl OwnedShardFraction {
66 /// Build a fraction from this node's owned-shard count and the cluster shard
67 /// count.
68 ///
69 /// A `total` of zero is meaningless (the keyspace always has ≥1 shard); it is
70 /// clamped to 1 so the fraction is well-defined. `owned` is clamped to `total`
71 /// — a node never owns more than the whole keyspace — so the fraction is always
72 /// in `(0, 1]` and a per-node ceiling never exceeds the cluster-wide quota.
73 #[must_use]
74 pub fn new(owned: u32, total: u32) -> Self {
75 let total = total.max(1);
76 let owned = owned.clamp(1, total);
77 Self { owned, total }
78 }
79
80 /// The whole cluster on one node (own-all): fraction 1, so a per-node ceiling
81 /// equals the full cluster-wide quota. This is the single-node default and the
82 /// byte-identical path.
83 #[must_use]
84 pub fn own_all() -> Self {
85 Self { owned: 1, total: 1 }
86 }
87
88 /// `ceil(quota × owned / total)` — this node's proportional slice of the
89 /// cluster-wide `quota`, rounded UP so the per-node ceilings sum to ≥ quota
90 /// (over-admit slightly under shard skew rather than starve — the right failure
91 /// direction with generous defaults, CP-Phase-2 §3.6).
92 #[must_use]
93 pub fn per_node_ceiling(self, quota: u32) -> u32 {
94 // Ceiling division in u64 to avoid overflow: (quota*owned + total-1) / total.
95 let numerator = u64::from(quota) * u64::from(self.owned) + u64::from(self.total) - 1;
96 let ceiling = numerator / u64::from(self.total);
97 u32::try_from(ceiling).unwrap_or(u32::MAX)
98 }
99}
100
101/// One namespace's claim plan for a single sweep: its CLAIMED-only headroom and the
102/// routes (`task_queue`/node pools) that carry its pending work.
103///
104/// The headroom is the hard per-tenant backstop; the routes are how a namespace's
105/// per-sweep allocation is spread round-robin across its several task queues so no
106/// single route hoards the namespace's own slice.
107#[derive(Clone, Debug)]
108struct NamespacePlan {
109 /// `per_node_ceiling − claimed`, clamped at zero. The hard backstop: a tenant
110 /// can never exceed this many NEW claims this sweep no matter the round-robin.
111 headroom: u32,
112 /// This namespace's routes (distinct `(task_queue, node)` pools), the units the
113 /// per-namespace allocation is round-robined over.
114 routes: Vec<ClaimScope>,
115}
116
117/// Keyed backpressure over the outbox claim: resolves per-namespace ceilings and
118/// plans a round-robin, headroom-capped, fair-shared claim per sweep.
119///
120/// Holds only read-side state (the quota cache + this node's shard fraction); the
121/// claim itself goes through the unchanged [`OutboxStore`] the dispatcher already
122/// owns. Cheap to clone (the cache shares its inner handle).
123#[derive(Clone)]
124pub struct Backpressure {
125 quota: QuotaCache,
126 fraction: OwnedShardFraction,
127}
128
129impl std::fmt::Debug for Backpressure {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.debug_struct("Backpressure")
132 .field("fraction", &self.fraction)
133 .finish_non_exhaustive()
134 }
135}
136
137impl Backpressure {
138 /// Build keyed backpressure from a quota cache and this node's owned-shard
139 /// fraction.
140 #[must_use]
141 pub fn new(quota: QuotaCache, fraction: OwnedShardFraction) -> Self {
142 Self { quota, fraction }
143 }
144
145 /// Claim up to `batch_size` rows across all namespaces-with-pending-work, with
146 /// the batch budget allocated PER NAMESPACE first (a guaranteed fair slice each),
147 /// then distributed round-robin across each namespace's routes. Returns every
148 /// claimed row, in claim order.
149 ///
150 /// Rows not claimed (a tenant at its ceiling, or the batch budget exhausted)
151 /// stay durably `Pending` and are reconsidered next sweep — the keyed
152 /// backpressure: nothing is dropped, no `RESOURCE_EXHAUSTED` is surfaced.
153 ///
154 /// # Errors
155 ///
156 /// Propagates a store error from the route probe or any scoped claim; the
157 /// dispatcher logs it and retries next tick (a transient backend failure must
158 /// not tear the loop down).
159 pub async fn claim_round_robin(
160 &self,
161 store: &Arc<dyn OutboxStore>,
162 batch_size: u32,
163 held: &std::collections::HashSet<aion_core::WorkflowId>,
164 ) -> Result<Vec<OutboxRow>, aion_store::StoreError> {
165 let routes = store.pending_outbox_routes().await?;
166 if routes.is_empty() {
167 return Ok(Vec::new());
168 }
169 let plan = self.plan_sweep(store, &routes, batch_size).await?;
170 self.execute_plan(store, &plan, batch_size, held).await
171 }
172
173 /// Resolve each pending namespace's per-sweep headroom (CLAIMED-only,
174 /// proportional ceiling) and group its routes, then compute the per-namespace
175 /// slice so every active tenant is guaranteed an allocation of the batch before
176 /// any single tenant can consume it.
177 async fn plan_sweep(
178 &self,
179 store: &Arc<dyn OutboxStore>,
180 routes: &[ClaimScope],
181 batch_size: u32,
182 ) -> Result<SweepPlan, aion_store::StoreError> {
183 // Group routes by namespace, deterministically ordered, so each namespace's
184 // slice is spread round-robin across ITS OWN task_queues/nodes. Routes are
185 // gathered first with an empty headroom; the CLAIMED-only headroom is filled
186 // in below from a SINGLE bucketed claimed-count scan (CP2-Q2 perf) rather than
187 // one owned-shard scan per namespace (the N+1 the old per-namespace path
188 // incurred over the same rows).
189 let mut namespaces: BTreeMap<String, NamespacePlan> = BTreeMap::new();
190 for route in routes {
191 namespaces
192 .entry(route.namespace.clone())
193 .or_insert_with(|| NamespacePlan {
194 headroom: 0,
195 routes: Vec::new(),
196 })
197 .routes
198 .push(route.clone());
199 }
200 // ONE scan over the owned shards, bucketed by namespace, instead of N scans
201 // (one per active namespace). The result is byte-identical to counting each
202 // namespace's Claimed rows separately: same owned-shard scope, same
203 // Claimed-only predicate, one entry per requested namespace.
204 let names: Vec<&str> = namespaces.keys().map(String::as_str).collect();
205 let claimed_by_namespace = store.count_claimed_outbox_rows_by_namespace(&names).await?;
206 for (namespace, plan) in &mut namespaces {
207 // Ceiling is the proportional per-node slice of the namespace's cached
208 // cluster-wide quota; the claimed count is this node's durable Claimed-row
209 // count (NEVER Pending+Claimed — that would wedge a tenant against its own
210 // backlog). Headroom = ceiling − claimed, clamped at zero.
211 let ceiling = self
212 .fraction
213 .per_node_ceiling(self.quota.ceiling(namespace).await);
214 let claimed = u32::try_from(claimed_by_namespace.get(namespace).copied().unwrap_or(0))
215 .unwrap_or(u32::MAX);
216 plan.headroom = ceiling.saturating_sub(claimed);
217 }
218 // Per-namespace slice: batch_size ÷ active (≥1) is the GUARANTEED allocation
219 // each active tenant reserves before any tenant can consume the whole batch,
220 // capped per tenant by its headroom. This is the fairness axis — per
221 // NAMESPACE, never per route — so a tenant with many routes cannot drain the
222 // budget on its own routes and starve a quiet single-route tenant.
223 let active = u32::try_from(namespaces.len()).unwrap_or(u32::MAX).max(1);
224 let per_namespace_slice = batch_size.div_ceil(active).max(1);
225 Ok(SweepPlan {
226 namespaces,
227 per_namespace_slice,
228 })
229 }
230
231 /// Execute the sweep in two passes: first the GUARANTEED per-namespace slice for
232 /// every active tenant (fairness), then a second pass offering any leftover
233 /// budget to tenants with more pending work (utilization). Rows over a tenant's
234 /// headroom or beyond the batch budget stay durably `Pending`.
235 async fn execute_plan(
236 &self,
237 store: &Arc<dyn OutboxStore>,
238 plan: &SweepPlan,
239 batch_size: u32,
240 held: &std::collections::HashSet<aion_core::WorkflowId>,
241 ) -> Result<Vec<OutboxRow>, aion_store::StoreError> {
242 // Remaining headroom per namespace, decremented as its routes are claimed.
243 let mut headroom: BTreeMap<&str, u32> = plan
244 .namespaces
245 .iter()
246 .map(|(name, ns)| (name.as_str(), ns.headroom))
247 .collect();
248 let mut budget = batch_size;
249 let mut claimed = Vec::new();
250 // Pass 1 — fairness: every active namespace gets its guaranteed slice first,
251 // capped by its own headroom, distributed round-robin across its routes.
252 for (name, ns) in &plan.namespaces {
253 let ns_headroom = headroom.entry(name.as_str()).or_default();
254 let allocation = plan.per_namespace_slice.min(*ns_headroom).min(budget);
255 let got =
256 Self::claim_namespace_slice(store, &ns.routes, allocation, &mut claimed, held)
257 .await?;
258 *ns_headroom = ns_headroom.saturating_sub(got);
259 budget = budget.saturating_sub(got);
260 }
261 // Pass 2 — utilization: spread any leftover budget over the namespaces that
262 // still have both headroom and unclaimed routes (fairness already honoured).
263 for (name, ns) in &plan.namespaces {
264 if budget == 0 {
265 break;
266 }
267 let ns_headroom = headroom.entry(name.as_str()).or_default();
268 let allocation = (*ns_headroom).min(budget);
269 let got =
270 Self::claim_namespace_slice(store, &ns.routes, allocation, &mut claimed, held)
271 .await?;
272 *ns_headroom = ns_headroom.saturating_sub(got);
273 budget = budget.saturating_sub(got);
274 }
275 if claimed.is_empty() && !plan.namespaces.is_empty() {
276 // Every route was at its ceiling this sweep: rows stay Pending (held),
277 // reconsidered next sweep when Claimed rows complete and headroom returns.
278 warn!("outbox backpressure held all pending routes at ceiling this sweep");
279 }
280 Ok(claimed)
281 }
282
283 /// Claim up to `allocation` rows for one namespace, distributed round-robin
284 /// across its `routes`, appending them to `claimed`. Returns how many were
285 /// claimed so the caller can decrement the namespace headroom and batch budget.
286 ///
287 /// One pass over the routes suffices: each route is claimed at its running share
288 /// of the remaining allocation, so a route with few pending rows yields the rest
289 /// back to the later routes rather than the allocation being under-used.
290 async fn claim_namespace_slice(
291 store: &Arc<dyn OutboxStore>,
292 routes: &[ClaimScope],
293 allocation: u32,
294 claimed: &mut Vec<OutboxRow>,
295 held: &std::collections::HashSet<aion_core::WorkflowId>,
296 ) -> Result<u32, aion_store::StoreError> {
297 let mut remaining = allocation;
298 let mut total: u32 = 0;
299 let mut left = u32::try_from(routes.len()).unwrap_or(u32::MAX).max(1);
300 for route in routes {
301 if remaining == 0 {
302 break;
303 }
304 // Even round-robin share of the remaining allocation across the remaining
305 // routes, rounded up so the last route can mop up any residue. The pause
306 // dispatch-hold (#204) is applied AT CLAIM TIME here too: a held run's row
307 // is never selected, so it stays Pending under backpressure exactly as it
308 // does on the unscoped claim.
309 let share = remaining.div_ceil(left).max(1).min(remaining);
310 let rows = store
311 .claim_outbox_rows_scoped_excluding(route, share, held)
312 .await?;
313 let got = u32::try_from(rows.len()).unwrap_or(u32::MAX);
314 remaining = remaining.saturating_sub(got);
315 total = total.saturating_add(got);
316 claimed.extend(rows);
317 left = left.saturating_sub(1).max(1);
318 }
319 Ok(total)
320 }
321}
322
323/// One sweep's resolved plan: each active namespace's headroom + routes, and the
324/// guaranteed per-namespace slice of the batch.
325struct SweepPlan {
326 namespaces: BTreeMap<String, NamespacePlan>,
327 per_namespace_slice: u32,
328}
329
330#[cfg(test)]
331mod tests {
332 #![allow(clippy::expect_used)]
333
334 use std::sync::Arc;
335 use std::sync::atomic::{AtomicUsize, Ordering};
336 use std::time::Duration;
337
338 use aion_store::{ClaimScope, OutboxRow, OutboxStatus, OutboxStore, StoreError};
339 use async_trait::async_trait;
340 use chrono::{DateTime, Utc};
341
342 use super::{Backpressure, OwnedShardFraction};
343 use crate::worker::QuotaCache;
344
345 /// Mock outbox that holds a fixed set of rows and tallies claimed counts against them, recording
346 /// how many times the scalar per-namespace count and the collapsed bucketed count are invoked so
347 /// the N+1 collapse is observable. Only the read-side methods the planner touches are meaningful;
348 /// the rest are inert stubs (the plan-sweep path never reaches them).
349 struct CountingStore {
350 rows: Vec<OutboxRow>,
351 scalar_count_calls: AtomicUsize,
352 bucketed_count_calls: AtomicUsize,
353 }
354
355 impl CountingStore {
356 fn new(rows: Vec<OutboxRow>) -> Self {
357 Self {
358 rows,
359 scalar_count_calls: AtomicUsize::new(0),
360 bucketed_count_calls: AtomicUsize::new(0),
361 }
362 }
363
364 fn claimed_in(&self, namespace: &str) -> u64 {
365 let count = self
366 .rows
367 .iter()
368 .filter(|row| {
369 row.namespace == namespace && matches!(row.status, OutboxStatus::Claimed)
370 })
371 .count();
372 u64::try_from(count).unwrap_or(u64::MAX)
373 }
374 }
375
376 #[async_trait]
377 impl OutboxStore for CountingStore {
378 async fn append_outbox_batch(&self, _rows: &[OutboxRow]) -> Result<(), StoreError> {
379 Ok(())
380 }
381 async fn claim_outbox_rows(&self, _limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
382 Ok(Vec::new())
383 }
384 async fn claim_outbox_rows_scoped(
385 &self,
386 _scope: &ClaimScope,
387 _limit: u32,
388 ) -> Result<Vec<OutboxRow>, StoreError> {
389 Ok(Vec::new())
390 }
391 async fn rearm_stale_claimed_outbox_rows(
392 &self,
393 _older_than: DateTime<Utc>,
394 _visible_after: DateTime<Utc>,
395 _limit: u32,
396 _excluded: &std::collections::HashSet<String>,
397 ) -> Result<Vec<OutboxRow>, StoreError> {
398 Ok(Vec::new())
399 }
400 async fn complete_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
401 Ok(())
402 }
403 async fn retry_outbox_row(
404 &self,
405 _dispatch_key: &str,
406 _next_attempt: u32,
407 _visible_after: DateTime<Utc>,
408 ) -> Result<(), StoreError> {
409 Ok(())
410 }
411 async fn fail_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
412 Ok(())
413 }
414 async fn count_inflight_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
415 Ok(0)
416 }
417 async fn count_claimed_outbox_rows(&self, namespace: &str) -> Result<u64, StoreError> {
418 self.scalar_count_calls.fetch_add(1, Ordering::SeqCst);
419 Ok(self.claimed_in(namespace))
420 }
421 async fn count_claimed_outbox_rows_by_namespace(
422 &self,
423 namespaces: &[&str],
424 ) -> Result<std::collections::BTreeMap<String, u64>, StoreError> {
425 self.bucketed_count_calls.fetch_add(1, Ordering::SeqCst);
426 Ok(namespaces
427 .iter()
428 .map(|ns| ((*ns).to_owned(), self.claimed_in(ns)))
429 .collect())
430 }
431 async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError> {
432 Ok(Vec::new())
433 }
434 }
435
436 fn quota_cache() -> QuotaCache {
437 // Empty namespace store: every ceiling resolves to the generous platform default, so the
438 // headroom is entirely a function of the claimed count the collapse must preserve.
439 let store: Arc<dyn aion_store::NamespaceStore> =
440 Arc::new(aion_store::InMemoryStore::default());
441 QuotaCache::new(store, 100, Duration::from_secs(60))
442 }
443
444 /// A Claimed row in `namespace` on `task_queue` (the count input the headroom subtracts).
445 fn claimed_row(namespace: &str, task_queue: &str) -> OutboxRow {
446 let now = Utc::now();
447 let mut row = OutboxRow::pending(
448 aion_core::WorkflowId::new_v4(),
449 0,
450 "act".to_owned(),
451 aion_core::Payload::new(aion_core::ContentType::Json, Vec::new()),
452 now,
453 )
454 .with_namespace(namespace)
455 .with_task_queue(task_queue);
456 row.status = OutboxStatus::Claimed;
457 row
458 }
459
460 #[tokio::test]
461 async fn collapsed_scan_yields_identical_namespace_buckets_and_one_scan() {
462 // Three namespaces with differing claimed counts spread over several routes each: the
463 // scenario the old path would scan the owned-shard set THREE times for (once per namespace).
464 let rows = vec![
465 claimed_row("alpha", "q1"),
466 claimed_row("alpha", "q2"),
467 claimed_row("alpha", "q1"),
468 claimed_row("beta", "q1"),
469 claimed_row("gamma", "q1"),
470 claimed_row("gamma", "q2"),
471 ];
472 let counting = Arc::new(CountingStore::new(rows));
473 let store: Arc<dyn OutboxStore> = Arc::clone(&counting) as Arc<dyn OutboxStore>;
474 let bp = Backpressure::new(quota_cache(), OwnedShardFraction::own_all());
475
476 // The routes the planner groups by namespace (a bursty tenant spread over many task queues).
477 let routes = vec![
478 ClaimScope::new("alpha", "q1"),
479 ClaimScope::new("alpha", "q2"),
480 ClaimScope::new("beta", "q1"),
481 ClaimScope::new("gamma", "q1"),
482 ClaimScope::new("gamma", "q2"),
483 ];
484
485 // OLD path baseline: each namespace's headroom = ceiling(100) − scalar_claimed(namespace).
486 let expected: std::collections::BTreeMap<&str, u32> =
487 [("alpha", 100 - 3), ("beta", 100 - 1), ("gamma", 100 - 2)]
488 .into_iter()
489 .collect();
490
491 let plan = bp
492 .plan_sweep(&store, &routes, 64)
493 .await
494 .expect("plan resolves");
495
496 // Identical per-namespace headroom buckets to the old per-namespace scans.
497 for (name, ns) in &plan.namespaces {
498 assert_eq!(
499 ns.headroom,
500 expected[name.as_str()],
501 "namespace {name} headroom must match the per-namespace-scan result"
502 );
503 }
504 assert_eq!(plan.namespaces.len(), 3, "one bucket per active namespace");
505
506 // Exactly ONE bucketed scan, and the scalar per-namespace scan is never used on this path.
507 assert_eq!(
508 counting.bucketed_count_calls.load(Ordering::SeqCst),
509 1,
510 "the owned-shard set is scanned exactly once for all namespaces"
511 );
512 assert_eq!(
513 counting.scalar_count_calls.load(Ordering::SeqCst),
514 0,
515 "the collapsed path never falls back to the N per-namespace scans"
516 );
517 }
518
519 #[test]
520 fn own_all_ceiling_equals_full_quota() {
521 // Single-node / own-all: fraction 1, so a per-node ceiling equals the
522 // cluster-wide quota (the byte-identical path).
523 let fraction = OwnedShardFraction::own_all();
524 assert_eq!(fraction.per_node_ceiling(256), 256);
525 assert_eq!(fraction.per_node_ceiling(0), 0);
526 assert_eq!(fraction.per_node_ceiling(1), 1);
527 }
528
529 #[test]
530 fn proportional_ceiling_is_owned_fraction_of_quota_rounded_up() {
531 // A node owning 2 of 8 shards enforces ceil(quota × 2/8) = ceil(quota/4).
532 let quarter = OwnedShardFraction::new(2, 8);
533 assert_eq!(quarter.per_node_ceiling(256), 64, "256 × 2/8 = 64");
534 assert_eq!(quarter.per_node_ceiling(100), 25, "100 × 2/8 = 25");
535 // Rounding is UP so per-node ceilings sum to >= quota (over-admit, not starve).
536 assert_eq!(
537 quarter.per_node_ceiling(10),
538 3,
539 "ceil(10 × 2/8) = ceil(2.5) = 3"
540 );
541 }
542
543 #[test]
544 fn per_node_ceilings_sum_to_at_least_the_cluster_quota() {
545 // Four nodes each owning 2 of 8 shards: each enforces ceil(quota/4); the
546 // four ceilings sum to >= quota, never under (the right failure direction).
547 let quota = 100;
548 let node = OwnedShardFraction::new(2, 8);
549 let per_node = node.per_node_ceiling(quota);
550 assert!(
551 u64::from(per_node) * 4 >= u64::from(quota),
552 "4 × 25 = 100 >= 100"
553 );
554 }
555
556 #[test]
557 fn fraction_clamps_degenerate_inputs() {
558 // Zero total is clamped to 1 (the keyspace always has >= 1 shard), and owned
559 // is clamped to total, so the fraction is always in (0, 1].
560 assert_eq!(OwnedShardFraction::new(0, 0).per_node_ceiling(64), 64);
561 assert_eq!(
562 OwnedShardFraction::new(9, 4).per_node_ceiling(64),
563 64,
564 "owned > total clamps to 1"
565 );
566 }
567}