1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3
4use lodestone_wire::{
5 ApplyIndexChangeRequest, IndexChangeApplyReceipt, IndexChangeResolutionState, ResolveIndexChangeRequest,
6 ResolveIndexChangeResult,
7};
8
9use crate::{CallOptions, Client, ClientError, SubmissionDisposition};
10
11#[derive(Clone, Debug, PartialEq)]
12pub struct IndexChangeExecution {
13 pub receipt: IndexChangeApplyReceipt,
14 pub recovered: bool,
15}
16
17#[derive(Clone, Debug, PartialEq)]
18pub struct IndexChangeRecovery {
19 request: ApplyIndexChangeRequest,
20 resolution: Option<ResolveIndexChangeResult>,
21}
22
23impl IndexChangeRecovery {
24 #[must_use]
25 pub const fn request(&self) -> &ApplyIndexChangeRequest {
26 &self.request
27 }
28
29 #[must_use]
30 pub const fn resolution(&self) -> Option<&ResolveIndexChangeResult> {
31 self.resolution.as_ref()
32 }
33}
34
35#[derive(Debug)]
36pub enum IndexChangeRecoveryError {
37 InvalidInput,
38 InvalidCoreOutcome,
39 Client(ClientError),
42 ExactReplayRequired(Box<IndexChangeRecovery>),
44 Indeterminate(Box<IndexChangeRecovery>),
46 Failed(Box<IndexChangeRecovery>),
47 KnownRetired(Box<IndexChangeRecovery>),
48 ExpiredUnknown(Box<IndexChangeRecovery>),
49}
50
51impl Client {
52 pub async fn execute_index_change(
56 &self,
57 request: ApplyIndexChangeRequest,
58 options: CallOptions,
59 ) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
60 validate_request(&request)?;
61 self.submit_exact_index_change(request, options, false).await
62 }
63
64 pub async fn replay_index_change(
67 &self,
68 recovery: IndexChangeRecovery,
69 options: CallOptions,
70 ) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
71 validate_recovery(&recovery)?;
72 if !recovery.resolution.as_ref().is_some_and(|resolution| {
73 matches!(
74 resolution.state,
75 IndexChangeResolutionState::NotSubmitted
76 | IndexChangeResolutionState::Prepared
77 | IndexChangeResolutionState::Pending
78 )
79 }) {
80 return Err(IndexChangeRecoveryError::InvalidInput);
81 }
82 self.submit_exact_index_change(recovery.request, options, true).await
83 }
84
85 pub async fn resume_index_change(
87 &self,
88 recovery: IndexChangeRecovery,
89 options: CallOptions,
90 ) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
91 validate_recovery(&recovery)?;
92 self.resolve_exact_index_change(recovery, options, true).await
93 }
94
95 async fn submit_exact_index_change(
96 &self,
97 request: ApplyIndexChangeRequest,
98 options: CallOptions,
99 recovered: bool,
100 ) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
101 match self.apply_index_change(request.clone(), options.clone()).await {
102 Ok(result) => {
103 validate_receipt(&request, &result.receipt)?;
104 Ok(IndexChangeExecution {
105 receipt: result.receipt,
106 recovered,
107 })
108 }
109 Err(error) if !recovered && error.submission_disposition() == SubmissionDisposition::NotSubmitted => {
110 Err(IndexChangeRecoveryError::Client(error))
111 }
112 Err(_) => {
113 self.resolve_exact_index_change(
114 IndexChangeRecovery {
115 request,
116 resolution: None,
117 },
118 options,
119 true,
120 )
121 .await
122 }
123 }
124 }
125
126 async fn resolve_exact_index_change(
127 &self,
128 mut recovery: IndexChangeRecovery,
129 options: CallOptions,
130 recovered: bool,
131 ) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
132 let resolution = self
133 .resolve_index_change(resolve_request(&recovery.request), options.for_resolution())
134 .await;
135 recovery.resolution = match resolution {
136 Ok(resolution) => Some(resolution),
137 Err(_) => return Err(IndexChangeRecoveryError::Indeterminate(Box::new(recovery))),
138 };
139 classify_resolution(recovery, recovered)
140 }
141}
142
143fn resolve_request(request: &ApplyIndexChangeRequest) -> ResolveIndexChangeRequest {
144 ResolveIndexChangeRequest {
145 action_id: request.action_id.clone(),
146 index: request.index.clone(),
147 expected_revision: request.expected_revision.clone(),
148 mapping: request.mapping.clone(),
149 policies: request.policies.clone(),
150 }
151}
152
153fn validate_request(request: &ApplyIndexChangeRequest) -> Result<(), IndexChangeRecoveryError> {
154 if request.action_id.validate().is_err()
155 || request.index.kind != "index"
156 || request.index.name.is_empty()
157 || request.index.incarnation.validate().is_err()
158 || request.expected_revision.validate().is_err()
159 || (request.mapping.is_some() == request.policies.is_some())
160 {
161 return Err(IndexChangeRecoveryError::InvalidInput);
162 }
163 if request
164 .mapping
165 .as_ref()
166 .is_some_and(|mapping| mapping.generation.validate().is_err())
167 {
168 return Err(IndexChangeRecoveryError::InvalidInput);
169 }
170 Ok(())
171}
172
173fn validate_recovery(recovery: &IndexChangeRecovery) -> Result<(), IndexChangeRecoveryError> {
174 validate_request(&recovery.request)?;
175 if let Some(resolution) = &recovery.resolution {
176 validate_resolution_shape(resolution)?;
177 if let Some(receipt) = &resolution.receipt {
178 validate_receipt(&recovery.request, receipt)?;
179 }
180 }
181 Ok(())
182}
183
184fn validate_receipt(
185 request: &ApplyIndexChangeRequest,
186 receipt: &IndexChangeApplyReceipt,
187) -> Result<(), IndexChangeRecoveryError> {
188 let expected_revision = request
189 .expected_revision
190 .value
191 .parse::<u64>()
192 .ok()
193 .and_then(|revision| revision.checked_add(1))
194 .map(|revision| revision.to_string())
195 .ok_or(IndexChangeRecoveryError::InvalidCoreOutcome)?;
196 let expected_state = if request.mapping.is_some() {
197 "mapping_applied"
198 } else {
199 "policy_applied"
200 };
201 if receipt.action_id != request.action_id
202 || receipt.semantic_digest != request.action_id
203 || receipt.index != request.index
204 || receipt.revision.value != expected_revision
205 || receipt.state != expected_state
206 || receipt.schema_generation.validate().is_err()
207 || request
208 .mapping
209 .as_ref()
210 .is_some_and(|mapping| mapping.generation != receipt.schema_generation)
211 {
212 return Err(IndexChangeRecoveryError::InvalidCoreOutcome);
213 }
214 Ok(())
215}
216
217fn validate_resolution_shape(resolution: &ResolveIndexChangeResult) -> Result<(), IndexChangeRecoveryError> {
218 let valid = match resolution.state {
219 IndexChangeResolutionState::Applied => resolution.receipt.is_some() && resolution.failure.is_none(),
220 IndexChangeResolutionState::Failed => resolution.receipt.is_none() && resolution.failure.is_some(),
221 IndexChangeResolutionState::NotSubmitted
222 | IndexChangeResolutionState::Prepared
223 | IndexChangeResolutionState::Pending
224 | IndexChangeResolutionState::KnownRetired
225 | IndexChangeResolutionState::ExpiredUnknown => resolution.receipt.is_none() && resolution.failure.is_none(),
226 };
227 valid.then_some(()).ok_or(IndexChangeRecoveryError::InvalidCoreOutcome)
228}
229
230fn classify_resolution(
231 recovery: IndexChangeRecovery,
232 recovered: bool,
233) -> Result<IndexChangeExecution, IndexChangeRecoveryError> {
234 validate_recovery(&recovery)?;
235 let resolution = recovery
236 .resolution
237 .as_ref()
238 .ok_or(IndexChangeRecoveryError::InvalidCoreOutcome)?;
239 match resolution.state {
240 IndexChangeResolutionState::Applied => Ok(IndexChangeExecution {
241 receipt: resolution
242 .receipt
243 .clone()
244 .ok_or(IndexChangeRecoveryError::InvalidCoreOutcome)?,
245 recovered,
246 }),
247 IndexChangeResolutionState::NotSubmitted
248 | IndexChangeResolutionState::Prepared
249 | IndexChangeResolutionState::Pending => Err(IndexChangeRecoveryError::ExactReplayRequired(Box::new(recovery))),
250 IndexChangeResolutionState::Failed => Err(IndexChangeRecoveryError::Failed(Box::new(recovery))),
251 IndexChangeResolutionState::KnownRetired => Err(IndexChangeRecoveryError::KnownRetired(Box::new(recovery))),
252 IndexChangeResolutionState::ExpiredUnknown => Err(IndexChangeRecoveryError::ExpiredUnknown(Box::new(recovery))),
253 }
254}
255
256impl Display for IndexChangeRecoveryError {
257 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
258 formatter.write_str(match self {
259 Self::InvalidInput => "index change request is not a valid current Lodestone API action",
260 Self::InvalidCoreOutcome => "Core changed the index action identity or recovery shape",
261 Self::Client(_) => "Core index change was not submitted",
262 Self::ExactReplayRequired(_) => "Core index change requires exact replay of the original action",
263 Self::Indeterminate(_) => "Core index change outcome remains indeterminate",
264 Self::Failed(_) => "Core index change reached a terminal failed receipt",
265 Self::KnownRetired(_) => "Core index incarnation is authoritatively retired",
266 Self::ExpiredUnknown(_) => "Core index change outcome evidence expired",
267 })
268 }
269}
270
271impl Error for IndexChangeRecoveryError {
272 fn source(&self) -> Option<&(dyn Error + 'static)> {
273 match self {
274 Self::Client(error) => Some(error),
275 Self::InvalidInput
276 | Self::InvalidCoreOutcome
277 | Self::ExactReplayRequired(_)
278 | Self::Indeterminate(_)
279 | Self::Failed(_)
280 | Self::KnownRetired(_)
281 | Self::ExpiredUnknown(_) => None,
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use lodestone_wire::{Digest, Identity, Resource, U64};
289
290 use super::*;
291
292 fn request() -> ApplyIndexChangeRequest {
293 ApplyIndexChangeRequest {
294 action_id: Digest { value: "a".repeat(64) },
295 index: Resource {
296 kind: "index".to_owned(),
297 name: "documents".to_owned(),
298 incarnation: Identity { value: "b".repeat(32) },
299 },
300 expected_revision: U64 { value: "7".to_owned() },
301 mapping: None,
302 policies: Some(lodestone_wire::IndexPolicies {
303 durability: "regional_object".to_owned(),
304 storage: "standard".to_owned(),
305 cache: "cold_tolerant".to_owned(),
306 default_visibility: Some("applied".to_owned()),
307 field_promotion: lodestone_wire::FieldPromotionConfiguration {
308 format_version: 3,
309 enabled: false,
310 minimum_query_count: 1,
311 maximum_promoted_fields: 1,
312 maximum_cardinality: U64 { value: "1".to_owned() },
313 maximum_extra_bytes: U64 { value: "1".to_owned() },
314 allow_sensitive: false,
315 },
316 overload: lodestone_wire::OverloadConfiguration {
317 format_version: 3,
318 mode: "reject".to_owned(),
319 maximum_backlog_bytes: U64 { value: "1".to_owned() },
320 maximum_backlog_age_millis: U64 { value: "1".to_owned() },
321 maximum_collection_share_bytes: U64 { value: "1".to_owned() },
322 maximum_recovery_ops_per_pass: 1,
323 maximum_pending_overlay_documents: U64 { value: "1".to_owned() },
324 },
325 }),
326 }
327 }
328
329 #[test]
330 fn pending_resolution_requires_exact_identity_replay() -> Result<(), Box<dyn Error>> {
331 let recovery = IndexChangeRecovery {
332 request: request(),
333 resolution: Some(ResolveIndexChangeResult {
334 state: IndexChangeResolutionState::Pending,
335 receipt: None,
336 failure: None,
337 }),
338 };
339 match classify_resolution(recovery, true) {
340 Err(IndexChangeRecoveryError::ExactReplayRequired(recovery)) => {
341 assert_eq!(recovery.request().action_id.value, "a".repeat(64));
342 Ok(())
343 }
344 _ => Err("pending resolution did not require exact replay".into()),
345 }
346 }
347
348 #[test]
349 fn applied_resolution_requires_exact_successor_receipt() -> Result<(), Box<dyn Error>> {
350 let request = request();
351 let receipt = IndexChangeApplyReceipt {
352 action_id: request.action_id.clone(),
353 semantic_digest: request.action_id.clone(),
354 index: request.index.clone(),
355 revision: U64 { value: "8".to_owned() },
356 schema_generation: U64 { value: "3".to_owned() },
357 state: "policy_applied".to_owned(),
358 };
359 let execution = classify_resolution(
360 IndexChangeRecovery {
361 request,
362 resolution: Some(ResolveIndexChangeResult {
363 state: IndexChangeResolutionState::Applied,
364 receipt: Some(receipt.clone()),
365 failure: None,
366 }),
367 },
368 true,
369 )?;
370 assert_eq!(execution.receipt, receipt);
371 assert!(execution.recovered);
372 Ok(())
373 }
374}