1use super::{ResidencyPermit, ResidencyReason};
4use crate::AsyncObjectStore;
5use crate::cancellation::CancellationToken;
6use crate::foundation::{GenerationId, OperationId, VolumeId};
7use crate::performance::{WorkBudget, WorkCounters, WorkError};
8use crate::storage::{
9 ObjectFailure, ObjectId, ObjectReadRequest, ObjectReadRetention, ObjectReceipt, ObjectResult,
10 ObjectStoreError,
11};
12use std::collections::{BTreeMap, VecDeque};
13use std::future::Future;
14use thiserror::Error;
15
16const BASIS_POINTS: u128 = 10_000;
17
18#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct StorageLocationId([u8; 16]);
21
22impl StorageLocationId {
23 #[must_use]
25 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
26 Self(bytes)
27 }
28
29 #[must_use]
31 pub const fn into_bytes(self) -> [u8; 16] {
32 self.0
33 }
34}
35
36#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub enum StorageTier {
39 ProcessMemory,
41 NodeLocal,
43 SharedCache,
45 DurableOrigin,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct ObjectResidency {
52 pub object_id: ObjectId,
54 pub location_id: StorageLocationId,
56 pub tier: StorageTier,
58 pub source_priority: u16,
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct PromotionDestination {
65 pub location_id: StorageLocationId,
67 pub tier: StorageTier,
69 pub writable: bool,
71 pub maximum_object_bytes: u64,
73 pub priority: u16,
75 pub cost_units_per_byte: u64,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct PromotionCandidate {
82 pub operation_id: OperationId,
84 pub volume_id: VolumeId,
86 pub generation_id: GenerationId,
88 pub request: ObjectReadRequest,
90 pub accepted_tiers: Vec<StorageTier>,
92 pub reason: ResidencyReason,
94}
95
96impl PromotionCandidate {
97 #[must_use]
99 pub fn from_residency(permit: ResidencyPermit, accepted_tiers: Vec<StorageTier>) -> Self {
100 let candidate = permit.candidate();
101 Self {
102 operation_id: candidate.operation_id,
103 volume_id: candidate.volume_id,
104 generation_id: candidate.generation_id,
105 request: candidate.request,
106 accepted_tiers,
107 reason: candidate.reason,
108 }
109 }
110}
111
112#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct PromotionPlan {
115 pub candidate: PromotionCandidate,
117 pub source: ObjectResidency,
119 pub destination: PromotionDestination,
121 pub estimated_cost_units: u64,
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct PromotionSpeculatorOptions {
128 pub maximum_active_operations: u32,
130 pub maximum_active_bytes: u64,
132 pub maximum_active_cost_units: u64,
134 pub maximum_residency_facts: u32,
136 pub maximum_destinations: u32,
138 pub maximum_accepted_tiers: u32,
140 pub outcome_window: u32,
142 pub minimum_usefulness_samples: u32,
144 pub minimum_usefulness_basis_points: u16,
146}
147
148impl Default for PromotionSpeculatorOptions {
149 fn default() -> Self {
150 Self {
151 maximum_active_operations: 4,
152 maximum_active_bytes: 16 * 1024 * 1024,
153 maximum_active_cost_units: u64::MAX,
154 maximum_residency_facts: 64,
155 maximum_destinations: 32,
156 maximum_accepted_tiers: 4,
157 outcome_window: 32,
158 minimum_usefulness_samples: 4,
159 minimum_usefulness_basis_points: 5_000,
160 }
161 }
162}
163
164impl PromotionSpeculatorOptions {
165 fn validate(self) -> Result<Self, PromotionSpeculatorError> {
166 if self.maximum_active_operations == 0
167 || self.maximum_active_bytes == 0
168 || self.maximum_active_cost_units == 0
169 || self.maximum_residency_facts == 0
170 || self.maximum_destinations == 0
171 || self.maximum_accepted_tiers == 0
172 || self.outcome_window == 0
173 || self.minimum_usefulness_samples == 0
174 {
175 return Err(PromotionSpeculatorError::ZeroBound);
176 }
177 if self.minimum_usefulness_samples > self.outcome_window {
178 return Err(PromotionSpeculatorError::UsefulnessWindowTooSmall);
179 }
180 if u128::from(self.minimum_usefulness_basis_points) > BASIS_POINTS {
181 return Err(PromotionSpeculatorError::InvalidBasisPoints);
182 }
183 Ok(self)
184 }
185}
186
187#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
189pub enum PromotionSpeculatorError {
190 #[error("promotion speculation bounds must be positive")]
192 ZeroBound,
193 #[error("promotion usefulness window is smaller than its minimum sample count")]
195 UsefulnessWindowTooSmall,
196 #[error("promotion usefulness basis points exceed 10,000")]
198 InvalidBasisPoints,
199 #[error("promotion speculation accounting overflowed")]
201 Overflow,
202 #[error("promotion speculation plan is not active")]
204 UnknownPlan,
205}
206
207#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum PromotionRejection {
210 WrongVolume,
212 StaleGeneration,
214 InvalidRequest,
216 InputCapacity,
218 MissingSource,
220 DuplicateObject,
222 DuplicateOperation,
224 ActiveCapacity,
226 NoDestination,
228 LowUsefulness,
230}
231
232#[derive(Clone, Debug, Eq, PartialEq)]
234pub enum PromotionAdmission {
235 Satisfied(ObjectResidency),
237 Planned(PromotionPlan),
239 Rejected(PromotionRejection),
241}
242
243#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
245pub struct PromotionMetrics {
246 pub candidates: u64,
248 pub satisfied: u64,
250 pub planned: u64,
252 pub active: u64,
254 pub active_bytes: u64,
256 pub active_cost_units: u64,
258 pub useful: u64,
260 pub wasted: u64,
262 pub rejected: u64,
264}
265
266#[derive(Clone)]
268pub struct PromotionSpeculator {
269 options: PromotionSpeculatorOptions,
270 volume_id: VolumeId,
271 generation_id: GenerationId,
272 active: BTreeMap<OperationId, PromotionPlan>,
273 outcomes: VecDeque<bool>,
274 metrics: PromotionMetrics,
275}
276
277impl PromotionSpeculator {
278 pub fn new(
284 options: PromotionSpeculatorOptions,
285 volume_id: VolumeId,
286 generation_id: GenerationId,
287 ) -> Result<Self, PromotionSpeculatorError> {
288 Ok(Self {
289 options: options.validate()?,
290 volume_id,
291 generation_id,
292 active: BTreeMap::new(),
293 outcomes: VecDeque::new(),
294 metrics: PromotionMetrics::default(),
295 })
296 }
297
298 pub fn plan(
304 &mut self,
305 candidate: PromotionCandidate,
306 residency: &[ObjectResidency],
307 destinations: &[PromotionDestination],
308 ) -> Result<PromotionAdmission, PromotionSpeculatorError> {
309 let mut next = self.clone();
310 let admission = next.plan_in_place(candidate, residency, destinations)?;
311 *self = next;
312 Ok(admission)
313 }
314
315 fn plan_in_place(
316 &mut self,
317 candidate: PromotionCandidate,
318 residency: &[ObjectResidency],
319 destinations: &[PromotionDestination],
320 ) -> Result<PromotionAdmission, PromotionSpeculatorError> {
321 self.metrics.candidates = checked_increment(self.metrics.candidates)?;
322 if let Some(rejection) = self.basic_rejection(&candidate, residency, destinations) {
323 return self.reject(rejection);
324 }
325 if let Some(current) = best_satisfied(&candidate, residency) {
326 self.metrics.satisfied = checked_increment(self.metrics.satisfied)?;
327 return Ok(PromotionAdmission::Satisfied(current));
328 }
329 let Some(source) = best_source(candidate.request.object_id, residency) else {
330 return self.reject(PromotionRejection::MissingSource);
331 };
332 let Some(destination) = best_destination(&candidate, source.location_id, destinations)
333 else {
334 return self.reject(PromotionRejection::NoDestination);
335 };
336 if !self.useful_enough() {
337 return self.reject(PromotionRejection::LowUsefulness);
338 }
339 let estimated_cost_units = candidate
340 .request
341 .maximum_bytes
342 .checked_mul(destination.cost_units_per_byte)
343 .ok_or(PromotionSpeculatorError::Overflow)?;
344 if self.active.len() >= self.options.maximum_active_operations as usize
345 || self
346 .metrics
347 .active_bytes
348 .checked_add(candidate.request.maximum_bytes)
349 .is_none_or(|bytes| bytes > self.options.maximum_active_bytes)
350 || self
351 .metrics
352 .active_cost_units
353 .checked_add(estimated_cost_units)
354 .is_none_or(|cost| cost > self.options.maximum_active_cost_units)
355 {
356 return self.reject(PromotionRejection::ActiveCapacity);
357 }
358 let plan = PromotionPlan {
359 candidate,
360 source,
361 destination,
362 estimated_cost_units,
363 };
364 self.metrics.planned = checked_increment(self.metrics.planned)?;
365 self.metrics.active = checked_increment(self.metrics.active)?;
366 self.metrics.active_bytes = self
367 .metrics
368 .active_bytes
369 .checked_add(plan.candidate.request.maximum_bytes)
370 .ok_or(PromotionSpeculatorError::Overflow)?;
371 self.metrics.active_cost_units = self
372 .metrics
373 .active_cost_units
374 .checked_add(plan.estimated_cost_units)
375 .ok_or(PromotionSpeculatorError::Overflow)?;
376 self.active
377 .insert(plan.candidate.operation_id, plan.clone());
378 Ok(PromotionAdmission::Planned(plan))
379 }
380
381 pub fn finish(
387 &mut self,
388 operation_id: OperationId,
389 useful: bool,
390 ) -> Result<(), PromotionSpeculatorError> {
391 let mut next = self.clone();
392 next.finish_in_place(operation_id, useful)?;
393 *self = next;
394 Ok(())
395 }
396
397 fn finish_in_place(
398 &mut self,
399 operation_id: OperationId,
400 useful: bool,
401 ) -> Result<(), PromotionSpeculatorError> {
402 let plan = self
403 .active
404 .remove(&operation_id)
405 .ok_or(PromotionSpeculatorError::UnknownPlan)?;
406 self.metrics.active = self
407 .metrics
408 .active
409 .checked_sub(1)
410 .ok_or(PromotionSpeculatorError::Overflow)?;
411 self.metrics.active_bytes = self
412 .metrics
413 .active_bytes
414 .checked_sub(plan.candidate.request.maximum_bytes)
415 .ok_or(PromotionSpeculatorError::Overflow)?;
416 self.metrics.active_cost_units = self
417 .metrics
418 .active_cost_units
419 .checked_sub(plan.estimated_cost_units)
420 .ok_or(PromotionSpeculatorError::Overflow)?;
421 if useful {
422 self.metrics.useful = checked_increment(self.metrics.useful)?;
423 } else {
424 self.metrics.wasted = checked_increment(self.metrics.wasted)?;
425 }
426 self.outcomes.push_back(useful);
427 while self.outcomes.len() > self.options.outcome_window as usize {
428 self.outcomes.pop_front();
429 }
430 Ok(())
431 }
432
433 pub fn preempt_for_foreground(&mut self) -> Result<Vec<OperationId>, PromotionSpeculatorError> {
439 let mut next = self.clone();
440 let operations = next.preempt_in_place()?;
441 *self = next;
442 Ok(operations)
443 }
444
445 fn preempt_in_place(&mut self) -> Result<Vec<OperationId>, PromotionSpeculatorError> {
446 let operations: Vec<_> = self.active.keys().copied().collect();
447 for operation_id in &operations {
448 self.finish_in_place(*operation_id, false)?;
449 }
450 Ok(operations)
451 }
452
453 pub fn replace_generation(
459 &mut self,
460 generation_id: GenerationId,
461 ) -> Result<Vec<OperationId>, PromotionSpeculatorError> {
462 let mut next = self.clone();
463 let preempted = next.preempt_in_place()?;
464 next.generation_id = generation_id;
465 *self = next;
466 Ok(preempted)
467 }
468
469 #[must_use]
471 pub const fn metrics(&self) -> PromotionMetrics {
472 self.metrics
473 }
474
475 fn basic_rejection(
476 &self,
477 candidate: &PromotionCandidate,
478 residency: &[ObjectResidency],
479 destinations: &[PromotionDestination],
480 ) -> Option<PromotionRejection> {
481 if candidate.volume_id != self.volume_id {
482 return Some(PromotionRejection::WrongVolume);
483 }
484 if candidate.generation_id != self.generation_id {
485 return Some(PromotionRejection::StaleGeneration);
486 }
487 if candidate.request.maximum_bytes == 0
488 || candidate.accepted_tiers.is_empty()
489 || candidate.accepted_tiers.len() > self.options.maximum_accepted_tiers as usize
490 || has_duplicate_tiers(&candidate.accepted_tiers)
491 || residency
492 .iter()
493 .any(|fact| fact.object_id != candidate.request.object_id)
494 || has_duplicate_residency_locations(residency)
495 || has_duplicate_destination_locations(destinations)
496 {
497 return Some(PromotionRejection::InvalidRequest);
498 }
499 if residency.len() > self.options.maximum_residency_facts as usize
500 || destinations.len() > self.options.maximum_destinations as usize
501 {
502 return Some(PromotionRejection::InputCapacity);
503 }
504 if self
505 .active
506 .values()
507 .any(|plan| plan.candidate.request.object_id == candidate.request.object_id)
508 {
509 return Some(PromotionRejection::DuplicateObject);
510 }
511 if self.active.contains_key(&candidate.operation_id) {
512 return Some(PromotionRejection::DuplicateOperation);
513 }
514 None
515 }
516
517 fn useful_enough(&self) -> bool {
518 if self.outcomes.len() < self.options.minimum_usefulness_samples as usize {
519 return true;
520 }
521 let useful = self.outcomes.iter().filter(|useful| **useful).count() as u128;
522 useful * BASIS_POINTS
523 >= self.outcomes.len() as u128
524 * u128::from(self.options.minimum_usefulness_basis_points)
525 }
526
527 fn reject(
528 &mut self,
529 rejection: PromotionRejection,
530 ) -> Result<PromotionAdmission, PromotionSpeculatorError> {
531 self.metrics.rejected = checked_increment(self.metrics.rejected)?;
532 Ok(PromotionAdmission::Rejected(rejection))
533 }
534}
535
536pub trait PromotionExecutor {
538 fn promote(
541 &self,
542 plan: &PromotionPlan,
543 budget: WorkBudget,
544 cancellation: &CancellationToken,
545 ) -> impl Future<Output = ObjectResult<()>>;
546}
547
548pub struct StorePromotionExecutor<S, D> {
555 source_location_id: StorageLocationId,
556 destination_location_id: StorageLocationId,
557 source: S,
558 destination: D,
559}
560
561impl<S, D> StorePromotionExecutor<S, D> {
562 pub fn new(
568 source_location_id: StorageLocationId,
569 source: S,
570 destination_location_id: StorageLocationId,
571 destination: D,
572 ) -> Result<Self, StorePromotionExecutorError> {
573 if source_location_id == destination_location_id {
574 return Err(StorePromotionExecutorError::SameLocation);
575 }
576 Ok(Self {
577 source_location_id,
578 destination_location_id,
579 source,
580 destination,
581 })
582 }
583
584 #[must_use]
586 pub const fn source(&self) -> &S {
587 &self.source
588 }
589
590 #[must_use]
592 pub const fn destination(&self) -> &D {
593 &self.destination
594 }
595}
596
597#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
599pub enum StorePromotionExecutorError {
600 #[error("promotion source and destination locations must differ")]
602 SameLocation,
603}
604
605impl<S: AsyncObjectStore, D: AsyncObjectStore> PromotionExecutor for StorePromotionExecutor<S, D> {
606 async fn promote(
607 &self,
608 plan: &PromotionPlan,
609 budget: WorkBudget,
610 cancellation: &CancellationToken,
611 ) -> ObjectResult<()> {
612 validate_executor_plan(plan, self.source_location_id, self.destination_location_id)?;
613 cancellation
614 .check()
615 .map_err(|_| ObjectFailure::before_work(ObjectStoreError::Cancelled))?;
616 let request = plan.candidate.request;
617 let read = self
618 .source
619 .read(
620 request.object_id,
621 request.maximum_bytes,
622 budget,
623 cancellation,
624 )
625 .await?;
626 let live_bytes = match read.value.retention {
627 ObjectReadRetention::Shared => 0,
628 ObjectReadRetention::Owned { logical_bytes } => logical_bytes,
629 };
630 let mut remaining = read
631 .work
632 .remaining(budget)
633 .map_err(|error| ObjectFailure::new(error.into(), read.work))?;
634 remaining.peak_allocation_bytes = remaining
635 .peak_allocation_bytes
636 .checked_sub(live_bytes)
637 .ok_or_else(|| {
638 ObjectFailure::new(
639 ObjectStoreError::Work(WorkError::BudgetExceeded {
640 counter: "peak_allocation_bytes",
641 observed: live_bytes,
642 maximum: budget.peak_allocation_bytes,
643 }),
644 read.work,
645 )
646 })?;
647 let prior = read.work;
648 let put = self
649 .destination
650 .put(request.object_id, read.value.bytes, remaining, cancellation)
651 .await
652 .map_err(|failure| merge_promotion_failure(prior, live_bytes, failure))?;
653 let work = merge_promotion_work(prior, live_bytes, put.work)
654 .map_err(|error| ObjectFailure::new(error.into(), prior))?;
655 work.verify(budget)
656 .map_err(|error| ObjectFailure::new(error.into(), work))?;
657 Ok(ObjectReceipt { value: (), work })
658 }
659}
660
661pub async fn execute_promotion<E: PromotionExecutor>(
668 speculator: &PromotionSpeculator,
669 executor: &E,
670 plan: &PromotionPlan,
671 budget: WorkBudget,
672 cancellation: &CancellationToken,
673) -> ObjectResult<()> {
674 if speculator.active.get(&plan.candidate.operation_id) != Some(plan) {
675 return Err(crate::performance::OperationFailure::before_work(
676 ObjectStoreError::Rejected(
677 "promotion speculation plan is stale or inactive".to_owned(),
678 ),
679 ));
680 }
681 executor.promote(plan, budget, cancellation).await
682}
683
684fn validate_executor_plan(
685 plan: &PromotionPlan,
686 source_location_id: StorageLocationId,
687 destination_location_id: StorageLocationId,
688) -> ObjectResult<()> {
689 if plan.source.location_id != source_location_id
690 || plan.destination.location_id != destination_location_id
691 || plan.source.object_id != plan.candidate.request.object_id
692 || source_location_id == destination_location_id
693 {
694 return Err(ObjectFailure::before_work(ObjectStoreError::Rejected(
695 "promotion plan does not match configured stores".to_owned(),
696 )));
697 }
698 Ok(ObjectReceipt {
699 value: (),
700 work: WorkCounters::default(),
701 })
702}
703
704fn merge_promotion_work(
705 prior: WorkCounters,
706 live_bytes: u64,
707 nested: WorkCounters,
708) -> Result<WorkCounters, WorkError> {
709 let simultaneous_peak = live_bytes
710 .checked_add(nested.peak_allocation_bytes)
711 .ok_or(WorkError::Overflow)?;
712 let mut combined = prior.checked_add(nested)?;
713 combined.peak_allocation_bytes = combined.peak_allocation_bytes.max(simultaneous_peak);
714 Ok(combined)
715}
716
717fn merge_promotion_failure(
718 prior: WorkCounters,
719 live_bytes: u64,
720 failure: ObjectFailure,
721) -> ObjectFailure {
722 match merge_promotion_work(prior, live_bytes, *failure.work) {
723 Ok(combined) => ObjectFailure::new(failure.error, combined),
724 Err(error) => ObjectFailure::new(error.into(), prior),
725 }
726}
727
728fn best_satisfied(
729 candidate: &PromotionCandidate,
730 residency: &[ObjectResidency],
731) -> Option<ObjectResidency> {
732 candidate.accepted_tiers.iter().find_map(|tier| {
733 residency
734 .iter()
735 .filter(|fact| fact.object_id == candidate.request.object_id && fact.tier == *tier)
736 .min_by_key(|fact| (fact.source_priority, fact.location_id))
737 .copied()
738 })
739}
740
741fn best_source(object_id: ObjectId, residency: &[ObjectResidency]) -> Option<ObjectResidency> {
742 residency
743 .iter()
744 .filter(|fact| fact.object_id == object_id)
745 .min_by_key(|fact| (fact.source_priority, fact.location_id))
746 .copied()
747}
748
749fn best_destination(
750 candidate: &PromotionCandidate,
751 source_location: StorageLocationId,
752 destinations: &[PromotionDestination],
753) -> Option<PromotionDestination> {
754 candidate.accepted_tiers.iter().find_map(|tier| {
755 destinations
756 .iter()
757 .filter(|destination| {
758 destination.tier == *tier
759 && destination.location_id != source_location
760 && destination.writable
761 && destination.maximum_object_bytes >= candidate.request.maximum_bytes
762 })
763 .min_by_key(|destination| (destination.priority, destination.location_id))
764 .copied()
765 })
766}
767
768fn has_duplicate_tiers(tiers: &[StorageTier]) -> bool {
769 tiers
770 .iter()
771 .enumerate()
772 .any(|(index, tier)| tiers.get(..index).unwrap_or(&[]).contains(tier))
773}
774
775fn has_duplicate_residency_locations(residency: &[ObjectResidency]) -> bool {
776 residency.iter().enumerate().any(|(index, fact)| {
777 residency
778 .get(..index)
779 .unwrap_or(&[])
780 .iter()
781 .any(|prior| prior.location_id == fact.location_id)
782 })
783}
784
785fn has_duplicate_destination_locations(destinations: &[PromotionDestination]) -> bool {
786 destinations.iter().enumerate().any(|(index, destination)| {
787 destinations
788 .get(..index)
789 .unwrap_or(&[])
790 .iter()
791 .any(|prior| prior.location_id == destination.location_id)
792 })
793}
794
795fn checked_increment(value: u64) -> Result<u64, PromotionSpeculatorError> {
796 value
797 .checked_add(1)
798 .ok_or(PromotionSpeculatorError::Overflow)
799}
800
801#[cfg(test)]
802#[path = "tests/promotion.rs"]
803mod tests;