1use std::sync::{
4 atomic::{AtomicU64, AtomicUsize, Ordering},
5 Arc, Mutex, MutexGuard,
6};
7use thiserror::Error;
8
9#[derive(Debug, Error, Clone, PartialEq, Eq)]
12pub enum ResourceError {
13 #[error("invalid resource budget: {0}")]
15 Invalid(&'static str),
16 #[error("resource budget `{budget}` exhausted: requested {requested} bytes, used {used}, limit {limit}")]
18 Exhausted {
19 budget: &'static str,
21 requested: usize,
23 used: usize,
25 limit: usize,
27 },
28 #[error("reservation belongs to a different resource tree")]
30 DifferentTree,
31 #[error("reservation operation exceeds its owned bytes")]
33 InvalidOwnership,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct BudgetSnapshot {
39 pub limit: usize,
41 pub used: usize,
43 pub peak: usize,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AdmissionError {
52 TimedOut,
54 Cancelled,
56 Exhausted,
58}
59
60static NEXT_TICKET: AtomicU64 = AtomicU64::new(1);
62
63struct Node {
64 name: &'static str,
65 limit: usize,
66 used: AtomicUsize,
67 peak: AtomicUsize,
68 depth: usize,
69 parent: Option<Arc<Node>>,
70 ledger: Arc<Mutex<()>>,
71}
72
73#[derive(Clone)]
78pub struct ByteBudget(Arc<Node>);
79
80impl ByteBudget {
81 pub fn new(name: &'static str, limit: usize) -> Result<Self, ResourceError> {
86 if limit == 0 {
87 return Err(ResourceError::Invalid("limit must be positive"));
88 }
89 Ok(Self(Arc::new(Node {
90 name,
91 limit,
92 used: AtomicUsize::new(0),
93 peak: AtomicUsize::new(0),
94 depth: 0,
95 parent: None,
96 ledger: Arc::new(Mutex::new(())),
97 })))
98 }
99
100 pub fn child(&self, name: &'static str, limit: usize) -> Result<Self, ResourceError> {
106 if limit == 0 || limit > self.0.limit {
107 return Err(ResourceError::Invalid(
108 "child limit must be positive and no greater than parent",
109 ));
110 }
111 if self.0.depth >= 32 {
112 return Err(ResourceError::Invalid("budget nesting exceeds 32 levels"));
113 }
114 Ok(Self(Arc::new(Node {
115 name,
116 limit,
117 used: AtomicUsize::new(0),
118 peak: AtomicUsize::new(0),
119 depth: self.0.depth + 1,
120 parent: Some(self.0.clone()),
121 ledger: self.0.ledger.clone(),
122 })))
123 }
124
125 fn lock(&self) -> MutexGuard<'_, ()> {
130 self.0
131 .ledger
132 .lock()
133 .unwrap_or_else(std::sync::PoisonError::into_inner)
134 }
135
136 fn ancestors(&self) -> impl Iterator<Item = &Node> {
137 std::iter::successors(Some(self.0.as_ref()), |node| node.parent.as_deref())
138 }
139
140 fn validate_add(&self, bytes: usize) -> Result<(), ResourceError> {
141 for node in self.ancestors() {
142 let used = node.used.load(Ordering::Acquire);
143 if bytes > node.limit - used {
144 return Err(ResourceError::Exhausted {
145 budget: node.name,
146 requested: bytes,
147 used,
148 limit: node.limit,
149 });
150 }
151 }
152 Ok(())
153 }
154
155 fn add(&self, bytes: usize) {
156 for node in self.ancestors() {
157 let used = node.used.fetch_add(bytes, Ordering::AcqRel) + bytes;
158 node.peak.fetch_max(used, Ordering::Relaxed);
159 }
160 }
161
162 fn subtract(&self, bytes: usize) {
166 for node in self.ancestors() {
167 node.used.fetch_sub(bytes, Ordering::Release);
168 }
169 }
170
171 #[must_use]
173 pub fn snapshot(&self) -> BudgetSnapshot {
174 BudgetSnapshot {
175 limit: self.0.limit,
176 used: self.0.used.load(Ordering::Acquire),
177 peak: self.0.peak.load(Ordering::Relaxed),
178 }
179 }
180
181 #[must_use]
187 pub fn available(&self) -> usize {
188 self.ancestors()
189 .map(|node| node.limit - node.used.load(Ordering::Acquire))
190 .min()
191 .unwrap()
192 }
193
194 pub fn try_reserve(&self, bytes: usize) -> Result<ByteReservation, ResourceError> {
201 let _guard = self.lock();
202 self.validate_add(bytes)?;
203 self.add(bytes);
204 Ok(ByteReservation {
205 budget: self.clone(),
206 bytes,
207 })
208 }
209}
210
211pub struct ByteReservation {
214 budget: ByteBudget,
215 bytes: usize,
216}
217
218impl ByteReservation {
219 #[must_use]
221 pub fn bytes(&self) -> usize {
222 self.bytes
223 }
224
225 pub fn resize(&mut self, bytes: usize) -> Result<(), ResourceError> {
231 if bytes > self.bytes {
232 let _guard = self.budget.lock();
234 self.budget.validate_add(bytes - self.bytes)?;
235 self.budget.add(bytes - self.bytes);
236 } else {
237 self.budget.subtract(self.bytes - bytes);
239 }
240 self.bytes = bytes;
241 Ok(())
242 }
243
244 pub fn split(&mut self, bytes: usize) -> Result<Self, ResourceError> {
250 if bytes > self.bytes {
251 return Err(ResourceError::InvalidOwnership);
252 }
253 self.bytes -= bytes;
254 Ok(Self {
255 budget: self.budget.clone(),
256 bytes,
257 })
258 }
259
260 pub fn transfer(&mut self, destination: &ByteBudget) -> Result<(), ResourceError> {
267 if !Arc::ptr_eq(&self.budget.0.ledger, &destination.0.ledger) {
268 return Err(ResourceError::DifferentTree);
269 }
270 {
271 let _guard = self.budget.lock();
272 self.budget.subtract(self.bytes);
273 if let Err(error) = destination.validate_add(self.bytes) {
274 self.budget.add(self.bytes);
275 return Err(error);
276 }
277 destination.add(self.bytes);
278 }
279 self.budget = destination.clone();
280 Ok(())
281 }
282}
283
284impl Drop for ByteReservation {
285 fn drop(&mut self) {
286 self.budget.subtract(self.bytes);
289 }
290}
291
292pub struct FairAdmission {
302 budget: ByteBudget,
303 queue: Mutex<std::collections::VecDeque<Waiter>>,
306 changed: std::sync::Condvar,
308}
309
310struct Waiter {
311 deadline_ms: u64,
312 cancelled: Arc<std::sync::atomic::AtomicBool>,
313 ticket: u64,
314}
315
316impl FairAdmission {
317 #[must_use]
319 pub fn new(budget: ByteBudget) -> Self {
320 Self {
321 budget,
322 queue: Mutex::new(std::collections::VecDeque::new()),
323 changed: std::sync::Condvar::new(),
324 }
325 }
326
327 pub fn try_acquire(&self, bytes: usize) -> Result<ByteReservation, ResourceError> {
333 self.budget.try_reserve(bytes)
334 }
335
336 pub fn acquire(
348 &self,
349 bytes: usize,
350 deadline_ms: u64,
351 cancelled: &std::sync::Arc<std::sync::atomic::AtomicBool>,
352 ) -> Result<ByteReservation, AdmissionError> {
353 if self.budget.available() >= bytes {
355 return self
356 .budget
357 .try_reserve(bytes)
358 .map_err(|_| AdmissionError::Exhausted);
359 }
360 if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
361 return Err(AdmissionError::Cancelled);
362 }
363 if now_ms() >= deadline_ms {
364 return Err(AdmissionError::TimedOut);
365 }
366
367 let ticket_id = NEXT_TICKET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
370
371 let waiter = Waiter {
372 deadline_ms,
373 cancelled: Arc::clone(cancelled),
374 ticket: ticket_id,
375 };
376 let mut guard = self
377 .queue
378 .lock()
379 .unwrap_or_else(std::sync::PoisonError::into_inner);
380 guard.push_back(waiter);
381
382 loop {
383 let my_ticket = ticket_id;
386 let now = now_ms();
387 let mut changed = false;
388 guard.retain(|w| {
389 if w.ticket == my_ticket {
390 true } else if w.cancelled.load(std::sync::atomic::Ordering::Relaxed)
392 || w.deadline_ms <= now
393 {
394 changed = true;
395 false
396 } else {
397 true
398 }
399 });
400 if changed {
401 self.changed.notify_all();
402 }
403
404 let is_head = guard.front().is_some_and(|w| w.ticket == my_ticket);
406 if is_head && self.budget.available() >= bytes {
407 if let Ok(res) = self.budget.try_reserve(bytes) {
408 guard.pop_front();
409 self.changed.notify_all();
410 return Ok(res);
411 }
412 guard.pop_front();
413 self.changed.notify_all();
414 return Err(AdmissionError::Exhausted);
415 }
416
417 if cancelled.load(std::sync::atomic::Ordering::Relaxed) {
419 guard.retain(|w| w.ticket != my_ticket);
420 self.changed.notify_all();
421 return Err(AdmissionError::Cancelled);
422 }
423 if now_ms() >= deadline_ms {
424 guard.retain(|w| w.ticket != my_ticket);
425 self.changed.notify_all();
426 return Err(AdmissionError::TimedOut);
427 }
428
429 let (guard2, _timeout) = self
430 .changed
431 .wait_timeout(guard, std::time::Duration::from_millis(2))
432 .unwrap_or_else(std::sync::PoisonError::into_inner);
433 guard = guard2;
434 }
435 }
436}
437
438fn now_ms() -> u64 {
439 std::time::SystemTime::now()
440 .duration_since(std::time::UNIX_EPOCH)
441 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
442}
443
444#[derive(Debug, Clone, Copy)]
446pub struct StorageResources {
447 pub managed_limit_bytes: usize,
449 pub page_cache_bytes: usize,
451 pub transaction_pool_bytes: usize,
453 pub durability: Durability,
455 pub shard_count: usize,
460 pub experimental_multi_shard: bool,
466 pub max_group_count: usize,
469 pub max_group_bytes: usize,
472 pub max_group_delay_ms: u64,
476 pub log_segment_bytes: u64,
479 pub logical_cache_bytes: usize,
483 pub vector_cache_bytes: usize,
488 pub graph_cache_bytes: usize,
492 pub phase_budget_bytes: usize,
499 pub query_memory_max_bytes: u64,
503 pub unindexed_walk_max_owners: u64,
507 pub lock_acquisition_timeout_ms: u64,
510 pub max_expanded_commit_bytes: usize,
524 pub planner_stats_bytes: usize,
528 pub planner_stats_idle_seconds: u64,
530 pub planner_stats_max_age_commits: u64,
532 pub idempotency_window_seconds: u64,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
545pub enum Durability {
546 Memory,
548 Async,
552 #[default]
554 Local,
555}
556
557impl Default for StorageResources {
558 fn default() -> Self {
559 Self {
560 managed_limit_bytes: 512 * 1024 * 1024,
561 page_cache_bytes: 128 * 1024 * 1024,
562 transaction_pool_bytes: 128 * 1024 * 1024,
563 durability: Durability::Local,
564 shard_count: 1,
565 experimental_multi_shard: false,
566 max_group_count: 0,
567 max_group_bytes: 0,
568 max_group_delay_ms: 0,
569 log_segment_bytes: 64 * 1024 * 1024,
570 logical_cache_bytes: 128 * 1024 * 1024,
574 vector_cache_bytes: 256 * 1024 * 1024,
576 graph_cache_bytes: 128 * 1024 * 1024,
579 phase_budget_bytes: 256 * 1024 * 1024,
583 query_memory_max_bytes: 0,
584 unindexed_walk_max_owners: 0,
585 lock_acquisition_timeout_ms: 0,
586 max_expanded_commit_bytes: 512 * 1024 * 1024,
592 planner_stats_bytes: 32 * 1024 * 1024,
593 planner_stats_idle_seconds: 600,
594 planner_stats_max_age_commits: 10_000,
595 idempotency_window_seconds: 24 * 3600,
596 }
597 }
598}
599
600impl StorageResources {
601 pub fn validate(&self) -> Result<(), ResourceError> {
609 if self.managed_limit_bytes == 0
610 || self.page_cache_bytes < 64 * 1024
611 || self.transaction_pool_bytes == 0
612 || self
613 .page_cache_bytes
614 .checked_add(self.transaction_pool_bytes)
615 .is_none_or(|total| total >= self.managed_limit_bytes)
616 {
617 return Err(ResourceError::Invalid(
618 "cache must be at least 64 KiB; positive cache/transaction pools must fit with headroom in the managed envelope",
619 ));
620 }
621 if matches!(self.durability, Durability::Memory) {
622 return Err(ResourceError::Invalid(
623 "Memory durability is for in-memory stores; use new_in_memory() instead of a configured redb cache",
624 ));
625 }
626 if self.shard_count == 0 {
627 return Err(ResourceError::Invalid("shard-count must be >= 1"));
628 }
629 if self.shard_count > 256 {
630 return Err(ResourceError::Invalid("shard-count must be <= 256"));
631 }
632 if self.shard_count > 1 && !self.experimental_multi_shard {
633 return Err(ResourceError::Invalid(
634 "shard-count > 1 is experimental: multi-shard commit groups are not crash-atomic \
635 (independent per-shard redb transactions). Set experimental_multi_shard / \
636 --experimental-sharding to acknowledge, or use shard-count 1",
637 ));
638 }
639 if self.log_segment_bytes < 64 * 1024 {
640 return Err(ResourceError::Invalid(
641 "log-segment-bytes must be at least 64 KiB",
642 ));
643 }
644 if self.max_expanded_commit_bytes == 0 {
645 return Err(ResourceError::Invalid(
646 "max-expanded-commit-bytes must be positive",
647 ));
648 }
649 Ok(())
650 }
651}
652
653#[must_use]
658#[expect(
659 clippy::cast_possible_truncation,
660 reason = "the hash is immediately reduced modulo shard_count, so truncating its high bits cannot change the restart-stable routing"
661)]
662pub fn physical_shard_for(
663 tenant: Option<&rushdb_types::ids::TenantId>,
664 shard_count: usize,
665) -> usize {
666 debug_assert!(shard_count >= 1);
667 match tenant {
668 Some(t) => {
669 let mut h = 2_166_136_261u64;
670 for b in &t.to_be_bytes() {
671 h ^= u64::from(*b);
672 h = h.wrapping_mul(16_777_619);
673 }
674 (h as usize) % shard_count
675 }
676 None => 0,
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn child_usage_draws_from_every_ancestor_without_oversubscription() {
686 let root = ByteBudget::new("root", 100).unwrap();
687 let query = root.child("query", 80).unwrap();
688 let leaf = query.child("operator", 60).unwrap();
689 let other = root.child("index", 80).unwrap();
690 let a = leaf.try_reserve(60).unwrap();
691 let b = other.try_reserve(40).unwrap();
692 assert_eq!(
693 root.snapshot(),
694 BudgetSnapshot {
695 limit: 100,
696 used: 100,
697 peak: 100
698 }
699 );
700 assert_eq!(query.snapshot().used, 60);
701 assert_eq!(query.available(), 0);
702 assert!(matches!(
703 query.try_reserve(1),
704 Err(ResourceError::Exhausted { budget: "root", .. })
705 ));
706 assert!(leaf.try_reserve(1).is_err());
707 drop(b);
708 assert_eq!(query.available(), 20);
709 drop(a);
710 assert_eq!(root.snapshot().used, 0);
711 assert_eq!(root.snapshot().peak, 100);
712 }
713
714 #[test]
715 fn split_resize_transfer_and_failed_transfer_preserve_ownership() {
716 let root = ByteBudget::new("root", 100).unwrap();
717 let a = root.child("a", 100).unwrap();
718 let b = root.child("b", 30).unwrap();
719 let mut owned = a.try_reserve(60).unwrap();
720 let mut split = owned.split(20).unwrap();
721 assert_eq!(
722 (owned.bytes(), split.bytes(), root.snapshot().used),
723 (40, 20, 60)
724 );
725 assert!(owned.split(41).is_err());
726 assert!(owned.resize(101).is_err());
727 assert_eq!(owned.bytes(), 40);
728 assert!(owned.transfer(&b).is_err());
729 assert_eq!(
730 (a.snapshot().used, b.snapshot().used, root.snapshot().used),
731 (60, 0, 60)
732 );
733 split.transfer(&b).unwrap();
734 assert_eq!(
735 (a.snapshot().used, b.snapshot().used, root.snapshot().used),
736 (40, 20, 60)
737 );
738 split.transfer(&root).unwrap();
739 split.transfer(&a).unwrap();
740 split.transfer(&a).unwrap(); let independent = ByteBudget::new("independent", 100).unwrap();
742 assert_eq!(
743 split.transfer(&independent),
744 Err(ResourceError::DifferentTree)
745 );
746 assert_eq!(independent.snapshot().used, 0);
747 owned.resize(10).unwrap();
748 assert_eq!(root.snapshot().used, 30);
749 drop(owned);
750 drop(split);
751 assert_eq!(root.snapshot().used, 0);
752 assert_eq!(root.snapshot().peak, 60);
753 }
754
755 #[test]
756 fn unwind_and_last_reservation_release_child_metadata() {
757 let root = ByteBudget::new("root", 100).unwrap();
758 let child = root.child("child", 100).unwrap();
759 let weak = Arc::downgrade(&child.0);
760 let token = child.try_reserve(50).unwrap();
761 drop(child);
762 assert!(weak.upgrade().is_some());
763 let panic = std::panic::catch_unwind(move || {
764 let _token = token;
765 panic!("simulated failed work");
766 });
767 assert!(panic.is_err());
768 assert_eq!(root.snapshot().used, 0);
769 assert!(weak.upgrade().is_none());
770 }
771
772 #[test]
773 fn concurrent_siblings_never_exceed_parent_or_child_limits() {
774 let root = ByteBudget::new("root", 64).unwrap();
775 let left = root.child("left", 32).unwrap();
776 let right = root.child("right", 32).unwrap();
777 let ready = std::sync::Barrier::new(16);
778 let release = std::sync::Barrier::new(16);
779 let admitted = AtomicUsize::new(0);
780 std::thread::scope(|scope| {
781 for i in 0..16 {
782 let pool = if i % 2 == 0 { &left } else { &right };
783 let ready = &ready;
784 let release = &release;
785 let admitted = &admitted;
786 scope.spawn(move || {
787 ready.wait();
788 let token = pool.try_reserve(8).ok();
789 if token.is_some() {
790 admitted.fetch_add(1, Ordering::Relaxed);
791 }
792 release.wait();
793 assert!(pool.snapshot().used <= 32);
794 drop(token);
795 });
796 }
797 });
798 assert_eq!(admitted.load(Ordering::Relaxed), 8);
799 assert_eq!(
800 root.snapshot(),
801 BudgetSnapshot {
802 limit: 64,
803 used: 0,
804 peak: 64
805 }
806 );
807 }
808
809 #[test]
810 fn invalid_limits_and_overflow_are_rejected_without_charging() {
811 assert!(ByteBudget::new("zero", 0).is_err());
812 let root = ByteBudget::new("root", usize::MAX).unwrap();
813 let all = root.try_reserve(usize::MAX).unwrap();
814 assert!(root.try_reserve(1).is_err());
815 assert!(root.child("zero", 0).is_err());
816 drop(all);
817 assert_eq!(root.snapshot().used, 0);
818 let mut child = root;
819 for _ in 0..32 {
820 child = child.child("depth", 1).unwrap();
821 }
822 assert!(child.child("too-deep", 1).is_err());
823 }
824
825 #[test]
826 fn fair_admission_admits_when_capacity_frees_and_times_out_on_deadline() {
827 let budget = ByteBudget::new("root", 10).unwrap();
828 let gate = FairAdmission::new(budget.clone());
829 let held = budget.try_reserve(9).unwrap();
831 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
832
833 let expired = now_ms() + 20;
836 let outcome = gate.acquire(5, expired, &cancel);
837 assert_eq!(
838 outcome.err(),
839 Some(AdmissionError::TimedOut),
840 "must time out on deadline"
841 );
842 assert_eq!(
843 budget.snapshot().used,
844 9,
845 "timed-out waiter charges nothing"
846 );
847
848 drop(held);
851 let reservation = gate.acquire(5, now_ms() + 1000, &cancel).unwrap();
852 assert_eq!(
853 budget.snapshot().used,
854 5,
855 "admitted caller holds the charge"
856 );
857 drop(reservation);
858 assert_eq!(budget.snapshot().used, 0, "drop releases the charge");
859 }
860
861 #[test]
862 fn fair_admission_cancelled_waiter_is_removed_without_charging() {
863 let budget = ByteBudget::new("root", 10).unwrap();
864 let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
865 let _held = budget.try_reserve(9).unwrap();
866 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
869 let gate2 = std::sync::Arc::clone(&gate);
870 let cancel2 = std::sync::Arc::clone(&cancel);
871 let handle = std::thread::spawn(move || gate2.acquire(5, now_ms() + 2000, &cancel2));
872 std::thread::sleep(std::time::Duration::from_millis(30));
874 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
875 let outcome = handle.join().unwrap();
876 assert_eq!(
877 outcome.err(),
878 Some(AdmissionError::Cancelled),
879 "waiter must observe cancellation"
880 );
881 assert_eq!(
882 budget.snapshot().used,
883 9,
884 "cancelled waiter charges nothing"
885 );
886 }
887
888 #[test]
889 fn fair_admission_fifo_and_release_wakes_next_waiter() {
890 let budget = ByteBudget::new("root", 100).unwrap();
891 let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
892 let held = budget.try_reserve(95).unwrap();
893 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
894 let cancel2 = std::sync::Arc::clone(&cancel);
895 let gate2 = std::sync::Arc::clone(&gate);
898 let handle = std::thread::spawn(move || gate2.acquire(10, now_ms() + 2000, &cancel2));
899 std::thread::sleep(std::time::Duration::from_millis(30));
900 drop(held);
901 let outcome = handle.join().unwrap();
902 match outcome {
903 Ok(_) => {}
904 Err(e) => panic!("release must wake the waiter: {e:?}"),
905 }
906 assert_eq!(
907 budget.snapshot().used,
908 10,
909 "waiter holds the admitted charge"
910 );
911 }
912}
913
914#[cfg(test)]
915mod shard_tests {
916 use super::*;
917 use rushdb_types::ids::TenantId;
918
919 #[test]
920 fn physical_shard_routing_is_deterministic_and_in_range() {
921 for count in [1usize, 2, 4, 8, 64] {
922 for n in 0..100u64 {
923 let t = TenantId::from_ordinal(n);
924 let s = physical_shard_for(Some(&t), count);
925 assert!(s < count);
926 assert_eq!(physical_shard_for(Some(&t), count), s);
927 }
928 }
929 assert_eq!(physical_shard_for(None, 8), 0);
930 }
931
932 #[test]
933 fn different_tenants_distribute_across_shards() {
934 let count = 4usize;
935 let mut seen = std::collections::BTreeSet::new();
936 for n in 0..256u64 {
937 let t = TenantId::from_ordinal(n);
938 seen.insert(physical_shard_for(Some(&t), count));
939 }
940 assert_eq!(seen.len(), count, "tenants should land on every shard");
941 }
942
943 #[test]
944 fn shard_count_validation() {
945 let mut resources = StorageResources::default();
946 assert!(resources.validate().is_ok());
947 resources.shard_count = 0;
948 assert!(resources.validate().is_err());
949 resources.shard_count = 257;
950 assert!(resources.validate().is_err());
951 }
952
953 #[test]
954 fn multi_shard_requires_explicit_experimental_acknowledgement() {
955 let mut resources = StorageResources {
958 shard_count: 2,
959 ..StorageResources::default()
960 };
961 assert!(
962 resources.validate().is_err(),
963 "shard_count > 1 must be refused without acknowledgement"
964 );
965 resources.experimental_multi_shard = true;
966 assert!(resources.validate().is_ok());
967 }
968}