Skip to main content

acyclic_fs/speculation/
promotion.rs

1//! Capability-aware planning for speculative movement between storage locations.
2
3use 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/// Opaque identity of one caller-configured storage location.
19#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct StorageLocationId([u8; 16]);
21
22impl StorageLocationId {
23    /// Constructs a location identity from canonical bytes.
24    #[must_use]
25    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
26        Self(bytes)
27    }
28
29    /// Returns canonical location bytes.
30    #[must_use]
31    pub const fn into_bytes(self) -> [u8; 16] {
32        self.0
33    }
34}
35
36/// Generic storage class. It does not imply a cloud, machine, or provider.
37#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub enum StorageTier {
39    /// Process-local disposable acceleration.
40    ProcessMemory,
41    /// Node-local durable or disposable object acceleration.
42    NodeLocal,
43    /// Shared low-latency cache selected by a downstream control plane.
44    SharedCache,
45    /// Durable origin capable of restoring the object after cache loss.
46    DurableOrigin,
47}
48
49/// Caller-observed exact object residency at one opaque location.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct ObjectResidency {
52    /// Authenticated immutable object.
53    pub object_id: ObjectId,
54    /// Opaque configured location.
55    pub location_id: StorageLocationId,
56    /// Generic tier represented by that location.
57    pub tier: StorageTier,
58    /// Deterministic preference when selecting a readable source.
59    pub source_priority: u16,
60}
61
62/// One available destination and its exact movement capability.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct PromotionDestination {
65    /// Opaque configured location.
66    pub location_id: StorageLocationId,
67    /// Generic tier represented by that location.
68    pub tier: StorageTier,
69    /// Whether the adapter currently admits immutable-object publication.
70    pub writable: bool,
71    /// Maximum complete canonical object accepted.
72    pub maximum_object_bytes: u64,
73    /// Deterministic preference within one requested tier.
74    pub priority: u16,
75    /// Conservative caller-supplied movement cost per canonical byte.
76    pub cost_units_per_byte: u64,
77}
78
79/// Generation-bound request produced from a residency prediction.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct PromotionCandidate {
82    /// Stable idempotency identity retained through execution and retry.
83    pub operation_id: OperationId,
84    /// Independently configured volume.
85    pub volume_id: VolumeId,
86    /// Immutable generation that motivated movement.
87    pub generation_id: GenerationId,
88    /// Exact authenticated object and complete-byte bound.
89    pub request: ObjectReadRequest,
90    /// Ordered destination tiers, most preferred first.
91    pub accepted_tiers: Vec<StorageTier>,
92    /// Original prediction source retained for policy and diagnostics.
93    pub reason: ResidencyReason,
94}
95
96impl PromotionCandidate {
97    /// Converts an admitted residency prediction into a promotion request.
98    #[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/// One bounded idempotent movement selected from caller-supplied facts.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct PromotionPlan {
115    /// Complete generation-bound request.
116    pub candidate: PromotionCandidate,
117    /// Existing exact source selected deterministically.
118    pub source: ObjectResidency,
119    /// Writable destination selected deterministically.
120    pub destination: PromotionDestination,
121    /// Conservative maximum movement cost.
122    pub estimated_cost_units: u64,
123}
124
125/// Hard bounds for one promotion planner.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct PromotionSpeculatorOptions {
128    /// Maximum concurrently executing plans.
129    pub maximum_active_operations: u32,
130    /// Maximum canonical bytes across active plans.
131    pub maximum_active_bytes: u64,
132    /// Maximum estimated movement cost across active plans.
133    pub maximum_active_cost_units: u64,
134    /// Maximum residency facts accepted per candidate.
135    pub maximum_residency_facts: u32,
136    /// Maximum destination capabilities accepted per candidate.
137    pub maximum_destinations: u32,
138    /// Maximum ordered tiers accepted per candidate.
139    pub maximum_accepted_tiers: u32,
140    /// Number of recent terminal outcomes retained.
141    pub outcome_window: u32,
142    /// Outcomes required before adaptive rejection is enabled.
143    pub minimum_usefulness_samples: u32,
144    /// Minimum useful plans per 10,000 terminal plans.
145    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/// Stable promotion configuration, state, or feedback error.
188#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
189pub enum PromotionSpeculatorError {
190    /// One hard bound is zero.
191    #[error("promotion speculation bounds must be positive")]
192    ZeroBound,
193    /// Adaptive policy cannot reach its minimum sample count.
194    #[error("promotion usefulness window is smaller than its minimum sample count")]
195    UsefulnessWindowTooSmall,
196    /// A configured ratio exceeds 10,000 basis points.
197    #[error("promotion usefulness basis points exceed 10,000")]
198    InvalidBasisPoints,
199    /// Exact accounting cannot be represented.
200    #[error("promotion speculation accounting overflowed")]
201    Overflow,
202    /// Terminal feedback did not identify an active plan.
203    #[error("promotion speculation plan is not active")]
204    UnknownPlan,
205}
206
207/// Typed reason why planning performed no movement.
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum PromotionRejection {
210    /// Candidate belongs to another volume.
211    WrongVolume,
212    /// Candidate belongs to a generation no longer selected.
213    StaleGeneration,
214    /// Request or accepted-tier list is empty, duplicated, or over bound.
215    InvalidRequest,
216    /// Residency or destination input exceeds its explicit bound.
217    InputCapacity,
218    /// No observed readable source contains the exact object.
219    MissingSource,
220    /// The same immutable object already has an active movement plan.
221    DuplicateObject,
222    /// The same idempotency identity already names an active movement plan.
223    DuplicateOperation,
224    /// Concurrent operation, byte, or estimated-cost capacity is exhausted.
225    ActiveCapacity,
226    /// No writable compatible destination can accept the complete object.
227    NoDestination,
228    /// Recent movement has not been useful enough.
229    LowUsefulness,
230}
231
232/// Exact planning outcome.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub enum PromotionAdmission {
235    /// An accepted tier already contains the exact object.
236    Satisfied(ObjectResidency),
237    /// A bounded idempotent movement may execute.
238    Planned(PromotionPlan),
239    /// No movement may begin.
240    Rejected(PromotionRejection),
241}
242
243/// Cumulative and current payload-free promotion observations.
244#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
245pub struct PromotionMetrics {
246    /// Candidates evaluated.
247    pub candidates: u64,
248    /// Candidates already satisfied.
249    pub satisfied: u64,
250    /// Movement plans admitted.
251    pub planned: u64,
252    /// Active plans.
253    pub active: u64,
254    /// Canonical bytes represented by active plans.
255    pub active_bytes: u64,
256    /// Conservative cost represented by active plans.
257    pub active_cost_units: u64,
258    /// Plans consumed by later foreground demand.
259    pub useful: u64,
260    /// Plans failed, preempted, or expired unused.
261    pub wasted: u64,
262    /// Candidate rejections.
263    pub rejected: u64,
264}
265
266/// Disposable capability-aware promotion planner for one volume generation.
267#[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    /// Creates one planner bound to an exact volume generation.
279    ///
280    /// # Errors
281    ///
282    /// Rejects zero, contradictory, or out-of-range policy bounds.
283    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    /// Selects at most one movement from bounded residency and capability facts.
299    ///
300    /// # Errors
301    ///
302    /// Fails closed if exact cost or metric accounting cannot be represented.
303    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    /// Completes one active plan exactly once.
382    ///
383    /// # Errors
384    ///
385    /// Rejects unknown or duplicate terminal feedback and accounting overflow.
386    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    /// Preempts every active plan and returns executor cancellation identities.
434    ///
435    /// # Errors
436    ///
437    /// Fails closed if terminal accounting cannot be represented.
438    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    /// Fences all old plans and selects a new immutable generation.
454    ///
455    /// # Errors
456    ///
457    /// Fails closed if preemption accounting cannot be represented.
458    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    /// Returns exact current and cumulative metrics.
470    #[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
536/// Backend-specific execution boundary for one already selected plan.
537pub trait PromotionExecutor {
538    /// Copies, authenticates, and durably admits the exact object at the plan's
539    /// destination, retaining the operation identity for idempotent retry.
540    fn promote(
541        &self,
542        plan: &PromotionPlan,
543        budget: WorkBudget,
544        cancellation: &CancellationToken,
545    ) -> impl Future<Output = ObjectResult<()>>;
546}
547
548/// Canonical zero-copy-capable executor between two configured object stores.
549///
550/// The executor binds opaque plan locations to exact store handles. It reads
551/// through the source's authenticated object contract and moves the returned
552/// `Bytes` directly into idempotent destination admission. It never discovers
553/// objects, chooses infrastructure, or retains a second body.
554pub 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    /// Binds two distinct opaque locations to their exact store handles.
563    ///
564    /// # Errors
565    ///
566    /// Rejects a self-copy configuration before either store can be accessed.
567    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    /// Returns the exact configured source handle.
585    #[must_use]
586    pub const fn source(&self) -> &S {
587        &self.source
588    }
589
590    /// Returns the exact configured destination handle.
591    #[must_use]
592    pub const fn destination(&self) -> &D {
593        &self.destination
594    }
595}
596
597/// Invalid canonical store-promotion composition.
598#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
599pub enum StorePromotionExecutorError {
600    /// Source and destination must identify distinct configured locations.
601    #[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
661/// Executes one selected plan without changing planner or filesystem authority.
662///
663/// # Errors
664///
665/// Returns the executor's typed storage, cancellation, authentication, or work
666/// failure together with all exact work completed before failure.
667pub 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;