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 phase_budget_bytes: usize,
495 pub query_memory_max_bytes: u64,
499 pub unindexed_walk_max_owners: u64,
503 pub lock_acquisition_timeout_ms: u64,
506 pub max_expanded_commit_bytes: usize,
520 pub planner_stats_bytes: usize,
524 pub planner_stats_idle_seconds: u64,
526 pub planner_stats_max_age_commits: u64,
528 pub idempotency_window_seconds: u64,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
541pub enum Durability {
542 Memory,
544 Async,
548 #[default]
550 Local,
551}
552
553impl Default for StorageResources {
554 fn default() -> Self {
555 Self {
556 managed_limit_bytes: 512 * 1024 * 1024,
557 page_cache_bytes: 128 * 1024 * 1024,
558 transaction_pool_bytes: 128 * 1024 * 1024,
559 durability: Durability::Local,
560 shard_count: 1,
561 experimental_multi_shard: false,
562 max_group_count: 0,
563 max_group_bytes: 0,
564 max_group_delay_ms: 0,
565 log_segment_bytes: 64 * 1024 * 1024,
566 logical_cache_bytes: 128 * 1024 * 1024,
570 vector_cache_bytes: 256 * 1024 * 1024,
572 phase_budget_bytes: 256 * 1024 * 1024,
576 query_memory_max_bytes: 0,
577 unindexed_walk_max_owners: 0,
578 lock_acquisition_timeout_ms: 0,
579 max_expanded_commit_bytes: 512 * 1024 * 1024,
585 planner_stats_bytes: 32 * 1024 * 1024,
586 planner_stats_idle_seconds: 600,
587 planner_stats_max_age_commits: 10_000,
588 idempotency_window_seconds: 24 * 3600,
589 }
590 }
591}
592
593impl StorageResources {
594 pub fn validate(&self) -> Result<(), ResourceError> {
602 if self.managed_limit_bytes == 0
603 || self.page_cache_bytes < 64 * 1024
604 || self.transaction_pool_bytes == 0
605 || self
606 .page_cache_bytes
607 .checked_add(self.transaction_pool_bytes)
608 .is_none_or(|total| total >= self.managed_limit_bytes)
609 {
610 return Err(ResourceError::Invalid(
611 "cache must be at least 64 KiB; positive cache/transaction pools must fit with headroom in the managed envelope",
612 ));
613 }
614 if matches!(self.durability, Durability::Memory) {
615 return Err(ResourceError::Invalid(
616 "Memory durability is for in-memory stores; use new_in_memory() instead of a configured redb cache",
617 ));
618 }
619 if self.shard_count == 0 {
620 return Err(ResourceError::Invalid("shard-count must be >= 1"));
621 }
622 if self.shard_count > 256 {
623 return Err(ResourceError::Invalid("shard-count must be <= 256"));
624 }
625 if self.shard_count > 1 && !self.experimental_multi_shard {
626 return Err(ResourceError::Invalid(
627 "shard-count > 1 is experimental: multi-shard commit groups are not crash-atomic \
628 (independent per-shard redb transactions). Set experimental_multi_shard / \
629 --experimental-sharding to acknowledge, or use shard-count 1",
630 ));
631 }
632 if self.log_segment_bytes < 64 * 1024 {
633 return Err(ResourceError::Invalid(
634 "log-segment-bytes must be at least 64 KiB",
635 ));
636 }
637 if self.max_expanded_commit_bytes == 0 {
638 return Err(ResourceError::Invalid(
639 "max-expanded-commit-bytes must be positive",
640 ));
641 }
642 Ok(())
643 }
644}
645
646#[must_use]
651#[expect(
652 clippy::cast_possible_truncation,
653 reason = "the hash is immediately reduced modulo shard_count, so truncating its high bits cannot change the restart-stable routing"
654)]
655pub fn physical_shard_for(
656 tenant: Option<&rushdb_types::ids::TenantId>,
657 shard_count: usize,
658) -> usize {
659 debug_assert!(shard_count >= 1);
660 match tenant {
661 Some(t) => {
662 let mut h = 2_166_136_261u64;
663 for b in &t.to_be_bytes() {
664 h ^= u64::from(*b);
665 h = h.wrapping_mul(16_777_619);
666 }
667 (h as usize) % shard_count
668 }
669 None => 0,
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn child_usage_draws_from_every_ancestor_without_oversubscription() {
679 let root = ByteBudget::new("root", 100).unwrap();
680 let query = root.child("query", 80).unwrap();
681 let leaf = query.child("operator", 60).unwrap();
682 let other = root.child("index", 80).unwrap();
683 let a = leaf.try_reserve(60).unwrap();
684 let b = other.try_reserve(40).unwrap();
685 assert_eq!(
686 root.snapshot(),
687 BudgetSnapshot {
688 limit: 100,
689 used: 100,
690 peak: 100
691 }
692 );
693 assert_eq!(query.snapshot().used, 60);
694 assert_eq!(query.available(), 0);
695 assert!(matches!(
696 query.try_reserve(1),
697 Err(ResourceError::Exhausted { budget: "root", .. })
698 ));
699 assert!(leaf.try_reserve(1).is_err());
700 drop(b);
701 assert_eq!(query.available(), 20);
702 drop(a);
703 assert_eq!(root.snapshot().used, 0);
704 assert_eq!(root.snapshot().peak, 100);
705 }
706
707 #[test]
708 fn split_resize_transfer_and_failed_transfer_preserve_ownership() {
709 let root = ByteBudget::new("root", 100).unwrap();
710 let a = root.child("a", 100).unwrap();
711 let b = root.child("b", 30).unwrap();
712 let mut owned = a.try_reserve(60).unwrap();
713 let mut split = owned.split(20).unwrap();
714 assert_eq!(
715 (owned.bytes(), split.bytes(), root.snapshot().used),
716 (40, 20, 60)
717 );
718 assert!(owned.split(41).is_err());
719 assert!(owned.resize(101).is_err());
720 assert_eq!(owned.bytes(), 40);
721 assert!(owned.transfer(&b).is_err());
722 assert_eq!(
723 (a.snapshot().used, b.snapshot().used, root.snapshot().used),
724 (60, 0, 60)
725 );
726 split.transfer(&b).unwrap();
727 assert_eq!(
728 (a.snapshot().used, b.snapshot().used, root.snapshot().used),
729 (40, 20, 60)
730 );
731 split.transfer(&root).unwrap();
732 split.transfer(&a).unwrap();
733 split.transfer(&a).unwrap(); let independent = ByteBudget::new("independent", 100).unwrap();
735 assert_eq!(
736 split.transfer(&independent),
737 Err(ResourceError::DifferentTree)
738 );
739 assert_eq!(independent.snapshot().used, 0);
740 owned.resize(10).unwrap();
741 assert_eq!(root.snapshot().used, 30);
742 drop(owned);
743 drop(split);
744 assert_eq!(root.snapshot().used, 0);
745 assert_eq!(root.snapshot().peak, 60);
746 }
747
748 #[test]
749 fn unwind_and_last_reservation_release_child_metadata() {
750 let root = ByteBudget::new("root", 100).unwrap();
751 let child = root.child("child", 100).unwrap();
752 let weak = Arc::downgrade(&child.0);
753 let token = child.try_reserve(50).unwrap();
754 drop(child);
755 assert!(weak.upgrade().is_some());
756 let panic = std::panic::catch_unwind(move || {
757 let _token = token;
758 panic!("simulated failed work");
759 });
760 assert!(panic.is_err());
761 assert_eq!(root.snapshot().used, 0);
762 assert!(weak.upgrade().is_none());
763 }
764
765 #[test]
766 fn concurrent_siblings_never_exceed_parent_or_child_limits() {
767 let root = ByteBudget::new("root", 64).unwrap();
768 let left = root.child("left", 32).unwrap();
769 let right = root.child("right", 32).unwrap();
770 let ready = std::sync::Barrier::new(16);
771 let release = std::sync::Barrier::new(16);
772 let admitted = AtomicUsize::new(0);
773 std::thread::scope(|scope| {
774 for i in 0..16 {
775 let pool = if i % 2 == 0 { &left } else { &right };
776 let ready = &ready;
777 let release = &release;
778 let admitted = &admitted;
779 scope.spawn(move || {
780 ready.wait();
781 let token = pool.try_reserve(8).ok();
782 if token.is_some() {
783 admitted.fetch_add(1, Ordering::Relaxed);
784 }
785 release.wait();
786 assert!(pool.snapshot().used <= 32);
787 drop(token);
788 });
789 }
790 });
791 assert_eq!(admitted.load(Ordering::Relaxed), 8);
792 assert_eq!(
793 root.snapshot(),
794 BudgetSnapshot {
795 limit: 64,
796 used: 0,
797 peak: 64
798 }
799 );
800 }
801
802 #[test]
803 fn invalid_limits_and_overflow_are_rejected_without_charging() {
804 assert!(ByteBudget::new("zero", 0).is_err());
805 let root = ByteBudget::new("root", usize::MAX).unwrap();
806 let all = root.try_reserve(usize::MAX).unwrap();
807 assert!(root.try_reserve(1).is_err());
808 assert!(root.child("zero", 0).is_err());
809 drop(all);
810 assert_eq!(root.snapshot().used, 0);
811 let mut child = root;
812 for _ in 0..32 {
813 child = child.child("depth", 1).unwrap();
814 }
815 assert!(child.child("too-deep", 1).is_err());
816 }
817
818 #[test]
819 fn fair_admission_admits_when_capacity_frees_and_times_out_on_deadline() {
820 let budget = ByteBudget::new("root", 10).unwrap();
821 let gate = FairAdmission::new(budget.clone());
822 let held = budget.try_reserve(9).unwrap();
824 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
825
826 let expired = now_ms() + 20;
829 let outcome = gate.acquire(5, expired, &cancel);
830 assert_eq!(
831 outcome.err(),
832 Some(AdmissionError::TimedOut),
833 "must time out on deadline"
834 );
835 assert_eq!(
836 budget.snapshot().used,
837 9,
838 "timed-out waiter charges nothing"
839 );
840
841 drop(held);
844 let reservation = gate.acquire(5, now_ms() + 1000, &cancel).unwrap();
845 assert_eq!(
846 budget.snapshot().used,
847 5,
848 "admitted caller holds the charge"
849 );
850 drop(reservation);
851 assert_eq!(budget.snapshot().used, 0, "drop releases the charge");
852 }
853
854 #[test]
855 fn fair_admission_cancelled_waiter_is_removed_without_charging() {
856 let budget = ByteBudget::new("root", 10).unwrap();
857 let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
858 let _held = budget.try_reserve(9).unwrap();
859 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
862 let gate2 = std::sync::Arc::clone(&gate);
863 let cancel2 = std::sync::Arc::clone(&cancel);
864 let handle = std::thread::spawn(move || gate2.acquire(5, now_ms() + 2000, &cancel2));
865 std::thread::sleep(std::time::Duration::from_millis(30));
867 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
868 let outcome = handle.join().unwrap();
869 assert_eq!(
870 outcome.err(),
871 Some(AdmissionError::Cancelled),
872 "waiter must observe cancellation"
873 );
874 assert_eq!(
875 budget.snapshot().used,
876 9,
877 "cancelled waiter charges nothing"
878 );
879 }
880
881 #[test]
882 fn fair_admission_fifo_and_release_wakes_next_waiter() {
883 let budget = ByteBudget::new("root", 100).unwrap();
884 let gate = std::sync::Arc::new(FairAdmission::new(budget.clone()));
885 let held = budget.try_reserve(95).unwrap();
886 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
887 let cancel2 = std::sync::Arc::clone(&cancel);
888 let gate2 = std::sync::Arc::clone(&gate);
891 let handle = std::thread::spawn(move || gate2.acquire(10, now_ms() + 2000, &cancel2));
892 std::thread::sleep(std::time::Duration::from_millis(30));
893 drop(held);
894 let outcome = handle.join().unwrap();
895 match outcome {
896 Ok(_) => {}
897 Err(e) => panic!("release must wake the waiter: {e:?}"),
898 }
899 assert_eq!(
900 budget.snapshot().used,
901 10,
902 "waiter holds the admitted charge"
903 );
904 }
905}
906
907#[cfg(test)]
908mod shard_tests {
909 use super::*;
910 use rushdb_types::ids::TenantId;
911
912 #[test]
913 fn physical_shard_routing_is_deterministic_and_in_range() {
914 for count in [1usize, 2, 4, 8, 64] {
915 for n in 0..100u64 {
916 let t = TenantId::from_ordinal(n);
917 let s = physical_shard_for(Some(&t), count);
918 assert!(s < count);
919 assert_eq!(physical_shard_for(Some(&t), count), s);
920 }
921 }
922 assert_eq!(physical_shard_for(None, 8), 0);
923 }
924
925 #[test]
926 fn different_tenants_distribute_across_shards() {
927 let count = 4usize;
928 let mut seen = std::collections::BTreeSet::new();
929 for n in 0..256u64 {
930 let t = TenantId::from_ordinal(n);
931 seen.insert(physical_shard_for(Some(&t), count));
932 }
933 assert_eq!(seen.len(), count, "tenants should land on every shard");
934 }
935
936 #[test]
937 fn shard_count_validation() {
938 let mut resources = StorageResources::default();
939 assert!(resources.validate().is_ok());
940 resources.shard_count = 0;
941 assert!(resources.validate().is_err());
942 resources.shard_count = 257;
943 assert!(resources.validate().is_err());
944 }
945
946 #[test]
947 fn multi_shard_requires_explicit_experimental_acknowledgement() {
948 let mut resources = StorageResources {
951 shard_count: 2,
952 ..StorageResources::default()
953 };
954 assert!(
955 resources.validate().is_err(),
956 "shard_count > 1 must be refused without acknowledgement"
957 );
958 resources.experimental_multi_shard = true;
959 assert!(resources.validate().is_ok());
960 }
961}