ic_backup/policy/execution_progress/
mod.rs1use crate::model::{
4 artifacts::ArtifactChecksumRecord,
5 attempt_journal::{AttemptJournalRecord, AttemptJournalView},
6 effect_graph::MAX_EFFECT_OPERATIONS,
7 operation_plan::OperationPlanRecord,
8};
9use serde::Serialize;
10use std::collections::BTreeMap;
11use thiserror::Error;
12
13#[derive(Clone, Debug)]
15pub struct ExecutionProgressRequest<'a> {
16 pub plan: &'a OperationPlanRecord,
18 pub journals: &'a [&'a AttemptJournalRecord],
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum OperationProgressState {
26 AwaitingDependencies,
28 MutationAvailable,
30 MutationExhausted,
32 MutationUnresolved,
34 ObservationUnresolved,
36 ReconciliationExhausted,
38 Applied,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
44pub struct OperationProgressView {
45 pub operation_sequence: u64,
47 pub state: OperationProgressState,
49 pub attempts: AttemptJournalView,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
55pub struct ExecutionAttemptTotalsView {
56 pub mutations_used: u32,
58 pub observations_used: u32,
60 pub mutations_remaining: u32,
62 pub observations_remaining: u32,
64}
65
66#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71pub struct ExecutionProgressView {
72 pub intent: ArtifactChecksumRecord,
74 pub graph: ArtifactChecksumRecord,
76 pub applied_operations: usize,
78 pub attempts: ExecutionAttemptTotalsView,
80 pub operations: Vec<OperationProgressView>,
82}
83
84impl ExecutionProgressView {
85 pub(crate) const fn is_unattempted(&self) -> bool {
86 self.attempts.mutations_used == 0 && self.attempts.observations_used == 0
87 }
88
89 pub(crate) fn all_applied(&self) -> bool {
90 self.applied_operations == self.operations.len()
91 }
92}
93
94pub fn progress(
105 request: &ExecutionProgressRequest<'_>,
106) -> Result<ExecutionProgressView, ExecutionProgressError> {
107 Ok(progress_with_journals(request.plan, request.journals.iter().copied())?.0)
108}
109
110pub(crate) fn progress_with_journals<'a>(
113 plan: &OperationPlanRecord,
114 originals: impl ExactSizeIterator<Item = &'a AttemptJournalRecord>,
115) -> Result<
116 (
117 ExecutionProgressView,
118 BTreeMap<u64, &'a AttemptJournalRecord>,
119 ),
120 ExecutionProgressError,
121> {
122 let (intent, journals) = admit_journals(plan, originals)?;
123 let is_applied = |sequence: &u64| {
124 journals
125 .get(sequence)
126 .is_some_and(|journal| journal.applied_receipt().is_some())
127 };
128 let mut totals = ExecutionAttemptTotalsView {
129 mutations_used: 0,
130 observations_used: 0,
131 mutations_remaining: 0,
132 observations_remaining: 0,
133 };
134 let mut operations = Vec::with_capacity(journals.len());
135 for node in plan.graph().ordered_nodes() {
136 let sequence = node.operation_sequence();
137 let attempts = journals
139 .get(&sequence)
140 .ok_or(ExecutionProgressError::MissingJournal(sequence))?
141 .view();
142 let unmet = node
143 .depends_on()
144 .iter()
145 .find(|dependency| !is_applied(dependency));
146 if attempts.mutations_used != 0
147 && let Some(prerequisite) = unmet
148 {
149 return Err(ExecutionProgressError::PrematureAttempt {
150 operation_sequence: sequence,
151 prerequisite: *prerequisite,
152 });
153 }
154 totals.add(&attempts)?;
155 operations.push(OperationProgressView {
156 operation_sequence: sequence,
157 state: condition(&attempts, unmet.is_some()),
158 attempts,
159 });
160 }
161 let applied_operations = operations
162 .iter()
163 .filter(|operation| operation.attempts.applied)
164 .count();
165 Ok((
166 ExecutionProgressView {
167 intent,
168 graph: plan.graph().digest(),
169 applied_operations,
170 attempts: totals,
171 operations,
172 },
173 journals,
174 ))
175}
176fn admit_journals<'a>(
178 plan: &OperationPlanRecord,
179 originals: impl ExactSizeIterator<Item = &'a AttemptJournalRecord>,
180) -> Result<
181 (
182 ArtifactChecksumRecord,
183 BTreeMap<u64, &'a AttemptJournalRecord>,
184 ),
185 ExecutionProgressError,
186> {
187 if originals.len() > MAX_EFFECT_OPERATIONS {
188 return Err(ExecutionProgressError::TooManyJournals);
189 }
190 let intent = plan.digest();
191 let mut journals = BTreeMap::new();
192 for journal in originals {
193 let sequence = journal.authority().binding().operation_sequence();
194 let operation = plan
195 .operation(sequence)
196 .map_err(|_| ExecutionProgressError::UnknownOperation(sequence))?;
197 if journals.contains_key(&sequence) {
198 return Err(ExecutionProgressError::DuplicateJournal(sequence));
199 }
200 if !plan.matches_authority(operation, &intent, journal.authority()) {
201 return Err(ExecutionProgressError::AuthorityMismatch(sequence));
202 }
203 journals.insert(sequence, journal);
204 }
205 for operation in plan.operations() {
206 if !journals.contains_key(&operation.operation_sequence()) {
207 return Err(ExecutionProgressError::MissingJournal(
208 operation.operation_sequence(),
209 ));
210 }
211 }
212 Ok((intent, journals))
213}
214
215impl ExecutionAttemptTotalsView {
216 fn add(&mut self, attempts: &AttemptJournalView) -> Result<(), ExecutionProgressError> {
217 self.mutations_used = sum(self.mutations_used, attempts.mutations_used)?;
218 self.observations_used = sum(self.observations_used, attempts.observations_used)?;
219 self.mutations_remaining = sum(self.mutations_remaining, attempts.mutations_remaining)?;
220 self.observations_remaining =
221 sum(self.observations_remaining, attempts.observations_remaining)?;
222 Ok(())
223 }
224}
225fn sum(left: u32, right: u32) -> Result<u32, ExecutionProgressError> {
226 left.checked_add(right)
227 .ok_or(ExecutionProgressError::AccountingOverflow)
228}
229fn condition(attempts: &AttemptJournalView, unmet_dependencies: bool) -> OperationProgressState {
230 if attempts.applied {
231 OperationProgressState::Applied
232 } else if attempts.pending_observation.is_some() {
233 OperationProgressState::ObservationUnresolved
234 } else if attempts.pending_mutation.is_some() {
235 if attempts.observations_remaining == 0 {
236 OperationProgressState::ReconciliationExhausted
237 } else {
238 OperationProgressState::MutationUnresolved
239 }
240 } else if unmet_dependencies {
241 OperationProgressState::AwaitingDependencies
242 } else if attempts.mutations_remaining == 0 {
243 OperationProgressState::MutationExhausted
244 } else {
245 OperationProgressState::MutationAvailable
246 }
247}
248
249#[derive(Debug, Error, Eq, PartialEq)]
251pub enum ExecutionProgressError {
252 #[error("execution progress exceeds {MAX_EFFECT_OPERATIONS} journals")]
254 TooManyJournals,
255 #[error("duplicate journal for operation {0}")]
257 DuplicateJournal(u64),
258 #[error("journal operation {0} is absent from the original plan")]
260 UnknownOperation(u64),
261 #[error("missing original journal for operation {0}")]
263 MissingJournal(u64),
264 #[error("original journal authority mismatch for operation {0}")]
266 AuthorityMismatch(u64),
267 #[error(
269 "operation {operation_sequence} was attempted without applied prerequisite {prerequisite}"
270 )]
271 PrematureAttempt {
272 operation_sequence: u64,
274 prerequisite: u64,
276 },
277 #[error("execution attempt accounting overflow")]
279 AccountingOverflow,
280}
281
282#[cfg(test)]
283mod tests;