1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3
4use lodestone_wire::{
5 BulkDocumentItem, BulkDocumentItemOutcome, BulkDocumentOutcome, Digest, PrepareDocumentBulkRequest,
6 ResolveDocumentBulkRequest, Resource, SubmissionState, SubmitDocumentBulkRequest,
7};
8
9use crate::{CallOptions, Client, ClientError, SubmissionDisposition};
10
11#[derive(Clone, Debug, PartialEq)]
12pub struct BulkExecution {
13 pub outcome: BulkDocumentOutcome,
14 pub recovered: bool,
15}
16
17#[derive(Clone, PartialEq)]
18pub struct BulkRecovery {
19 batch_id: Digest,
20 index: Resource,
21 items: Vec<BulkDocumentItem>,
22 outcome: BulkDocumentOutcome,
23}
24
25impl BulkRecovery {
26 #[must_use]
27 pub const fn batch_id(&self) -> &Digest {
28 &self.batch_id
29 }
30
31 #[must_use]
32 pub const fn index(&self) -> &Resource {
33 &self.index
34 }
35
36 #[must_use]
37 pub fn items(&self) -> &[BulkDocumentItem] {
38 &self.items
39 }
40
41 #[must_use]
42 pub const fn outcome(&self) -> &BulkDocumentOutcome {
43 &self.outcome
44 }
45}
46
47impl fmt::Debug for BulkRecovery {
48 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
49 formatter
50 .debug_struct("BulkRecovery")
51 .field("batch_id", &self.batch_id)
52 .field("index", &self.index)
53 .field("item_count", &self.items.len())
54 .field("outcome", &self.outcome)
55 .finish()
56 }
57}
58
59#[derive(Debug)]
60pub enum BulkRecoveryError {
61 InvalidInput,
62 InvalidCoreOutcome,
63 Client(ClientError),
66 Indeterminate(Box<BulkRecovery>),
70 ExactReplayRequired(Box<BulkRecovery>),
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum ResolutionProgress {
79 Resolved,
80 ExactReplayRequired,
81 Pending,
82}
83
84impl Client {
85 pub async fn execute_document_bulk(
89 &self,
90 batch_id: Digest,
91 index: Resource,
92 items: Vec<BulkDocumentItem>,
93 options: CallOptions,
94 ) -> Result<BulkExecution, BulkRecoveryError> {
95 validate_bulk_input(&batch_id, &index, &items)?;
96 let prepared = self
97 .prepare_document_bulk(
98 PrepareDocumentBulkRequest {
99 batch_id: batch_id.clone(),
100 index: index.clone(),
101 items: items.clone(),
102 },
103 options.clone(),
104 )
105 .await
106 .map_err(BulkRecoveryError::Client)?
107 .outcome;
108 validate_bulk_identity(&prepared, &batch_id, &index, &items)?;
109 if !prepared.complete
110 || prepared.items.iter().any(|item| {
111 item.state != SubmissionState::NotSubmitted
112 || item.locator.is_none()
113 || item.sequence.is_some()
114 || item.mapping_generation.is_some()
115 || item.mapping_adopted
116 || item.no_document_effect
117 || item.error.is_some()
118 })
119 {
120 return Err(BulkRecoveryError::InvalidCoreOutcome);
121 }
122
123 let preparations = prepared.items.clone();
124 let recovery = BulkRecovery {
125 batch_id,
126 index,
127 items,
128 outcome: prepared,
129 };
130 self.submit_exact_document_bulk(recovery, preparations, options, false)
131 .await
132 }
133
134 pub async fn replay_document_bulk(
138 &self,
139 recovery: BulkRecovery,
140 options: CallOptions,
141 ) -> Result<BulkExecution, BulkRecoveryError> {
142 validate_bulk_recovery(&recovery)?;
143 if resolution_progress(&recovery.outcome)? != ResolutionProgress::ExactReplayRequired {
144 return Err(BulkRecoveryError::InvalidInput);
145 }
146 let preparations = exact_replay_preparations(&recovery.outcome)?;
147 self.submit_exact_document_bulk(recovery, preparations, options, true)
148 .await
149 }
150
151 pub async fn resume_document_bulk(
153 &self,
154 recovery: BulkRecovery,
155 options: CallOptions,
156 ) -> Result<BulkExecution, BulkRecoveryError> {
157 validate_bulk_recovery(&recovery)?;
158 self.resolve_exact_document_bulk(recovery, options, true).await
159 }
160
161 async fn submit_exact_document_bulk(
162 &self,
163 mut recovery: BulkRecovery,
164 preparations: Vec<BulkDocumentItemOutcome>,
165 options: CallOptions,
166 exact_replay: bool,
167 ) -> Result<BulkExecution, BulkRecoveryError> {
168 let submitted = self
169 .submit_document_bulk(
170 SubmitDocumentBulkRequest {
171 batch_id: recovery.batch_id.clone(),
172 index: recovery.index.clone(),
173 items: recovery.items.clone(),
174 preparations,
175 },
176 options.clone(),
177 )
178 .await;
179 match submitted {
180 Ok(result) => {
181 validate_bulk_identity(&result.outcome, &recovery.batch_id, &recovery.index, &recovery.items)?;
182 validate_submit_completion(&result.outcome)?;
183 if result.outcome.complete {
184 return Ok(BulkExecution {
185 outcome: result.outcome,
186 recovered: exact_replay,
187 });
188 }
189 recovery.outcome = result.outcome;
190 }
191 Err(error) if error.submission_disposition() != SubmissionDisposition::NotSubmitted => {}
192 Err(_error) if exact_replay => {
193 return Err(BulkRecoveryError::ExactReplayRequired(Box::new(recovery)));
194 }
195 Err(error) => return Err(BulkRecoveryError::Client(error)),
196 }
197
198 self.resolve_exact_document_bulk(recovery, options, true).await
199 }
200
201 async fn resolve_exact_document_bulk(
202 &self,
203 mut recovery: BulkRecovery,
204 options: CallOptions,
205 recovered: bool,
206 ) -> Result<BulkExecution, BulkRecoveryError> {
207 let maximum_resolutions = usize::from(self.pool_config().maximum_read_retries).saturating_add(1);
208 for _ in 0..maximum_resolutions {
209 let previous = recovery.outcome.clone();
210 let resolution = self
211 .resolve_document_bulk(
212 ResolveDocumentBulkRequest {
213 batch_id: recovery.batch_id.clone(),
214 index: recovery.index.clone(),
215 items: recovery.outcome.items.clone(),
218 },
219 options.for_resolution(),
220 )
221 .await;
222 recovery.outcome = match resolution {
223 Ok(result) => result.outcome,
224 Err(error) => return Err(indeterminate_resolution_failure(recovery, error)),
225 };
226 validate_bulk_recovery(&recovery)?;
227 match resolution_progress(&recovery.outcome)? {
228 ResolutionProgress::Resolved => {
229 return Ok(BulkExecution {
230 outcome: recovery.outcome,
231 recovered,
232 });
233 }
234 ResolutionProgress::ExactReplayRequired => {
235 return Err(BulkRecoveryError::ExactReplayRequired(Box::new(recovery)));
236 }
237 ResolutionProgress::Pending => {}
238 }
239 if recovery.outcome == previous {
240 break;
241 }
242 }
243 Err(BulkRecoveryError::Indeterminate(Box::new(recovery)))
244 }
245}
246
247fn indeterminate_resolution_failure(recovery: BulkRecovery, _resolver_error: ClientError) -> BulkRecoveryError {
248 BulkRecoveryError::Indeterminate(Box::new(recovery))
252}
253
254fn validate_bulk_recovery(recovery: &BulkRecovery) -> Result<(), BulkRecoveryError> {
255 validate_bulk_input(&recovery.batch_id, &recovery.index, &recovery.items)?;
256 validate_bulk_identity(&recovery.outcome, &recovery.batch_id, &recovery.index, &recovery.items)
257}
258
259fn exact_replay_preparations(outcome: &BulkDocumentOutcome) -> Result<Vec<BulkDocumentItemOutcome>, BulkRecoveryError> {
260 outcome
261 .items
262 .iter()
263 .map(|item| {
264 if !matches!(item.state, SubmissionState::NotSubmitted | SubmissionState::Resolved) {
265 return Err(BulkRecoveryError::InvalidCoreOutcome);
266 }
267 let locator = item.locator.clone().ok_or(BulkRecoveryError::InvalidCoreOutcome)?;
268 Ok(BulkDocumentItemOutcome {
269 item_action_id: item.item_action_id.clone(),
270 locator: Some(locator),
271 state: SubmissionState::NotSubmitted,
272 sequence: None,
273 mapping_generation: None,
274 mapping_adopted: false,
275 no_document_effect: false,
276 error: None,
277 })
278 })
279 .collect()
280}
281
282fn validate_bulk_input(
283 batch_id: &Digest,
284 index: &Resource,
285 items: &[BulkDocumentItem],
286) -> Result<(), BulkRecoveryError> {
287 if batch_id.validate().is_err()
288 || index.kind != "index"
289 || index.name.is_empty()
290 || index.incarnation.validate().is_err()
291 || items.is_empty()
292 || items.len() > 1_000
293 || items.iter().any(|item| item.item_action_id.validate().is_err())
294 {
295 return Err(BulkRecoveryError::InvalidInput);
296 }
297 let mut action_ids = std::collections::BTreeSet::new();
298 if items
299 .iter()
300 .any(|item| !action_ids.insert(item.item_action_id.value.as_str()))
301 {
302 return Err(BulkRecoveryError::InvalidInput);
303 }
304 Ok(())
305}
306
307fn validate_bulk_identity(
308 outcome: &BulkDocumentOutcome,
309 batch_id: &Digest,
310 index: &Resource,
311 items: &[BulkDocumentItem],
312) -> Result<(), BulkRecoveryError> {
313 if &outcome.batch_id != batch_id || &outcome.index != index || outcome.items.len() != items.len() {
314 return Err(BulkRecoveryError::InvalidCoreOutcome);
315 }
316 for (expected, observed) in items.iter().zip(&outcome.items) {
317 if observed.item_action_id != expected.item_action_id || !valid_item_outcome(observed) {
318 return Err(BulkRecoveryError::InvalidCoreOutcome);
319 }
320 }
321 Ok(())
322}
323
324fn valid_item_outcome(item: &BulkDocumentItemOutcome) -> bool {
325 item.item_action_id.validate().is_ok()
326 && match item.state {
327 SubmissionState::NotSubmitted => item.sequence.is_none(),
328 SubmissionState::Submitted | SubmissionState::Indeterminate => item.locator.is_some(),
329 SubmissionState::Resolved => item.locator.is_some(),
330 SubmissionState::KnownRetired => item.locator.is_some() && item.error.is_some(),
331 }
332}
333
334fn validate_submit_completion(outcome: &BulkDocumentOutcome) -> Result<(), BulkRecoveryError> {
335 let fully_resolved = outcome.items.iter().all(|item| item.state == SubmissionState::Resolved);
336 if outcome.complete != fully_resolved {
337 return Err(BulkRecoveryError::InvalidCoreOutcome);
338 }
339 Ok(())
340}
341
342fn validate_resolution_completion(outcome: &BulkDocumentOutcome) -> Result<(), BulkRecoveryError> {
343 let terminal = outcome
344 .items
345 .iter()
346 .all(|item| matches!(item.state, SubmissionState::NotSubmitted | SubmissionState::Resolved));
347 if outcome.complete != terminal {
348 return Err(BulkRecoveryError::InvalidCoreOutcome);
349 }
350 Ok(())
351}
352
353fn resolution_progress(outcome: &BulkDocumentOutcome) -> Result<ResolutionProgress, BulkRecoveryError> {
354 validate_resolution_completion(outcome)?;
355 if outcome.items.iter().all(|item| item.state == SubmissionState::Resolved) {
356 return Ok(ResolutionProgress::Resolved);
357 }
358 if outcome.complete {
359 return Ok(ResolutionProgress::ExactReplayRequired);
360 }
361 Ok(ResolutionProgress::Pending)
362}
363
364impl Display for BulkRecoveryError {
365 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
366 formatter.write_str(match self {
367 Self::InvalidInput => "bulk request is not a valid current Lodestone API batch",
368 Self::InvalidCoreOutcome => "Core changed the bulk identity, order, or submission shape",
369 Self::Client(_) => "Core bulk execution failed",
370 Self::Indeterminate(_) => "Core bulk outcome remains indeterminate after submission may have occurred",
371 Self::ExactReplayRequired(_) => {
372 "Core bulk outcome requires exact replay of the original batch and item identities"
373 }
374 })
375 }
376}
377
378impl Error for BulkRecoveryError {}
379
380#[cfg(test)]
381mod tests {
382 use lodestone_wire::{Digest, Identity, Locator, Resource};
383
384 use super::*;
385
386 fn outcome(states: &[SubmissionState], complete: bool) -> BulkDocumentOutcome {
387 BulkDocumentOutcome {
388 batch_id: Digest { value: "a".repeat(64) },
389 index: Resource {
390 kind: "index".to_owned(),
391 name: "events".to_owned(),
392 incarnation: Identity { value: "b".repeat(32) },
393 },
394 items: states
395 .iter()
396 .enumerate()
397 .map(|(ordinal, state)| BulkDocumentItemOutcome {
398 item_action_id: Digest {
399 value: format!("{:064x}", ordinal + 1),
400 },
401 locator: Some(Locator {
402 authority: "document".to_owned(),
403 value: format!("locator-{ordinal}"),
404 }),
405 state: *state,
406 sequence: None,
407 mapping_generation: None,
408 mapping_adopted: false,
409 no_document_effect: false,
410 error: None,
411 })
412 .collect(),
413 complete,
414 achievement: None,
415 }
416 }
417
418 fn recovery(states: &[SubmissionState], complete: bool) -> BulkRecovery {
419 let outcome = outcome(states, complete);
420 BulkRecovery {
421 batch_id: outcome.batch_id.clone(),
422 index: outcome.index.clone(),
423 items: outcome
424 .items
425 .iter()
426 .enumerate()
427 .map(|(ordinal, item)| BulkDocumentItem {
428 item_action_id: item.item_action_id.clone(),
429 document_id: format!("document-{ordinal}"),
430 operation: "delete".to_owned(),
431 source: None,
432 record: None,
433 routing: None,
434 condition: None,
435 pipeline: None,
436 semantic_digest: Digest {
437 value: format!("{:064x}", ordinal + 100),
438 },
439 })
440 .collect(),
441 outcome,
442 }
443 }
444
445 #[test]
446 fn partial_submit_is_recovered_instead_of_rejected_as_a_false_completion() {
447 let partial = outcome(&[SubmissionState::Resolved, SubmissionState::NotSubmitted], false);
448 assert!(validate_submit_completion(&partial).is_ok());
449 assert!(validate_resolution_completion(&partial).is_err());
450
451 let false_complete = outcome(&[SubmissionState::NotSubmitted], true);
452 assert!(validate_submit_completion(&false_complete).is_err());
453 }
454
455 #[test]
456 fn resolver_failure_preserves_the_last_authoritative_outcome_as_indeterminate() {
457 let authoritative = recovery(&[SubmissionState::Indeterminate], false);
458 let resolver_error = ClientError::ProfileNotVerified;
459 assert_eq!(
460 resolver_error.submission_disposition(),
461 SubmissionDisposition::NotSubmitted
462 );
463 let error = indeterminate_resolution_failure(authoritative.clone(), resolver_error);
464
465 assert!(matches!(&error, BulkRecoveryError::Indeterminate(_)));
466 if let BulkRecoveryError::Indeterminate(observed) = error {
467 assert_eq!(*observed, authoritative);
468 }
469 }
470
471 #[test]
472 fn all_not_submitted_resolution_requires_exact_replay_instead_of_success() -> Result<(), BulkRecoveryError> {
473 let outcome = outcome(&[SubmissionState::NotSubmitted, SubmissionState::NotSubmitted], true);
474 assert_eq!(resolution_progress(&outcome)?, ResolutionProgress::ExactReplayRequired);
475 Ok(())
476 }
477
478 #[test]
479 fn mixed_resolved_and_not_submitted_resolution_requires_exact_replay() -> Result<(), BulkRecoveryError> {
480 let outcome = outcome(&[SubmissionState::Resolved, SubmissionState::NotSubmitted], true);
481 assert_eq!(resolution_progress(&outcome)?, ResolutionProgress::ExactReplayRequired);
482 let preparations = exact_replay_preparations(&outcome)?;
483 assert_eq!(preparations.len(), outcome.items.len());
484 for (replayed, authoritative) in preparations.iter().zip(&outcome.items) {
485 assert_eq!(replayed.item_action_id, authoritative.item_action_id);
486 assert_eq!(replayed.locator, authoritative.locator);
487 assert_eq!(replayed.state, SubmissionState::NotSubmitted);
488 assert!(replayed.sequence.is_none());
489 }
490 Ok(())
491 }
492}