Skip to main content

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}