Skip to main content

lodestone_client/
bulk.rs

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    /// Preparation failed, or submission authoritatively reported that it did
64    /// not start. Replaying the caller's original batch is admitted.
65    Client(ClientError),
66    /// Submission may have started and the last authoritative bulk outcome
67    /// did not reach completion. Resolver-call failures stay in this phase
68    /// even when the read-only resolver itself reports `NotSubmitted`.
69    Indeterminate(Box<BulkRecovery>),
70    /// Resolution proved that at least one exact item was not submitted while
71    /// every other item is resolved or also not submitted. Replay must use
72    /// this recovery value so the batch, action, and locator identities stay
73    /// unchanged.
74    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    /// Prepares every item, submits the batch at most once, and resolves any
86    /// ambiguous or partial outcome by the stable per-item locators. Success
87    /// means every item is authoritatively resolved.
88    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    /// Retries only the exact batch, item actions, semantics, and locators
135    /// returned by an authoritative terminal resolution containing one or more
136    /// `NotSubmitted` items.
137    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    /// Continues read-only resolution for an exact possibly submitted batch.
152    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                        // Keep the last authoritative observation intact so a
216                        // failed resolver can return exact recovery state.
217                        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    // INVARIANT: the resolver describes an already possibly submitted batch.
249    // Its read-only call disposition cannot downgrade the original mutation to
250    // a caller-retryable pre-submission failure.
251    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}