1use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::future::Future;
10use std::path::{Path, PathBuf};
11use std::pin::Pin;
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use aws_lc_rs::digest;
16use lodestone_wire::{
17 Digest, ErrorCode, GetIndexRequest, Identity, IndexResourceSpec, Locator, MutationOutcome,
18 ObserveAppendCommittedRequest, PrepareAppendBulkRequest, PrepareCreateIndexRequest, PrepareDocumentMutationRequest,
19 ResolveAppendBulkRequest, ResolveCatalogActionRequest, ResolveDocumentMutationRequest, Resource,
20 RetainedAppendBulkOutcome, RetainedAppendItem, RetainedAppendItemOutcome, RetainedAppendObservationOutcome,
21 RetainedAppendObservationState, RetainedAppendRetrySafety, RetainedAppendState, SubmitAppendBulkRequest,
22 SubmitCatalogActionRequest, SubmitDocumentMutationRequest, U64,
23};
24use serde::{Deserialize, Serialize};
25
26use crate::workflow_journal::sealed;
27use crate::{
28 CallCancellation, CallOptions, Client, ClientError, LocalOperatorWorkflowKind, SubmissionDisposition,
29 WorkflowJournal, WorkflowJournalError, WorkflowJournalIntent, WorkflowJournalTransition,
30};
31
32pub const EXACT_MUTATION_FORMAT_VERSION: u16 = 2;
33pub const DEFAULT_EXACT_WORKFLOW_STORE_CONFLICT_LIMIT: u32 = 8;
34const MAX_EFFECT_CALL_MILLIS: u64 = 599_000;
35const MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
36
37#[must_use]
43pub const fn create_index_action_kind(retained_event_identity: Option<bool>) -> &'static str {
44 if matches!(retained_event_identity, Some(true)) {
45 "create_retained_append_log_index"
46 } else {
47 "create_index"
48 }
49}
50
51#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub enum ExactMutationKindV1 {
55 Append,
56 Document,
57 Index,
58}
59
60#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum ExactMutationOutcomeV1 {
64 AlreadySatisfied,
65 Applied,
66 Resolved,
67 Conflict,
68 NotSubmitted,
69 Indeterminate,
70 KnownRetired,
71 EffectDeadlineExpired,
72 ClockRegression,
73}
74
75#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "snake_case")]
77enum ExactSubmissionDispositionV1 {
78 NotSubmitted,
79 Submitted,
80 Resolved,
81 KnownRetired,
82 Indeterminate,
83}
84
85#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum ExactWorkflowPhaseV2 {
89 Initialized,
90 Prepared,
91 SubmissionStarted,
92 SubmissionObserved,
93 Terminal,
94}
95
96impl From<SubmissionDisposition> for ExactSubmissionDispositionV1 {
97 fn from(value: SubmissionDisposition) -> Self {
98 match value {
99 SubmissionDisposition::NotSubmitted => Self::NotSubmitted,
100 SubmissionDisposition::Submitted => Self::Submitted,
101 SubmissionDisposition::Resolved => Self::Resolved,
102 SubmissionDisposition::KnownRetired => Self::KnownRetired,
103 SubmissionDisposition::Indeterminate => Self::Indeterminate,
104 }
105 }
106}
107
108fn exact_submission_disposition(outcome: &MutationOutcome) -> ExactSubmissionDispositionV1 {
109 match outcome {
110 MutationOutcome::Terminal => ExactSubmissionDispositionV1::Resolved,
111 MutationOutcome::Pending { .. } => ExactSubmissionDispositionV1::Submitted,
112 MutationOutcome::Indeterminate { .. } | MutationOutcome::ResolutionRequired { .. } => {
113 ExactSubmissionDispositionV1::Indeterminate
114 }
115 }
116}
117
118fn resolution_disposition(outcome: ExactMutationOutcomeV1) -> ExactSubmissionDispositionV1 {
119 match outcome {
120 ExactMutationOutcomeV1::NotSubmitted | ExactMutationOutcomeV1::EffectDeadlineExpired => {
121 ExactSubmissionDispositionV1::NotSubmitted
122 }
123 ExactMutationOutcomeV1::KnownRetired => ExactSubmissionDispositionV1::KnownRetired,
124 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::ClockRegression => {
125 ExactSubmissionDispositionV1::Indeterminate
126 }
127 ExactMutationOutcomeV1::AlreadySatisfied
128 | ExactMutationOutcomeV1::Applied
129 | ExactMutationOutcomeV1::Resolved
130 | ExactMutationOutcomeV1::Conflict => ExactSubmissionDispositionV1::Resolved,
131 }
132}
133
134fn append_resolution_disposition(outcome: &RetainedAppendBulkOutcome) -> ExactSubmissionDispositionV1 {
135 if outcome
136 .items
137 .iter()
138 .any(|item| item.state == RetainedAppendState::SubmittedIndeterminate)
139 {
140 ExactSubmissionDispositionV1::Indeterminate
141 } else if outcome
142 .items
143 .iter()
144 .any(|item| item.state == RetainedAppendState::Retired)
145 {
146 ExactSubmissionDispositionV1::KnownRetired
147 } else if outcome.items.iter().any(|item| {
148 matches!(
149 item.state,
150 RetainedAppendState::Prepared
151 | RetainedAppendState::NotSubmittedRetryable
152 | RetainedAppendState::NotSubmittedTerminal
153 )
154 }) {
155 ExactSubmissionDispositionV1::NotSubmitted
156 } else {
157 ExactSubmissionDispositionV1::Resolved
158 }
159}
160
161#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct AppendExactItemResultV1 {
166 pub item_action_id: Digest,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub locator: Option<Locator>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub semantic_digest: Option<Digest>,
171 pub state: RetainedAppendState,
172 pub retry_safety: RetainedAppendRetrySafety,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub sequence: Option<U64>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub primary_term: Option<U64>,
177}
178
179#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
180#[serde(deny_unknown_fields)]
181pub struct AppendExactResultV1 {
182 pub outcome: ExactMutationOutcomeV1,
183 pub batch_id: Digest,
184 pub index: Resource,
185 pub items: Vec<AppendExactItemResultV1>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub visibility: Vec<RetainedAppendObservationOutcome>,
188}
189
190#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
191#[serde(deny_unknown_fields)]
192pub struct DocumentMutationExactResultV1 {
193 pub outcome: ExactMutationOutcomeV1,
194 pub index: Resource,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub locator: Option<Locator>,
197 pub state: String,
198 pub submitted: bool,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub sequence: Option<U64>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub mapping_generation: Option<U64>,
203 pub mapping_adopted: bool,
204}
205
206#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
207#[serde(deny_unknown_fields)]
208pub struct ReconcileIndexExactResultV1 {
209 pub outcome: ExactMutationOutcomeV1,
210 pub name: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub locator: Option<Locator>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub resource: Option<Resource>,
215 pub state: String,
216}
217
218#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
219#[serde(tag = "kind", content = "result", rename_all = "snake_case", deny_unknown_fields)]
220enum ExactTerminalResultV1 {
221 Append(AppendExactResultV1),
222 Document(DocumentMutationExactResultV1),
223 Index(ReconcileIndexExactResultV1),
224}
225
226#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
227#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
228enum ExactPreparationV1 {
229 Append(RetainedAppendBulkOutcome),
230 Document {
231 locator: Locator,
232 semantic_digest: Digest,
233 },
234 Index {
235 locator: Locator,
236 expires_at: U64,
237 semantic_digest: Digest,
238 resource_spec: Box<IndexResourceSpec>,
239 },
240}
241
242#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
246#[serde(deny_unknown_fields)]
247pub struct ExactWorkflowRecoveryV2 {
248 pub format_version: u16,
249 pub revision: u64,
250 pub phase: ExactWorkflowPhaseV2,
251 pub mutation_kind: ExactMutationKindV1,
252 pub request_sha256: Digest,
253 pub cluster_id: Identity,
254 pub collection_id: Identity,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub index: Option<Resource>,
257 pub caller_action_id: Digest,
258 pub effect_deadline_unix_millis: U64,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub last_observed_unix_millis: Option<U64>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 preparation: Option<ExactPreparationV1>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 submission_disposition: Option<ExactSubmissionDispositionV1>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 terminal: Option<ExactTerminalResultV1>,
267}
268
269#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
271#[serde(deny_unknown_fields)]
272pub struct ExactExecutionV2<T> {
273 pub result: T,
274 pub recovery: ExactWorkflowRecoveryV2,
275}
276
277pub type ExactWorkflowStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
279
280#[derive(Clone, Debug, PartialEq)]
282pub enum ExactWorkflowStoreError {
283 Unavailable,
284 Conflict {
285 observed: Option<Box<ExactWorkflowRecoveryV2>>,
286 },
287 Malformed,
288 UnsupportedFormat {
289 observed: u16,
290 },
291 IntentMismatch,
292 Internal,
293}
294
295impl Display for ExactWorkflowStoreError {
296 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
297 formatter.write_str(match self {
298 Self::Unavailable => "the exact workflow recovery store is unavailable",
299 Self::Conflict { .. } => "the exact workflow recovery store compare-and-exchange conflicted",
300 Self::Malformed => "the exact workflow recovery store returned malformed current state",
301 Self::UnsupportedFormat { .. } => "the exact workflow recovery store returned an unsupported format",
302 Self::IntentMismatch => "the exact workflow recovery store key belongs to another intent",
303 Self::Internal => "the exact workflow recovery store failed internally",
304 })
305 }
306}
307
308impl Error for ExactWorkflowStoreError {}
309
310pub trait ExactWorkflowRecoveryStore: Send + Sync {
315 fn load(&self) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>>;
316
317 fn compare_exchange(
318 &self,
319 expected_revision: Option<u64>,
320 next: ExactWorkflowRecoveryV2,
321 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>>;
322}
323
324#[derive(Clone)]
327pub struct FilesystemExactWorkflowRecoveryStore {
328 path: PathBuf,
329 session: Arc<tokio::sync::Mutex<Option<FilesystemExactWorkflowSession>>>,
330}
331
332struct FilesystemExactWorkflowSession {
333 journal: WorkflowJournal<ExactMutationIntent>,
334 recovery: ExactWorkflowRecoveryV2,
335}
336
337impl fmt::Debug for FilesystemExactWorkflowRecoveryStore {
338 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
339 formatter
340 .debug_struct("FilesystemExactWorkflowRecoveryStore")
341 .field("path", &self.path)
342 .finish()
343 }
344}
345
346impl FilesystemExactWorkflowRecoveryStore {
347 #[must_use]
348 pub fn new(path: impl Into<PathBuf>) -> Self {
349 Self {
350 path: path.into(),
351 session: Arc::new(tokio::sync::Mutex::new(None)),
352 }
353 }
354
355 #[must_use]
356 pub fn path(&self) -> &Path {
357 &self.path
358 }
359}
360
361impl ExactWorkflowRecoveryStore for FilesystemExactWorkflowRecoveryStore {
362 fn load(&self) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
363 Box::pin(async move {
364 let mut session = self.session.lock().await;
365 if let Some(current) = session.as_ref() {
366 return Ok(Some(current.recovery.clone()));
367 }
368 let Some(opened) = open_filesystem_recovery(&self.path).await? else {
369 return Ok(None);
370 };
371 let recovery = opened.recovery.clone();
372 *session = Some(opened);
373 Ok(Some(recovery))
374 })
375 }
376
377 fn compare_exchange(
378 &self,
379 expected_revision: Option<u64>,
380 next: ExactWorkflowRecoveryV2,
381 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
382 Box::pin(async move {
383 let mut session = self.session.lock().await;
384 if session.is_none() {
385 *session = open_filesystem_recovery(&self.path).await?;
386 }
387 match (session.as_mut(), expected_revision) {
388 (Some(current), Some(expected)) if current.recovery.revision == expected => {
389 let transition = transition_between(¤t.recovery, &next)?;
390 current
391 .journal
392 .append(transition)
393 .await
394 .map_err(map_journal_store_error)?;
395 current.recovery = next;
396 Ok(())
397 }
398 (Some(current), _) => Err(ExactWorkflowStoreError::Conflict {
399 observed: Some(Box::new(current.recovery.clone())),
400 }),
401 (None, Some(_)) => Err(ExactWorkflowStoreError::Conflict { observed: None }),
402 (None, None) => {
403 validate_initial_recovery(&next)?;
404 let intent = intent_from_recovery(&next);
405 let replay = WorkflowJournal::open_or_create(&self.path, intent)
406 .await
407 .map_err(map_journal_store_error)?;
408 let created = replay.was_created();
409 let opened = filesystem_recovery_from_replay(replay)?;
410 if !created {
411 let observed = opened.recovery.clone();
412 *session = Some(opened);
413 return Err(ExactWorkflowStoreError::Conflict {
414 observed: Some(Box::new(observed)),
415 });
416 }
417 if opened.recovery != next {
418 return Err(ExactWorkflowStoreError::Malformed);
419 }
420 *session = Some(opened);
421 Ok(())
422 }
423 }
424 })
425 }
426}
427
428async fn open_filesystem_recovery(
429 path: &Path,
430) -> Result<Option<FilesystemExactWorkflowSession>, ExactWorkflowStoreError> {
431 let replay = WorkflowJournal::<ExactMutationIntent>::open_existing(path)
432 .await
433 .map_err(map_journal_store_error)?;
434 replay.map(filesystem_recovery_from_replay).transpose()
435}
436
437fn filesystem_recovery_from_replay(
438 replay: crate::WorkflowJournalReplay<ExactMutationIntent>,
439) -> Result<FilesystemExactWorkflowSession, ExactWorkflowStoreError> {
440 let mut recovery = recovery_from_intent(replay.intent());
441 let (journal, transitions) = replay.into_parts();
442 for transition in transitions {
443 reduce_transition(&mut recovery, &transition).map_err(|_| ExactWorkflowStoreError::Malformed)?;
444 }
445 validate_recovery_shape(&recovery).map_err(|_| ExactWorkflowStoreError::Malformed)?;
446 Ok(FilesystemExactWorkflowSession { journal, recovery })
447}
448
449const fn map_journal_store_error(error: WorkflowJournalError) -> ExactWorkflowStoreError {
450 match error {
451 WorkflowJournalError::UnsupportedFormat(observed) => ExactWorkflowStoreError::UnsupportedFormat { observed },
452 WorkflowJournalError::IntentMismatch => ExactWorkflowStoreError::IntentMismatch,
453 WorkflowJournalError::InvalidIntent
454 | WorkflowJournalError::InvalidTransition
455 | WorkflowJournalError::Corrupt
456 | WorkflowJournalError::Codec
457 | WorkflowJournalError::LimitExceeded => ExactWorkflowStoreError::Malformed,
458 WorkflowJournalError::UnsupportedPlatform
459 | WorkflowJournalError::InvalidPath
460 | WorkflowJournalError::InsecurePath
461 | WorkflowJournalError::Busy
462 | WorkflowJournalError::Io
463 | WorkflowJournalError::RandomUnavailable
464 | WorkflowJournalError::ReopenRequired
465 | WorkflowJournalError::WorkerUnavailable => ExactWorkflowStoreError::Unavailable,
466 }
467}
468
469pub trait ExactWorkflowClock: Send + Sync {
472 fn unix_millis(&self) -> Result<u64, ExactWorkflowError>;
473}
474
475#[derive(Debug)]
476struct SystemExactWorkflowClock;
477
478impl ExactWorkflowClock for SystemExactWorkflowClock {
479 fn unix_millis(&self) -> Result<u64, ExactWorkflowError> {
480 let duration = SystemTime::now()
481 .duration_since(UNIX_EPOCH)
482 .map_err(|_| ExactWorkflowError::ClockUnavailable)?;
483 u64::try_from(duration.as_millis()).map_err(|_| ExactWorkflowError::ClockUnavailable)
484 }
485}
486
487#[derive(Clone)]
489pub struct ExactWorkflowOptions {
490 effect_deadline_unix_millis: u64,
491 observation_deadline_unix_millis: Option<u64>,
492 store: Option<Arc<dyn ExactWorkflowRecoveryStore>>,
493 recovery: Option<ExactWorkflowRecoveryV2>,
494 clock: Arc<dyn ExactWorkflowClock>,
495 cancellation: CallCancellation,
496 store_conflict_limit: u32,
497}
498
499impl ExactWorkflowOptions {
500 #[must_use]
501 pub fn new(effect_deadline_unix_millis: u64) -> Self {
502 Self {
503 effect_deadline_unix_millis,
504 observation_deadline_unix_millis: None,
505 store: None,
506 recovery: None,
507 clock: Arc::new(SystemExactWorkflowClock),
508 cancellation: CallCancellation::new(),
509 store_conflict_limit: DEFAULT_EXACT_WORKFLOW_STORE_CONFLICT_LIMIT,
510 }
511 }
512
513 #[must_use]
514 pub fn with_observation_deadline_unix_millis(mut self, value: u64) -> Self {
515 self.observation_deadline_unix_millis = Some(value);
516 self
517 }
518
519 #[must_use]
520 pub fn with_recovery_store(mut self, store: Arc<dyn ExactWorkflowRecoveryStore>) -> Self {
521 self.store = Some(store);
522 self
523 }
524
525 #[must_use]
526 pub fn with_store_conflict_limit(mut self, value: u32) -> Self {
527 self.store_conflict_limit = value;
528 self
529 }
530
531 #[must_use]
532 pub fn with_recovery(mut self, recovery: ExactWorkflowRecoveryV2) -> Self {
533 self.recovery = Some(recovery);
534 self
535 }
536
537 #[must_use]
538 pub fn with_clock(mut self, clock: Arc<dyn ExactWorkflowClock>) -> Self {
539 self.clock = clock;
540 self
541 }
542
543 #[must_use]
544 pub fn with_cancellation(mut self, cancellation: CallCancellation) -> Self {
545 self.cancellation = cancellation;
546 self
547 }
548}
549
550#[derive(Debug)]
551pub enum ExactWorkflowError {
552 InvalidInput,
553 InvalidRecovery,
554 RequestCodec,
555 CoreShape,
556 ClockUnavailable,
557 Journal(WorkflowJournalError),
558 Store {
559 source: ExactWorkflowStoreError,
560 last_durable_recovery: Option<Box<ExactWorkflowRecoveryV2>>,
561 },
562 StoreConflictBudgetExhausted {
563 last_durable_recovery: Box<ExactWorkflowRecoveryV2>,
564 },
565 Cancelled {
566 last_durable_recovery: Box<ExactWorkflowRecoveryV2>,
567 },
568 Client(ClientError),
569}
570
571impl Display for ExactWorkflowError {
572 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
573 formatter.write_str(match self {
574 Self::InvalidInput => "the exact mutation input is invalid",
575 Self::InvalidRecovery => "the exact mutation recovery state does not match this request",
576 Self::RequestCodec => "the exact mutation request cannot be canonically encoded",
577 Self::CoreShape => "Core changed an exact mutation identity or outcome shape",
578 Self::ClockUnavailable => "the exact mutation wall clock is unavailable",
579 Self::Journal(_) => "the exact mutation journal could not be verified or updated",
580 Self::Store { .. } => "the exact mutation recovery store failed",
581 Self::StoreConflictBudgetExhausted { .. } => {
582 "the exact mutation recovery store conflict budget was exhausted"
583 }
584 Self::Cancelled { .. } => "the exact mutation workflow was cancelled",
585 Self::Client(_) => "the exact mutation client call failed",
586 })
587 }
588}
589
590impl Error for ExactWorkflowError {}
591
592impl ExactWorkflowError {
593 #[must_use]
594 pub fn last_durable_recovery(&self) -> Option<&ExactWorkflowRecoveryV2> {
595 match self {
596 Self::Store {
597 last_durable_recovery, ..
598 } => last_durable_recovery.as_deref(),
599 Self::StoreConflictBudgetExhausted { last_durable_recovery }
600 | Self::Cancelled { last_durable_recovery } => Some(last_durable_recovery),
601 _ => None,
602 }
603 }
604}
605
606impl From<WorkflowJournalError> for ExactWorkflowError {
607 fn from(value: WorkflowJournalError) -> Self {
608 Self::Journal(value)
609 }
610}
611
612#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
615#[serde(deny_unknown_fields)]
616pub struct AppendExactRequestV1 {
617 pub batch_id: Digest,
618 pub index: Resource,
619 pub items: Vec<RetainedAppendItem>,
620 #[serde(default)]
621 pub observe_committed: bool,
622}
623
624#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
625#[serde(deny_unknown_fields)]
626struct ExactMutationIntent {
627 format_version: u16,
628 mutation_kind: ExactMutationKindV1,
629 request_sha256: Digest,
630 cluster_id: Identity,
631 collection_id: Identity,
632 index: Option<Resource>,
633 caller_action_id: Digest,
634 effect_deadline_unix_millis: U64,
635}
636
637impl sealed::Sealed for ExactMutationIntent {}
638
639impl WorkflowJournalIntent for ExactMutationIntent {
640 type Transition = ExactMutationTransition;
641
642 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::ExactMutation;
643
644 fn validate(&self) -> Result<(), WorkflowJournalError> {
645 if self.format_version != EXACT_MUTATION_FORMAT_VERSION
646 || self.request_sha256.validate().is_err()
647 || self.cluster_id.validate().is_err()
648 || self.collection_id.validate().is_err()
649 || self.caller_action_id.validate().is_err()
650 || parse_u64(&self.effect_deadline_unix_millis).is_none_or(|value| value == 0)
651 || self.index.as_ref().is_some_and(|value| !valid_index(value))
652 {
653 return Err(WorkflowJournalError::InvalidIntent);
654 }
655 Ok(())
656 }
657}
658
659#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
660#[serde(tag = "transition", rename_all = "snake_case", deny_unknown_fields)]
661enum ExactMutationTransition {
662 ClockObserved { unix_millis: U64 },
663 Prepared { preparation: ExactPreparationV1 },
664 SubmissionStarted,
665 SubmissionObserved { disposition: ExactSubmissionDispositionV1 },
666 Terminal { result: ExactTerminalResultV1 },
667}
668
669impl sealed::Sealed for ExactMutationTransition {}
670
671impl WorkflowJournalTransition for ExactMutationTransition {
672 fn validate(&self) -> Result<(), WorkflowJournalError> {
673 match self {
674 Self::ClockObserved { unix_millis } if parse_u64(unix_millis).is_some() => Ok(()),
675 Self::Prepared { preparation } if valid_preparation(preparation) => Ok(()),
676 Self::SubmissionStarted => Ok(()),
677 Self::SubmissionObserved { .. } => Ok(()),
678 Self::Terminal { result } if valid_terminal(result) => Ok(()),
679 _ => Err(WorkflowJournalError::InvalidTransition),
680 }
681 }
682}
683
684enum ExactSession {
685 Durable {
686 store: Arc<dyn ExactWorkflowRecoveryStore>,
687 conflict_limit: u32,
688 deadline_unix_millis: u64,
689 clock: Arc<dyn ExactWorkflowClock>,
690 cancellation: CallCancellation,
691 },
692 Ephemeral,
693}
694
695impl ExactSession {
696 async fn record(
697 &self,
698 state: &mut ExactWorkflowRecoveryV2,
699 transition: ExactMutationTransition,
700 ) -> Result<bool, ExactWorkflowError> {
701 let Self::Durable {
702 store,
703 conflict_limit,
704 deadline_unix_millis,
705 clock,
706 cancellation,
707 } = self
708 else {
709 reduce_transition(state, &transition)?;
710 return Ok(true);
711 };
712 let mut conflicts = 0_u32;
713 loop {
714 if cancellation.is_cancelled() {
715 return Err(ExactWorkflowError::Cancelled {
716 last_durable_recovery: Box::new(state.clone()),
717 });
718 }
719 if transition_is_reflected(state, &transition) {
720 return Ok(false);
721 }
722 if conflicts > 0 && clock.unix_millis()? >= *deadline_unix_millis {
723 return Err(ExactWorkflowError::StoreConflictBudgetExhausted {
724 last_durable_recovery: Box::new(state.clone()),
725 });
726 }
727 let mut next = state.clone();
728 reduce_transition(&mut next, &transition)?;
729 match store.compare_exchange(Some(state.revision), next.clone()).await {
730 Ok(()) => {
731 *state = next;
732 return Ok(true);
733 }
734 Err(ExactWorkflowStoreError::Conflict {
735 observed: Some(observed),
736 }) => {
737 let observed = *observed;
738 validate_recovery_descendant(state, &observed)?;
739 *state = observed;
740 if transition_is_reflected(state, &transition) {
741 return Ok(false);
742 }
743 conflicts = conflicts.saturating_add(1);
744 if conflicts > *conflict_limit {
745 return Err(ExactWorkflowError::StoreConflictBudgetExhausted {
746 last_durable_recovery: Box::new(state.clone()),
747 });
748 }
749 }
750 Err(ExactWorkflowStoreError::Conflict { observed: None }) => {
751 return Err(store_failure(ExactWorkflowStoreError::Malformed, Some(state.clone())));
752 }
753 Err(error) => return Err(store_failure(error, Some(state.clone()))),
754 }
755 }
756 }
757}
758
759impl Client {
760 pub async fn append_exact(
763 &self,
764 request: AppendExactRequestV1,
765 options: ExactWorkflowOptions,
766 ) -> Result<ExactExecutionV2<AppendExactResultV1>, ExactWorkflowError> {
767 validate_append_request(&request)?;
768 let request_sha256 = canonical_sha256(&request)?;
769 let (cluster_id, collection_id) = self.exact_profile_scope();
770 let intent = exact_intent(
771 ExactMutationKindV1::Append,
772 request_sha256,
773 cluster_id,
774 collection_id,
775 Some(request.index.clone()),
776 request.batch_id.clone(),
777 options.effect_deadline_unix_millis,
778 );
779 let (session, mut recovery) = open_session(&intent, &options).await?;
780 let mut options = options;
781 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
782 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
783 return Ok(execution(result.clone(), recovery));
784 }
785 let now = observe_clock(&session, &mut recovery, &options).await?;
786 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
787 return Ok(execution(result.clone(), recovery));
788 }
789 if clock_regressed(&recovery, now) {
790 return Ok(execution(
791 append_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
792 recovery,
793 ));
794 }
795
796 if recovery.preparation.is_none() {
797 if now >= options.effect_deadline_unix_millis {
798 let result = append_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
799 return finish_append(&session, &mut recovery, result).await;
800 }
801 let prepared = self
802 .prepare_append_bulk(
803 PrepareAppendBulkRequest {
804 batch_id: request.batch_id.clone(),
805 index: request.index.clone(),
806 items: request.items.clone(),
807 },
808 effect_options(now, options.effect_deadline_unix_millis, &options)?,
809 )
810 .await
811 .map_err(ExactWorkflowError::Client)?
812 .outcome;
813 validate_append_outcome(&request, &prepared, true)?;
814 session
815 .record(
816 &mut recovery,
817 ExactMutationTransition::Prepared {
818 preparation: ExactPreparationV1::Append(redact_append_outcome(prepared)),
819 },
820 )
821 .await?;
822 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
823 return Ok(execution(result.clone(), recovery));
824 }
825 }
826
827 let preparations = append_preparation(&recovery)?.items.clone();
828 let may_submit = recovery.phase == ExactWorkflowPhaseV2::Prepared
829 || recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted);
830 if may_submit && now < options.effect_deadline_unix_millis {
831 let owns_submission = session
832 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
833 .await?;
834 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
835 return Ok(execution(result.clone(), recovery));
836 }
837 if owns_submission {
838 match self
839 .submit_append_bulk(
840 SubmitAppendBulkRequest {
841 batch_id: request.batch_id.clone(),
842 index: request.index.clone(),
843 items: request.items.clone(),
844 preparations,
845 },
846 effect_options(now, options.effect_deadline_unix_millis, &options)?,
847 )
848 .await
849 {
850 Ok(submitted) => {
851 validate_append_outcome(&request, &submitted.outcome, false)?;
852 let disposition = if submitted.outcome.items.iter().any(|item| {
853 matches!(
854 item.state,
855 RetainedAppendState::Committed
856 | RetainedAppendState::Replayed
857 | RetainedAppendState::SubmittedIndeterminate
858 )
859 }) {
860 ExactSubmissionDispositionV1::Submitted
861 } else {
862 ExactSubmissionDispositionV1::NotSubmitted
863 };
864 let published = session
865 .record(
866 &mut recovery,
867 ExactMutationTransition::SubmissionObserved { disposition },
868 )
869 .await?;
870 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
871 return Ok(execution(result.clone(), recovery));
872 }
873 if published
874 && let Some(mut result) = append_terminal_result(&request, &submitted.outcome, false)
875 {
876 if !terminal_outcome(result.outcome) {
877 return Ok(execution(result, recovery));
878 }
879 if request.observe_committed
880 && matches!(
881 result.outcome,
882 ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
883 )
884 {
885 let Some(observe_options) = resolution_options(now, &options)? else {
886 result.outcome = ExactMutationOutcomeV1::Indeterminate;
887 return Ok(execution(result, recovery));
888 };
889 result = observe_append_visibility(self, &request, result, observe_options).await?;
890 if result.outcome == ExactMutationOutcomeV1::Indeterminate {
891 return Ok(execution(result, recovery));
892 }
893 }
894 return finish_append(&session, &mut recovery, result).await;
895 }
896 }
897 Err(error) => {
898 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
899 session
900 .record(
901 &mut recovery,
902 ExactMutationTransition::SubmissionObserved { disposition },
903 )
904 .await?;
905 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
906 return Ok(execution(result.clone(), recovery));
907 }
908 if recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
909 return Ok(execution(
910 append_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
911 recovery,
912 ));
913 }
914 }
915 }
916 }
917 }
918
919 let Some(resolve_options) = resolution_options(now, &options)? else {
920 return Ok(execution(
921 append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
922 recovery,
923 ));
924 };
925 let resolved = self
926 .resolve_append_bulk(
927 ResolveAppendBulkRequest {
928 batch_id: request.batch_id.clone(),
929 index: request.index.clone(),
930 items: append_preparation(&recovery)?.items.clone(),
931 },
932 resolve_options,
933 )
934 .await;
935 let outcome = match resolved {
936 Ok(value) => value.outcome,
937 Err(_) => {
938 return Ok(execution(
939 append_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
940 recovery,
941 ));
942 }
943 };
944 validate_append_outcome(&request, &outcome, false)?;
945 record_resolution_observation(&session, &mut recovery, append_resolution_disposition(&outcome)).await?;
946 if let Some(ExactTerminalResultV1::Append(result)) = &recovery.terminal {
947 return Ok(execution(result.clone(), recovery));
948 }
949 let Some(mut result) = append_terminal_result(&request, &outcome, true) else {
950 return Ok(execution(
951 append_result(&request, &outcome, ExactMutationOutcomeV1::Indeterminate),
952 recovery,
953 ));
954 };
955 if request.observe_committed
956 && matches!(
957 result.outcome,
958 ExactMutationOutcomeV1::Applied | ExactMutationOutcomeV1::Resolved
959 )
960 {
961 let Some(observe_options) = resolution_options(now, &options)? else {
962 result.outcome = ExactMutationOutcomeV1::Indeterminate;
963 return Ok(execution(result, recovery));
964 };
965 result = observe_append_visibility(self, &request, result, observe_options).await?;
966 if result.outcome == ExactMutationOutcomeV1::Indeterminate {
967 return Ok(execution(result, recovery));
968 }
969 }
970 finish_append(&session, &mut recovery, result).await
971 }
972
973 pub async fn mutate_document_exact(
976 &self,
977 request: PrepareDocumentMutationRequest,
978 options: ExactWorkflowOptions,
979 ) -> Result<ExactExecutionV2<DocumentMutationExactResultV1>, ExactWorkflowError> {
980 if !valid_index(&request.index) || request.document_id.is_empty() {
981 return Err(ExactWorkflowError::InvalidInput);
982 }
983 let request_sha256 = canonical_sha256(&request)?;
984 let (cluster_id, collection_id) = self.exact_profile_scope();
985 let intent = exact_intent(
986 ExactMutationKindV1::Document,
987 request_sha256.clone(),
988 cluster_id,
989 collection_id,
990 Some(request.index.clone()),
991 request_sha256,
992 options.effect_deadline_unix_millis,
993 );
994 let (session, mut recovery) = open_session(&intent, &options).await?;
995 let mut options = options;
996 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
997 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
998 return Ok(execution(result.clone(), recovery));
999 }
1000 let now = observe_clock(&session, &mut recovery, &options).await?;
1001 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1002 return Ok(execution(result.clone(), recovery));
1003 }
1004 if clock_regressed(&recovery, now) {
1005 return Ok(execution(
1006 document_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
1007 recovery,
1008 ));
1009 }
1010 if recovery.preparation.is_none() {
1011 if now >= options.effect_deadline_unix_millis {
1012 let result = document_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
1013 return finish_document(&session, &mut recovery, result).await;
1014 }
1015 let prepared = self
1016 .prepare_document_mutation(
1017 request.clone(),
1018 effect_options(now, options.effect_deadline_unix_millis, &options)?,
1019 )
1020 .await
1021 .map_err(ExactWorkflowError::Client)?;
1022 if prepared.locator.authority.is_empty() || prepared.semantic_digest.validate().is_err() {
1023 return Err(ExactWorkflowError::CoreShape);
1024 }
1025 session
1026 .record(
1027 &mut recovery,
1028 ExactMutationTransition::Prepared {
1029 preparation: ExactPreparationV1::Document {
1030 locator: prepared.locator,
1031 semantic_digest: prepared.semantic_digest,
1032 },
1033 },
1034 )
1035 .await?;
1036 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1037 return Ok(execution(result.clone(), recovery));
1038 }
1039 }
1040 let (locator, semantic_digest) = document_preparation(&recovery)?;
1041 let may_submit = recovery.phase == ExactWorkflowPhaseV2::Prepared
1042 || recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted);
1043 if may_submit && now < options.effect_deadline_unix_millis {
1044 let owns_submission = session
1045 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
1046 .await?;
1047 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1048 return Ok(execution(result.clone(), recovery));
1049 }
1050 if owns_submission {
1051 match self
1052 .submit_document_mutation(
1053 SubmitDocumentMutationRequest {
1054 locator: locator.clone(),
1055 semantic_digest,
1056 index: request.index.clone(),
1057 document_id: request.document_id.clone(),
1058 operation: request.operation.clone(),
1059 source: request.source.clone(),
1060 record: request.record.clone(),
1061 routing: request.routing.clone(),
1062 condition: request.condition.clone(),
1063 pipeline: request.pipeline.clone(),
1064 },
1065 effect_options(now, options.effect_deadline_unix_millis, &options)?,
1066 )
1067 .await
1068 {
1069 Ok(value) => {
1070 if value.locator != locator {
1071 return Err(ExactWorkflowError::CoreShape);
1072 }
1073 let published = session
1074 .record(
1075 &mut recovery,
1076 ExactMutationTransition::SubmissionObserved {
1077 disposition: exact_submission_disposition(&value.mutation_outcome),
1078 },
1079 )
1080 .await?;
1081 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1082 return Ok(execution(result.clone(), recovery));
1083 }
1084 if published
1085 && matches!(&value.mutation_outcome, MutationOutcome::Terminal)
1086 && matches!(value.state.as_str(), "applied" | "dropped")
1087 {
1088 let submitted = value.state == "applied";
1089 return finish_document(
1090 &session,
1091 &mut recovery,
1092 DocumentMutationExactResultV1 {
1093 outcome: ExactMutationOutcomeV1::Applied,
1094 index: request.index,
1095 locator: Some(value.locator),
1096 state: value.state,
1097 submitted,
1098 sequence: value.sequence,
1099 mapping_generation: value.mapping_generation,
1100 mapping_adopted: value.mapping_adopted,
1101 },
1102 )
1103 .await;
1104 }
1105 }
1106 Err(error) => {
1107 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
1108 session
1109 .record(
1110 &mut recovery,
1111 ExactMutationTransition::SubmissionObserved { disposition },
1112 )
1113 .await?;
1114 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1115 return Ok(execution(result.clone(), recovery));
1116 }
1117 if recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
1118 return Ok(execution(
1119 document_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1120 recovery,
1121 ));
1122 }
1123 }
1124 }
1125 }
1126 }
1127 let Some(resolve_options) = resolution_options(now, &options)? else {
1128 return Ok(execution(
1129 document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1130 recovery,
1131 ));
1132 };
1133 let resolve_options = resolve_options
1134 .with_index_name(request.index.name.clone())
1135 .map_err(ExactWorkflowError::Client)?;
1136 let value = match self
1137 .resolve_document_mutation(
1138 ResolveDocumentMutationRequest {
1139 locator: locator.clone(),
1140 },
1141 resolve_options,
1142 )
1143 .await
1144 {
1145 Ok(value) => value,
1146 Err(_) => {
1147 return Ok(execution(
1148 document_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1149 recovery,
1150 ));
1151 }
1152 };
1153 if value.locator != locator || value.source.is_some() {
1154 return Err(ExactWorkflowError::CoreShape);
1155 }
1156 let outcome = match value.state.as_str() {
1157 "applied" => ExactMutationOutcomeV1::Resolved,
1158 "failed" => ExactMutationOutcomeV1::Resolved,
1159 "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
1160 "prepared" => ExactMutationOutcomeV1::NotSubmitted,
1161 "pending" => ExactMutationOutcomeV1::Indeterminate,
1162 _ => return Err(ExactWorkflowError::CoreShape),
1163 };
1164 record_resolution_observation(&session, &mut recovery, resolution_disposition(outcome)).await?;
1165 if let Some(ExactTerminalResultV1::Document(result)) = &recovery.terminal {
1166 return Ok(execution(result.clone(), recovery));
1167 }
1168 let result = DocumentMutationExactResultV1 {
1169 outcome,
1170 index: request.index,
1171 locator: Some(value.locator),
1172 state: value.state,
1173 submitted: !matches!(outcome, ExactMutationOutcomeV1::NotSubmitted),
1174 sequence: value.sequence,
1175 mapping_generation: value.mapping_generation,
1176 mapping_adopted: value.mapping_adopted,
1177 };
1178 if matches!(
1179 outcome,
1180 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
1181 ) {
1182 Ok(execution(result, recovery))
1183 } else {
1184 finish_document(&session, &mut recovery, result).await
1185 }
1186 }
1187
1188 pub async fn reconcile_index_exact(
1191 &self,
1192 request: PrepareCreateIndexRequest,
1193 options: ExactWorkflowOptions,
1194 ) -> Result<ExactExecutionV2<ReconcileIndexExactResultV1>, ExactWorkflowError> {
1195 if request.name.is_empty() || request.resource_spec.is_none() {
1196 return Err(ExactWorkflowError::InvalidInput);
1197 }
1198 let request_sha256 = canonical_sha256(&request)?;
1199 let (cluster_id, collection_id) = self.exact_profile_scope();
1200 let intent = exact_intent(
1201 ExactMutationKindV1::Index,
1202 request_sha256.clone(),
1203 cluster_id,
1204 collection_id,
1205 None,
1206 request_sha256,
1207 options.effect_deadline_unix_millis,
1208 );
1209 let (session, mut recovery) = open_session(&intent, &options).await?;
1210 let mut options = options;
1211 options.effect_deadline_unix_millis = recovery_effect_deadline(&recovery)?;
1212 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1213 return Ok(execution(result.clone(), recovery));
1214 }
1215 let now = observe_clock(&session, &mut recovery, &options).await?;
1216 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1217 return Ok(execution(result.clone(), recovery));
1218 }
1219 if clock_regressed(&recovery, now) {
1220 return Ok(execution(
1221 index_status(&request, &recovery, ExactMutationOutcomeV1::ClockRegression),
1222 recovery,
1223 ));
1224 }
1225
1226 if recovery.preparation.is_none() {
1227 let Some(lookup_options) = resolution_options(now, &options)? else {
1228 let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
1229 return finish_index(&session, &mut recovery, result).await;
1230 };
1231 let lookup = self
1232 .get_index(
1233 GetIndexRequest {
1234 name: request.name.clone(),
1235 expected_incarnation: None,
1236 },
1237 lookup_options,
1238 )
1239 .await;
1240 match lookup {
1241 Ok(existing) => {
1242 let exact = existing.resource.current_mapping == request.mapping
1243 && request.resource_spec.as_ref() == Some(&existing.resource.resource_spec)
1244 && existing.resource.summary.retained_event_identity
1245 == request.retained_event_identity.unwrap_or(false);
1246 let outcome = if exact && existing.resource.summary.state == "active" {
1247 ExactMutationOutcomeV1::AlreadySatisfied
1248 } else if exact {
1249 ExactMutationOutcomeV1::Indeterminate
1250 } else {
1251 ExactMutationOutcomeV1::Conflict
1252 };
1253 let result = ReconcileIndexExactResultV1 {
1254 outcome,
1255 name: request.name,
1256 locator: None,
1257 resource: Some(Resource {
1258 kind: "index".to_owned(),
1259 name: existing.resource.summary.name,
1260 incarnation: existing.resource.summary.incarnation,
1261 }),
1262 state: existing.resource.summary.state,
1263 };
1264 if outcome == ExactMutationOutcomeV1::Indeterminate {
1265 return Ok(execution(result, recovery));
1266 }
1267 return finish_index(&session, &mut recovery, result).await;
1268 }
1269 Err(ClientError::Server(error)) if error.code == ErrorCode::NotFound => {}
1270 Err(error) => return Err(ExactWorkflowError::Client(error)),
1271 }
1272 if now >= options.effect_deadline_unix_millis {
1273 let result = index_status(&request, &recovery, ExactMutationOutcomeV1::EffectDeadlineExpired);
1274 return finish_index(&session, &mut recovery, result).await;
1275 }
1276 let prepared = self
1277 .prepare_create_index(
1278 request.clone(),
1279 effect_options(now, options.effect_deadline_unix_millis, &options)?,
1280 )
1281 .await
1282 .map_err(ExactWorkflowError::Client)?;
1283 if prepared.locator.authority.is_empty()
1284 || prepared.semantic_digest.validate().is_err()
1285 || prepared.resource_spec != *request.resource_spec.as_ref().ok_or(ExactWorkflowError::InvalidInput)?
1286 {
1287 return Err(ExactWorkflowError::CoreShape);
1288 }
1289 session
1290 .record(
1291 &mut recovery,
1292 ExactMutationTransition::Prepared {
1293 preparation: ExactPreparationV1::Index {
1294 locator: prepared.locator,
1295 expires_at: prepared.expires_at,
1296 semantic_digest: prepared.semantic_digest,
1297 resource_spec: Box::new(prepared.resource_spec),
1298 },
1299 },
1300 )
1301 .await?;
1302 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1303 return Ok(execution(result.clone(), recovery));
1304 }
1305 }
1306 let (locator, expires_at, semantic_digest, resource_spec) = index_preparation(&recovery)?;
1307 let expiry = parse_u64(&expires_at).ok_or(ExactWorkflowError::InvalidRecovery)?;
1308 let submit_deadline = options.effect_deadline_unix_millis.min(expiry);
1309 let submission = SubmitCatalogActionRequest {
1310 locator: locator.clone(),
1311 semantic_digest,
1312 action_kind: create_index_action_kind(request.retained_event_identity).to_owned(),
1313 name: request.name.clone(),
1314 mapping: Some(request.mapping.clone()),
1315 resource_spec: Some(resource_spec),
1316 expected_incarnation: None,
1317 };
1318 let may_submit = recovery.phase == ExactWorkflowPhaseV2::Prepared
1319 || recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted);
1320 if may_submit && now < submit_deadline {
1321 let owns_submission = session
1322 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
1323 .await?;
1324 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1325 return Ok(execution(result.clone(), recovery));
1326 }
1327 if owns_submission {
1328 match self
1329 .submit_catalog_action(submission.clone(), effect_options(now, submit_deadline, &options)?)
1330 .await
1331 {
1332 Ok(value) => {
1333 if value.locator != locator {
1334 return Err(ExactWorkflowError::CoreShape);
1335 }
1336 session
1337 .record(
1338 &mut recovery,
1339 ExactMutationTransition::SubmissionObserved {
1340 disposition: exact_submission_disposition(&value.mutation_outcome),
1341 },
1342 )
1343 .await?;
1344 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1345 return Ok(execution(result.clone(), recovery));
1346 }
1347 }
1348 Err(error) => {
1349 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
1350 session
1351 .record(
1352 &mut recovery,
1353 ExactMutationTransition::SubmissionObserved { disposition },
1354 )
1355 .await?;
1356 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1357 return Ok(execution(result.clone(), recovery));
1358 }
1359 if recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
1360 return Ok(execution(
1361 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1362 recovery,
1363 ));
1364 }
1365 }
1366 }
1367 }
1368 }
1369 let Some(resolve_options) = resolution_options(now, &options)? else {
1370 return Ok(execution(
1371 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1372 recovery,
1373 ));
1374 };
1375 let resolve_options = resolve_options
1376 .with_index_name(request.name.clone())
1377 .map_err(ExactWorkflowError::Client)?;
1378 let mut value = match self
1379 .resolve_catalog_action(
1380 ResolveCatalogActionRequest {
1381 locator: locator.clone(),
1382 },
1383 resolve_options.clone(),
1384 )
1385 .await
1386 {
1387 Ok(value) => value,
1388 Err(_) => {
1389 return Ok(execution(
1390 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1391 recovery,
1392 ));
1393 }
1394 };
1395 if value.locator != locator {
1396 return Err(ExactWorkflowError::CoreShape);
1397 }
1398 let exact_replay =
1399 catalog_resolution_requires_exact_replay(value.state.as_str(), recovery.submission_disposition);
1400 if exact_replay {
1401 match self.submit_catalog_action(submission, resolve_options.clone()).await {
1405 Ok(replayed) if replayed.locator == locator => {}
1406 Ok(_) => return Err(ExactWorkflowError::CoreShape),
1407 Err(error) if error.submission_disposition() == SubmissionDisposition::NotSubmitted => {
1408 return Ok(execution(
1409 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1410 recovery,
1411 ));
1412 }
1413 Err(_) => {
1414 return Ok(execution(
1415 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1416 recovery,
1417 ));
1418 }
1419 }
1420 value = match self
1421 .resolve_catalog_action(
1422 ResolveCatalogActionRequest {
1423 locator: locator.clone(),
1424 },
1425 resolve_options,
1426 )
1427 .await
1428 {
1429 Ok(value) => value,
1430 Err(_) => {
1431 return Ok(execution(
1432 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1433 recovery,
1434 ));
1435 }
1436 };
1437 if value.locator != locator {
1438 return Err(ExactWorkflowError::CoreShape);
1439 }
1440 }
1441 let outcome = match value.state.as_str() {
1442 "applied" => ExactMutationOutcomeV1::Resolved,
1443 "failed" => ExactMutationOutcomeV1::Conflict,
1444 "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
1445 "prepared" => ExactMutationOutcomeV1::NotSubmitted,
1446 "pending" => ExactMutationOutcomeV1::Indeterminate,
1447 _ => return Err(ExactWorkflowError::CoreShape),
1448 };
1449 record_resolution_observation(&session, &mut recovery, resolution_disposition(outcome)).await?;
1450 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1451 return Ok(execution(result.clone(), recovery));
1452 }
1453 let mut result = ReconcileIndexExactResultV1 {
1454 outcome,
1455 name: request.name,
1456 locator: Some(value.locator),
1457 resource: value.resource,
1458 state: value.state,
1459 };
1460 if outcome == ExactMutationOutcomeV1::Resolved {
1461 let Some(verify_options) = resolution_options(now, &options)? else {
1462 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1463 return Ok(execution(result, recovery));
1464 };
1465 let existing = match self
1466 .get_index(
1467 GetIndexRequest {
1468 name: result.name.clone(),
1469 expected_incarnation: result.resource.as_ref().map(|value| value.incarnation.clone()),
1470 },
1471 verify_options,
1472 )
1473 .await
1474 {
1475 Ok(value) => value.resource,
1476 Err(_) => {
1477 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1478 return Ok(execution(result, recovery));
1479 }
1480 };
1481 if existing.current_mapping != request.mapping
1482 || request.resource_spec.as_ref() != Some(&existing.resource_spec)
1483 || existing.summary.retained_event_identity != request.retained_event_identity.unwrap_or(false)
1484 {
1485 result.outcome = ExactMutationOutcomeV1::Conflict;
1486 result.state = "definition_conflict".to_owned();
1487 } else if existing.summary.state != "active" {
1488 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1489 result.state = existing.summary.state;
1490 return Ok(execution(result, recovery));
1491 } else {
1492 result.resource = Some(Resource {
1493 kind: "index".to_owned(),
1494 name: existing.summary.name,
1495 incarnation: existing.summary.incarnation,
1496 });
1497 result.state = "active".to_owned();
1498 }
1499 }
1500 if matches!(
1501 outcome,
1502 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
1503 ) {
1504 Ok(execution(result, recovery))
1505 } else {
1506 finish_index(&session, &mut recovery, result).await
1507 }
1508 }
1509
1510 fn exact_profile_scope(&self) -> (Identity, Identity) {
1511 let profile = self.connection_profile().as_wire();
1512 (
1513 profile.expected_cluster_id.clone(),
1514 profile.expected_collection_id.clone(),
1515 )
1516 }
1517}
1518
1519fn catalog_resolution_requires_exact_replay(state: &str, submission: Option<ExactSubmissionDispositionV1>) -> bool {
1520 state == "pending"
1521 || state == "prepared"
1522 && matches!(
1523 submission,
1524 Some(
1525 ExactSubmissionDispositionV1::Submitted
1526 | ExactSubmissionDispositionV1::Resolved
1527 | ExactSubmissionDispositionV1::Indeterminate
1528 )
1529 )
1530}
1531
1532async fn observe_append_visibility(
1533 client: &Client,
1534 request: &AppendExactRequestV1,
1535 mut result: AppendExactResultV1,
1536 options: CallOptions,
1537) -> Result<AppendExactResultV1, ExactWorkflowError> {
1538 let mut visibility = Vec::with_capacity(result.items.len());
1539 for item in &result.items {
1540 let Some(locator) = item.locator.clone() else {
1541 return Err(ExactWorkflowError::CoreShape);
1542 };
1543 match client
1544 .observe_append_committed(
1545 ObserveAppendCommittedRequest {
1546 index: request.index.clone(),
1547 locator,
1548 },
1549 options.clone(),
1550 )
1551 .await
1552 {
1553 Ok(value) => visibility.push(value.outcome),
1554 Err(_) => {
1555 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1556 result.visibility = visibility;
1557 return Ok(result);
1558 }
1559 }
1560 }
1561 if visibility
1562 .iter()
1563 .any(|value| value.state != RetainedAppendObservationState::Visible)
1564 {
1565 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1566 }
1567 result.visibility = visibility;
1568 Ok(result)
1569}
1570
1571fn exact_intent(
1572 mutation_kind: ExactMutationKindV1,
1573 request_sha256: Digest,
1574 cluster_id: Identity,
1575 collection_id: Identity,
1576 index: Option<Resource>,
1577 caller_action_id: Digest,
1578 effect_deadline_unix_millis: u64,
1579) -> ExactMutationIntent {
1580 ExactMutationIntent {
1581 format_version: EXACT_MUTATION_FORMAT_VERSION,
1582 mutation_kind,
1583 request_sha256,
1584 cluster_id,
1585 collection_id,
1586 index,
1587 caller_action_id,
1588 effect_deadline_unix_millis: wire_u64(effect_deadline_unix_millis),
1589 }
1590}
1591
1592async fn open_session(
1593 intent: &ExactMutationIntent,
1594 options: &ExactWorkflowOptions,
1595) -> Result<(ExactSession, ExactWorkflowRecoveryV2), ExactWorkflowError> {
1596 intent.validate()?;
1597 if options.effect_deadline_unix_millis == 0 || options.store.is_some() && options.recovery.is_some() {
1598 return Err(ExactWorkflowError::InvalidRecovery);
1599 }
1600 let (session, recovery) = if let Some(store) = &options.store {
1601 let initial = recovery_from_intent(intent);
1602 let loaded = store.load().await.map_err(|error| store_failure(error, None))?;
1603 let recovery = match loaded {
1604 Some(value) => {
1605 validate_store_recovery(&value, intent)?;
1606 value
1607 }
1608 None => match store.compare_exchange(None, initial.clone()).await {
1609 Ok(()) => initial,
1610 Err(ExactWorkflowStoreError::Conflict {
1611 observed: Some(observed),
1612 }) => {
1613 let observed = *observed;
1614 validate_store_recovery(&observed, intent)?;
1615 observed
1616 }
1617 Err(ExactWorkflowStoreError::Conflict { observed: None }) => {
1618 return Err(store_failure(ExactWorkflowStoreError::Malformed, None));
1619 }
1620 Err(error) => return Err(store_failure(error, None)),
1621 },
1622 };
1623 let conflict_deadline = options
1624 .observation_deadline_unix_millis
1625 .unwrap_or(options.effect_deadline_unix_millis);
1626 (
1627 ExactSession::Durable {
1628 store: Arc::clone(store),
1629 conflict_limit: options.store_conflict_limit,
1630 deadline_unix_millis: conflict_deadline,
1631 clock: Arc::clone(&options.clock),
1632 cancellation: options.cancellation.clone(),
1633 },
1634 recovery,
1635 )
1636 } else {
1637 let recovery = match &options.recovery {
1638 Some(value) => {
1639 validate_recovery_matches(value, intent)?;
1640 value.clone()
1641 }
1642 None => recovery_from_intent(intent),
1643 };
1644 (ExactSession::Ephemeral, recovery)
1645 };
1646 Ok((session, recovery))
1647}
1648
1649fn recovery_from_intent(intent: &ExactMutationIntent) -> ExactWorkflowRecoveryV2 {
1650 ExactWorkflowRecoveryV2 {
1651 format_version: intent.format_version,
1652 revision: 0,
1653 phase: ExactWorkflowPhaseV2::Initialized,
1654 mutation_kind: intent.mutation_kind,
1655 request_sha256: intent.request_sha256.clone(),
1656 cluster_id: intent.cluster_id.clone(),
1657 collection_id: intent.collection_id.clone(),
1658 index: intent.index.clone(),
1659 caller_action_id: intent.caller_action_id.clone(),
1660 effect_deadline_unix_millis: intent.effect_deadline_unix_millis.clone(),
1661 last_observed_unix_millis: None,
1662 preparation: None,
1663 submission_disposition: None,
1664 terminal: None,
1665 }
1666}
1667
1668fn validate_recovery_matches(
1669 recovery: &ExactWorkflowRecoveryV2,
1670 intent: &ExactMutationIntent,
1671) -> Result<(), ExactWorkflowError> {
1672 let expected = recovery_from_intent(intent);
1673 validate_recovery_shape(recovery)?;
1674 if recovery.format_version != expected.format_version
1675 || recovery.mutation_kind != expected.mutation_kind
1676 || recovery.request_sha256 != expected.request_sha256
1677 || recovery.cluster_id != expected.cluster_id
1678 || recovery.collection_id != expected.collection_id
1679 || recovery.index != expected.index
1680 || recovery.caller_action_id != expected.caller_action_id
1681 {
1682 return Err(ExactWorkflowError::InvalidRecovery);
1683 }
1684 Ok(())
1685}
1686
1687fn reduce_transition(
1688 state: &mut ExactWorkflowRecoveryV2,
1689 transition: &ExactMutationTransition,
1690) -> Result<(), ExactWorkflowError> {
1691 if state.revision >= MAX_SAFE_REVISION || state.phase == ExactWorkflowPhaseV2::Terminal {
1692 return Err(ExactWorkflowError::InvalidRecovery);
1693 }
1694 match transition {
1695 ExactMutationTransition::ClockObserved { unix_millis } => {
1696 let next = parse_u64(unix_millis).ok_or(ExactWorkflowError::InvalidRecovery)?;
1697 if state
1698 .last_observed_unix_millis
1699 .as_ref()
1700 .and_then(parse_u64)
1701 .is_some_and(|prior| next < prior)
1702 {
1703 return Err(ExactWorkflowError::InvalidRecovery);
1704 }
1705 state.last_observed_unix_millis = Some(unix_millis.clone());
1706 }
1707 ExactMutationTransition::Prepared { preparation }
1708 if state.preparation.is_none()
1709 && state.phase == ExactWorkflowPhaseV2::Initialized
1710 && preparation_kind(preparation) == state.mutation_kind =>
1711 {
1712 state.preparation = Some(preparation.clone());
1713 state.phase = ExactWorkflowPhaseV2::Prepared;
1714 }
1715 ExactMutationTransition::SubmissionStarted if state.preparation.is_some() && state.terminal.is_none() => {
1716 if state.phase < ExactWorkflowPhaseV2::SubmissionStarted {
1717 state.phase = ExactWorkflowPhaseV2::SubmissionStarted;
1718 } else if state.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
1719 state.submission_disposition = Some(ExactSubmissionDispositionV1::Indeterminate);
1723 } else {
1724 return Err(ExactWorkflowError::InvalidRecovery);
1725 }
1726 }
1727 ExactMutationTransition::SubmissionObserved { disposition }
1728 if state.preparation.is_some()
1729 && state.terminal.is_none()
1730 && (state.phase >= ExactWorkflowPhaseV2::SubmissionStarted
1731 || state.phase == ExactWorkflowPhaseV2::Prepared
1732 && *disposition == ExactSubmissionDispositionV1::NotSubmitted)
1733 && valid_disposition_successor(state.submission_disposition, *disposition) =>
1734 {
1735 state.submission_disposition = Some(*disposition);
1736 state.phase = ExactWorkflowPhaseV2::SubmissionObserved;
1737 }
1738 ExactMutationTransition::Terminal { result }
1739 if state.terminal.is_none() && terminal_kind(result) == state.mutation_kind =>
1740 {
1741 state.terminal = Some(result.clone());
1742 state.phase = ExactWorkflowPhaseV2::Terminal;
1743 }
1744 _ => return Err(ExactWorkflowError::InvalidRecovery),
1745 }
1746 state.revision = state
1747 .revision
1748 .checked_add(1)
1749 .filter(|value| *value <= MAX_SAFE_REVISION)
1750 .ok_or(ExactWorkflowError::InvalidRecovery)?;
1751 validate_recovery_shape(state)?;
1752 Ok(())
1753}
1754
1755fn validate_recovery_shape(recovery: &ExactWorkflowRecoveryV2) -> Result<(), ExactWorkflowError> {
1756 let preparation_valid = recovery
1757 .preparation
1758 .as_ref()
1759 .is_none_or(|value| valid_preparation(value) && preparation_kind(value) == recovery.mutation_kind);
1760 let terminal_valid = recovery
1761 .terminal
1762 .as_ref()
1763 .is_none_or(|value| valid_terminal(value) && terminal_kind(value) == recovery.mutation_kind);
1764 let phase_valid = match recovery.phase {
1765 ExactWorkflowPhaseV2::Initialized => {
1766 recovery.preparation.is_none() && recovery.submission_disposition.is_none() && recovery.terminal.is_none()
1767 }
1768 ExactWorkflowPhaseV2::Prepared | ExactWorkflowPhaseV2::SubmissionStarted => {
1769 recovery.preparation.is_some() && recovery.submission_disposition.is_none() && recovery.terminal.is_none()
1770 }
1771 ExactWorkflowPhaseV2::SubmissionObserved => {
1772 recovery.preparation.is_some() && recovery.submission_disposition.is_some() && recovery.terminal.is_none()
1773 }
1774 ExactWorkflowPhaseV2::Terminal => recovery.terminal.is_some(),
1775 };
1776 if recovery.format_version != EXACT_MUTATION_FORMAT_VERSION
1777 || recovery.revision > MAX_SAFE_REVISION
1778 || recovery.request_sha256.validate().is_err()
1779 || recovery.cluster_id.validate().is_err()
1780 || recovery.collection_id.validate().is_err()
1781 || recovery.caller_action_id.validate().is_err()
1782 || recovery.index.as_ref().is_some_and(|value| !valid_index(value))
1783 || parse_u64(&recovery.effect_deadline_unix_millis).is_none_or(|value| value == 0)
1784 || recovery
1785 .last_observed_unix_millis
1786 .as_ref()
1787 .is_some_and(|value| parse_u64(value).is_none())
1788 || !preparation_valid
1789 || !terminal_valid
1790 || !phase_valid
1791 || recovery.revision == 0
1792 && (recovery.phase != ExactWorkflowPhaseV2::Initialized || recovery.last_observed_unix_millis.is_some())
1793 {
1794 return Err(ExactWorkflowError::InvalidRecovery);
1795 }
1796 Ok(())
1797}
1798
1799const fn valid_disposition_successor(
1800 current: Option<ExactSubmissionDispositionV1>,
1801 next: ExactSubmissionDispositionV1,
1802) -> bool {
1803 match current {
1804 None | Some(ExactSubmissionDispositionV1::NotSubmitted) => true,
1805 Some(ExactSubmissionDispositionV1::Submitted) => !matches!(next, ExactSubmissionDispositionV1::NotSubmitted),
1806 Some(ExactSubmissionDispositionV1::Indeterminate) => true,
1807 Some(ExactSubmissionDispositionV1::Resolved) => matches!(next, ExactSubmissionDispositionV1::Resolved),
1808 Some(ExactSubmissionDispositionV1::KnownRetired) => {
1809 matches!(next, ExactSubmissionDispositionV1::KnownRetired)
1810 }
1811 }
1812}
1813
1814fn validate_initial_recovery(recovery: &ExactWorkflowRecoveryV2) -> Result<(), ExactWorkflowStoreError> {
1815 validate_recovery_shape(recovery).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1816 if recovery.revision != 0 || recovery.phase != ExactWorkflowPhaseV2::Initialized {
1817 return Err(ExactWorkflowStoreError::Malformed);
1818 }
1819 Ok(())
1820}
1821
1822fn intent_from_recovery(recovery: &ExactWorkflowRecoveryV2) -> ExactMutationIntent {
1823 ExactMutationIntent {
1824 format_version: recovery.format_version,
1825 mutation_kind: recovery.mutation_kind,
1826 request_sha256: recovery.request_sha256.clone(),
1827 cluster_id: recovery.cluster_id.clone(),
1828 collection_id: recovery.collection_id.clone(),
1829 index: recovery.index.clone(),
1830 caller_action_id: recovery.caller_action_id.clone(),
1831 effect_deadline_unix_millis: recovery.effect_deadline_unix_millis.clone(),
1832 }
1833}
1834
1835fn transition_between(
1836 current: &ExactWorkflowRecoveryV2,
1837 next: &ExactWorkflowRecoveryV2,
1838) -> Result<ExactMutationTransition, ExactWorkflowStoreError> {
1839 validate_recovery_shape(current).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1840 validate_recovery_shape(next).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1841 if next.revision
1842 != current
1843 .revision
1844 .checked_add(1)
1845 .ok_or(ExactWorkflowStoreError::Malformed)?
1846 || !same_recovery_intent(current, next)
1847 {
1848 return Err(ExactWorkflowStoreError::Malformed);
1849 }
1850 let retry_started = current.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted)
1851 && next.submission_disposition == Some(ExactSubmissionDispositionV1::Indeterminate)
1852 && current.phase == next.phase;
1853 let candidates = [
1854 next.last_observed_unix_millis
1855 .as_ref()
1856 .filter(|value| current.last_observed_unix_millis.as_ref() != Some(*value))
1857 .map(|value| ExactMutationTransition::ClockObserved {
1858 unix_millis: value.clone(),
1859 }),
1860 next.preparation
1861 .as_ref()
1862 .filter(|value| current.preparation.as_ref() != Some(*value))
1863 .map(|value| ExactMutationTransition::Prepared {
1864 preparation: value.clone(),
1865 }),
1866 (retry_started
1867 || current.phase < ExactWorkflowPhaseV2::SubmissionStarted
1868 && next.phase >= ExactWorkflowPhaseV2::SubmissionStarted)
1869 .then_some(ExactMutationTransition::SubmissionStarted),
1870 next.submission_disposition
1871 .filter(|_| !retry_started)
1872 .map(|disposition| ExactMutationTransition::SubmissionObserved { disposition }),
1873 next.terminal
1874 .as_ref()
1875 .filter(|value| current.terminal.as_ref() != Some(*value))
1876 .map(|value| ExactMutationTransition::Terminal { result: value.clone() }),
1877 ];
1878 let mut matched = None;
1879 for candidate in candidates.into_iter().flatten() {
1880 let mut reduced = current.clone();
1881 if reduce_transition(&mut reduced, &candidate).is_ok() && reduced == *next {
1882 if matched.is_some() {
1883 return Err(ExactWorkflowStoreError::Malformed);
1884 }
1885 matched = Some(candidate);
1886 }
1887 }
1888 matched.ok_or(ExactWorkflowStoreError::Malformed)
1889}
1890
1891fn validate_store_recovery(
1892 recovery: &ExactWorkflowRecoveryV2,
1893 intent: &ExactMutationIntent,
1894) -> Result<(), ExactWorkflowError> {
1895 if recovery.format_version != EXACT_MUTATION_FORMAT_VERSION {
1896 return Err(store_failure(
1897 ExactWorkflowStoreError::UnsupportedFormat {
1898 observed: recovery.format_version,
1899 },
1900 Some(recovery.clone()),
1901 ));
1902 }
1903 validate_recovery_shape(recovery)
1904 .map_err(|_| store_failure(ExactWorkflowStoreError::Malformed, Some(recovery.clone())))?;
1905 if !recovery_matches_intent(recovery, intent) {
1906 return Err(store_failure(
1907 ExactWorkflowStoreError::IntentMismatch,
1908 Some(recovery.clone()),
1909 ));
1910 }
1911 Ok(())
1912}
1913
1914fn validate_recovery_descendant(
1915 current: &ExactWorkflowRecoveryV2,
1916 observed: &ExactWorkflowRecoveryV2,
1917) -> Result<(), ExactWorkflowError> {
1918 if observed.format_version != EXACT_MUTATION_FORMAT_VERSION {
1919 return Err(store_failure(
1920 ExactWorkflowStoreError::UnsupportedFormat {
1921 observed: observed.format_version,
1922 },
1923 Some(current.clone()),
1924 ));
1925 }
1926 validate_recovery_shape(observed)
1927 .map_err(|_| store_failure(ExactWorkflowStoreError::Malformed, Some(current.clone())))?;
1928 let clock_monotonic = current.last_observed_unix_millis.as_ref().is_none_or(|prior| {
1929 observed
1930 .last_observed_unix_millis
1931 .as_ref()
1932 .and_then(parse_u64)
1933 .zip(parse_u64(prior))
1934 .is_some_and(|(next, prior)| next >= prior)
1935 });
1936 let disposition_monotonic = current.submission_disposition.is_none_or(|prior| {
1937 observed
1938 .submission_disposition
1939 .is_some_and(|next| valid_disposition_successor(Some(prior), next))
1940 });
1941 if !same_recovery_intent(current, observed) {
1942 return Err(store_failure(
1943 ExactWorkflowStoreError::IntentMismatch,
1944 Some(current.clone()),
1945 ));
1946 }
1947 if observed.revision <= current.revision
1948 || observed.phase < current.phase
1949 || !clock_monotonic
1950 || current
1951 .preparation
1952 .as_ref()
1953 .is_some_and(|value| observed.preparation.as_ref() != Some(value))
1954 || !disposition_monotonic
1955 || current
1956 .terminal
1957 .as_ref()
1958 .is_some_and(|value| observed.terminal.as_ref() != Some(value))
1959 {
1960 return Err(store_failure(ExactWorkflowStoreError::Malformed, Some(current.clone())));
1961 }
1962 Ok(())
1963}
1964
1965fn transition_is_reflected(state: &ExactWorkflowRecoveryV2, transition: &ExactMutationTransition) -> bool {
1966 if state.terminal.is_some() {
1967 return true;
1971 }
1972 match transition {
1973 ExactMutationTransition::ClockObserved { unix_millis } => parse_u64(unix_millis).is_some_and(|requested| {
1974 state
1975 .last_observed_unix_millis
1976 .as_ref()
1977 .and_then(parse_u64)
1978 .is_some_and(|observed| observed >= requested)
1979 }),
1980 ExactMutationTransition::Prepared { preparation } => state.preparation.as_ref() == Some(preparation),
1981 ExactMutationTransition::SubmissionStarted => {
1982 state.preparation.is_some()
1983 && (state.phase == ExactWorkflowPhaseV2::SubmissionStarted
1984 || state.phase >= ExactWorkflowPhaseV2::SubmissionObserved
1985 && state.submission_disposition != Some(ExactSubmissionDispositionV1::NotSubmitted))
1986 }
1987 ExactMutationTransition::SubmissionObserved { disposition } => state
1988 .submission_disposition
1989 .is_some_and(|observed| valid_disposition_successor(Some(*disposition), observed)),
1990 ExactMutationTransition::Terminal { .. } => false,
1991 }
1992}
1993
1994fn same_recovery_intent(left: &ExactWorkflowRecoveryV2, right: &ExactWorkflowRecoveryV2) -> bool {
1995 left.format_version == right.format_version
1996 && left.mutation_kind == right.mutation_kind
1997 && left.request_sha256 == right.request_sha256
1998 && left.cluster_id == right.cluster_id
1999 && left.collection_id == right.collection_id
2000 && left.index == right.index
2001 && left.caller_action_id == right.caller_action_id
2002 && left.effect_deadline_unix_millis == right.effect_deadline_unix_millis
2003}
2004
2005fn recovery_matches_intent(recovery: &ExactWorkflowRecoveryV2, intent: &ExactMutationIntent) -> bool {
2006 recovery.format_version == intent.format_version
2007 && recovery.mutation_kind == intent.mutation_kind
2008 && recovery.request_sha256 == intent.request_sha256
2009 && recovery.cluster_id == intent.cluster_id
2010 && recovery.collection_id == intent.collection_id
2011 && recovery.index == intent.index
2012 && recovery.caller_action_id == intent.caller_action_id
2013}
2014
2015fn store_failure(source: ExactWorkflowStoreError, recovery: Option<ExactWorkflowRecoveryV2>) -> ExactWorkflowError {
2016 ExactWorkflowError::Store {
2017 source,
2018 last_durable_recovery: recovery.map(Box::new),
2019 }
2020}
2021
2022async fn record_resolution_observation(
2023 session: &ExactSession,
2024 recovery: &mut ExactWorkflowRecoveryV2,
2025 disposition: ExactSubmissionDispositionV1,
2026) -> Result<(), ExactWorkflowError> {
2027 session
2031 .record(recovery, ExactMutationTransition::SubmissionObserved { disposition })
2032 .await
2033 .map(|_| ())
2034}
2035
2036async fn observe_clock(
2037 session: &ExactSession,
2038 recovery: &mut ExactWorkflowRecoveryV2,
2039 options: &ExactWorkflowOptions,
2040) -> Result<u64, ExactWorkflowError> {
2041 let now = options.clock.unix_millis()?;
2042 if recovery.phase >= ExactWorkflowPhaseV2::SubmissionStarted
2047 && recovery.submission_disposition != Some(ExactSubmissionDispositionV1::NotSubmitted)
2048 {
2049 return Ok(now);
2050 }
2051 if recovery
2052 .last_observed_unix_millis
2053 .as_ref()
2054 .and_then(parse_u64)
2055 .is_some_and(|prior| now <= prior)
2056 {
2057 return Ok(now);
2058 }
2059 session
2060 .record(
2061 recovery,
2062 ExactMutationTransition::ClockObserved {
2063 unix_millis: wire_u64(now),
2064 },
2065 )
2066 .await?;
2067 Ok(now)
2068}
2069
2070fn clock_regressed(recovery: &ExactWorkflowRecoveryV2, now: u64) -> bool {
2071 recovery
2072 .last_observed_unix_millis
2073 .as_ref()
2074 .and_then(parse_u64)
2075 .is_some_and(|observed| now < observed)
2076}
2077
2078fn recovery_effect_deadline(recovery: &ExactWorkflowRecoveryV2) -> Result<u64, ExactWorkflowError> {
2079 parse_u64(&recovery.effect_deadline_unix_millis)
2080 .filter(|value| *value > 0)
2081 .ok_or(ExactWorkflowError::InvalidRecovery)
2082}
2083
2084fn effect_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
2085 bounded_options(now, deadline, options)
2086}
2087
2088fn resolution_options(now: u64, options: &ExactWorkflowOptions) -> Result<Option<CallOptions>, ExactWorkflowError> {
2089 let deadline = if now < options.effect_deadline_unix_millis {
2090 options.effect_deadline_unix_millis
2091 } else {
2092 match options.observation_deadline_unix_millis {
2093 Some(value) if value > now => value,
2094 _ => return Ok(None),
2095 }
2096 };
2097 bounded_options(now, deadline, options).map(Some)
2098}
2099
2100fn bounded_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
2101 if deadline <= now {
2102 return Err(ExactWorkflowError::InvalidInput);
2103 }
2104 let bounded = deadline.min(now.saturating_add(MAX_EFFECT_CALL_MILLIS));
2105 CallOptions::from_absolute_deadline_unix_millis(bounded)
2106 .map(|value| value.with_cancellation(options.cancellation.clone()))
2107 .map_err(ExactWorkflowError::Client)
2108}
2109
2110fn validate_append_request(request: &AppendExactRequestV1) -> Result<(), ExactWorkflowError> {
2111 if request.batch_id.validate().is_err()
2112 || !valid_index(&request.index)
2113 || request.items.is_empty()
2114 || request.items.len() > 1_000
2115 || request.items.iter().any(|item| {
2116 item.item_action_id.validate().is_err() || item.document_id.is_empty() || item.document_id.len() > 512
2117 })
2118 {
2119 return Err(ExactWorkflowError::InvalidInput);
2120 }
2121 let mut identities = std::collections::BTreeSet::new();
2122 if request
2123 .items
2124 .iter()
2125 .any(|item| !identities.insert(item.item_action_id.value.as_str()))
2126 {
2127 return Err(ExactWorkflowError::InvalidInput);
2128 }
2129 Ok(())
2130}
2131
2132fn validate_append_outcome(
2133 request: &AppendExactRequestV1,
2134 outcome: &RetainedAppendBulkOutcome,
2135 prepared: bool,
2136) -> Result<(), ExactWorkflowError> {
2137 if outcome.batch_id != request.batch_id
2138 || outcome.index != request.index
2139 || outcome.items.len() != request.items.len()
2140 {
2141 return Err(ExactWorkflowError::CoreShape);
2142 }
2143 for (expected, observed) in request.items.iter().zip(&outcome.items) {
2144 if observed.item_action_id != expected.item_action_id
2145 || observed
2146 .locator
2147 .as_ref()
2148 .is_some_and(|value| value.authority.is_empty() || value.value.is_empty())
2149 || observed
2150 .semantic_digest
2151 .as_ref()
2152 .is_some_and(|value| value.validate().is_err())
2153 || prepared
2154 && (observed.state != RetainedAppendState::Prepared
2155 || observed.retry_safety != RetainedAppendRetrySafety::Safe
2156 || observed.locator.is_none()
2157 || observed.semantic_digest.is_none()
2158 || observed.sequence.is_some()
2159 || observed.primary_term.is_some()
2160 || observed.error.is_some())
2161 {
2162 return Err(ExactWorkflowError::CoreShape);
2163 }
2164 }
2165 Ok(())
2166}
2167
2168fn redact_append_outcome(mut outcome: RetainedAppendBulkOutcome) -> RetainedAppendBulkOutcome {
2169 for item in &mut outcome.items {
2170 item.error = None;
2171 }
2172 outcome
2173}
2174
2175fn append_preparation(recovery: &ExactWorkflowRecoveryV2) -> Result<&RetainedAppendBulkOutcome, ExactWorkflowError> {
2176 match &recovery.preparation {
2177 Some(ExactPreparationV1::Append(value)) => Ok(value),
2178 _ => Err(ExactWorkflowError::InvalidRecovery),
2179 }
2180}
2181
2182fn document_preparation(recovery: &ExactWorkflowRecoveryV2) -> Result<(Locator, Digest), ExactWorkflowError> {
2183 match &recovery.preparation {
2184 Some(ExactPreparationV1::Document {
2185 locator,
2186 semantic_digest,
2187 }) => Ok((locator.clone(), semantic_digest.clone())),
2188 _ => Err(ExactWorkflowError::InvalidRecovery),
2189 }
2190}
2191
2192fn index_preparation(
2193 recovery: &ExactWorkflowRecoveryV2,
2194) -> Result<(Locator, U64, Digest, IndexResourceSpec), ExactWorkflowError> {
2195 match &recovery.preparation {
2196 Some(ExactPreparationV1::Index {
2197 locator,
2198 expires_at,
2199 semantic_digest,
2200 resource_spec,
2201 }) => Ok((
2202 locator.clone(),
2203 expires_at.clone(),
2204 semantic_digest.clone(),
2205 resource_spec.as_ref().clone(),
2206 )),
2207 _ => Err(ExactWorkflowError::InvalidRecovery),
2208 }
2209}
2210
2211fn append_terminal_result(
2212 request: &AppendExactRequestV1,
2213 outcome: &RetainedAppendBulkOutcome,
2214 recovered: bool,
2215) -> Option<AppendExactResultV1> {
2216 let state = if outcome
2217 .items
2218 .iter()
2219 .any(|item| item.state == RetainedAppendState::SubmittedIndeterminate)
2220 {
2221 return None;
2222 } else if outcome
2223 .items
2224 .iter()
2225 .any(|item| item.state == RetainedAppendState::Conflict)
2226 {
2227 ExactMutationOutcomeV1::Conflict
2228 } else if outcome
2229 .items
2230 .iter()
2231 .any(|item| item.state == RetainedAppendState::Retired)
2232 {
2233 ExactMutationOutcomeV1::KnownRetired
2234 } else if outcome.items.iter().any(|item| {
2235 matches!(
2236 item.state,
2237 RetainedAppendState::NotSubmittedRetryable | RetainedAppendState::NotSubmittedTerminal
2238 )
2239 }) {
2240 ExactMutationOutcomeV1::NotSubmitted
2241 } else if outcome.items.iter().all(|item| {
2242 matches!(
2243 item.state,
2244 RetainedAppendState::Committed | RetainedAppendState::Replayed
2245 )
2246 }) {
2247 if recovered {
2248 ExactMutationOutcomeV1::Resolved
2249 } else {
2250 ExactMutationOutcomeV1::Applied
2251 }
2252 } else {
2253 return None;
2254 };
2255 Some(append_result(request, outcome, state))
2256}
2257
2258fn append_result(
2259 request: &AppendExactRequestV1,
2260 outcome: &RetainedAppendBulkOutcome,
2261 state: ExactMutationOutcomeV1,
2262) -> AppendExactResultV1 {
2263 AppendExactResultV1 {
2264 outcome: state,
2265 batch_id: request.batch_id.clone(),
2266 index: request.index.clone(),
2267 items: outcome.items.iter().map(redact_append_item).collect(),
2268 visibility: Vec::new(),
2269 }
2270}
2271
2272fn append_status(
2273 request: &AppendExactRequestV1,
2274 recovery: &ExactWorkflowRecoveryV2,
2275 state: ExactMutationOutcomeV1,
2276) -> AppendExactResultV1 {
2277 let items = append_preparation(recovery)
2278 .map(|value| value.items.iter().map(redact_append_item).collect())
2279 .unwrap_or_default();
2280 AppendExactResultV1 {
2281 outcome: state,
2282 batch_id: request.batch_id.clone(),
2283 index: request.index.clone(),
2284 items,
2285 visibility: Vec::new(),
2286 }
2287}
2288
2289fn redact_append_item(item: &RetainedAppendItemOutcome) -> AppendExactItemResultV1 {
2290 AppendExactItemResultV1 {
2291 item_action_id: item.item_action_id.clone(),
2292 locator: item.locator.clone(),
2293 semantic_digest: item.semantic_digest.clone(),
2294 state: item.state,
2295 retry_safety: item.retry_safety,
2296 sequence: item.sequence.clone(),
2297 primary_term: item.primary_term.clone(),
2298 }
2299}
2300
2301fn document_status(
2302 request: &PrepareDocumentMutationRequest,
2303 recovery: &ExactWorkflowRecoveryV2,
2304 outcome: ExactMutationOutcomeV1,
2305) -> DocumentMutationExactResultV1 {
2306 let locator = document_preparation(recovery).map(|value| value.0).ok();
2307 DocumentMutationExactResultV1 {
2308 outcome,
2309 index: request.index.clone(),
2310 locator,
2311 state: outcome_name(outcome).to_owned(),
2312 submitted: recovery
2313 .submission_disposition
2314 .is_some_and(|value| !matches!(value, ExactSubmissionDispositionV1::NotSubmitted)),
2315 sequence: None,
2316 mapping_generation: None,
2317 mapping_adopted: false,
2318 }
2319}
2320
2321fn index_status(
2322 request: &PrepareCreateIndexRequest,
2323 recovery: &ExactWorkflowRecoveryV2,
2324 outcome: ExactMutationOutcomeV1,
2325) -> ReconcileIndexExactResultV1 {
2326 let locator = match &recovery.preparation {
2327 Some(ExactPreparationV1::Index { locator, .. }) => Some(locator.clone()),
2328 _ => None,
2329 };
2330 ReconcileIndexExactResultV1 {
2331 outcome,
2332 name: request.name.clone(),
2333 locator,
2334 resource: None,
2335 state: outcome_name(outcome).to_owned(),
2336 }
2337}
2338
2339const fn outcome_name(outcome: ExactMutationOutcomeV1) -> &'static str {
2340 match outcome {
2341 ExactMutationOutcomeV1::AlreadySatisfied => "already_satisfied",
2342 ExactMutationOutcomeV1::Applied => "applied",
2343 ExactMutationOutcomeV1::Resolved => "resolved",
2344 ExactMutationOutcomeV1::Conflict => "conflict",
2345 ExactMutationOutcomeV1::NotSubmitted => "not_submitted",
2346 ExactMutationOutcomeV1::Indeterminate => "indeterminate",
2347 ExactMutationOutcomeV1::KnownRetired => "known_retired",
2348 ExactMutationOutcomeV1::EffectDeadlineExpired => "effect_deadline_expired",
2349 ExactMutationOutcomeV1::ClockRegression => "clock_regression",
2350 }
2351}
2352
2353async fn finish_append(
2354 session: &ExactSession,
2355 recovery: &mut ExactWorkflowRecoveryV2,
2356 result: AppendExactResultV1,
2357) -> Result<ExactExecutionV2<AppendExactResultV1>, ExactWorkflowError> {
2358 session
2359 .record(
2360 recovery,
2361 ExactMutationTransition::Terminal {
2362 result: ExactTerminalResultV1::Append(result.clone()),
2363 },
2364 )
2365 .await?;
2366 let durable = match &recovery.terminal {
2367 Some(ExactTerminalResultV1::Append(durable)) => durable.clone(),
2368 _ => return Err(ExactWorkflowError::InvalidRecovery),
2369 };
2370 Ok(execution(durable, recovery.clone()))
2371}
2372
2373async fn finish_document(
2374 session: &ExactSession,
2375 recovery: &mut ExactWorkflowRecoveryV2,
2376 result: DocumentMutationExactResultV1,
2377) -> Result<ExactExecutionV2<DocumentMutationExactResultV1>, ExactWorkflowError> {
2378 session
2379 .record(
2380 recovery,
2381 ExactMutationTransition::Terminal {
2382 result: ExactTerminalResultV1::Document(result.clone()),
2383 },
2384 )
2385 .await?;
2386 let durable = match &recovery.terminal {
2387 Some(ExactTerminalResultV1::Document(durable)) => durable.clone(),
2388 _ => return Err(ExactWorkflowError::InvalidRecovery),
2389 };
2390 Ok(execution(durable, recovery.clone()))
2391}
2392
2393async fn finish_index(
2394 session: &ExactSession,
2395 recovery: &mut ExactWorkflowRecoveryV2,
2396 result: ReconcileIndexExactResultV1,
2397) -> Result<ExactExecutionV2<ReconcileIndexExactResultV1>, ExactWorkflowError> {
2398 session
2399 .record(
2400 recovery,
2401 ExactMutationTransition::Terminal {
2402 result: ExactTerminalResultV1::Index(result.clone()),
2403 },
2404 )
2405 .await?;
2406 let durable = match &recovery.terminal {
2407 Some(ExactTerminalResultV1::Index(durable)) => durable.clone(),
2408 _ => return Err(ExactWorkflowError::InvalidRecovery),
2409 };
2410 Ok(execution(durable, recovery.clone()))
2411}
2412
2413fn execution<T>(result: T, recovery: ExactWorkflowRecoveryV2) -> ExactExecutionV2<T> {
2414 ExactExecutionV2 { result, recovery }
2415}
2416
2417fn valid_preparation(value: &ExactPreparationV1) -> bool {
2418 match value {
2419 ExactPreparationV1::Append(outcome) => {
2420 !outcome.items.is_empty()
2421 && outcome.items.len() <= 1_000
2422 && outcome.items.iter().all(|item| {
2423 item.item_action_id.validate().is_ok()
2424 && item
2425 .locator
2426 .as_ref()
2427 .is_some_and(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
2428 && item
2429 .semantic_digest
2430 .as_ref()
2431 .is_some_and(|digest| digest.validate().is_ok())
2432 && item.state == RetainedAppendState::Prepared
2433 && item.retry_safety == RetainedAppendRetrySafety::Safe
2434 && item.error.is_none()
2435 })
2436 }
2437 ExactPreparationV1::Document {
2438 locator,
2439 semantic_digest,
2440 } => !locator.authority.is_empty() && !locator.value.is_empty() && semantic_digest.validate().is_ok(),
2441 ExactPreparationV1::Index {
2442 locator,
2443 expires_at,
2444 semantic_digest,
2445 ..
2446 } => {
2447 !locator.authority.is_empty()
2448 && !locator.value.is_empty()
2449 && parse_u64(expires_at).is_some_and(|value| value > 0)
2450 && semantic_digest.validate().is_ok()
2451 }
2452 }
2453}
2454
2455fn valid_terminal(value: &ExactTerminalResultV1) -> bool {
2456 match value {
2457 ExactTerminalResultV1::Append(result) => {
2458 result.batch_id.validate().is_ok()
2459 && valid_index(&result.index)
2460 && terminal_outcome(result.outcome)
2461 && result.items.iter().all(|item| item.item_action_id.validate().is_ok())
2462 }
2463 ExactTerminalResultV1::Document(result) => {
2464 valid_index(&result.index)
2465 && result
2466 .locator
2467 .as_ref()
2468 .is_none_or(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
2469 && terminal_outcome(result.outcome)
2470 }
2471 ExactTerminalResultV1::Index(result) => !result.name.is_empty() && terminal_outcome(result.outcome),
2472 }
2473}
2474
2475const fn terminal_outcome(value: ExactMutationOutcomeV1) -> bool {
2476 matches!(
2477 value,
2478 ExactMutationOutcomeV1::AlreadySatisfied
2479 | ExactMutationOutcomeV1::Applied
2480 | ExactMutationOutcomeV1::Resolved
2481 | ExactMutationOutcomeV1::Conflict
2482 | ExactMutationOutcomeV1::KnownRetired
2483 | ExactMutationOutcomeV1::EffectDeadlineExpired
2484 )
2485}
2486
2487const fn preparation_kind(value: &ExactPreparationV1) -> ExactMutationKindV1 {
2488 match value {
2489 ExactPreparationV1::Append(_) => ExactMutationKindV1::Append,
2490 ExactPreparationV1::Document { .. } => ExactMutationKindV1::Document,
2491 ExactPreparationV1::Index { .. } => ExactMutationKindV1::Index,
2492 }
2493}
2494
2495const fn terminal_kind(value: &ExactTerminalResultV1) -> ExactMutationKindV1 {
2496 match value {
2497 ExactTerminalResultV1::Append(_) => ExactMutationKindV1::Append,
2498 ExactTerminalResultV1::Document(_) => ExactMutationKindV1::Document,
2499 ExactTerminalResultV1::Index(_) => ExactMutationKindV1::Index,
2500 }
2501}
2502
2503fn valid_index(value: &Resource) -> bool {
2504 value.kind == "index" && !value.name.is_empty() && value.name.len() <= 255 && value.incarnation.validate().is_ok()
2505}
2506
2507fn canonical_sha256<T: Serialize>(value: &T) -> Result<Digest, ExactWorkflowError> {
2508 let bytes = serde_json::to_vec(value).map_err(|_| ExactWorkflowError::RequestCodec)?;
2509 let observed = digest::digest(&digest::SHA256, &bytes);
2510 Ok(Digest {
2511 value: lower_hex(observed.as_ref()),
2512 })
2513}
2514
2515fn parse_u64(value: &U64) -> Option<u64> {
2516 if value.value.is_empty() || value.value.len() > 20 || value.value.starts_with('0') && value.value != "0" {
2517 return None;
2518 }
2519 value.value.parse().ok()
2520}
2521
2522fn wire_u64(value: u64) -> U64 {
2523 U64 {
2524 value: value.to_string(),
2525 }
2526}
2527
2528fn lower_hex(bytes: &[u8]) -> String {
2529 const HEX: &[u8; 16] = b"0123456789abcdef";
2530 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
2531 for byte in bytes {
2532 output.push(char::from(HEX[usize::from(byte >> 4)]));
2533 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
2534 }
2535 output
2536}
2537
2538#[cfg(test)]
2539mod tests {
2540 use std::os::unix::fs::PermissionsExt as _;
2541 use std::sync::Mutex;
2542
2543 use super::*;
2544
2545 #[derive(Default)]
2546 struct PostgresShapedRecoveryStore {
2547 row: Mutex<Option<ExactWorkflowRecoveryV2>>,
2548 }
2549
2550 impl ExactWorkflowRecoveryStore for PostgresShapedRecoveryStore {
2551 fn load(
2552 &self,
2553 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2554 Box::pin(async move {
2555 self.row
2556 .lock()
2557 .map(|row| row.clone())
2558 .map_err(|_| ExactWorkflowStoreError::Internal)
2559 })
2560 }
2561
2562 fn compare_exchange(
2563 &self,
2564 expected_revision: Option<u64>,
2565 next: ExactWorkflowRecoveryV2,
2566 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2567 Box::pin(async move {
2568 let mut row = self.row.lock().map_err(|_| ExactWorkflowStoreError::Internal)?;
2569 let observed_revision = row.as_ref().map(|value| value.revision);
2570 if observed_revision != expected_revision {
2571 return Err(ExactWorkflowStoreError::Conflict {
2572 observed: row.clone().map(Box::new),
2573 });
2574 }
2575 *row = Some(next);
2576 Ok(())
2577 })
2578 }
2579 }
2580
2581 struct AdvancingConflictStore {
2582 row: Mutex<ExactWorkflowRecoveryV2>,
2583 }
2584
2585 struct UnavailableRecoveryStore;
2586
2587 struct AdvancingClock {
2588 next: Mutex<u64>,
2589 }
2590
2591 impl ExactWorkflowClock for AdvancingClock {
2592 fn unix_millis(&self) -> Result<u64, ExactWorkflowError> {
2593 let mut next = self.next.lock().map_err(|_| ExactWorkflowError::ClockUnavailable)?;
2594 let observed = *next;
2595 *next = next.saturating_add(1);
2596 Ok(observed)
2597 }
2598 }
2599
2600 impl ExactWorkflowRecoveryStore for UnavailableRecoveryStore {
2601 fn load(
2602 &self,
2603 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2604 Box::pin(async { Err(ExactWorkflowStoreError::Unavailable) })
2605 }
2606
2607 fn compare_exchange(
2608 &self,
2609 _expected_revision: Option<u64>,
2610 _next: ExactWorkflowRecoveryV2,
2611 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2612 Box::pin(async { Err(ExactWorkflowStoreError::Unavailable) })
2613 }
2614 }
2615
2616 impl ExactWorkflowRecoveryStore for AdvancingConflictStore {
2617 fn load(
2618 &self,
2619 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2620 Box::pin(async move {
2621 self.row
2622 .lock()
2623 .map(|row| Some(row.clone()))
2624 .map_err(|_| ExactWorkflowStoreError::Internal)
2625 })
2626 }
2627
2628 fn compare_exchange(
2629 &self,
2630 _expected_revision: Option<u64>,
2631 _next: ExactWorkflowRecoveryV2,
2632 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2633 Box::pin(async move {
2634 let mut row = self.row.lock().map_err(|_| ExactWorkflowStoreError::Internal)?;
2635 let next_clock = row
2636 .last_observed_unix_millis
2637 .as_ref()
2638 .and_then(parse_u64)
2639 .unwrap_or(0)
2640 .saturating_add(1);
2641 reduce_transition(
2642 &mut row,
2643 &ExactMutationTransition::ClockObserved {
2644 unix_millis: wire_u64(next_clock),
2645 },
2646 )
2647 .map_err(|_| ExactWorkflowStoreError::Internal)?;
2648 Err(ExactWorkflowStoreError::Conflict {
2649 observed: Some(Box::new(row.clone())),
2650 })
2651 })
2652 }
2653 }
2654
2655 fn identity(character: char) -> Identity {
2656 Identity {
2657 value: character.to_string().repeat(32),
2658 }
2659 }
2660
2661 fn digest(character: char) -> Digest {
2662 Digest {
2663 value: character.to_string().repeat(64),
2664 }
2665 }
2666
2667 fn resource() -> Resource {
2668 Resource {
2669 kind: "index".to_owned(),
2670 name: "events".to_owned(),
2671 incarnation: identity('a'),
2672 }
2673 }
2674
2675 fn append_preparation_value() -> ExactPreparationV1 {
2676 ExactPreparationV1::Append(RetainedAppendBulkOutcome {
2677 batch_id: digest('e'),
2678 index: resource(),
2679 items: vec![RetainedAppendItemOutcome {
2680 item_action_id: digest('f'),
2681 locator: Some(Locator {
2682 authority: "retained_append".to_owned(),
2683 value: "01".to_owned(),
2684 }),
2685 semantic_digest: Some(digest('1')),
2686 state: RetainedAppendState::Prepared,
2687 retry_safety: RetainedAppendRetrySafety::Safe,
2688 sequence: None,
2689 primary_term: None,
2690 error: None,
2691 }],
2692 complete: true,
2693 achievement: None,
2694 })
2695 }
2696
2697 #[test]
2698 fn retained_event_identity_preserves_the_catalog_submit_kind() {
2699 assert_eq!(create_index_action_kind(None), "create_index");
2700 assert_eq!(create_index_action_kind(Some(false)), "create_index");
2701 assert_eq!(create_index_action_kind(Some(true)), "create_retained_append_log_index");
2702 }
2703
2704 #[test]
2705 fn catalog_recovery_replays_only_an_exact_prepared_or_pending_identity() {
2706 assert!(catalog_resolution_requires_exact_replay("pending", None));
2707 assert!(catalog_resolution_requires_exact_replay(
2708 "prepared",
2709 Some(ExactSubmissionDispositionV1::Indeterminate),
2710 ));
2711 assert!(catalog_resolution_requires_exact_replay(
2712 "prepared",
2713 Some(ExactSubmissionDispositionV1::Submitted),
2714 ));
2715 assert!(!catalog_resolution_requires_exact_replay("prepared", None));
2716 assert!(!catalog_resolution_requires_exact_replay(
2717 "prepared",
2718 Some(ExactSubmissionDispositionV1::NotSubmitted),
2719 ));
2720 assert!(!catalog_resolution_requires_exact_replay(
2721 "expired_unknown",
2722 Some(ExactSubmissionDispositionV1::Indeterminate),
2723 ));
2724 }
2725
2726 #[test]
2727 fn recovery_refuses_changed_request_or_scope_and_retains_first_deadline() -> Result<(), Box<dyn Error>> {
2728 let intent = exact_intent(
2729 ExactMutationKindV1::Append,
2730 digest('b'),
2731 identity('c'),
2732 identity('d'),
2733 Some(resource()),
2734 digest('e'),
2735 100,
2736 );
2737 let recovery = recovery_from_intent(&intent);
2738 validate_recovery_matches(&recovery, &intent)?;
2739 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
2740 let mut changed = intent.clone();
2741 changed.collection_id = identity('f');
2742 assert!(matches!(
2743 validate_recovery_matches(&recovery, &changed),
2744 Err(ExactWorkflowError::InvalidRecovery)
2745 ));
2746 changed = intent.clone();
2747 changed.request_sha256 = digest('f');
2748 assert!(matches!(
2749 validate_recovery_matches(&recovery, &changed),
2750 Err(ExactWorkflowError::InvalidRecovery)
2751 ));
2752 Ok(())
2753 }
2754
2755 #[test]
2756 fn reducer_never_regresses_clock_or_replaces_possible_submission() -> Result<(), Box<dyn Error>> {
2757 let intent = exact_intent(
2758 ExactMutationKindV1::Append,
2759 digest('b'),
2760 identity('c'),
2761 identity('d'),
2762 Some(resource()),
2763 digest('e'),
2764 100,
2765 );
2766 let mut state = recovery_from_intent(&intent);
2767 reduce_transition(
2768 &mut state,
2769 &ExactMutationTransition::ClockObserved {
2770 unix_millis: wire_u64(10),
2771 },
2772 )?;
2773 assert!(
2774 reduce_transition(
2775 &mut state,
2776 &ExactMutationTransition::ClockObserved {
2777 unix_millis: wire_u64(9),
2778 },
2779 )
2780 .is_err()
2781 );
2782 let preparation = ExactPreparationV1::Append(RetainedAppendBulkOutcome {
2783 batch_id: digest('e'),
2784 index: resource(),
2785 items: vec![RetainedAppendItemOutcome {
2786 item_action_id: digest('f'),
2787 locator: Some(Locator {
2788 authority: "retained_append".to_owned(),
2789 value: "01".to_owned(),
2790 }),
2791 semantic_digest: Some(digest('1')),
2792 state: RetainedAppendState::Prepared,
2793 retry_safety: RetainedAppendRetrySafety::Safe,
2794 sequence: None,
2795 primary_term: None,
2796 error: None,
2797 }],
2798 complete: true,
2799 achievement: None,
2800 });
2801 reduce_transition(
2802 &mut state,
2803 &ExactMutationTransition::Prepared {
2804 preparation: preparation.clone(),
2805 },
2806 )?;
2807 reduce_transition(&mut state, &ExactMutationTransition::SubmissionStarted)?;
2808 reduce_transition(
2809 &mut state,
2810 &ExactMutationTransition::SubmissionObserved {
2811 disposition: ExactSubmissionDispositionV1::Indeterminate,
2812 },
2813 )?;
2814 assert!(reduce_transition(&mut state, &ExactMutationTransition::Prepared { preparation },).is_err());
2815 Ok(())
2816 }
2817
2818 #[test]
2819 fn reducer_refuses_revision_overflow_without_changing_state() {
2820 let intent = exact_intent(
2821 ExactMutationKindV1::Append,
2822 digest('b'),
2823 identity('c'),
2824 identity('d'),
2825 Some(resource()),
2826 digest('e'),
2827 100,
2828 );
2829 let mut state = recovery_from_intent(&intent);
2830 state.revision = MAX_SAFE_REVISION;
2831 let unchanged = state.clone();
2832 assert!(
2833 reduce_transition(
2834 &mut state,
2835 &ExactMutationTransition::ClockObserved {
2836 unix_millis: wire_u64(1),
2837 },
2838 )
2839 .is_err()
2840 );
2841 assert_eq!(state, unchanged);
2842 }
2843
2844 #[test]
2845 fn not_submitted_append_outcome_remains_recovery_resumable() -> Result<(), Box<dyn Error>> {
2846 let request = AppendExactRequestV1 {
2847 batch_id: digest('a'),
2848 index: resource(),
2849 items: vec![RetainedAppendItem {
2850 item_action_id: digest('b'),
2851 document_id: "event-1".to_owned(),
2852 record: lodestone_wire::NativeRecord::Document {
2853 source: lodestone_wire::RawJson {
2854 bytes_base64: "e30=".to_owned(),
2855 },
2856 },
2857 }],
2858 observe_committed: true,
2859 };
2860 let outcome = RetainedAppendBulkOutcome {
2861 batch_id: request.batch_id.clone(),
2862 index: request.index.clone(),
2863 items: vec![RetainedAppendItemOutcome {
2864 item_action_id: request.items[0].item_action_id.clone(),
2865 locator: Some(Locator {
2866 authority: "retained_append".to_owned(),
2867 value: "01".to_owned(),
2868 }),
2869 semantic_digest: Some(digest('c')),
2870 state: RetainedAppendState::NotSubmittedRetryable,
2871 retry_safety: RetainedAppendRetrySafety::Safe,
2872 sequence: None,
2873 primary_term: None,
2874 error: None,
2875 }],
2876 complete: false,
2877 achievement: None,
2878 };
2879
2880 let result = append_terminal_result(&request, &outcome, false).ok_or("not-submitted observation absent")?;
2881 assert_eq!(result.outcome, ExactMutationOutcomeV1::NotSubmitted);
2882 assert!(!terminal_outcome(result.outcome));
2883 Ok(())
2884 }
2885
2886 #[test]
2887 fn append_recovery_redacts_server_error_messages() {
2888 let outcome = RetainedAppendBulkOutcome {
2889 batch_id: digest('a'),
2890 index: resource(),
2891 items: vec![RetainedAppendItemOutcome {
2892 item_action_id: digest('b'),
2893 locator: Some(Locator {
2894 authority: "retained_append".to_owned(),
2895 value: "01".to_owned(),
2896 }),
2897 semantic_digest: Some(digest('c')),
2898 state: RetainedAppendState::Prepared,
2899 retry_safety: RetainedAppendRetrySafety::Safe,
2900 sequence: None,
2901 primary_term: None,
2902 error: None,
2903 }],
2904 complete: true,
2905 achievement: None,
2906 };
2907 assert!(redact_append_outcome(outcome).items[0].error.is_none());
2908 }
2909
2910 #[tokio::test]
2911 async fn caller_owned_cas_converges_two_replicas_across_every_phase() -> Result<(), Box<dyn Error>> {
2912 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
2913 let intent = exact_intent(
2914 ExactMutationKindV1::Append,
2915 digest('b'),
2916 identity('c'),
2917 identity('d'),
2918 Some(resource()),
2919 digest('e'),
2920 10_000,
2921 );
2922 let options = ExactWorkflowOptions::new(10_000)
2923 .with_observation_deadline_unix_millis(20_000)
2924 .with_recovery_store(Arc::clone(&store));
2925 let (left_session, mut left) = open_session(&intent, &options).await?;
2926 let (right_session, mut right) = open_session(&intent, &options).await?;
2927 assert_eq!(left.revision, 0);
2928 assert_eq!(right.revision, 0);
2929
2930 assert!(
2931 left_session
2932 .record(
2933 &mut left,
2934 ExactMutationTransition::ClockObserved {
2935 unix_millis: wire_u64(1),
2936 },
2937 )
2938 .await?
2939 );
2940 assert!(
2941 !right_session
2942 .record(
2943 &mut right,
2944 ExactMutationTransition::ClockObserved {
2945 unix_millis: wire_u64(1),
2946 },
2947 )
2948 .await?
2949 );
2950
2951 let preparation = append_preparation_value();
2952 assert!(
2953 left_session
2954 .record(
2955 &mut left,
2956 ExactMutationTransition::Prepared {
2957 preparation: preparation.clone(),
2958 },
2959 )
2960 .await?
2961 );
2962 assert!(
2963 !right_session
2964 .record(&mut right, ExactMutationTransition::Prepared { preparation },)
2965 .await?
2966 );
2967
2968 assert!(
2969 left_session
2970 .record(&mut left, ExactMutationTransition::SubmissionStarted)
2971 .await?
2972 );
2973 assert!(
2974 !right_session
2975 .record(&mut right, ExactMutationTransition::SubmissionStarted)
2976 .await?
2977 );
2978
2979 let observation = ExactMutationTransition::SubmissionObserved {
2980 disposition: ExactSubmissionDispositionV1::Resolved,
2981 };
2982 assert!(left_session.record(&mut left, observation.clone()).await?);
2983 assert!(!right_session.record(&mut right, observation).await?);
2984
2985 let result = ExactTerminalResultV1::Append(AppendExactResultV1 {
2986 outcome: ExactMutationOutcomeV1::Resolved,
2987 batch_id: digest('e'),
2988 index: resource(),
2989 items: Vec::new(),
2990 visibility: Vec::new(),
2991 });
2992 assert!(
2993 left_session
2994 .record(&mut left, ExactMutationTransition::Terminal { result: result.clone() },)
2995 .await?
2996 );
2997 let losing = AppendExactResultV1 {
2998 outcome: ExactMutationOutcomeV1::Conflict,
2999 batch_id: digest('e'),
3000 index: resource(),
3001 items: Vec::new(),
3002 visibility: Vec::new(),
3003 };
3004 let adopted = finish_append(&right_session, &mut right, losing).await?;
3005 assert_eq!(adopted.result.outcome, ExactMutationOutcomeV1::Resolved);
3006 assert_eq!(left, right);
3007 assert_eq!(left.phase, ExactWorkflowPhaseV2::Terminal);
3008 assert_eq!(left.revision, 5);
3009 assert_eq!(store.load().await?, Some(left));
3010 Ok(())
3011 }
3012
3013 #[test]
3014 fn rust_recovery_v2_matches_the_cross_language_fixture() -> Result<(), Box<dyn Error>> {
3015 let fixture: serde_json::Value = serde_json::from_str(include_str!(
3016 "../../../clients/native/fixtures/lodestone-exact-workflow-recovery-v2.json"
3017 ))?;
3018 if fixture.get("format_version").and_then(serde_json::Value::as_u64) != Some(2) {
3019 return Err("exact recovery fixture format differs".into());
3020 }
3021 let values = fixture
3022 .get("states")
3023 .and_then(serde_json::Value::as_array)
3024 .ok_or("exact recovery fixture states are absent")?;
3025 let states: Vec<ExactWorkflowRecoveryV2> = values
3026 .iter()
3027 .cloned()
3028 .map(serde_json::from_value)
3029 .collect::<Result<_, _>>()?;
3030 assert_eq!(states.len(), 6);
3031 for state in &states {
3032 validate_recovery_shape(state)?;
3033 }
3034 for pair in states.windows(2) {
3035 let [current, next] = pair else {
3036 return Err("exact recovery fixture window differs".into());
3037 };
3038 let transition = transition_between(current, next)?;
3039 let mut reduced = current.clone();
3040 reduce_transition(&mut reduced, &transition)?;
3041 assert_eq!(&reduced, next);
3042 }
3043 assert_eq!(
3044 states.last().map(|value| value.phase),
3045 Some(ExactWorkflowPhaseV2::Terminal)
3046 );
3047 Ok(())
3048 }
3049
3050 #[tokio::test]
3051 async fn store_values_fail_closed_by_format_shape_and_intent() -> Result<(), Box<dyn Error>> {
3052 let intent = exact_intent(
3053 ExactMutationKindV1::Append,
3054 digest('b'),
3055 identity('c'),
3056 identity('d'),
3057 Some(resource()),
3058 digest('e'),
3059 10_000,
3060 );
3061 let initial = recovery_from_intent(&intent);
3062
3063 let mut future = initial.clone();
3064 future.format_version = EXACT_MUTATION_FORMAT_VERSION.saturating_add(1);
3065 let future_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3066 row: Mutex::new(Some(future)),
3067 });
3068 let future_result = open_session(
3069 &intent,
3070 &ExactWorkflowOptions::new(10_000).with_recovery_store(future_store),
3071 )
3072 .await;
3073 assert!(matches!(
3074 future_result,
3075 Err(ExactWorkflowError::Store {
3076 source: ExactWorkflowStoreError::UnsupportedFormat { .. },
3077 ..
3078 })
3079 ));
3080
3081 let mut malformed = initial.clone();
3082 malformed.phase = ExactWorkflowPhaseV2::Prepared;
3083 let malformed_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3084 row: Mutex::new(Some(malformed)),
3085 });
3086 let malformed_result = open_session(
3087 &intent,
3088 &ExactWorkflowOptions::new(10_000).with_recovery_store(malformed_store),
3089 )
3090 .await;
3091 assert!(matches!(
3092 malformed_result,
3093 Err(ExactWorkflowError::Store {
3094 source: ExactWorkflowStoreError::Malformed,
3095 ..
3096 })
3097 ));
3098
3099 let mut mismatched = initial;
3100 mismatched.request_sha256 = digest('9');
3101 let mismatch_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3102 row: Mutex::new(Some(mismatched)),
3103 });
3104 let mismatch_result = open_session(
3105 &intent,
3106 &ExactWorkflowOptions::new(10_000).with_recovery_store(mismatch_store),
3107 )
3108 .await;
3109 assert!(matches!(
3110 mismatch_result,
3111 Err(ExactWorkflowError::Store {
3112 source: ExactWorkflowStoreError::IntentMismatch,
3113 ..
3114 })
3115 ));
3116
3117 let unavailable: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(UnavailableRecoveryStore);
3118 let unavailable_result = open_session(
3119 &intent,
3120 &ExactWorkflowOptions::new(10_000).with_recovery_store(unavailable),
3121 )
3122 .await;
3123 assert!(matches!(
3124 unavailable_result,
3125 Err(ExactWorkflowError::Store {
3126 source: ExactWorkflowStoreError::Unavailable,
3127 last_durable_recovery: None,
3128 })
3129 ));
3130 Ok(())
3131 }
3132
3133 #[tokio::test]
3134 async fn store_conflicts_and_cancellation_are_bounded_with_recovery() -> Result<(), Box<dyn Error>> {
3135 let intent = exact_intent(
3136 ExactMutationKindV1::Append,
3137 digest('b'),
3138 identity('c'),
3139 identity('d'),
3140 Some(resource()),
3141 digest('e'),
3142 u64::MAX,
3143 );
3144 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(AdvancingConflictStore {
3145 row: Mutex::new(recovery_from_intent(&intent)),
3146 });
3147 let options = ExactWorkflowOptions::new(u64::MAX)
3148 .with_observation_deadline_unix_millis(u64::MAX)
3149 .with_store_conflict_limit(0)
3150 .with_recovery_store(store);
3151 let (session, mut recovery) = open_session(&intent, &options).await?;
3152 let conflict = session
3153 .record(
3154 &mut recovery,
3155 ExactMutationTransition::Prepared {
3156 preparation: append_preparation_value(),
3157 },
3158 )
3159 .await;
3160 assert!(matches!(
3161 conflict,
3162 Err(ExactWorkflowError::StoreConflictBudgetExhausted {
3163 last_durable_recovery
3164 }) if last_durable_recovery.revision == 1
3165 ));
3166
3167 let cancellation = CallCancellation::new();
3168 cancellation.cancel();
3169 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
3170 let options = ExactWorkflowOptions::new(u64::MAX)
3171 .with_cancellation(cancellation)
3172 .with_recovery_store(store);
3173 let (session, mut recovery) = open_session(&intent, &options).await?;
3174 let cancelled = session
3175 .record(
3176 &mut recovery,
3177 ExactMutationTransition::ClockObserved {
3178 unix_millis: wire_u64(1),
3179 },
3180 )
3181 .await;
3182 assert!(matches!(
3183 cancelled,
3184 Err(ExactWorkflowError::Cancelled {
3185 last_durable_recovery
3186 }) if last_durable_recovery.revision == 0
3187 ));
3188 Ok(())
3189 }
3190
3191 #[tokio::test]
3192 async fn durable_visibility_polling_does_not_exhaust_recovery_records() -> Result<(), Box<dyn Error>> {
3193 let intent = exact_intent(
3194 ExactMutationKindV1::Append,
3195 digest('b'),
3196 identity('c'),
3197 identity('d'),
3198 Some(resource()),
3199 digest('e'),
3200 u64::MAX,
3201 );
3202 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
3203 let clock: Arc<dyn ExactWorkflowClock> = Arc::new(AdvancingClock { next: Mutex::new(1) });
3204 let options = ExactWorkflowOptions::new(u64::MAX)
3205 .with_clock(clock)
3206 .with_recovery_store(Arc::clone(&store));
3207 let (session, mut recovery) = open_session(&intent, &options).await?;
3208
3209 assert_eq!(observe_clock(&session, &mut recovery, &options).await?, 1);
3210 assert!(
3211 session
3212 .record(
3213 &mut recovery,
3214 ExactMutationTransition::Prepared {
3215 preparation: append_preparation_value(),
3216 },
3217 )
3218 .await?
3219 );
3220 assert!(
3221 session
3222 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
3223 .await?
3224 );
3225 assert!(
3226 session
3227 .record(
3228 &mut recovery,
3229 ExactMutationTransition::SubmissionObserved {
3230 disposition: ExactSubmissionDispositionV1::Resolved,
3231 },
3232 )
3233 .await?
3234 );
3235 assert_eq!(recovery.revision, 4);
3236
3237 for expected_now in 2..=301 {
3238 assert_eq!(observe_clock(&session, &mut recovery, &options).await?, expected_now);
3239 record_resolution_observation(&session, &mut recovery, ExactSubmissionDispositionV1::Resolved).await?;
3240 }
3241 assert_eq!(recovery.revision, 4);
3242 assert_eq!(store.load().await?.map(|stored| stored.revision), Some(4));
3243 Ok(())
3244 }
3245
3246 #[tokio::test]
3247 async fn durable_resume_retains_the_first_effect_deadline() -> Result<(), Box<dyn Error>> {
3248 let mut suffix = [0_u8; 8];
3249 getrandom::fill(&mut suffix)?;
3250 let parent =
3251 std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-{}", lower_hex(&suffix)));
3252 std::fs::create_dir(&parent)?;
3253 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
3254 let path = parent.join("journal");
3255 let first_intent = exact_intent(
3256 ExactMutationKindV1::Append,
3257 digest('b'),
3258 identity('c'),
3259 identity('d'),
3260 Some(resource()),
3261 digest('e'),
3262 100,
3263 );
3264 {
3265 let store = Arc::new(FilesystemExactWorkflowRecoveryStore::new(path.clone()));
3266 let first = ExactWorkflowOptions::new(100).with_recovery_store(store);
3267 let (session, recovery) = open_session(&first_intent, &first).await?;
3268 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
3269 drop(session);
3270 }
3271
3272 let resumed_intent = ExactMutationIntent {
3273 effect_deadline_unix_millis: wire_u64(999),
3274 ..first_intent
3275 };
3276 let store = Arc::new(FilesystemExactWorkflowRecoveryStore::new(path));
3277 let resumed = ExactWorkflowOptions::new(999).with_recovery_store(store);
3278 let (session, recovery) = open_session(&resumed_intent, &resumed).await?;
3279 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
3280 drop(session);
3281 std::fs::remove_dir_all(&parent)?;
3282 Ok(())
3283 }
3284
3285 #[tokio::test]
3286 async fn filesystem_store_restarts_at_each_v2_durability_boundary() -> Result<(), Box<dyn Error>> {
3287 let mut suffix = [0_u8; 8];
3288 getrandom::fill(&mut suffix)?;
3289 let parent =
3290 std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-v2-{}", lower_hex(&suffix)));
3291 std::fs::create_dir(&parent)?;
3292 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
3293 let path = parent.join("journal");
3294 let intent = exact_intent(
3295 ExactMutationKindV1::Append,
3296 digest('b'),
3297 identity('c'),
3298 identity('d'),
3299 Some(resource()),
3300 digest('e'),
3301 10_000,
3302 );
3303 let mut state = recovery_from_intent(&intent);
3304 let store = FilesystemExactWorkflowRecoveryStore::new(path.clone());
3305 store.compare_exchange(None, state.clone()).await?;
3306 drop(store);
3307
3308 let transitions = [
3309 ExactMutationTransition::Prepared {
3310 preparation: append_preparation_value(),
3311 },
3312 ExactMutationTransition::SubmissionStarted,
3313 ExactMutationTransition::SubmissionObserved {
3314 disposition: ExactSubmissionDispositionV1::Indeterminate,
3315 },
3316 ExactMutationTransition::SubmissionObserved {
3317 disposition: ExactSubmissionDispositionV1::Resolved,
3318 },
3319 ExactMutationTransition::Terminal {
3320 result: ExactTerminalResultV1::Append(AppendExactResultV1 {
3321 outcome: ExactMutationOutcomeV1::Resolved,
3322 batch_id: digest('e'),
3323 index: resource(),
3324 items: Vec::new(),
3325 visibility: Vec::new(),
3326 }),
3327 },
3328 ];
3329 for transition in transitions {
3330 let store = FilesystemExactWorkflowRecoveryStore::new(path.clone());
3331 assert_eq!(store.load().await?, Some(state.clone()));
3332 let mut next = state.clone();
3333 reduce_transition(&mut next, &transition)?;
3334 store.compare_exchange(Some(state.revision), next.clone()).await?;
3335 state = next;
3336 drop(store);
3337 }
3338 let store = FilesystemExactWorkflowRecoveryStore::new(path);
3339 let loaded = store.load().await?.ok_or("filesystem recovery disappeared")?;
3340 assert_eq!(loaded.phase, ExactWorkflowPhaseV2::Terminal);
3341 assert_eq!(loaded.caller_action_id, digest('e'));
3342 assert_eq!(
3343 append_preparation(&loaded)?.items[0]
3344 .locator
3345 .as_ref()
3346 .map(|value| value.value.as_str()),
3347 Some("01")
3348 );
3349 drop(store);
3350 std::fs::remove_dir_all(&parent)?;
3351 Ok(())
3352 }
3353}