1use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use aws_lc_rs::digest;
14use lodestone_wire::{
15 Digest, ErrorCode, GetIndexRequest, Identity, IndexResourceSpec, Locator, MutationOutcome,
16 ObserveAppendCommittedRequest, PrepareAppendBulkRequest, PrepareCreateIndexRequest, PrepareDocumentMutationRequest,
17 ResolveAppendBulkRequest, ResolveCatalogActionRequest, ResolveDocumentMutationRequest, Resource,
18 RetainedAppendBulkOutcome, RetainedAppendItem, RetainedAppendItemOutcome, RetainedAppendObservationOutcome,
19 RetainedAppendObservationState, RetainedAppendRetrySafety, RetainedAppendState, SubmitAppendBulkRequest,
20 SubmitCatalogActionRequest, SubmitDocumentMutationRequest, U64,
21};
22use serde::{Deserialize, Serialize};
23
24use crate::workflow_journal::sealed;
25use crate::{
26 CallCancellation, CallOptions, Client, ClientError, LocalOperatorWorkflowKind, SubmissionDisposition,
27 WorkflowJournal, WorkflowJournalError, WorkflowJournalIntent, WorkflowJournalTransition,
28};
29
30pub const EXACT_MUTATION_FORMAT_VERSION: u16 = 1;
31const MAX_EFFECT_CALL_MILLIS: u64 = 599_000;
32
33#[must_use]
39pub const fn create_index_action_kind(retained_event_identity: Option<bool>) -> &'static str {
40 if matches!(retained_event_identity, Some(true)) {
41 "create_retained_append_log_index"
42 } else {
43 "create_index"
44 }
45}
46
47#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ExactMutationKindV1 {
51 Append,
52 Document,
53 Index,
54}
55
56#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ExactMutationOutcomeV1 {
60 AlreadySatisfied,
61 Applied,
62 Resolved,
63 Conflict,
64 NotSubmitted,
65 Indeterminate,
66 KnownRetired,
67 EffectDeadlineExpired,
68 ClockRegression,
69}
70
71#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73enum ExactSubmissionDispositionV1 {
74 NotSubmitted,
75 Submitted,
76 Resolved,
77 KnownRetired,
78 Indeterminate,
79}
80
81impl From<SubmissionDisposition> for ExactSubmissionDispositionV1 {
82 fn from(value: SubmissionDisposition) -> Self {
83 match value {
84 SubmissionDisposition::NotSubmitted => Self::NotSubmitted,
85 SubmissionDisposition::Submitted => Self::Submitted,
86 SubmissionDisposition::Resolved => Self::Resolved,
87 SubmissionDisposition::KnownRetired => Self::KnownRetired,
88 SubmissionDisposition::Indeterminate => Self::Indeterminate,
89 }
90 }
91}
92
93fn exact_submission_disposition(outcome: &MutationOutcome) -> ExactSubmissionDispositionV1 {
94 match outcome {
95 MutationOutcome::Terminal => ExactSubmissionDispositionV1::Resolved,
96 MutationOutcome::Pending { .. } => ExactSubmissionDispositionV1::Submitted,
97 MutationOutcome::Indeterminate { .. } | MutationOutcome::ResolutionRequired { .. } => {
98 ExactSubmissionDispositionV1::Indeterminate
99 }
100 }
101}
102
103#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
106#[serde(deny_unknown_fields)]
107pub struct AppendExactItemResultV1 {
108 pub item_action_id: Digest,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub locator: Option<Locator>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub semantic_digest: Option<Digest>,
113 pub state: RetainedAppendState,
114 pub retry_safety: RetainedAppendRetrySafety,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub sequence: Option<U64>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub primary_term: Option<U64>,
119}
120
121#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
122#[serde(deny_unknown_fields)]
123pub struct AppendExactResultV1 {
124 pub outcome: ExactMutationOutcomeV1,
125 pub batch_id: Digest,
126 pub index: Resource,
127 pub items: Vec<AppendExactItemResultV1>,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub visibility: Vec<RetainedAppendObservationOutcome>,
130}
131
132#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
133#[serde(deny_unknown_fields)]
134pub struct DocumentMutationExactResultV1 {
135 pub outcome: ExactMutationOutcomeV1,
136 pub index: Resource,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub locator: Option<Locator>,
139 pub state: String,
140 pub submitted: bool,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub sequence: Option<U64>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub mapping_generation: Option<U64>,
145 pub mapping_adopted: bool,
146}
147
148#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
149#[serde(deny_unknown_fields)]
150pub struct ReconcileIndexExactResultV1 {
151 pub outcome: ExactMutationOutcomeV1,
152 pub name: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub locator: Option<Locator>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub resource: Option<Resource>,
157 pub state: String,
158}
159
160#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
161#[serde(tag = "kind", content = "result", rename_all = "snake_case", deny_unknown_fields)]
162enum ExactTerminalResultV1 {
163 Append(AppendExactResultV1),
164 Document(DocumentMutationExactResultV1),
165 Index(ReconcileIndexExactResultV1),
166}
167
168#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
169#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
170enum ExactPreparationV1 {
171 Append(RetainedAppendBulkOutcome),
172 Document {
173 locator: Locator,
174 semantic_digest: Digest,
175 },
176 Index {
177 locator: Locator,
178 expires_at: U64,
179 semantic_digest: Digest,
180 resource_spec: Box<IndexResourceSpec>,
181 },
182}
183
184#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
188#[serde(deny_unknown_fields)]
189pub struct ExactWorkflowRecoveryV1 {
190 pub format_version: u16,
191 pub mutation_kind: ExactMutationKindV1,
192 pub request_sha256: Digest,
193 pub cluster_id: Identity,
194 pub collection_id: Identity,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub index: Option<Resource>,
197 pub caller_action_id: Digest,
198 pub effect_deadline_unix_millis: U64,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub last_observed_unix_millis: Option<U64>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 preparation: Option<ExactPreparationV1>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 submission_disposition: Option<ExactSubmissionDispositionV1>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 terminal: Option<ExactTerminalResultV1>,
207}
208
209#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
211#[serde(deny_unknown_fields)]
212pub struct ExactExecutionV1<T> {
213 pub result: T,
214 pub recovery: ExactWorkflowRecoveryV1,
215}
216
217#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct ExactWorkflowStore {
221 path: PathBuf,
222}
223
224impl ExactWorkflowStore {
225 #[must_use]
226 pub fn new(path: impl Into<PathBuf>) -> Self {
227 Self { path: path.into() }
228 }
229
230 #[must_use]
231 pub fn path(&self) -> &Path {
232 &self.path
233 }
234}
235
236pub trait ExactWorkflowClock: Send + Sync {
239 fn unix_millis(&self) -> Result<u64, ExactWorkflowError>;
240}
241
242#[derive(Debug)]
243struct SystemExactWorkflowClock;
244
245impl ExactWorkflowClock for SystemExactWorkflowClock {
246 fn unix_millis(&self) -> Result<u64, ExactWorkflowError> {
247 let duration = SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .map_err(|_| ExactWorkflowError::ClockUnavailable)?;
250 u64::try_from(duration.as_millis()).map_err(|_| ExactWorkflowError::ClockUnavailable)
251 }
252}
253
254#[derive(Clone)]
256pub struct ExactWorkflowOptions {
257 effect_deadline_unix_millis: u64,
258 observation_deadline_unix_millis: Option<u64>,
259 store: Option<ExactWorkflowStore>,
260 recovery: Option<ExactWorkflowRecoveryV1>,
261 clock: Arc<dyn ExactWorkflowClock>,
262 cancellation: CallCancellation,
263}
264
265impl ExactWorkflowOptions {
266 #[must_use]
267 pub fn new(effect_deadline_unix_millis: u64) -> Self {
268 Self {
269 effect_deadline_unix_millis,
270 observation_deadline_unix_millis: None,
271 store: None,
272 recovery: None,
273 clock: Arc::new(SystemExactWorkflowClock),
274 cancellation: CallCancellation::new(),
275 }
276 }
277
278 #[must_use]
279 pub fn with_observation_deadline_unix_millis(mut self, value: u64) -> Self {
280 self.observation_deadline_unix_millis = Some(value);
281 self
282 }
283
284 #[must_use]
285 pub fn with_store(mut self, store: ExactWorkflowStore) -> Self {
286 self.store = Some(store);
287 self
288 }
289
290 #[must_use]
291 pub fn with_recovery(mut self, recovery: ExactWorkflowRecoveryV1) -> Self {
292 self.recovery = Some(recovery);
293 self
294 }
295
296 #[must_use]
297 pub fn with_clock(mut self, clock: Arc<dyn ExactWorkflowClock>) -> Self {
298 self.clock = clock;
299 self
300 }
301
302 #[must_use]
303 pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
304 self.cancellation = cancellation;
305 self
306 }
307}
308
309#[derive(Debug)]
310pub enum ExactWorkflowError {
311 InvalidInput,
312 InvalidRecovery,
313 RequestCodec,
314 CoreShape,
315 ClockUnavailable,
316 Journal(WorkflowJournalError),
317 Client(ClientError),
318}
319
320impl Display for ExactWorkflowError {
321 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
322 formatter.write_str(match self {
323 Self::InvalidInput => "the exact mutation input is invalid",
324 Self::InvalidRecovery => "the exact mutation recovery state does not match this request",
325 Self::RequestCodec => "the exact mutation request cannot be canonically encoded",
326 Self::CoreShape => "Core changed an exact mutation identity or outcome shape",
327 Self::ClockUnavailable => "the exact mutation wall clock is unavailable",
328 Self::Journal(_) => "the exact mutation journal could not be verified or updated",
329 Self::Client(_) => "the exact mutation client call failed",
330 })
331 }
332}
333
334impl Error for ExactWorkflowError {}
335
336impl From<WorkflowJournalError> for ExactWorkflowError {
337 fn from(value: WorkflowJournalError) -> Self {
338 Self::Journal(value)
339 }
340}
341
342#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
345#[serde(deny_unknown_fields)]
346pub struct AppendExactRequestV1 {
347 pub batch_id: Digest,
348 pub index: Resource,
349 pub items: Vec<RetainedAppendItem>,
350 #[serde(default)]
351 pub observe_committed: bool,
352}
353
354#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
355#[serde(deny_unknown_fields)]
356struct ExactMutationIntent {
357 format_version: u16,
358 mutation_kind: ExactMutationKindV1,
359 request_sha256: Digest,
360 cluster_id: Identity,
361 collection_id: Identity,
362 index: Option<Resource>,
363 caller_action_id: Digest,
364}
365
366impl sealed::Sealed for ExactMutationIntent {}
367
368impl WorkflowJournalIntent for ExactMutationIntent {
369 type Transition = ExactMutationTransition;
370
371 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::ExactMutation;
372
373 fn validate(&self) -> Result<(), WorkflowJournalError> {
374 if self.format_version != EXACT_MUTATION_FORMAT_VERSION
375 || self.request_sha256.validate().is_err()
376 || self.cluster_id.validate().is_err()
377 || self.collection_id.validate().is_err()
378 || self.caller_action_id.validate().is_err()
379 || self.index.as_ref().is_some_and(|value| !valid_index(value))
380 {
381 return Err(WorkflowJournalError::InvalidIntent);
382 }
383 Ok(())
384 }
385}
386
387#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
388#[serde(tag = "transition", rename_all = "snake_case", deny_unknown_fields)]
389enum ExactMutationTransition {
390 DeadlineEstablished { unix_millis: U64 },
391 ClockObserved { unix_millis: U64 },
392 Prepared { preparation: ExactPreparationV1 },
393 SubmissionObserved { disposition: ExactSubmissionDispositionV1 },
394 Terminal { result: ExactTerminalResultV1 },
395}
396
397impl sealed::Sealed for ExactMutationTransition {}
398
399impl WorkflowJournalTransition for ExactMutationTransition {
400 fn validate(&self) -> Result<(), WorkflowJournalError> {
401 match self {
402 Self::DeadlineEstablished { unix_millis } if parse_u64(unix_millis).is_some_and(|value| value > 0) => {
403 Ok(())
404 }
405 Self::ClockObserved { unix_millis } if parse_u64(unix_millis).is_some() => Ok(()),
406 Self::Prepared { preparation } if valid_preparation(preparation) => Ok(()),
407 Self::SubmissionObserved { .. } => Ok(()),
408 Self::Terminal { result } if valid_terminal(result) => Ok(()),
409 _ => Err(WorkflowJournalError::InvalidTransition),
410 }
411 }
412}
413
414enum ExactSession {
415 Durable(WorkflowJournal<ExactMutationIntent>),
416 Ephemeral,
417}
418
419impl ExactSession {
420 async fn record(
421 &self,
422 state: &mut ExactWorkflowRecoveryV1,
423 transition: ExactMutationTransition,
424 ) -> Result<(), ExactWorkflowError> {
425 let mut next = state.clone();
426 reduce_transition(&mut next, &transition)?;
427 if let Self::Durable(journal) = self {
428 journal.append(transition).await?;
429 }
430 *state = next;
431 Ok(())
432 }
433}
434
435impl Client {
436 pub async fn append_exact(
439 &self,
440 request: AppendExactRequestV1,
441 options: ExactWorkflowOptions,
442 ) -> Result<ExactExecutionV1<AppendExactResultV1>, ExactWorkflowError> {
443 validate_append_request(&request)?;
444 let request_sha256 = canonical_sha256(&request)?;
445 let (cluster_id, collection_id) = self.exact_profile_scope();
446 let intent = exact_intent(
447 ExactMutationKindV1::Append,
448 request_sha256,
449 cluster_id,
450 collection_id,
451 Some(request.index.clone()),
452 request.batch_id.clone(),
453 );
454 let (session, mut recovery) = open_session(&intent, &options).await?;
455 let mut options = options;
456 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
457 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
458 return Ok(execution(result.clone(), recovery));
459 }
460 let now = observe_clock(&session, &mut recovery, &options).await?;
461 if clock_regressed(&recovery, now) {
462 return Ok(execution(
463 append_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
464 recovery,
465 ));
466 }
467
468 if recovery.preparation.is_none() {
469 if now >= options.effect_deadline_unix_millis {
470 let result = append_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
471 return finish_append(&session, &mut recovery, result).await;
472 }
473 let prepared = self
474 .prepare_append_bulk(
475 PrepareAppendBulkRequest {
476 batch_id: request.batch_id.clone(),
477 index: request.index.clone(),
478 items: request.items.clone(),
479 },
480 effect_options(now, options.effect_deadline_unix_millis, &options)?,
481 )
482 .await
483 .map_err(ExactWorkflowError::Client)?
484 .outcome;
485 validate_append_outcome(&request, &prepared, true)?;
486 session
487 .record(
488 &mut recovery,
489 ExactMutationTransition::Prepared {
490 preparation: ExactPreparationV1::Append(redact_append_outcome(prepared)),
491 },
492 )
493 .await?;
494 }
495
496 let preparations = append_preparation(&recovery)?.items.clone();
497 let may_submit = !matches!(
498 recovery.submission_disposition,
499 Some(
500 ExactSubmissionDispositionV1::Submitted
501 | ExactSubmissionDispositionV1::Resolved
502 | ExactSubmissionDispositionV1::KnownRetired
503 | ExactSubmissionDispositionV1::Indeterminate
504 )
505 );
506 if may_submit && now < options.effect_deadline_unix_millis {
507 match self
508 .submit_append_bulk(
509 SubmitAppendBulkRequest {
510 batch_id: request.batch_id.clone(),
511 index: request.index.clone(),
512 items: request.items.clone(),
513 preparations,
514 },
515 effect_options(now, options.effect_deadline_unix_millis, &options)?,
516 )
517 .await
518 {
519 Ok(submitted) => {
520 validate_append_outcome(&request, &submitted.outcome, false)?;
521 let disposition = if submitted.outcome.items.iter().any(|item| {
522 matches!(
523 item.state,
524 RetainedAppendState::Committed
525 | RetainedAppendState::Replayed
526 | RetainedAppendState::SubmittedIndeterminate
527 )
528 }) {
529 ExactSubmissionDispositionV1::Submitted
530 } else {
531 ExactSubmissionDispositionV1::NotSubmitted
532 };
533 session
534 .record(
535 &mut recovery,
536 ExactMutationTransition::SubmissionObserved { disposition },
537 )
538 .await?;
539 if let Some(mut result) = append_terminal_result(&request, &submitted.outcome, false) {
540 if !terminal_outcome(result.outcome) {
541 return Ok(execution(result, recovery));
542 }
543 if request.observe_committed
544 && matches!(
545 result.outcome,
546 ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
547 )
548 {
549 let Some(observe_options) = resolution_options(now, &options)? else {
550 result.outcome = ExactMutationOutcomeV1::Indeterminate;
551 return Ok(execution(result, recovery));
552 };
553 result = observe_append_visibility(self, &request, result, observe_options).await?;
554 if result.outcome == ExactMutationOutcomeV1::Indeterminate {
555 return Ok(execution(result, recovery));
556 }
557 }
558 return finish_append(&session, &mut recovery, result).await;
559 }
560 }
561 Err(error) => {
562 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
563 session
564 .record(
565 &mut recovery,
566 ExactMutationTransition::SubmissionObserved { disposition },
567 )
568 .await?;
569 if disposition == ExactSubmissionDispositionV1::NotSubmitted {
570 return Ok(execution(
571 append_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
572 recovery,
573 ));
574 }
575 }
576 }
577 }
578
579 let Some(resolve_options) = resolution_options(now, &options)? else {
580 return Ok(execution(
581 append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
582 recovery,
583 ));
584 };
585 let resolved = self
586 .resolve_append_bulk(
587 ResolveAppendBulkRequest {
588 batch_id: request.batch_id.clone(),
589 index: request.index.clone(),
590 items: append_preparation(&recovery)?.items.clone(),
591 },
592 resolve_options,
593 )
594 .await;
595 let outcome = match resolved {
596 Ok(value) => value.outcome,
597 Err(_) => {
598 return Ok(execution(
599 append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
600 recovery,
601 ));
602 }
603 };
604 validate_append_outcome(&request, &outcome, false)?;
605 let Some(mut result) = append_terminal_result(&request, &outcome, true) else {
606 return Ok(execution(
607 append_result(&request, &outcome, ExactMutationOutcomeV1::Indeterminate),
608 recovery,
609 ));
610 };
611 if request.observe_committed
612 && matches!(
613 result.outcome,
614 ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
615 )
616 {
617 let Some(observe_options) = resolution_options(now, &options)? else {
618 result.outcome = ExactMutationOutcomeV1::Indeterminate;
619 return Ok(execution(result, recovery));
620 };
621 result = observe_append_visibility(self, &request, result, observe_options).await?;
622 if result.outcome == ExactMutationOutcomeV1::Indeterminate {
623 return Ok(execution(result, recovery));
624 }
625 }
626 finish_append(&session, &mut recovery, result).await
627 }
628
629 pub async fn mutate_document_exact(
632 &self,
633 request: PrepareDocumentMutationRequest,
634 options: ExactWorkflowOptions,
635 ) -> Result<ExactExecutionV1<DocumentMutationExactResultV1>, ExactWorkflowError> {
636 if !valid_index(&request.index) || request.document_id.is_empty() {
637 return Err(ExactWorkflowError::InvalidInput);
638 }
639 let request_sha256 = canonical_sha256(&request)?;
640 let (cluster_id, collection_id) = self.exact_profile_scope();
641 let intent = exact_intent(
642 ExactMutationKindV1::Document,
643 request_sha256.clone(),
644 cluster_id,
645 collection_id,
646 Some(request.index.clone()),
647 request_sha256,
648 );
649 let (session, mut recovery) = open_session(&intent, &options).await?;
650 let mut options = options;
651 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
652 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
653 return Ok(execution(result.clone(), recovery));
654 }
655 let now = observe_clock(&session, &mut recovery, &options).await?;
656 if clock_regressed(&recovery, now) {
657 return Ok(execution(
658 document_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
659 recovery,
660 ));
661 }
662 if recovery.preparation.is_none() {
663 if now >= options.effect_deadline_unix_millis {
664 let result = document_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
665 return finish_document(&session, &mut recovery, result).await;
666 }
667 let prepared = self
668 .prepare_document_mutation(
669 request.clone(),
670 effect_options(now, options.effect_deadline_unix_millis, &options)?,
671 )
672 .await
673 .map_err(ExactWorkflowError::Client)?;
674 if prepared.locator.authority.is_empty() || prepared.semantic_digest.validate().is_err() {
675 return Err(ExactWorkflowError::CoreShape);
676 }
677 session
678 .record(
679 &mut recovery,
680 ExactMutationTransition::Prepared {
681 preparation: ExactPreparationV1::Document {
682 locator: prepared.locator,
683 semantic_digest: prepared.semantic_digest,
684 },
685 },
686 )
687 .await?;
688 }
689 let (locator, semantic_digest) = document_preparation(&recovery)?;
690 let may_submit = !matches!(
691 recovery.submission_disposition,
692 Some(
693 ExactSubmissionDispositionV1::Submitted
694 | ExactSubmissionDispositionV1::Resolved
695 | ExactSubmissionDispositionV1::KnownRetired
696 | ExactSubmissionDispositionV1::Indeterminate
697 )
698 );
699 if may_submit && now < options.effect_deadline_unix_millis {
700 match self
701 .submit_document_mutation(
702 SubmitDocumentMutationRequest {
703 locator: locator.clone(),
704 semantic_digest,
705 index: request.index.clone(),
706 document_id: request.document_id.clone(),
707 operation: request.operation.clone(),
708 source: request.source.clone(),
709 routing: request.routing.clone(),
710 condition: request.condition.clone(),
711 pipeline: request.pipeline.clone(),
712 },
713 effect_options(now, options.effect_deadline_unix_millis, &options)?,
714 )
715 .await
716 {
717 Ok(value) => {
718 if value.locator != locator {
719 return Err(ExactWorkflowError::CoreShape);
720 }
721 session
722 .record(
723 &mut recovery,
724 ExactMutationTransition::SubmissionObserved {
725 disposition: exact_submission_disposition(&value.mutation_outcome),
726 },
727 )
728 .await?;
729 if matches!(&value.mutation_outcome, MutationOutcome::Terminal)
730 && matches!(value.state.as_str(), "applied" | "dropped")
731 {
732 let submitted = value.state == "applied";
733 return finish_document(
734 &session,
735 &mut recovery,
736 DocumentMutationExactResultV1 {
737 outcome: ExactMutationOutcomeV1::Applied,
738 index: request.index,
739 locator: Some(value.locator),
740 state: value.state,
741 submitted,
742 sequence: value.sequence,
743 mapping_generation: value.mapping_generation,
744 mapping_adopted: value.mapping_adopted,
745 },
746 )
747 .await;
748 }
749 }
750 Err(error) => {
751 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
752 session
753 .record(
754 &mut recovery,
755 ExactMutationTransition::SubmissionObserved { disposition },
756 )
757 .await?;
758 if disposition == ExactSubmissionDispositionV1::NotSubmitted {
759 return Ok(execution(
760 document_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
761 recovery,
762 ));
763 }
764 }
765 }
766 }
767 let Some(resolve_options) = resolution_options(now, &options)? else {
768 return Ok(execution(
769 document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
770 recovery,
771 ));
772 };
773 let resolve_options = resolve_options
774 .with_index_name(request.index.name.clone())
775 .map_err(ExactWorkflowError::Client)?;
776 let value = match self
777 .resolve_document_mutation(
778 ResolveDocumentMutationRequest {
779 locator: locator.clone(),
780 },
781 resolve_options,
782 )
783 .await
784 {
785 Ok(value) => value,
786 Err(_) => {
787 return Ok(execution(
788 document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
789 recovery,
790 ));
791 }
792 };
793 if value.locator != locator || value.source.is_some() {
794 return Err(ExactWorkflowError::CoreShape);
795 }
796 let outcome = match value.state.as_str() {
797 "applied" => ExactMutationOutcomeV1::Resolved,
798 "failed" => ExactMutationOutcomeV1::Resolved,
799 "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
800 "prepared" => ExactMutationOutcomeV1::NotSubmitted,
801 "pending" => ExactMutationOutcomeV1::Indeterminate,
802 _ => return Err(ExactWorkflowError::CoreShape),
803 };
804 let result = DocumentMutationExactResultV1 {
805 outcome,
806 index: request.index,
807 locator: Some(value.locator),
808 state: value.state,
809 submitted: !matches!(outcome, ExactMutationOutcomeV1::NotSubmitted),
810 sequence: value.sequence,
811 mapping_generation: value.mapping_generation,
812 mapping_adopted: value.mapping_adopted,
813 };
814 if matches!(
815 outcome,
816 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
817 ) {
818 Ok(execution(result, recovery))
819 } else {
820 finish_document(&session, &mut recovery, result).await
821 }
822 }
823
824 pub async fn reconcile_index_exact(
827 &self,
828 request: PrepareCreateIndexRequest,
829 options: ExactWorkflowOptions,
830 ) -> Result<ExactExecutionV1<ReconcileIndexExactResultV1>, ExactWorkflowError> {
831 if request.name.is_empty() || request.resource_spec.is_none() {
832 return Err(ExactWorkflowError::InvalidInput);
833 }
834 let request_sha256 = canonical_sha256(&request)?;
835 let (cluster_id, collection_id) = self.exact_profile_scope();
836 let intent = exact_intent(
837 ExactMutationKindV1::Index,
838 request_sha256.clone(),
839 cluster_id,
840 collection_id,
841 None,
842 request_sha256,
843 );
844 let (session, mut recovery) = open_session(&intent, &options).await?;
845 let mut options = options;
846 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
847 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
848 return Ok(execution(result.clone(), recovery));
849 }
850 let now = observe_clock(&session, &mut recovery, &options).await?;
851 if clock_regressed(&recovery, now) {
852 return Ok(execution(
853 index_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
854 recovery,
855 ));
856 }
857
858 if recovery.preparation.is_none() {
859 let Some(lookup_options) = resolution_options(now, &options)? else {
860 let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
861 return finish_index(&session, &mut recovery, result).await;
862 };
863 let lookup = self
864 .get_index(
865 GetIndexRequest {
866 name: request.name.clone(),
867 expected_incarnation: None,
868 },
869 lookup_options,
870 )
871 .await;
872 match lookup {
873 Ok(existing) => {
874 let exact = existing.resource.current_mapping == request.mapping
875 && request.resource_spec.as_ref() == Some(&existing.resource.resource_spec)
876 && existing.resource.summary.retained_event_identity
877 == request.retained_event_identity.unwrap_or(false);
878 let outcome = if exact && existing.resource.summary.state == "active" {
879 ExactMutationOutcomeV1::AlreadySatisfied
880 } else if exact {
881 ExactMutationOutcomeV1::Indeterminate
882 } else {
883 ExactMutationOutcomeV1::Conflict
884 };
885 let result = ReconcileIndexExactResultV1 {
886 outcome,
887 name: request.name,
888 locator: None,
889 resource: Some(Resource {
890 kind: "index".to_owned(),
891 name: existing.resource.summary.name,
892 incarnation: existing.resource.summary.incarnation,
893 }),
894 state: existing.resource.summary.state,
895 };
896 if outcome == ExactMutationOutcomeV1::Indeterminate {
897 return Ok(execution(result, recovery));
898 }
899 return finish_index(&session, &mut recovery, result).await;
900 }
901 Err(ClientError::Server(error)) if error.code == ErrorCode::NotFound => {}
902 Err(error) => return Err(ExactWorkflowError::Client(error)),
903 }
904 if now >= options.effect_deadline_unix_millis {
905 let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
906 return finish_index(&session, &mut recovery, result).await;
907 }
908 let prepared = self
909 .prepare_create_index(
910 request.clone(),
911 effect_options(now, options.effect_deadline_unix_millis, &options)?,
912 )
913 .await
914 .map_err(ExactWorkflowError::Client)?;
915 if prepared.locator.authority.is_empty()
916 || prepared.semantic_digest.validate().is_err()
917 || prepared.resource_spec != *request.resource_spec.as_ref().ok_or(ExactWorkflowError::InvalidInput)?
918 {
919 return Err(ExactWorkflowError::CoreShape);
920 }
921 session
922 .record(
923 &mut recovery,
924 ExactMutationTransition::Prepared {
925 preparation: ExactPreparationV1::Index {
926 locator: prepared.locator,
927 expires_at: prepared.expires_at,
928 semantic_digest: prepared.semantic_digest,
929 resource_spec: Box::new(prepared.resource_spec),
930 },
931 },
932 )
933 .await?;
934 }
935 let (locator, expires_at, semantic_digest, resource_spec) = index_preparation(&recovery)?;
936 let expiry = parse_u64(&expires_at).ok_or(ExactWorkflowError::InvalidRecovery)?;
937 let submit_deadline = options.effect_deadline_unix_millis.min(expiry);
938 let submission = SubmitCatalogActionRequest {
939 locator: locator.clone(),
940 semantic_digest,
941 action_kind: create_index_action_kind(request.retained_event_identity).to_owned(),
942 name: request.name.clone(),
943 mapping: Some(request.mapping.clone()),
944 resource_spec: Some(resource_spec),
945 expected_incarnation: None,
946 };
947 let may_submit = !matches!(
948 recovery.submission_disposition,
949 Some(
950 ExactSubmissionDispositionV1::Submitted
951 | ExactSubmissionDispositionV1::Resolved
952 | ExactSubmissionDispositionV1::KnownRetired
953 | ExactSubmissionDispositionV1::Indeterminate
954 )
955 );
956 if may_submit && now < submit_deadline {
957 match self
958 .submit_catalog_action(submission.clone(), effect_options(now, submit_deadline, &options)?)
959 .await
960 {
961 Ok(value) => {
962 if value.locator != locator {
963 return Err(ExactWorkflowError::CoreShape);
964 }
965 session
966 .record(
967 &mut recovery,
968 ExactMutationTransition::SubmissionObserved {
969 disposition: exact_submission_disposition(&value.mutation_outcome),
970 },
971 )
972 .await?;
973 }
974 Err(error) => {
975 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
976 session
977 .record(
978 &mut recovery,
979 ExactMutationTransition::SubmissionObserved { disposition },
980 )
981 .await?;
982 if disposition == ExactSubmissionDispositionV1::NotSubmitted {
983 return Ok(execution(
984 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
985 recovery,
986 ));
987 }
988 }
989 }
990 }
991 let Some(resolve_options) = resolution_options(now, &options)? else {
992 return Ok(execution(
993 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
994 recovery,
995 ));
996 };
997 let resolve_options = resolve_options
998 .with_index_name(request.name.clone())
999 .map_err(ExactWorkflowError::Client)?;
1000 let mut value = match self
1001 .resolve_catalog_action(
1002 ResolveCatalogActionRequest {
1003 locator: locator.clone(),
1004 },
1005 resolve_options.clone(),
1006 )
1007 .await
1008 {
1009 Ok(value) => value,
1010 Err(_) => {
1011 return Ok(execution(
1012 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1013 recovery,
1014 ));
1015 }
1016 };
1017 if value.locator != locator {
1018 return Err(ExactWorkflowError::CoreShape);
1019 }
1020 if catalog_resolution_requires_exact_replay(value.state.as_str(), recovery.submission_disposition) {
1021 match self.submit_catalog_action(submission, resolve_options.clone()).await {
1025 Ok(replayed) if replayed.locator == locator => {}
1026 Ok(_) => return Err(ExactWorkflowError::CoreShape),
1027 Err(error) if error.submission_disposition() == SubmissionDisposition::NotSubmitted => {
1028 return Ok(execution(
1029 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1030 recovery,
1031 ));
1032 }
1033 Err(_) => {
1034 return Ok(execution(
1035 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1036 recovery,
1037 ));
1038 }
1039 }
1040 value = match self
1041 .resolve_catalog_action(
1042 ResolveCatalogActionRequest {
1043 locator: locator.clone(),
1044 },
1045 resolve_options,
1046 )
1047 .await
1048 {
1049 Ok(value) => value,
1050 Err(_) => {
1051 return Ok(execution(
1052 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1053 recovery,
1054 ));
1055 }
1056 };
1057 if value.locator != locator {
1058 return Err(ExactWorkflowError::CoreShape);
1059 }
1060 }
1061 let outcome = match value.state.as_str() {
1062 "applied" => ExactMutationOutcomeV1::Resolved,
1063 "failed" => ExactMutationOutcomeV1::Conflict,
1064 "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
1065 "prepared" => ExactMutationOutcomeV1::NotSubmitted,
1066 "pending" => ExactMutationOutcomeV1::Indeterminate,
1067 _ => return Err(ExactWorkflowError::CoreShape),
1068 };
1069 let mut result = ReconcileIndexExactResultV1 {
1070 outcome,
1071 name: request.name,
1072 locator: Some(value.locator),
1073 resource: value.resource,
1074 state: value.state,
1075 };
1076 if outcome == ExactMutationOutcomeV1::Resolved {
1077 let Some(verify_options) = resolution_options(now, &options)? else {
1078 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1079 return Ok(execution(result, recovery));
1080 };
1081 let existing = match self
1082 .get_index(
1083 GetIndexRequest {
1084 name: result.name.clone(),
1085 expected_incarnation: result.resource.as_ref().map(|value| value.incarnation.clone()),
1086 },
1087 verify_options,
1088 )
1089 .await
1090 {
1091 Ok(value) => value.resource,
1092 Err(_) => {
1093 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1094 return Ok(execution(result, recovery));
1095 }
1096 };
1097 if existing.current_mapping != request.mapping
1098 || request.resource_spec.as_ref() != Some(&existing.resource_spec)
1099 || existing.summary.retained_event_identity != request.retained_event_identity.unwrap_or(false)
1100 {
1101 result.outcome = ExactMutationOutcomeV1::Conflict;
1102 result.state = "definition_conflict".to_owned();
1103 } else if existing.summary.state != "active" {
1104 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1105 result.state = existing.summary.state;
1106 return Ok(execution(result, recovery));
1107 } else {
1108 result.resource = Some(Resource {
1109 kind: "index".to_owned(),
1110 name: existing.summary.name,
1111 incarnation: existing.summary.incarnation,
1112 });
1113 result.state = "active".to_owned();
1114 }
1115 }
1116 if matches!(
1117 outcome,
1118 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
1119 ) {
1120 Ok(execution(result, recovery))
1121 } else {
1122 finish_index(&session, &mut recovery, result).await
1123 }
1124 }
1125
1126 fn exact_profile_scope(&self) -> (Identity, Identity) {
1127 let profile = self.connection_profile().as_wire();
1128 (
1129 profile.expected_cluster_id.clone(),
1130 profile.expected_collection_id.clone(),
1131 )
1132 }
1133}
1134
1135fn catalog_resolution_requires_exact_replay(state: &str, submission: Option<ExactSubmissionDispositionV1>) -> bool {
1136 state == "pending"
1137 || state == "prepared"
1138 && matches!(
1139 submission,
1140 Some(
1141 ExactSubmissionDispositionV1::Submitted
1142 | ExactSubmissionDispositionV1::Resolved
1143 | ExactSubmissionDispositionV1::Indeterminate
1144 )
1145 )
1146}
1147
1148async fn observe_append_visibility(
1149 client: &Client,
1150 request: &AppendExactRequestV1,
1151 mut result: AppendExactResultV1,
1152 options: CallOptions,
1153) -> Result<AppendExactResultV1, ExactWorkflowError> {
1154 let mut visibility = Vec::with_capacity(result.items.len());
1155 for item in &result.items {
1156 let Some(locator) = item.locator.clone() else {
1157 return Err(ExactWorkflowError::CoreShape);
1158 };
1159 match client
1160 .observe_append_committed(
1161 ObserveAppendCommittedRequest {
1162 index: request.index.clone(),
1163 locator,
1164 },
1165 options.clone(),
1166 )
1167 .await
1168 {
1169 Ok(value) => visibility.push(value.outcome),
1170 Err(_) => {
1171 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1172 result.visibility = visibility;
1173 return Ok(result);
1174 }
1175 }
1176 }
1177 if visibility
1178 .iter()
1179 .any(|value| value.state != RetainedAppendObservationState::Visible)
1180 {
1181 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1182 }
1183 result.visibility = visibility;
1184 Ok(result)
1185}
1186
1187fn exact_intent(
1188 mutation_kind: ExactMutationKindV1,
1189 request_sha256: Digest,
1190 cluster_id: Identity,
1191 collection_id: Identity,
1192 index: Option<Resource>,
1193 caller_action_id: Digest,
1194) -> ExactMutationIntent {
1195 ExactMutationIntent {
1196 format_version: EXACT_MUTATION_FORMAT_VERSION,
1197 mutation_kind,
1198 request_sha256,
1199 cluster_id,
1200 collection_id,
1201 index,
1202 caller_action_id,
1203 }
1204}
1205
1206async fn open_session(
1207 intent: &ExactMutationIntent,
1208 options: &ExactWorkflowOptions,
1209) -> Result<(ExactSession, ExactWorkflowRecoveryV1), ExactWorkflowError> {
1210 intent.validate()?;
1211 if options.effect_deadline_unix_millis == 0 || options.store.is_some() && options.recovery.is_some() {
1212 return Err(ExactWorkflowError::InvalidRecovery);
1213 }
1214 let (session, mut recovery) = if let Some(store) = &options.store {
1215 let replay = WorkflowJournal::open_or_create(store.path(), intent.clone()).await?;
1216 let (journal, transitions) = replay.into_parts();
1217 let mut recovery = recovery_from_intent(intent);
1218 for transition in transitions {
1219 reduce_transition(&mut recovery, &transition)?;
1220 }
1221 (ExactSession::Durable(journal), recovery)
1222 } else {
1223 let recovery = match &options.recovery {
1224 Some(value) => {
1225 validate_recovery_matches(value, intent)?;
1226 value.clone()
1227 }
1228 None => recovery_from_intent(intent),
1229 };
1230 (ExactSession::Ephemeral, recovery)
1231 };
1232 if parse_u64(&recovery.effect_deadline_unix_millis) == Some(0) {
1233 session
1234 .record(
1235 &mut recovery,
1236 ExactMutationTransition::DeadlineEstablished {
1237 unix_millis: wire_u64(options.effect_deadline_unix_millis),
1238 },
1239 )
1240 .await?;
1241 }
1242 Ok((session, recovery))
1243}
1244
1245fn recovery_from_intent(intent: &ExactMutationIntent) -> ExactWorkflowRecoveryV1 {
1246 ExactWorkflowRecoveryV1 {
1247 format_version: intent.format_version,
1248 mutation_kind: intent.mutation_kind,
1249 request_sha256: intent.request_sha256.clone(),
1250 cluster_id: intent.cluster_id.clone(),
1251 collection_id: intent.collection_id.clone(),
1252 index: intent.index.clone(),
1253 caller_action_id: intent.caller_action_id.clone(),
1254 effect_deadline_unix_millis: wire_u64(0),
1255 last_observed_unix_millis: None,
1256 preparation: None,
1257 submission_disposition: None,
1258 terminal: None,
1259 }
1260}
1261
1262fn validate_recovery_matches(
1263 recovery: &ExactWorkflowRecoveryV1,
1264 intent: &ExactMutationIntent,
1265) -> Result<(), ExactWorkflowError> {
1266 let expected = recovery_from_intent(intent);
1267 if recovery.format_version != expected.format_version
1268 || recovery.mutation_kind != expected.mutation_kind
1269 || recovery.request_sha256 != expected.request_sha256
1270 || recovery.cluster_id != expected.cluster_id
1271 || recovery.collection_id != expected.collection_id
1272 || recovery.index != expected.index
1273 || recovery.caller_action_id != expected.caller_action_id
1274 || parse_u64(&recovery.effect_deadline_unix_millis).is_none_or(|value| value == 0)
1275 || recovery
1276 .last_observed_unix_millis
1277 .as_ref()
1278 .is_some_and(|value| parse_u64(value).is_none())
1279 || recovery
1280 .preparation
1281 .as_ref()
1282 .is_some_and(|value| !valid_preparation(value))
1283 || recovery
1284 .preparation
1285 .as_ref()
1286 .is_some_and(|value| preparation_kind(value) != recovery.mutation_kind)
1287 || recovery.terminal.as_ref().is_some_and(|value| !valid_terminal(value))
1288 || recovery
1289 .terminal
1290 .as_ref()
1291 .is_some_and(|value| terminal_kind(value) != recovery.mutation_kind)
1292 || recovery.submission_disposition.is_some() && recovery.preparation.is_none()
1293 {
1294 return Err(ExactWorkflowError::InvalidRecovery);
1295 }
1296 Ok(())
1297}
1298
1299fn reduce_transition(
1300 state: &mut ExactWorkflowRecoveryV1,
1301 transition: &ExactMutationTransition,
1302) -> Result<(), ExactWorkflowError> {
1303 match transition {
1304 ExactMutationTransition::DeadlineEstablished { unix_millis }
1305 if parse_u64(&state.effect_deadline_unix_millis) == Some(0)
1306 && parse_u64(unix_millis).is_some_and(|value| value > 0) =>
1307 {
1308 state.effect_deadline_unix_millis = unix_millis.clone();
1309 }
1310 ExactMutationTransition::ClockObserved { unix_millis } => {
1311 let next = parse_u64(unix_millis).ok_or(ExactWorkflowError::InvalidRecovery)?;
1312 if state
1313 .last_observed_unix_millis
1314 .as_ref()
1315 .and_then(parse_u64)
1316 .is_some_and(|prior| next < prior)
1317 {
1318 return Err(ExactWorkflowError::InvalidRecovery);
1319 }
1320 state.last_observed_unix_millis = Some(unix_millis.clone());
1321 }
1322 ExactMutationTransition::Prepared { preparation }
1323 if state.preparation.is_none()
1324 && state.terminal.is_none()
1325 && preparation_kind(preparation) == state.mutation_kind =>
1326 {
1327 state.preparation = Some(preparation.clone());
1328 }
1329 ExactMutationTransition::SubmissionObserved { disposition }
1330 if state.preparation.is_some()
1331 && state.terminal.is_none()
1332 && !matches!(
1333 state.submission_disposition,
1334 Some(
1335 ExactSubmissionDispositionV1::Submitted
1336 | ExactSubmissionDispositionV1::Resolved
1337 | ExactSubmissionDispositionV1::KnownRetired
1338 | ExactSubmissionDispositionV1::Indeterminate
1339 )
1340 ) =>
1341 {
1342 state.submission_disposition = Some(*disposition);
1343 }
1344 ExactMutationTransition::Terminal { result }
1345 if state.terminal.is_none() && terminal_kind(result) == state.mutation_kind =>
1346 {
1347 state.terminal = Some(result.clone());
1348 }
1349 _ => return Err(ExactWorkflowError::InvalidRecovery),
1350 }
1351 Ok(())
1352}
1353
1354async fn observe_clock(
1355 session: &ExactSession,
1356 recovery: &mut ExactWorkflowRecoveryV1,
1357 options: &ExactWorkflowOptions,
1358) -> Result<u64, ExactWorkflowError> {
1359 let now = options.clock.unix_millis()?;
1360 if recovery
1361 .last_observed_unix_millis
1362 .as_ref()
1363 .and_then(parse_u64)
1364 .is_some_and(|prior| now < prior)
1365 {
1366 return Ok(now);
1367 }
1368 session
1369 .record(
1370 recovery,
1371 ExactMutationTransition::ClockObserved {
1372 unix_millis: wire_u64(now),
1373 },
1374 )
1375 .await?;
1376 Ok(now)
1377}
1378
1379fn clock_regressed(recovery: &ExactWorkflowRecoveryV1, now: u64) -> bool {
1380 recovery
1381 .last_observed_unix_millis
1382 .as_ref()
1383 .and_then(parse_u64)
1384 .is_some_and(|observed| now < observed)
1385}
1386
1387fn recovery_effect_deadline(recovery: &ExactWorkflowRecoveryV1) -> Result<u64, ExactWorkflowError> {
1388 parse_u64(&recovery.effect_deadline_unix_millis)
1389 .filter(|value| *value > 0)
1390 .ok_or(ExactWorkflowError::InvalidRecovery)
1391}
1392
1393fn effect_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
1394 bounded_options(now, deadline, options)
1395}
1396
1397fn resolution_options(now: u64, options: &ExactWorkflowOptions) -> Result<Option<CallOptions>, ExactWorkflowError> {
1398 let deadline = if now < options.effect_deadline_unix_millis {
1399 options.effect_deadline_unix_millis
1400 } else {
1401 match options.observation_deadline_unix_millis {
1402 Some(value) if value > now => value,
1403 _ => return Ok(None),
1404 }
1405 };
1406 bounded_options(now, deadline, options).map(Some)
1407}
1408
1409fn bounded_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
1410 if deadline <= now {
1411 return Err(ExactWorkflowError::InvalidInput);
1412 }
1413 let bounded = deadline.min(now.saturating_add(MAX_EFFECT_CALL_MILLIS));
1414 CallOptions::from_absolute_deadline_unix_millis(bounded)
1415 .map(|value| value.with_cancellation(options.cancellation.clone()))
1416 .map_err(ExactWorkflowError::Client)
1417}
1418
1419fn validate_append_request(request: &AppendExactRequestV1) -> Result<(), ExactWorkflowError> {
1420 if request.batch_id.validate().is_err()
1421 || !valid_index(&request.index)
1422 || request.items.is_empty()
1423 || request.items.len() > 1_000
1424 || request.items.iter().any(|item| {
1425 item.item_action_id.validate().is_err() || item.document_id.is_empty() || item.document_id.len() > 512
1426 })
1427 {
1428 return Err(ExactWorkflowError::InvalidInput);
1429 }
1430 let mut identities = std::collections::BTreeSet::new();
1431 if request
1432 .items
1433 .iter()
1434 .any(|item| !identities.insert(item.item_action_id.value.as_str()))
1435 {
1436 return Err(ExactWorkflowError::InvalidInput);
1437 }
1438 Ok(())
1439}
1440
1441fn validate_append_outcome(
1442 request: &AppendExactRequestV1,
1443 outcome: &RetainedAppendBulkOutcome,
1444 prepared: bool,
1445) -> Result<(), ExactWorkflowError> {
1446 if outcome.batch_id != request.batch_id
1447 || outcome.index != request.index
1448 || outcome.items.len() != request.items.len()
1449 {
1450 return Err(ExactWorkflowError::CoreShape);
1451 }
1452 for (expected, observed) in request.items.iter().zip(&outcome.items) {
1453 if observed.item_action_id != expected.item_action_id
1454 || observed
1455 .locator
1456 .as_ref()
1457 .is_some_and(|value| value.authority.is_empty() || value.value.is_empty())
1458 || observed
1459 .semantic_digest
1460 .as_ref()
1461 .is_some_and(|value| value.validate().is_err())
1462 || prepared
1463 && (observed.state != RetainedAppendState::Prepared
1464 || observed.retry_safety != RetainedAppendRetrySafety::Safe
1465 || observed.locator.is_none()
1466 || observed.semantic_digest.is_none()
1467 || observed.sequence.is_some()
1468 || observed.primary_term.is_some()
1469 || observed.error.is_some())
1470 {
1471 return Err(ExactWorkflowError::CoreShape);
1472 }
1473 }
1474 Ok(())
1475}
1476
1477fn redact_append_outcome(mut outcome: RetainedAppendBulkOutcome) -> RetainedAppendBulkOutcome {
1478 for item in &mut outcome.items {
1479 item.error = None;
1480 }
1481 outcome
1482}
1483
1484fn append_preparation(recovery: &ExactWorkflowRecoveryV1) -> Result<&RetainedAppendBulkOutcome, ExactWorkflowError> {
1485 match &recovery.preparation {
1486 Some(ExactPreparationV1::Append(value)) => Ok(value),
1487 _ => Err(ExactWorkflowError::InvalidRecovery),
1488 }
1489}
1490
1491fn document_preparation(recovery: &ExactWorkflowRecoveryV1) -> Result<(Locator, Digest), ExactWorkflowError> {
1492 match &recovery.preparation {
1493 Some(ExactPreparationV1::Document {
1494 locator,
1495 semantic_digest,
1496 }) => Ok((locator.clone(), semantic_digest.clone())),
1497 _ => Err(ExactWorkflowError::InvalidRecovery),
1498 }
1499}
1500
1501fn index_preparation(
1502 recovery: &ExactWorkflowRecoveryV1,
1503) -> Result<(Locator, U64, Digest, IndexResourceSpec), ExactWorkflowError> {
1504 match &recovery.preparation {
1505 Some(ExactPreparationV1::Index {
1506 locator,
1507 expires_at,
1508 semantic_digest,
1509 resource_spec,
1510 }) => Ok((
1511 locator.clone(),
1512 expires_at.clone(),
1513 semantic_digest.clone(),
1514 resource_spec.as_ref().clone(),
1515 )),
1516 _ => Err(ExactWorkflowError::InvalidRecovery),
1517 }
1518}
1519
1520fn append_terminal_result(
1521 request: &AppendExactRequestV1,
1522 outcome: &RetainedAppendBulkOutcome,
1523 recovered: bool,
1524) -> Option<AppendExactResultV1> {
1525 let state = if outcome
1526 .items
1527 .iter()
1528 .any(|item| item.state == RetainedAppendState::SubmittedIndeterminate)
1529 {
1530 return None;
1531 } else if outcome
1532 .items
1533 .iter()
1534 .any(|item| item.state == RetainedAppendState::Conflict)
1535 {
1536 ExactMutationOutcomeV1::Conflict
1537 } else if outcome
1538 .items
1539 .iter()
1540 .any(|item| item.state == RetainedAppendState::Retired)
1541 {
1542 ExactMutationOutcomeV1::KnownRetired
1543 } else if outcome.items.iter().any(|item| {
1544 matches!(
1545 item.state,
1546 RetainedAppendState::NotSubmittedRetryable | RetainedAppendState::NotSubmittedTerminal
1547 )
1548 }) {
1549 ExactMutationOutcomeV1::NotSubmitted
1550 } else if outcome.items.iter().all(|item| {
1551 matches!(
1552 item.state,
1553 RetainedAppendState::Committed | RetainedAppendState::Replayed
1554 )
1555 }) {
1556 if recovered {
1557 ExactMutationOutcomeV1::Resolved
1558 } else {
1559 ExactMutationOutcomeV1::Applied
1560 }
1561 } else {
1562 return None;
1563 };
1564 Some(append_result(request, outcome, state))
1565}
1566
1567fn append_result(
1568 request: &AppendExactRequestV1,
1569 outcome: &RetainedAppendBulkOutcome,
1570 state: ExactMutationOutcomeV1,
1571) -> AppendExactResultV1 {
1572 AppendExactResultV1 {
1573 outcome: state,
1574 batch_id: request.batch_id.clone(),
1575 index: request.index.clone(),
1576 items: outcome.items.iter().map(redact_append_item).collect(),
1577 visibility: Vec::new(),
1578 }
1579}
1580
1581fn append_status(
1582 request: &AppendExactRequestV1,
1583 recovery: &ExactWorkflowRecoveryV1,
1584 state: ExactMutationOutcomeV1,
1585) -> AppendExactResultV1 {
1586 let items = append_preparation(recovery)
1587 .map(|value| value.items.iter().map(redact_append_item).collect())
1588 .unwrap_or_default();
1589 AppendExactResultV1 {
1590 outcome: state,
1591 batch_id: request.batch_id.clone(),
1592 index: request.index.clone(),
1593 items,
1594 visibility: Vec::new(),
1595 }
1596}
1597
1598fn redact_append_item(item: &RetainedAppendItemOutcome) -> AppendExactItemResultV1 {
1599 AppendExactItemResultV1 {
1600 item_action_id: item.item_action_id.clone(),
1601 locator: item.locator.clone(),
1602 semantic_digest: item.semantic_digest.clone(),
1603 state: item.state,
1604 retry_safety: item.retry_safety,
1605 sequence: item.sequence.clone(),
1606 primary_term: item.primary_term.clone(),
1607 }
1608}
1609
1610fn document_status(
1611 request: &PrepareDocumentMutationRequest,
1612 recovery: &ExactWorkflowRecoveryV1,
1613 outcome: ExactMutationOutcomeV1,
1614) -> DocumentMutationExactResultV1 {
1615 let locator = document_preparation(recovery).map(|value| value.0).ok();
1616 DocumentMutationExactResultV1 {
1617 outcome,
1618 index: request.index.clone(),
1619 locator,
1620 state: outcome_name(outcome).to_owned(),
1621 submitted: recovery
1622 .submission_disposition
1623 .is_some_and(|value| !matches!(value, ExactSubmissionDispositionV1::NotSubmitted)),
1624 sequence: None,
1625 mapping_generation: None,
1626 mapping_adopted: false,
1627 }
1628}
1629
1630fn index_status(
1631 request: &PrepareCreateIndexRequest,
1632 recovery: &ExactWorkflowRecoveryV1,
1633 outcome: ExactMutationOutcomeV1,
1634) -> ReconcileIndexExactResultV1 {
1635 let locator = match &recovery.preparation {
1636 Some(ExactPreparationV1::Index { locator, .. }) => Some(locator.clone()),
1637 _ => None,
1638 };
1639 ReconcileIndexExactResultV1 {
1640 outcome,
1641 name: request.name.clone(),
1642 locator,
1643 resource: None,
1644 state: outcome_name(outcome).to_owned(),
1645 }
1646}
1647
1648const fn outcome_name(outcome: ExactMutationOutcomeV1) -> &'static str {
1649 match outcome {
1650 ExactMutationOutcomeV1::AlreadySatisfied => "already_satisfied",
1651 ExactMutationOutcomeV1::Applied => "applied",
1652 ExactMutationOutcomeV1::Resolved => "resolved",
1653 ExactMutationOutcomeV1::Conflict => "conflict",
1654 ExactMutationOutcomeV1::NotSubmitted => "not_submitted",
1655 ExactMutationOutcomeV1::Indeterminate => "indeterminate",
1656 ExactMutationOutcomeV1::KnownRetired => "known_retired",
1657 ExactMutationOutcomeV1::EffectDeadlineExpired => "effect_deadline_expired",
1658 ExactMutationOutcomeV1::ClockRegression => "clock_regression",
1659 }
1660}
1661
1662async fn finish_append(
1663 session: &ExactSession,
1664 recovery: &mut ExactWorkflowRecoveryV1,
1665 result: AppendExactResultV1,
1666) -> Result<ExactExecutionV1<AppendExactResultV1>, ExactWorkflowError> {
1667 session
1668 .record(
1669 recovery,
1670 ExactMutationTransition::Terminal {
1671 result: ExactTerminalResultV1::Append(result.clone()),
1672 },
1673 )
1674 .await?;
1675 Ok(execution(result, recovery.clone()))
1676}
1677
1678async fn finish_document(
1679 session: &ExactSession,
1680 recovery: &mut ExactWorkflowRecoveryV1,
1681 result: DocumentMutationExactResultV1,
1682) -> Result<ExactExecutionV1<DocumentMutationExactResultV1>, ExactWorkflowError> {
1683 session
1684 .record(
1685 recovery,
1686 ExactMutationTransition::Terminal {
1687 result: ExactTerminalResultV1::Document(result.clone()),
1688 },
1689 )
1690 .await?;
1691 Ok(execution(result, recovery.clone()))
1692}
1693
1694async fn finish_index(
1695 session: &ExactSession,
1696 recovery: &mut ExactWorkflowRecoveryV1,
1697 result: ReconcileIndexExactResultV1,
1698) -> Result<ExactExecutionV1<ReconcileIndexExactResultV1>, ExactWorkflowError> {
1699 session
1700 .record(
1701 recovery,
1702 ExactMutationTransition::Terminal {
1703 result: ExactTerminalResultV1::Index(result.clone()),
1704 },
1705 )
1706 .await?;
1707 Ok(execution(result, recovery.clone()))
1708}
1709
1710fn execution<T>(result: T, recovery: ExactWorkflowRecoveryV1) -> ExactExecutionV1<T> {
1711 ExactExecutionV1 { result, recovery }
1712}
1713
1714fn valid_preparation(value: &ExactPreparationV1) -> bool {
1715 match value {
1716 ExactPreparationV1::Append(outcome) => {
1717 !outcome.items.is_empty()
1718 && outcome.items.len() <= 1_000
1719 && outcome.items.iter().all(|item| {
1720 item.item_action_id.validate().is_ok()
1721 && item
1722 .locator
1723 .as_ref()
1724 .is_some_and(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
1725 && item
1726 .semantic_digest
1727 .as_ref()
1728 .is_some_and(|digest| digest.validate().is_ok())
1729 && item.state == RetainedAppendState::Prepared
1730 && item.retry_safety == RetainedAppendRetrySafety::Safe
1731 && item.error.is_none()
1732 })
1733 }
1734 ExactPreparationV1::Document {
1735 locator,
1736 semantic_digest,
1737 } => !locator.authority.is_empty() && !locator.value.is_empty() && semantic_digest.validate().is_ok(),
1738 ExactPreparationV1::Index {
1739 locator,
1740 expires_at,
1741 semantic_digest,
1742 ..
1743 } => {
1744 !locator.authority.is_empty()
1745 && !locator.value.is_empty()
1746 && parse_u64(expires_at).is_some_and(|value| value > 0)
1747 && semantic_digest.validate().is_ok()
1748 }
1749 }
1750}
1751
1752fn valid_terminal(value: &ExactTerminalResultV1) -> bool {
1753 match value {
1754 ExactTerminalResultV1::Append(result) => {
1755 result.batch_id.validate().is_ok()
1756 && valid_index(&result.index)
1757 && terminal_outcome(result.outcome)
1758 && result.items.iter().all(|item| item.item_action_id.validate().is_ok())
1759 }
1760 ExactTerminalResultV1::Document(result) => {
1761 valid_index(&result.index)
1762 && result
1763 .locator
1764 .as_ref()
1765 .is_none_or(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
1766 && terminal_outcome(result.outcome)
1767 }
1768 ExactTerminalResultV1::Index(result) => !result.name.is_empty() && terminal_outcome(result.outcome),
1769 }
1770}
1771
1772const fn terminal_outcome(value: ExactMutationOutcomeV1) -> bool {
1773 matches!(
1774 value,
1775 ExactMutationOutcomeV1::AlreadySatisfied
1776 | ExactMutationOutcomeV1::Applied
1777 | ExactMutationOutcomeV1::Resolved
1778 | ExactMutationOutcomeV1::Conflict
1779 | ExactMutationOutcomeV1::KnownRetired
1780 | ExactMutationOutcomeV1::EffectDeadlineExpired
1781 )
1782}
1783
1784const fn preparation_kind(value: &ExactPreparationV1) -> ExactMutationKindV1 {
1785 match value {
1786 ExactPreparationV1::Append(_) => ExactMutationKindV1::Append,
1787 ExactPreparationV1::Document { .. } => ExactMutationKindV1::Document,
1788 ExactPreparationV1::Index { .. } => ExactMutationKindV1::Index,
1789 }
1790}
1791
1792const fn terminal_kind(value: &ExactTerminalResultV1) -> ExactMutationKindV1 {
1793 match value {
1794 ExactTerminalResultV1::Append(_) => ExactMutationKindV1::Append,
1795 ExactTerminalResultV1::Document(_) => ExactMutationKindV1::Document,
1796 ExactTerminalResultV1::Index(_) => ExactMutationKindV1::Index,
1797 }
1798}
1799
1800fn valid_index(value: &Resource) -> bool {
1801 value.kind == "index" && !value.name.is_empty() && value.name.len() <= 255 && value.incarnation.validate().is_ok()
1802}
1803
1804fn canonical_sha256<T: Serialize>(value: &T) -> Result<Digest, ExactWorkflowError> {
1805 let bytes = serde_json::to_vec(value).map_err(|_| ExactWorkflowError::RequestCodec)?;
1806 let observed = digest::digest(&digest::SHA256, &bytes);
1807 Ok(Digest {
1808 value: lower_hex(observed.as_ref()),
1809 })
1810}
1811
1812fn parse_u64(value: &U64) -> Option<u64> {
1813 if value.value.is_empty() || value.value.len() > 20 || value.value.starts_with('0') && value.value != "0" {
1814 return None;
1815 }
1816 value.value.parse().ok()
1817}
1818
1819fn wire_u64(value: u64) -> U64 {
1820 U64 {
1821 value: value.to_string(),
1822 }
1823}
1824
1825fn lower_hex(bytes: &[u8]) -> String {
1826 const HEX: &[u8; 16] = b"0123456789abcdef";
1827 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1828 for byte in bytes {
1829 output.push(char::from(HEX[usize::from(byte >> 4)]));
1830 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1831 }
1832 output
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837 use std::os::unix::fs::PermissionsExt as _;
1838
1839 use super::*;
1840
1841 fn identity(character: char) -> Identity {
1842 Identity {
1843 value: character.to_string().repeat(32),
1844 }
1845 }
1846
1847 fn digest(character: char) -> Digest {
1848 Digest {
1849 value: character.to_string().repeat(64),
1850 }
1851 }
1852
1853 fn resource() -> Resource {
1854 Resource {
1855 kind: "index".to_owned(),
1856 name: "events".to_owned(),
1857 incarnation: identity('a'),
1858 }
1859 }
1860
1861 #[test]
1862 fn retained_event_identity_preserves_the_catalog_submit_kind() {
1863 assert_eq!(create_index_action_kind(None), "create_index");
1864 assert_eq!(create_index_action_kind(Some(false)), "create_index");
1865 assert_eq!(create_index_action_kind(Some(true)), "create_retained_append_log_index");
1866 }
1867
1868 #[test]
1869 fn catalog_recovery_replays_only_an_exact_prepared_or_pending_identity() {
1870 assert!(catalog_resolution_requires_exact_replay("pending", None));
1871 assert!(catalog_resolution_requires_exact_replay(
1872 "prepared",
1873 Some(ExactSubmissionDispositionV1::Indeterminate),
1874 ));
1875 assert!(catalog_resolution_requires_exact_replay(
1876 "prepared",
1877 Some(ExactSubmissionDispositionV1::Submitted),
1878 ));
1879 assert!(!catalog_resolution_requires_exact_replay("prepared", None));
1880 assert!(!catalog_resolution_requires_exact_replay(
1881 "prepared",
1882 Some(ExactSubmissionDispositionV1::NotSubmitted),
1883 ));
1884 assert!(!catalog_resolution_requires_exact_replay(
1885 "expired_unknown",
1886 Some(ExactSubmissionDispositionV1::Indeterminate),
1887 ));
1888 }
1889
1890 #[test]
1891 fn recovery_refuses_changed_request_or_scope_and_retains_first_deadline() -> Result<(), Box<dyn Error>> {
1892 let intent = exact_intent(
1893 ExactMutationKindV1::Append,
1894 digest('b'),
1895 identity('c'),
1896 identity('d'),
1897 Some(resource()),
1898 digest('e'),
1899 );
1900 let mut recovery = recovery_from_intent(&intent);
1901 reduce_transition(
1902 &mut recovery,
1903 &ExactMutationTransition::DeadlineEstablished {
1904 unix_millis: wire_u64(100),
1905 },
1906 )?;
1907 validate_recovery_matches(&recovery, &intent)?;
1908 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
1909 let mut changed = intent.clone();
1910 changed.collection_id = identity('f');
1911 assert!(matches!(
1912 validate_recovery_matches(&recovery, &changed),
1913 Err(ExactWorkflowError::InvalidRecovery)
1914 ));
1915 changed = intent.clone();
1916 changed.request_sha256 = digest('f');
1917 assert!(matches!(
1918 validate_recovery_matches(&recovery, &changed),
1919 Err(ExactWorkflowError::InvalidRecovery)
1920 ));
1921 Ok(())
1922 }
1923
1924 #[test]
1925 fn reducer_never_regresses_clock_or_replaces_possible_submission() -> Result<(), Box<dyn Error>> {
1926 let intent = exact_intent(
1927 ExactMutationKindV1::Append,
1928 digest('b'),
1929 identity('c'),
1930 identity('d'),
1931 Some(resource()),
1932 digest('e'),
1933 );
1934 let mut state = recovery_from_intent(&intent);
1935 reduce_transition(
1936 &mut state,
1937 &ExactMutationTransition::DeadlineEstablished {
1938 unix_millis: wire_u64(100),
1939 },
1940 )?;
1941 reduce_transition(
1942 &mut state,
1943 &ExactMutationTransition::ClockObserved {
1944 unix_millis: wire_u64(10),
1945 },
1946 )?;
1947 assert!(
1948 reduce_transition(
1949 &mut state,
1950 &ExactMutationTransition::ClockObserved {
1951 unix_millis: wire_u64(9),
1952 },
1953 )
1954 .is_err()
1955 );
1956 state.preparation = Some(ExactPreparationV1::Append(RetainedAppendBulkOutcome {
1957 batch_id: digest('e'),
1958 index: resource(),
1959 items: vec![RetainedAppendItemOutcome {
1960 item_action_id: digest('f'),
1961 locator: Some(Locator {
1962 authority: "retained_append".to_owned(),
1963 value: "01".to_owned(),
1964 }),
1965 semantic_digest: Some(digest('1')),
1966 state: RetainedAppendState::Prepared,
1967 retry_safety: RetainedAppendRetrySafety::Safe,
1968 sequence: None,
1969 primary_term: None,
1970 error: None,
1971 }],
1972 complete: true,
1973 achievement: None,
1974 }));
1975 reduce_transition(
1976 &mut state,
1977 &ExactMutationTransition::SubmissionObserved {
1978 disposition: ExactSubmissionDispositionV1::Indeterminate,
1979 },
1980 )?;
1981 assert!(
1982 reduce_transition(
1983 &mut state,
1984 &ExactMutationTransition::SubmissionObserved {
1985 disposition: ExactSubmissionDispositionV1::NotSubmitted,
1986 },
1987 )
1988 .is_err()
1989 );
1990 Ok(())
1991 }
1992
1993 #[test]
1994 fn not_submitted_append_outcome_remains_recovery_resumable() -> Result<(), Box<dyn Error>> {
1995 let request = AppendExactRequestV1 {
1996 batch_id: digest('a'),
1997 index: resource(),
1998 items: vec![RetainedAppendItem {
1999 item_action_id: digest('b'),
2000 document_id: "event-1".to_owned(),
2001 source: lodestone_wire::RawJson {
2002 bytes_base64: "e30=".to_owned(),
2003 },
2004 }],
2005 observe_committed: true,
2006 };
2007 let outcome = RetainedAppendBulkOutcome {
2008 batch_id: request.batch_id.clone(),
2009 index: request.index.clone(),
2010 items: vec![RetainedAppendItemOutcome {
2011 item_action_id: request.items[0].item_action_id.clone(),
2012 locator: Some(Locator {
2013 authority: "retained_append".to_owned(),
2014 value: "01".to_owned(),
2015 }),
2016 semantic_digest: Some(digest('c')),
2017 state: RetainedAppendState::NotSubmittedRetryable,
2018 retry_safety: RetainedAppendRetrySafety::Safe,
2019 sequence: None,
2020 primary_term: None,
2021 error: None,
2022 }],
2023 complete: false,
2024 achievement: None,
2025 };
2026
2027 let result = append_terminal_result(&request, &outcome, false).ok_or("not-submitted observation absent")?;
2028 assert_eq!(result.outcome, ExactMutationOutcomeV1::NotSubmitted);
2029 assert!(!terminal_outcome(result.outcome));
2030 Ok(())
2031 }
2032
2033 #[test]
2034 fn append_recovery_redacts_server_error_messages() {
2035 let outcome = RetainedAppendBulkOutcome {
2036 batch_id: digest('a'),
2037 index: resource(),
2038 items: vec![RetainedAppendItemOutcome {
2039 item_action_id: digest('b'),
2040 locator: Some(Locator {
2041 authority: "retained_append".to_owned(),
2042 value: "01".to_owned(),
2043 }),
2044 semantic_digest: Some(digest('c')),
2045 state: RetainedAppendState::Prepared,
2046 retry_safety: RetainedAppendRetrySafety::Safe,
2047 sequence: None,
2048 primary_term: None,
2049 error: None,
2050 }],
2051 complete: true,
2052 achievement: None,
2053 };
2054 assert!(redact_append_outcome(outcome).items[0].error.is_none());
2055 }
2056
2057 #[tokio::test]
2058 async fn durable_resume_retains_the_first_effect_deadline() -> Result<(), Box<dyn Error>> {
2059 let mut suffix = [0_u8; 8];
2060 getrandom::fill(&mut suffix)?;
2061 let parent =
2062 std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-{}", lower_hex(&suffix)));
2063 std::fs::create_dir(&parent)?;
2064 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
2065 let store = ExactWorkflowStore::new(parent.join("journal"));
2066 let intent = exact_intent(
2067 ExactMutationKindV1::Append,
2068 digest('b'),
2069 identity('c'),
2070 identity('d'),
2071 Some(resource()),
2072 digest('e'),
2073 );
2074 let first = ExactWorkflowOptions::new(100).with_store(store.clone());
2075 let (session, recovery) = open_session(&intent, &first).await?;
2076 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
2077 drop(session);
2078
2079 let resumed = ExactWorkflowOptions::new(999).with_store(store);
2080 let (session, recovery) = open_session(&intent, &resumed).await?;
2081 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
2082 drop(session);
2083 std::fs::remove_dir_all(&parent)?;
2084 Ok(())
2085 }
2086}