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 submit_deadline = options.effect_deadline_unix_millis;
1312 let submission = SubmitCatalogActionRequest {
1313 locator: locator.clone(),
1314 semantic_digest,
1315 action_kind: create_index_action_kind(request.retained_event_identity).to_owned(),
1316 name: request.name.clone(),
1317 mapping: Some(request.mapping.clone()),
1318 resource_spec: Some(resource_spec),
1319 expected_incarnation: None,
1320 };
1321 if may_start_catalog_submission(&recovery, now, submit_deadline) {
1322 let owns_submission = session
1323 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
1324 .await?;
1325 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1326 return Ok(execution(result.clone(), recovery));
1327 }
1328 if owns_submission {
1329 match self
1330 .submit_catalog_action(submission.clone(), effect_options(now, submit_deadline, &options)?)
1331 .await
1332 {
1333 Ok(value) => {
1334 if value.locator != locator {
1335 return Err(ExactWorkflowError::CoreShape);
1336 }
1337 session
1338 .record(
1339 &mut recovery,
1340 ExactMutationTransition::SubmissionObserved {
1341 disposition: exact_submission_disposition(&value.mutation_outcome),
1342 },
1343 )
1344 .await?;
1345 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1346 return Ok(execution(result.clone(), recovery));
1347 }
1348 }
1349 Err(error) => {
1350 let disposition = ExactSubmissionDispositionV1::from(error.submission_disposition());
1351 session
1352 .record(
1353 &mut recovery,
1354 ExactMutationTransition::SubmissionObserved { disposition },
1355 )
1356 .await?;
1357 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1358 return Ok(execution(result.clone(), recovery));
1359 }
1360 if recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
1361 return Ok(execution(
1362 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1363 recovery,
1364 ));
1365 }
1366 }
1367 }
1368 }
1369 }
1370 let Some(resolve_options) = resolution_options(now, &options)? else {
1371 return Ok(execution(
1372 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1373 recovery,
1374 ));
1375 };
1376 let resolve_options = resolve_options
1377 .with_index_name(request.name.clone())
1378 .map_err(ExactWorkflowError::Client)?;
1379 let mut value = match self
1380 .resolve_catalog_action(
1381 ResolveCatalogActionRequest {
1382 locator: locator.clone(),
1383 },
1384 resolve_options.clone(),
1385 )
1386 .await
1387 {
1388 Ok(value) => value,
1389 Err(_) => {
1390 return Ok(execution(
1391 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1392 recovery,
1393 ));
1394 }
1395 };
1396 if value.locator != locator {
1397 return Err(ExactWorkflowError::CoreShape);
1398 }
1399 let exact_replay =
1400 catalog_resolution_requires_exact_replay(value.state.as_str(), recovery.submission_disposition);
1401 if exact_replay {
1402 match self.submit_catalog_action(submission, resolve_options.clone()).await {
1406 Ok(replayed) if replayed.locator == locator => {}
1407 Ok(_) => return Err(ExactWorkflowError::CoreShape),
1408 Err(error) if error.submission_disposition() == SubmissionDisposition::NotSubmitted => {
1409 return Ok(execution(
1410 index_status(&request, &recovery, ExactMutationOutcomeV1::NotSubmitted),
1411 recovery,
1412 ));
1413 }
1414 Err(_) => {
1415 return Ok(execution(
1416 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1417 recovery,
1418 ));
1419 }
1420 }
1421 value = match self
1422 .resolve_catalog_action(
1423 ResolveCatalogActionRequest {
1424 locator: locator.clone(),
1425 },
1426 resolve_options,
1427 )
1428 .await
1429 {
1430 Ok(value) => value,
1431 Err(_) => {
1432 return Ok(execution(
1433 index_status(&request, &recovery, ExactMutationOutcomeV1::Indeterminate),
1434 recovery,
1435 ));
1436 }
1437 };
1438 if value.locator != locator {
1439 return Err(ExactWorkflowError::CoreShape);
1440 }
1441 }
1442 let outcome = match value.state.as_str() {
1443 "applied" => ExactMutationOutcomeV1::Resolved,
1444 "failed" => ExactMutationOutcomeV1::Conflict,
1445 "expired_unknown" => ExactMutationOutcomeV1::KnownRetired,
1446 "prepared" => ExactMutationOutcomeV1::NotSubmitted,
1447 "pending" => ExactMutationOutcomeV1::Indeterminate,
1448 _ => return Err(ExactWorkflowError::CoreShape),
1449 };
1450 record_resolution_observation(&session, &mut recovery, resolution_disposition(outcome)).await?;
1451 if let Some(ExactTerminalResultV1::Index(result)) = &recovery.terminal {
1452 return Ok(execution(result.clone(), recovery));
1453 }
1454 let mut result = ReconcileIndexExactResultV1 {
1455 outcome,
1456 name: request.name,
1457 locator: Some(value.locator),
1458 resource: value.resource,
1459 state: value.state,
1460 };
1461 if outcome == ExactMutationOutcomeV1::Resolved {
1462 let Some(verify_options) = resolution_options(now, &options)? else {
1463 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1464 return Ok(execution(result, recovery));
1465 };
1466 let existing = match self
1467 .get_index(
1468 GetIndexRequest {
1469 name: result.name.clone(),
1470 expected_incarnation: result.resource.as_ref().map(|value| value.incarnation.clone()),
1471 },
1472 verify_options,
1473 )
1474 .await
1475 {
1476 Ok(value) => value.resource,
1477 Err(_) => {
1478 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1479 return Ok(execution(result, recovery));
1480 }
1481 };
1482 if existing.current_mapping != request.mapping
1483 || request.resource_spec.as_ref() != Some(&existing.resource_spec)
1484 || existing.summary.retained_event_identity != request.retained_event_identity.unwrap_or(false)
1485 {
1486 result.outcome = ExactMutationOutcomeV1::Conflict;
1487 result.state = "definition_conflict".to_owned();
1488 } else if existing.summary.state != "active" {
1489 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1490 result.state = existing.summary.state;
1491 return Ok(execution(result, recovery));
1492 } else {
1493 result.resource = Some(Resource {
1494 kind: "index".to_owned(),
1495 name: existing.summary.name,
1496 incarnation: existing.summary.incarnation,
1497 });
1498 result.state = "active".to_owned();
1499 }
1500 }
1501 if matches!(
1502 outcome,
1503 ExactMutationOutcomeV1::Indeterminate | ExactMutationOutcomeV1::NotSubmitted
1504 ) {
1505 Ok(execution(result, recovery))
1506 } else {
1507 finish_index(&session, &mut recovery, result).await
1508 }
1509 }
1510
1511 fn exact_profile_scope(&self) -> (Identity, Identity) {
1512 let profile = self.connection_profile().as_wire();
1513 (
1514 profile.expected_cluster_id.clone(),
1515 profile.expected_collection_id.clone(),
1516 )
1517 }
1518}
1519
1520fn catalog_resolution_requires_exact_replay(state: &str, submission: Option<ExactSubmissionDispositionV1>) -> bool {
1521 state == "pending"
1522 || state == "prepared"
1523 && matches!(
1524 submission,
1525 Some(
1526 ExactSubmissionDispositionV1::Submitted
1527 | ExactSubmissionDispositionV1::Resolved
1528 | ExactSubmissionDispositionV1::Indeterminate
1529 )
1530 )
1531}
1532
1533async fn observe_append_visibility(
1534 client: &Client,
1535 request: &AppendExactRequestV1,
1536 mut result: AppendExactResultV1,
1537 options: CallOptions,
1538) -> Result<AppendExactResultV1, ExactWorkflowError> {
1539 let mut visibility = Vec::with_capacity(result.items.len());
1540 for item in &result.items {
1541 let Some(locator) = item.locator.clone() else {
1542 return Err(ExactWorkflowError::CoreShape);
1543 };
1544 match client
1545 .observe_append_committed(
1546 ObserveAppendCommittedRequest {
1547 index: request.index.clone(),
1548 locator,
1549 },
1550 options.clone(),
1551 )
1552 .await
1553 {
1554 Ok(value) => visibility.push(value.outcome),
1555 Err(_) => {
1556 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1557 result.visibility = visibility;
1558 return Ok(result);
1559 }
1560 }
1561 }
1562 if visibility
1563 .iter()
1564 .any(|value| value.state != RetainedAppendObservationState::Visible)
1565 {
1566 result.outcome = ExactMutationOutcomeV1::Indeterminate;
1567 }
1568 result.visibility = visibility;
1569 Ok(result)
1570}
1571
1572fn exact_intent(
1573 mutation_kind: ExactMutationKindV1,
1574 request_sha256: Digest,
1575 cluster_id: Identity,
1576 collection_id: Identity,
1577 index: Option<Resource>,
1578 caller_action_id: Digest,
1579 effect_deadline_unix_millis: u64,
1580) -> ExactMutationIntent {
1581 ExactMutationIntent {
1582 format_version: EXACT_MUTATION_FORMAT_VERSION,
1583 mutation_kind,
1584 request_sha256,
1585 cluster_id,
1586 collection_id,
1587 index,
1588 caller_action_id,
1589 effect_deadline_unix_millis: wire_u64(effect_deadline_unix_millis),
1590 }
1591}
1592
1593async fn open_session(
1594 intent: &ExactMutationIntent,
1595 options: &ExactWorkflowOptions,
1596) -> Result<(ExactSession, ExactWorkflowRecoveryV2), ExactWorkflowError> {
1597 intent.validate()?;
1598 if options.effect_deadline_unix_millis == 0 || options.store.is_some() && options.recovery.is_some() {
1599 return Err(ExactWorkflowError::InvalidRecovery);
1600 }
1601 let (session, recovery) = if let Some(store) = &options.store {
1602 let initial = recovery_from_intent(intent);
1603 let loaded = store.load().await.map_err(|error| store_failure(error, None))?;
1604 let recovery = match loaded {
1605 Some(value) => {
1606 validate_store_recovery(&value, intent)?;
1607 value
1608 }
1609 None => match store.compare_exchange(None, initial.clone()).await {
1610 Ok(()) => initial,
1611 Err(ExactWorkflowStoreError::Conflict {
1612 observed: Some(observed),
1613 }) => {
1614 let observed = *observed;
1615 validate_store_recovery(&observed, intent)?;
1616 observed
1617 }
1618 Err(ExactWorkflowStoreError::Conflict { observed: None }) => {
1619 return Err(store_failure(ExactWorkflowStoreError::Malformed, None));
1620 }
1621 Err(error) => return Err(store_failure(error, None)),
1622 },
1623 };
1624 let conflict_deadline = options
1625 .observation_deadline_unix_millis
1626 .unwrap_or(options.effect_deadline_unix_millis);
1627 (
1628 ExactSession::Durable {
1629 store: Arc::clone(store),
1630 conflict_limit: options.store_conflict_limit,
1631 deadline_unix_millis: conflict_deadline,
1632 clock: Arc::clone(&options.clock),
1633 cancellation: options.cancellation.clone(),
1634 },
1635 recovery,
1636 )
1637 } else {
1638 let recovery = match &options.recovery {
1639 Some(value) => {
1640 validate_recovery_matches(value, intent)?;
1641 value.clone()
1642 }
1643 None => recovery_from_intent(intent),
1644 };
1645 (ExactSession::Ephemeral, recovery)
1646 };
1647 Ok((session, recovery))
1648}
1649
1650fn recovery_from_intent(intent: &ExactMutationIntent) -> ExactWorkflowRecoveryV2 {
1651 ExactWorkflowRecoveryV2 {
1652 format_version: intent.format_version,
1653 revision: 0,
1654 phase: ExactWorkflowPhaseV2::Initialized,
1655 mutation_kind: intent.mutation_kind,
1656 request_sha256: intent.request_sha256.clone(),
1657 cluster_id: intent.cluster_id.clone(),
1658 collection_id: intent.collection_id.clone(),
1659 index: intent.index.clone(),
1660 caller_action_id: intent.caller_action_id.clone(),
1661 effect_deadline_unix_millis: intent.effect_deadline_unix_millis.clone(),
1662 last_observed_unix_millis: None,
1663 preparation: None,
1664 submission_disposition: None,
1665 terminal: None,
1666 }
1667}
1668
1669fn validate_recovery_matches(
1670 recovery: &ExactWorkflowRecoveryV2,
1671 intent: &ExactMutationIntent,
1672) -> Result<(), ExactWorkflowError> {
1673 let expected = recovery_from_intent(intent);
1674 validate_recovery_shape(recovery)?;
1675 if recovery.format_version != expected.format_version
1676 || recovery.mutation_kind != expected.mutation_kind
1677 || recovery.request_sha256 != expected.request_sha256
1678 || recovery.cluster_id != expected.cluster_id
1679 || recovery.collection_id != expected.collection_id
1680 || recovery.index != expected.index
1681 || recovery.caller_action_id != expected.caller_action_id
1682 {
1683 return Err(ExactWorkflowError::InvalidRecovery);
1684 }
1685 Ok(())
1686}
1687
1688fn reduce_transition(
1689 state: &mut ExactWorkflowRecoveryV2,
1690 transition: &ExactMutationTransition,
1691) -> Result<(), ExactWorkflowError> {
1692 if state.revision >= MAX_SAFE_REVISION || state.phase == ExactWorkflowPhaseV2::Terminal {
1693 return Err(ExactWorkflowError::InvalidRecovery);
1694 }
1695 match transition {
1696 ExactMutationTransition::ClockObserved { unix_millis } => {
1697 let next = parse_u64(unix_millis).ok_or(ExactWorkflowError::InvalidRecovery)?;
1698 if state
1699 .last_observed_unix_millis
1700 .as_ref()
1701 .and_then(parse_u64)
1702 .is_some_and(|prior| next < prior)
1703 {
1704 return Err(ExactWorkflowError::InvalidRecovery);
1705 }
1706 state.last_observed_unix_millis = Some(unix_millis.clone());
1707 }
1708 ExactMutationTransition::Prepared { preparation }
1709 if state.preparation.is_none()
1710 && state.phase == ExactWorkflowPhaseV2::Initialized
1711 && preparation_kind(preparation) == state.mutation_kind =>
1712 {
1713 state.preparation = Some(preparation.clone());
1714 state.phase = ExactWorkflowPhaseV2::Prepared;
1715 }
1716 ExactMutationTransition::SubmissionStarted if state.preparation.is_some() && state.terminal.is_none() => {
1717 if state.phase < ExactWorkflowPhaseV2::SubmissionStarted {
1718 state.phase = ExactWorkflowPhaseV2::SubmissionStarted;
1719 } else if state.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted) {
1720 state.submission_disposition = Some(ExactSubmissionDispositionV1::Indeterminate);
1724 } else {
1725 return Err(ExactWorkflowError::InvalidRecovery);
1726 }
1727 }
1728 ExactMutationTransition::SubmissionObserved { disposition }
1729 if state.preparation.is_some()
1730 && state.terminal.is_none()
1731 && (state.phase >= ExactWorkflowPhaseV2::SubmissionStarted
1732 || state.phase == ExactWorkflowPhaseV2::Prepared
1733 && *disposition == ExactSubmissionDispositionV1::NotSubmitted)
1734 && valid_disposition_successor(state.submission_disposition, *disposition) =>
1735 {
1736 state.submission_disposition = Some(*disposition);
1737 state.phase = ExactWorkflowPhaseV2::SubmissionObserved;
1738 }
1739 ExactMutationTransition::Terminal { result }
1740 if state.terminal.is_none() && terminal_kind(result) == state.mutation_kind =>
1741 {
1742 state.terminal = Some(result.clone());
1743 state.phase = ExactWorkflowPhaseV2::Terminal;
1744 }
1745 _ => return Err(ExactWorkflowError::InvalidRecovery),
1746 }
1747 state.revision = state
1748 .revision
1749 .checked_add(1)
1750 .filter(|value| *value <= MAX_SAFE_REVISION)
1751 .ok_or(ExactWorkflowError::InvalidRecovery)?;
1752 validate_recovery_shape(state)?;
1753 Ok(())
1754}
1755
1756fn validate_recovery_shape(recovery: &ExactWorkflowRecoveryV2) -> Result<(), ExactWorkflowError> {
1757 let preparation_valid = recovery
1758 .preparation
1759 .as_ref()
1760 .is_none_or(|value| valid_preparation(value) && preparation_kind(value) == recovery.mutation_kind);
1761 let terminal_valid = recovery
1762 .terminal
1763 .as_ref()
1764 .is_none_or(|value| valid_terminal(value) && terminal_kind(value) == recovery.mutation_kind);
1765 let phase_valid = match recovery.phase {
1766 ExactWorkflowPhaseV2::Initialized => {
1767 recovery.preparation.is_none() && recovery.submission_disposition.is_none() && recovery.terminal.is_none()
1768 }
1769 ExactWorkflowPhaseV2::Prepared | ExactWorkflowPhaseV2::SubmissionStarted => {
1770 recovery.preparation.is_some() && recovery.submission_disposition.is_none() && recovery.terminal.is_none()
1771 }
1772 ExactWorkflowPhaseV2::SubmissionObserved => {
1773 recovery.preparation.is_some() && recovery.submission_disposition.is_some() && recovery.terminal.is_none()
1774 }
1775 ExactWorkflowPhaseV2::Terminal => recovery.terminal.is_some(),
1776 };
1777 if recovery.format_version != EXACT_MUTATION_FORMAT_VERSION
1778 || recovery.revision > MAX_SAFE_REVISION
1779 || recovery.request_sha256.validate().is_err()
1780 || recovery.cluster_id.validate().is_err()
1781 || recovery.collection_id.validate().is_err()
1782 || recovery.caller_action_id.validate().is_err()
1783 || recovery.index.as_ref().is_some_and(|value| !valid_index(value))
1784 || parse_u64(&recovery.effect_deadline_unix_millis).is_none_or(|value| value == 0)
1785 || recovery
1786 .last_observed_unix_millis
1787 .as_ref()
1788 .is_some_and(|value| parse_u64(value).is_none())
1789 || !preparation_valid
1790 || !terminal_valid
1791 || !phase_valid
1792 || recovery.revision == 0
1793 && (recovery.phase != ExactWorkflowPhaseV2::Initialized || recovery.last_observed_unix_millis.is_some())
1794 {
1795 return Err(ExactWorkflowError::InvalidRecovery);
1796 }
1797 Ok(())
1798}
1799
1800const fn valid_disposition_successor(
1801 current: Option<ExactSubmissionDispositionV1>,
1802 next: ExactSubmissionDispositionV1,
1803) -> bool {
1804 match current {
1805 None | Some(ExactSubmissionDispositionV1::NotSubmitted) => true,
1806 Some(ExactSubmissionDispositionV1::Submitted) => !matches!(next, ExactSubmissionDispositionV1::NotSubmitted),
1807 Some(ExactSubmissionDispositionV1::Indeterminate) => true,
1808 Some(ExactSubmissionDispositionV1::Resolved) => matches!(next, ExactSubmissionDispositionV1::Resolved),
1809 Some(ExactSubmissionDispositionV1::KnownRetired) => {
1810 matches!(next, ExactSubmissionDispositionV1::KnownRetired)
1811 }
1812 }
1813}
1814
1815fn validate_initial_recovery(recovery: &ExactWorkflowRecoveryV2) -> Result<(), ExactWorkflowStoreError> {
1816 validate_recovery_shape(recovery).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1817 if recovery.revision != 0 || recovery.phase != ExactWorkflowPhaseV2::Initialized {
1818 return Err(ExactWorkflowStoreError::Malformed);
1819 }
1820 Ok(())
1821}
1822
1823fn intent_from_recovery(recovery: &ExactWorkflowRecoveryV2) -> ExactMutationIntent {
1824 ExactMutationIntent {
1825 format_version: recovery.format_version,
1826 mutation_kind: recovery.mutation_kind,
1827 request_sha256: recovery.request_sha256.clone(),
1828 cluster_id: recovery.cluster_id.clone(),
1829 collection_id: recovery.collection_id.clone(),
1830 index: recovery.index.clone(),
1831 caller_action_id: recovery.caller_action_id.clone(),
1832 effect_deadline_unix_millis: recovery.effect_deadline_unix_millis.clone(),
1833 }
1834}
1835
1836fn transition_between(
1837 current: &ExactWorkflowRecoveryV2,
1838 next: &ExactWorkflowRecoveryV2,
1839) -> Result<ExactMutationTransition, ExactWorkflowStoreError> {
1840 validate_recovery_shape(current).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1841 validate_recovery_shape(next).map_err(|_| ExactWorkflowStoreError::Malformed)?;
1842 if next.revision
1843 != current
1844 .revision
1845 .checked_add(1)
1846 .ok_or(ExactWorkflowStoreError::Malformed)?
1847 || !same_recovery_intent(current, next)
1848 {
1849 return Err(ExactWorkflowStoreError::Malformed);
1850 }
1851 let retry_started = current.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted)
1852 && next.submission_disposition == Some(ExactSubmissionDispositionV1::Indeterminate)
1853 && current.phase == next.phase;
1854 let candidates = [
1855 next.last_observed_unix_millis
1856 .as_ref()
1857 .filter(|value| current.last_observed_unix_millis.as_ref() != Some(*value))
1858 .map(|value| ExactMutationTransition::ClockObserved {
1859 unix_millis: value.clone(),
1860 }),
1861 next.preparation
1862 .as_ref()
1863 .filter(|value| current.preparation.as_ref() != Some(*value))
1864 .map(|value| ExactMutationTransition::Prepared {
1865 preparation: value.clone(),
1866 }),
1867 (retry_started
1868 || current.phase < ExactWorkflowPhaseV2::SubmissionStarted
1869 && next.phase >= ExactWorkflowPhaseV2::SubmissionStarted)
1870 .then_some(ExactMutationTransition::SubmissionStarted),
1871 next.submission_disposition
1872 .filter(|_| !retry_started)
1873 .map(|disposition| ExactMutationTransition::SubmissionObserved { disposition }),
1874 next.terminal
1875 .as_ref()
1876 .filter(|value| current.terminal.as_ref() != Some(*value))
1877 .map(|value| ExactMutationTransition::Terminal { result: value.clone() }),
1878 ];
1879 let mut matched = None;
1880 for candidate in candidates.into_iter().flatten() {
1881 let mut reduced = current.clone();
1882 if reduce_transition(&mut reduced, &candidate).is_ok() && reduced == *next {
1883 if matched.is_some() {
1884 return Err(ExactWorkflowStoreError::Malformed);
1885 }
1886 matched = Some(candidate);
1887 }
1888 }
1889 matched.ok_or(ExactWorkflowStoreError::Malformed)
1890}
1891
1892fn validate_store_recovery(
1893 recovery: &ExactWorkflowRecoveryV2,
1894 intent: &ExactMutationIntent,
1895) -> Result<(), ExactWorkflowError> {
1896 if recovery.format_version != EXACT_MUTATION_FORMAT_VERSION {
1897 return Err(store_failure(
1898 ExactWorkflowStoreError::UnsupportedFormat {
1899 observed: recovery.format_version,
1900 },
1901 Some(recovery.clone()),
1902 ));
1903 }
1904 validate_recovery_shape(recovery)
1905 .map_err(|_| store_failure(ExactWorkflowStoreError::Malformed, Some(recovery.clone())))?;
1906 if !recovery_matches_intent(recovery, intent) {
1907 return Err(store_failure(
1908 ExactWorkflowStoreError::IntentMismatch,
1909 Some(recovery.clone()),
1910 ));
1911 }
1912 Ok(())
1913}
1914
1915fn validate_recovery_descendant(
1916 current: &ExactWorkflowRecoveryV2,
1917 observed: &ExactWorkflowRecoveryV2,
1918) -> Result<(), ExactWorkflowError> {
1919 if observed.format_version != EXACT_MUTATION_FORMAT_VERSION {
1920 return Err(store_failure(
1921 ExactWorkflowStoreError::UnsupportedFormat {
1922 observed: observed.format_version,
1923 },
1924 Some(current.clone()),
1925 ));
1926 }
1927 validate_recovery_shape(observed)
1928 .map_err(|_| store_failure(ExactWorkflowStoreError::Malformed, Some(current.clone())))?;
1929 let clock_monotonic = current.last_observed_unix_millis.as_ref().is_none_or(|prior| {
1930 observed
1931 .last_observed_unix_millis
1932 .as_ref()
1933 .and_then(parse_u64)
1934 .zip(parse_u64(prior))
1935 .is_some_and(|(next, prior)| next >= prior)
1936 });
1937 let disposition_monotonic = current.submission_disposition.is_none_or(|prior| {
1938 observed
1939 .submission_disposition
1940 .is_some_and(|next| valid_disposition_successor(Some(prior), next))
1941 });
1942 if !same_recovery_intent(current, observed) {
1943 return Err(store_failure(
1944 ExactWorkflowStoreError::IntentMismatch,
1945 Some(current.clone()),
1946 ));
1947 }
1948 if observed.revision <= current.revision
1949 || observed.phase < current.phase
1950 || !clock_monotonic
1951 || current
1952 .preparation
1953 .as_ref()
1954 .is_some_and(|value| observed.preparation.as_ref() != Some(value))
1955 || !disposition_monotonic
1956 || current
1957 .terminal
1958 .as_ref()
1959 .is_some_and(|value| observed.terminal.as_ref() != Some(value))
1960 {
1961 return Err(store_failure(ExactWorkflowStoreError::Malformed, Some(current.clone())));
1962 }
1963 Ok(())
1964}
1965
1966fn transition_is_reflected(state: &ExactWorkflowRecoveryV2, transition: &ExactMutationTransition) -> bool {
1967 if state.terminal.is_some() {
1968 return true;
1972 }
1973 match transition {
1974 ExactMutationTransition::ClockObserved { unix_millis } => parse_u64(unix_millis).is_some_and(|requested| {
1975 state
1976 .last_observed_unix_millis
1977 .as_ref()
1978 .and_then(parse_u64)
1979 .is_some_and(|observed| observed >= requested)
1980 }),
1981 ExactMutationTransition::Prepared { preparation } => state.preparation.as_ref() == Some(preparation),
1982 ExactMutationTransition::SubmissionStarted => {
1983 state.preparation.is_some()
1984 && (state.phase == ExactWorkflowPhaseV2::SubmissionStarted
1985 || state.phase >= ExactWorkflowPhaseV2::SubmissionObserved
1986 && state.submission_disposition != Some(ExactSubmissionDispositionV1::NotSubmitted))
1987 }
1988 ExactMutationTransition::SubmissionObserved { disposition } => state
1989 .submission_disposition
1990 .is_some_and(|observed| valid_disposition_successor(Some(*disposition), observed)),
1991 ExactMutationTransition::Terminal { .. } => false,
1992 }
1993}
1994
1995fn same_recovery_intent(left: &ExactWorkflowRecoveryV2, right: &ExactWorkflowRecoveryV2) -> bool {
1996 left.format_version == right.format_version
1997 && left.mutation_kind == right.mutation_kind
1998 && left.request_sha256 == right.request_sha256
1999 && left.cluster_id == right.cluster_id
2000 && left.collection_id == right.collection_id
2001 && left.index == right.index
2002 && left.caller_action_id == right.caller_action_id
2003 && left.effect_deadline_unix_millis == right.effect_deadline_unix_millis
2004}
2005
2006fn recovery_matches_intent(recovery: &ExactWorkflowRecoveryV2, intent: &ExactMutationIntent) -> bool {
2007 recovery.format_version == intent.format_version
2008 && recovery.mutation_kind == intent.mutation_kind
2009 && recovery.request_sha256 == intent.request_sha256
2010 && recovery.cluster_id == intent.cluster_id
2011 && recovery.collection_id == intent.collection_id
2012 && recovery.index == intent.index
2013 && recovery.caller_action_id == intent.caller_action_id
2014}
2015
2016fn store_failure(source: ExactWorkflowStoreError, recovery: Option<ExactWorkflowRecoveryV2>) -> ExactWorkflowError {
2017 ExactWorkflowError::Store {
2018 source,
2019 last_durable_recovery: recovery.map(Box::new),
2020 }
2021}
2022
2023async fn record_resolution_observation(
2024 session: &ExactSession,
2025 recovery: &mut ExactWorkflowRecoveryV2,
2026 disposition: ExactSubmissionDispositionV1,
2027) -> Result<(), ExactWorkflowError> {
2028 session
2032 .record(recovery, ExactMutationTransition::SubmissionObserved { disposition })
2033 .await
2034 .map(|_| ())
2035}
2036
2037async fn observe_clock(
2038 session: &ExactSession,
2039 recovery: &mut ExactWorkflowRecoveryV2,
2040 options: &ExactWorkflowOptions,
2041) -> Result<u64, ExactWorkflowError> {
2042 let now = options.clock.unix_millis()?;
2043 if recovery.phase >= ExactWorkflowPhaseV2::SubmissionStarted
2048 && recovery.submission_disposition != Some(ExactSubmissionDispositionV1::NotSubmitted)
2049 {
2050 return Ok(now);
2051 }
2052 if recovery
2053 .last_observed_unix_millis
2054 .as_ref()
2055 .and_then(parse_u64)
2056 .is_some_and(|prior| now <= prior)
2057 {
2058 return Ok(now);
2059 }
2060 session
2061 .record(
2062 recovery,
2063 ExactMutationTransition::ClockObserved {
2064 unix_millis: wire_u64(now),
2065 },
2066 )
2067 .await?;
2068 Ok(now)
2069}
2070
2071fn clock_regressed(recovery: &ExactWorkflowRecoveryV2, now: u64) -> bool {
2072 recovery
2073 .last_observed_unix_millis
2074 .as_ref()
2075 .and_then(parse_u64)
2076 .is_some_and(|observed| now < observed)
2077}
2078
2079fn recovery_effect_deadline(recovery: &ExactWorkflowRecoveryV2) -> Result<u64, ExactWorkflowError> {
2080 parse_u64(&recovery.effect_deadline_unix_millis)
2081 .filter(|value| *value > 0)
2082 .ok_or(ExactWorkflowError::InvalidRecovery)
2083}
2084
2085fn effect_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
2086 bounded_options(now, deadline, options)
2087}
2088
2089fn resolution_options(now: u64, options: &ExactWorkflowOptions) -> Result<Option<CallOptions>, ExactWorkflowError> {
2090 let deadline = if now < options.effect_deadline_unix_millis {
2091 options.effect_deadline_unix_millis
2092 } else {
2093 match options.observation_deadline_unix_millis {
2094 Some(value) if value > now => value,
2095 _ => return Ok(None),
2096 }
2097 };
2098 bounded_options(now, deadline, options).map(Some)
2099}
2100
2101fn bounded_options(now: u64, deadline: u64, options: &ExactWorkflowOptions) -> Result<CallOptions, ExactWorkflowError> {
2102 if deadline <= now {
2103 return Err(ExactWorkflowError::InvalidInput);
2104 }
2105 let bounded = deadline.min(now.saturating_add(MAX_EFFECT_CALL_MILLIS));
2106 CallOptions::from_absolute_deadline_unix_millis(bounded)
2107 .map(|value| value.with_cancellation(options.cancellation.clone()))
2108 .map_err(ExactWorkflowError::Client)
2109}
2110
2111fn validate_append_request(request: &AppendExactRequestV1) -> Result<(), ExactWorkflowError> {
2112 if request.batch_id.validate().is_err()
2113 || !valid_index(&request.index)
2114 || request.items.is_empty()
2115 || request.items.len() > 1_000
2116 || request.items.iter().any(|item| {
2117 item.item_action_id.validate().is_err() || item.document_id.is_empty() || item.document_id.len() > 512
2118 })
2119 {
2120 return Err(ExactWorkflowError::InvalidInput);
2121 }
2122 let mut identities = std::collections::BTreeSet::new();
2123 if request
2124 .items
2125 .iter()
2126 .any(|item| !identities.insert(item.item_action_id.value.as_str()))
2127 {
2128 return Err(ExactWorkflowError::InvalidInput);
2129 }
2130 Ok(())
2131}
2132
2133fn validate_append_outcome(
2134 request: &AppendExactRequestV1,
2135 outcome: &RetainedAppendBulkOutcome,
2136 prepared: bool,
2137) -> Result<(), ExactWorkflowError> {
2138 if outcome.batch_id != request.batch_id
2139 || outcome.index != request.index
2140 || outcome.items.len() != request.items.len()
2141 {
2142 return Err(ExactWorkflowError::CoreShape);
2143 }
2144 for (expected, observed) in request.items.iter().zip(&outcome.items) {
2145 if observed.item_action_id != expected.item_action_id
2146 || observed
2147 .locator
2148 .as_ref()
2149 .is_some_and(|value| value.authority.is_empty() || value.value.is_empty())
2150 || observed
2151 .semantic_digest
2152 .as_ref()
2153 .is_some_and(|value| value.validate().is_err())
2154 || prepared
2155 && (observed.state != RetainedAppendState::Prepared
2156 || observed.retry_safety != RetainedAppendRetrySafety::Safe
2157 || observed.locator.is_none()
2158 || observed.semantic_digest.is_none()
2159 || observed.sequence.is_some()
2160 || observed.primary_term.is_some()
2161 || observed.error.is_some())
2162 {
2163 return Err(ExactWorkflowError::CoreShape);
2164 }
2165 }
2166 Ok(())
2167}
2168
2169fn redact_append_outcome(mut outcome: RetainedAppendBulkOutcome) -> RetainedAppendBulkOutcome {
2170 for item in &mut outcome.items {
2171 item.error = None;
2172 }
2173 outcome
2174}
2175
2176fn append_preparation(recovery: &ExactWorkflowRecoveryV2) -> Result<&RetainedAppendBulkOutcome, ExactWorkflowError> {
2177 match &recovery.preparation {
2178 Some(ExactPreparationV1::Append(value)) => Ok(value),
2179 _ => Err(ExactWorkflowError::InvalidRecovery),
2180 }
2181}
2182
2183fn document_preparation(recovery: &ExactWorkflowRecoveryV2) -> Result<(Locator, Digest), ExactWorkflowError> {
2184 match &recovery.preparation {
2185 Some(ExactPreparationV1::Document {
2186 locator,
2187 semantic_digest,
2188 }) => Ok((locator.clone(), semantic_digest.clone())),
2189 _ => Err(ExactWorkflowError::InvalidRecovery),
2190 }
2191}
2192
2193fn index_preparation(
2194 recovery: &ExactWorkflowRecoveryV2,
2195) -> Result<(Locator, U64, Digest, IndexResourceSpec), ExactWorkflowError> {
2196 match &recovery.preparation {
2197 Some(ExactPreparationV1::Index {
2198 locator,
2199 expires_at,
2200 semantic_digest,
2201 resource_spec,
2202 }) => Ok((
2203 locator.clone(),
2204 expires_at.clone(),
2205 semantic_digest.clone(),
2206 resource_spec.as_ref().clone(),
2207 )),
2208 _ => Err(ExactWorkflowError::InvalidRecovery),
2209 }
2210}
2211
2212fn may_start_catalog_submission(
2213 recovery: &ExactWorkflowRecoveryV2,
2214 now_unix_millis: u64,
2215 effect_deadline_unix_millis: u64,
2216) -> bool {
2217 (recovery.phase == ExactWorkflowPhaseV2::Prepared
2220 || recovery.submission_disposition == Some(ExactSubmissionDispositionV1::NotSubmitted))
2221 && now_unix_millis < effect_deadline_unix_millis
2222}
2223
2224fn append_terminal_result(
2225 request: &AppendExactRequestV1,
2226 outcome: &RetainedAppendBulkOutcome,
2227 recovered: bool,
2228) -> Option<AppendExactResultV1> {
2229 let state = if outcome
2230 .items
2231 .iter()
2232 .any(|item| item.state == RetainedAppendState::SubmittedIndeterminate)
2233 {
2234 return None;
2235 } else if outcome
2236 .items
2237 .iter()
2238 .any(|item| item.state == RetainedAppendState::Conflict)
2239 {
2240 ExactMutationOutcomeV1::Conflict
2241 } else if outcome
2242 .items
2243 .iter()
2244 .any(|item| item.state == RetainedAppendState::Retired)
2245 {
2246 ExactMutationOutcomeV1::KnownRetired
2247 } else if outcome.items.iter().any(|item| {
2248 matches!(
2249 item.state,
2250 RetainedAppendState::NotSubmittedRetryable | RetainedAppendState::NotSubmittedTerminal
2251 )
2252 }) {
2253 ExactMutationOutcomeV1::NotSubmitted
2254 } else if outcome.items.iter().all(|item| {
2255 matches!(
2256 item.state,
2257 RetainedAppendState::Committed | RetainedAppendState::Replayed
2258 )
2259 }) {
2260 if recovered {
2261 ExactMutationOutcomeV1::Resolved
2262 } else {
2263 ExactMutationOutcomeV1::Applied
2264 }
2265 } else {
2266 return None;
2267 };
2268 Some(append_result(request, outcome, state))
2269}
2270
2271fn append_result(
2272 request: &AppendExactRequestV1,
2273 outcome: &RetainedAppendBulkOutcome,
2274 state: ExactMutationOutcomeV1,
2275) -> AppendExactResultV1 {
2276 AppendExactResultV1 {
2277 outcome: state,
2278 batch_id: request.batch_id.clone(),
2279 index: request.index.clone(),
2280 items: outcome.items.iter().map(redact_append_item).collect(),
2281 visibility: Vec::new(),
2282 }
2283}
2284
2285fn append_status(
2286 request: &AppendExactRequestV1,
2287 recovery: &ExactWorkflowRecoveryV2,
2288 state: ExactMutationOutcomeV1,
2289) -> AppendExactResultV1 {
2290 let items = append_preparation(recovery)
2291 .map(|value| value.items.iter().map(redact_append_item).collect())
2292 .unwrap_or_default();
2293 AppendExactResultV1 {
2294 outcome: state,
2295 batch_id: request.batch_id.clone(),
2296 index: request.index.clone(),
2297 items,
2298 visibility: Vec::new(),
2299 }
2300}
2301
2302fn redact_append_item(item: &RetainedAppendItemOutcome) -> AppendExactItemResultV1 {
2303 AppendExactItemResultV1 {
2304 item_action_id: item.item_action_id.clone(),
2305 locator: item.locator.clone(),
2306 semantic_digest: item.semantic_digest.clone(),
2307 state: item.state,
2308 retry_safety: item.retry_safety,
2309 sequence: item.sequence.clone(),
2310 primary_term: item.primary_term.clone(),
2311 }
2312}
2313
2314fn document_status(
2315 request: &PrepareDocumentMutationRequest,
2316 recovery: &ExactWorkflowRecoveryV2,
2317 outcome: ExactMutationOutcomeV1,
2318) -> DocumentMutationExactResultV1 {
2319 let locator = document_preparation(recovery).map(|value| value.0).ok();
2320 DocumentMutationExactResultV1 {
2321 outcome,
2322 index: request.index.clone(),
2323 locator,
2324 state: outcome_name(outcome).to_owned(),
2325 submitted: recovery
2326 .submission_disposition
2327 .is_some_and(|value| !matches!(value, ExactSubmissionDispositionV1::NotSubmitted)),
2328 sequence: None,
2329 mapping_generation: None,
2330 mapping_adopted: false,
2331 }
2332}
2333
2334fn index_status(
2335 request: &PrepareCreateIndexRequest,
2336 recovery: &ExactWorkflowRecoveryV2,
2337 outcome: ExactMutationOutcomeV1,
2338) -> ReconcileIndexExactResultV1 {
2339 let locator = match &recovery.preparation {
2340 Some(ExactPreparationV1::Index { locator, .. }) => Some(locator.clone()),
2341 _ => None,
2342 };
2343 ReconcileIndexExactResultV1 {
2344 outcome,
2345 name: request.name.clone(),
2346 locator,
2347 resource: None,
2348 state: outcome_name(outcome).to_owned(),
2349 }
2350}
2351
2352const fn outcome_name(outcome: ExactMutationOutcomeV1) -> &'static str {
2353 match outcome {
2354 ExactMutationOutcomeV1::AlreadySatisfied => "already_satisfied",
2355 ExactMutationOutcomeV1::Applied => "applied",
2356 ExactMutationOutcomeV1::Resolved => "resolved",
2357 ExactMutationOutcomeV1::Conflict => "conflict",
2358 ExactMutationOutcomeV1::NotSubmitted => "not_submitted",
2359 ExactMutationOutcomeV1::Indeterminate => "indeterminate",
2360 ExactMutationOutcomeV1::KnownRetired => "known_retired",
2361 ExactMutationOutcomeV1::EffectDeadlineExpired => "effect_deadline_expired",
2362 ExactMutationOutcomeV1::ClockRegression => "clock_regression",
2363 }
2364}
2365
2366async fn finish_append(
2367 session: &ExactSession,
2368 recovery: &mut ExactWorkflowRecoveryV2,
2369 result: AppendExactResultV1,
2370) -> Result<ExactExecutionV2<AppendExactResultV1>, ExactWorkflowError> {
2371 session
2372 .record(
2373 recovery,
2374 ExactMutationTransition::Terminal {
2375 result: ExactTerminalResultV1::Append(result.clone()),
2376 },
2377 )
2378 .await?;
2379 let durable = match &recovery.terminal {
2380 Some(ExactTerminalResultV1::Append(durable)) => durable.clone(),
2381 _ => return Err(ExactWorkflowError::InvalidRecovery),
2382 };
2383 Ok(execution(durable, recovery.clone()))
2384}
2385
2386async fn finish_document(
2387 session: &ExactSession,
2388 recovery: &mut ExactWorkflowRecoveryV2,
2389 result: DocumentMutationExactResultV1,
2390) -> Result<ExactExecutionV2<DocumentMutationExactResultV1>, ExactWorkflowError> {
2391 session
2392 .record(
2393 recovery,
2394 ExactMutationTransition::Terminal {
2395 result: ExactTerminalResultV1::Document(result.clone()),
2396 },
2397 )
2398 .await?;
2399 let durable = match &recovery.terminal {
2400 Some(ExactTerminalResultV1::Document(durable)) => durable.clone(),
2401 _ => return Err(ExactWorkflowError::InvalidRecovery),
2402 };
2403 Ok(execution(durable, recovery.clone()))
2404}
2405
2406async fn finish_index(
2407 session: &ExactSession,
2408 recovery: &mut ExactWorkflowRecoveryV2,
2409 result: ReconcileIndexExactResultV1,
2410) -> Result<ExactExecutionV2<ReconcileIndexExactResultV1>, ExactWorkflowError> {
2411 session
2412 .record(
2413 recovery,
2414 ExactMutationTransition::Terminal {
2415 result: ExactTerminalResultV1::Index(result.clone()),
2416 },
2417 )
2418 .await?;
2419 let durable = match &recovery.terminal {
2420 Some(ExactTerminalResultV1::Index(durable)) => durable.clone(),
2421 _ => return Err(ExactWorkflowError::InvalidRecovery),
2422 };
2423 Ok(execution(durable, recovery.clone()))
2424}
2425
2426fn execution<T>(result: T, recovery: ExactWorkflowRecoveryV2) -> ExactExecutionV2<T> {
2427 ExactExecutionV2 { result, recovery }
2428}
2429
2430fn valid_preparation(value: &ExactPreparationV1) -> bool {
2431 match value {
2432 ExactPreparationV1::Append(outcome) => {
2433 !outcome.items.is_empty()
2434 && outcome.items.len() <= 1_000
2435 && outcome.items.iter().all(|item| {
2436 item.item_action_id.validate().is_ok()
2437 && item
2438 .locator
2439 .as_ref()
2440 .is_some_and(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
2441 && item
2442 .semantic_digest
2443 .as_ref()
2444 .is_some_and(|digest| digest.validate().is_ok())
2445 && item.state == RetainedAppendState::Prepared
2446 && item.retry_safety == RetainedAppendRetrySafety::Safe
2447 && item.error.is_none()
2448 })
2449 }
2450 ExactPreparationV1::Document {
2451 locator,
2452 semantic_digest,
2453 } => !locator.authority.is_empty() && !locator.value.is_empty() && semantic_digest.validate().is_ok(),
2454 ExactPreparationV1::Index {
2455 locator,
2456 expires_at,
2457 semantic_digest,
2458 ..
2459 } => {
2460 !locator.authority.is_empty()
2461 && !locator.value.is_empty()
2462 && parse_u64(expires_at).is_some_and(|value| value > 0)
2463 && semantic_digest.validate().is_ok()
2464 }
2465 }
2466}
2467
2468fn valid_terminal(value: &ExactTerminalResultV1) -> bool {
2469 match value {
2470 ExactTerminalResultV1::Append(result) => {
2471 result.batch_id.validate().is_ok()
2472 && valid_index(&result.index)
2473 && terminal_outcome(result.outcome)
2474 && result.items.iter().all(|item| item.item_action_id.validate().is_ok())
2475 }
2476 ExactTerminalResultV1::Document(result) => {
2477 valid_index(&result.index)
2478 && result
2479 .locator
2480 .as_ref()
2481 .is_none_or(|locator| !locator.authority.is_empty() && !locator.value.is_empty())
2482 && terminal_outcome(result.outcome)
2483 }
2484 ExactTerminalResultV1::Index(result) => !result.name.is_empty() && terminal_outcome(result.outcome),
2485 }
2486}
2487
2488const fn terminal_outcome(value: ExactMutationOutcomeV1) -> bool {
2489 matches!(
2490 value,
2491 ExactMutationOutcomeV1::AlreadySatisfied
2492 | ExactMutationOutcomeV1::Applied
2493 | ExactMutationOutcomeV1::Resolved
2494 | ExactMutationOutcomeV1::Conflict
2495 | ExactMutationOutcomeV1::KnownRetired
2496 | ExactMutationOutcomeV1::EffectDeadlineExpired
2497 )
2498}
2499
2500const fn preparation_kind(value: &ExactPreparationV1) -> ExactMutationKindV1 {
2501 match value {
2502 ExactPreparationV1::Append(_) => ExactMutationKindV1::Append,
2503 ExactPreparationV1::Document { .. } => ExactMutationKindV1::Document,
2504 ExactPreparationV1::Index { .. } => ExactMutationKindV1::Index,
2505 }
2506}
2507
2508const fn terminal_kind(value: &ExactTerminalResultV1) -> ExactMutationKindV1 {
2509 match value {
2510 ExactTerminalResultV1::Append(_) => ExactMutationKindV1::Append,
2511 ExactTerminalResultV1::Document(_) => ExactMutationKindV1::Document,
2512 ExactTerminalResultV1::Index(_) => ExactMutationKindV1::Index,
2513 }
2514}
2515
2516fn valid_index(value: &Resource) -> bool {
2517 value.kind == "index" && !value.name.is_empty() && value.name.len() <= 255 && value.incarnation.validate().is_ok()
2518}
2519
2520fn canonical_sha256<T: Serialize>(value: &T) -> Result<Digest, ExactWorkflowError> {
2521 let bytes = serde_json::to_vec(value).map_err(|_| ExactWorkflowError::RequestCodec)?;
2522 let observed = digest::digest(&digest::SHA256, &bytes);
2523 Ok(Digest {
2524 value: lower_hex(observed.as_ref()),
2525 })
2526}
2527
2528fn parse_u64(value: &U64) -> Option<u64> {
2529 if value.value.is_empty() || value.value.len() > 20 || value.value.starts_with('0') && value.value != "0" {
2530 return None;
2531 }
2532 value.value.parse().ok()
2533}
2534
2535fn wire_u64(value: u64) -> U64 {
2536 U64 {
2537 value: value.to_string(),
2538 }
2539}
2540
2541fn lower_hex(bytes: &[u8]) -> String {
2542 const HEX: &[u8; 16] = b"0123456789abcdef";
2543 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
2544 for byte in bytes {
2545 output.push(char::from(HEX[usize::from(byte >> 4)]));
2546 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
2547 }
2548 output
2549}
2550
2551#[cfg(test)]
2552mod tests {
2553 use std::os::unix::fs::PermissionsExt as _;
2554 use std::sync::Mutex;
2555
2556 use super::*;
2557
2558 #[derive(Default)]
2559 struct PostgresShapedRecoveryStore {
2560 row: Mutex<Option<ExactWorkflowRecoveryV2>>,
2561 }
2562
2563 impl ExactWorkflowRecoveryStore for PostgresShapedRecoveryStore {
2564 fn load(
2565 &self,
2566 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2567 Box::pin(async move {
2568 self.row
2569 .lock()
2570 .map(|row| row.clone())
2571 .map_err(|_| ExactWorkflowStoreError::Internal)
2572 })
2573 }
2574
2575 fn compare_exchange(
2576 &self,
2577 expected_revision: Option<u64>,
2578 next: ExactWorkflowRecoveryV2,
2579 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2580 Box::pin(async move {
2581 let mut row = self.row.lock().map_err(|_| ExactWorkflowStoreError::Internal)?;
2582 let observed_revision = row.as_ref().map(|value| value.revision);
2583 if observed_revision != expected_revision {
2584 return Err(ExactWorkflowStoreError::Conflict {
2585 observed: row.clone().map(Box::new),
2586 });
2587 }
2588 *row = Some(next);
2589 Ok(())
2590 })
2591 }
2592 }
2593
2594 struct AdvancingConflictStore {
2595 row: Mutex<ExactWorkflowRecoveryV2>,
2596 }
2597
2598 struct UnavailableRecoveryStore;
2599
2600 struct AdvancingClock {
2601 next: Mutex<u64>,
2602 }
2603
2604 impl ExactWorkflowClock for AdvancingClock {
2605 fn unix_millis(&self) -> Result<u64, ExactWorkflowError> {
2606 let mut next = self.next.lock().map_err(|_| ExactWorkflowError::ClockUnavailable)?;
2607 let observed = *next;
2608 *next = next.saturating_add(1);
2609 Ok(observed)
2610 }
2611 }
2612
2613 impl ExactWorkflowRecoveryStore for UnavailableRecoveryStore {
2614 fn load(
2615 &self,
2616 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2617 Box::pin(async { Err(ExactWorkflowStoreError::Unavailable) })
2618 }
2619
2620 fn compare_exchange(
2621 &self,
2622 _expected_revision: Option<u64>,
2623 _next: ExactWorkflowRecoveryV2,
2624 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2625 Box::pin(async { Err(ExactWorkflowStoreError::Unavailable) })
2626 }
2627 }
2628
2629 impl ExactWorkflowRecoveryStore for AdvancingConflictStore {
2630 fn load(
2631 &self,
2632 ) -> ExactWorkflowStoreFuture<'_, Result<Option<ExactWorkflowRecoveryV2>, ExactWorkflowStoreError>> {
2633 Box::pin(async move {
2634 self.row
2635 .lock()
2636 .map(|row| Some(row.clone()))
2637 .map_err(|_| ExactWorkflowStoreError::Internal)
2638 })
2639 }
2640
2641 fn compare_exchange(
2642 &self,
2643 _expected_revision: Option<u64>,
2644 _next: ExactWorkflowRecoveryV2,
2645 ) -> ExactWorkflowStoreFuture<'_, Result<(), ExactWorkflowStoreError>> {
2646 Box::pin(async move {
2647 let mut row = self.row.lock().map_err(|_| ExactWorkflowStoreError::Internal)?;
2648 let next_clock = row
2649 .last_observed_unix_millis
2650 .as_ref()
2651 .and_then(parse_u64)
2652 .unwrap_or(0)
2653 .saturating_add(1);
2654 reduce_transition(
2655 &mut row,
2656 &ExactMutationTransition::ClockObserved {
2657 unix_millis: wire_u64(next_clock),
2658 },
2659 )
2660 .map_err(|_| ExactWorkflowStoreError::Internal)?;
2661 Err(ExactWorkflowStoreError::Conflict {
2662 observed: Some(Box::new(row.clone())),
2663 })
2664 })
2665 }
2666 }
2667
2668 fn identity(character: char) -> Identity {
2669 Identity {
2670 value: character.to_string().repeat(32),
2671 }
2672 }
2673
2674 fn digest(character: char) -> Digest {
2675 Digest {
2676 value: character.to_string().repeat(64),
2677 }
2678 }
2679
2680 fn resource() -> Resource {
2681 Resource {
2682 kind: "index".to_owned(),
2683 name: "events".to_owned(),
2684 incarnation: identity('a'),
2685 }
2686 }
2687
2688 fn append_preparation_value() -> ExactPreparationV1 {
2689 ExactPreparationV1::Append(RetainedAppendBulkOutcome {
2690 batch_id: digest('e'),
2691 index: resource(),
2692 items: vec![RetainedAppendItemOutcome {
2693 item_action_id: digest('f'),
2694 locator: Some(Locator {
2695 authority: "retained_append".to_owned(),
2696 value: "01".to_owned(),
2697 }),
2698 semantic_digest: Some(digest('1')),
2699 state: RetainedAppendState::Prepared,
2700 retry_safety: RetainedAppendRetrySafety::Safe,
2701 sequence: None,
2702 primary_term: None,
2703 error: None,
2704 }],
2705 complete: true,
2706 achievement: None,
2707 })
2708 }
2709
2710 fn index_preparation_value(expires_at: u64) -> ExactPreparationV1 {
2711 ExactPreparationV1::Index {
2712 locator: Locator {
2713 authority: "catalog_confirmation".to_owned(),
2714 value: "logical-clock-preparation".to_owned(),
2715 },
2716 expires_at: wire_u64(expires_at),
2717 semantic_digest: digest('8'),
2718 resource_spec: Box::new(IndexResourceSpec {
2719 format_version: u32::from(lodestone_wire::LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION),
2720 revision: wire_u64(1),
2721 initial_shards: 1,
2722 target_shard_bytes: wire_u64(1),
2723 durability: lodestone_wire::ResourceDurabilityClass::RegionalObject,
2724 storage_class: lodestone_wire::ResourceStorageClass::Standard,
2725 ram_admission_weight: 1,
2726 ram_hard_max_bytes: wire_u64(1),
2727 nvme_admission_weight: 1,
2728 nvme_hard_max_bytes: wire_u64(1),
2729 refresh_objective_millis: 1,
2730 memtable_hard_max_bytes: wire_u64(1),
2731 seal_parallelism: 1,
2732 merge_parallelism: 1,
2733 vector: lodestone_wire::VectorResourcePolicy {
2734 quality: lodestone_wire::VectorQualityObjective::Balanced,
2735 indexing: lodestone_wire::VectorIndexingObjective::Balanced,
2736 residency: None,
2737 calibration: None,
2738 },
2739 lifecycle_binding: None,
2740 encryption_required: true,
2741 resolver_capability: None,
2742 }),
2743 }
2744 }
2745
2746 #[test]
2747 fn retained_event_identity_preserves_the_catalog_submit_kind() {
2748 assert_eq!(create_index_action_kind(None), "create_index");
2749 assert_eq!(create_index_action_kind(Some(false)), "create_index");
2750 assert_eq!(create_index_action_kind(Some(true)), "create_retained_append_log_index");
2751 }
2752
2753 #[test]
2754 fn catalog_recovery_replays_only_an_exact_prepared_or_pending_identity() {
2755 assert!(catalog_resolution_requires_exact_replay("pending", None));
2756 assert!(catalog_resolution_requires_exact_replay(
2757 "prepared",
2758 Some(ExactSubmissionDispositionV1::Indeterminate),
2759 ));
2760 assert!(catalog_resolution_requires_exact_replay(
2761 "prepared",
2762 Some(ExactSubmissionDispositionV1::Submitted),
2763 ));
2764 assert!(!catalog_resolution_requires_exact_replay("prepared", None));
2765 assert!(!catalog_resolution_requires_exact_replay(
2766 "prepared",
2767 Some(ExactSubmissionDispositionV1::NotSubmitted),
2768 ));
2769 assert!(!catalog_resolution_requires_exact_replay(
2770 "expired_unknown",
2771 Some(ExactSubmissionDispositionV1::Indeterminate),
2772 ));
2773 }
2774
2775 #[test]
2776 fn catalog_logical_expiry_never_clamps_the_client_unix_deadline() -> Result<(), Box<dyn Error>> {
2777 const CLIENT_NOW: u64 = 1_793_100_501_940;
2778 const EFFECT_DEADLINE: u64 = CLIENT_NOW + 10_000;
2779 const AUTHORITY_LOGICAL_EXPIRY: u64 = 8_390_001;
2780
2781 let intent = exact_intent(
2782 ExactMutationKindV1::Index,
2783 digest('b'),
2784 identity('c'),
2785 identity('d'),
2786 None,
2787 digest('e'),
2788 EFFECT_DEADLINE,
2789 );
2790 let mut prepared = recovery_from_intent(&intent);
2791 reduce_transition(
2792 &mut prepared,
2793 &ExactMutationTransition::ClockObserved {
2794 unix_millis: wire_u64(CLIENT_NOW),
2795 },
2796 )?;
2797 reduce_transition(
2798 &mut prepared,
2799 &ExactMutationTransition::Prepared {
2800 preparation: index_preparation_value(AUTHORITY_LOGICAL_EXPIRY),
2801 },
2802 )?;
2803
2804 let restarted: ExactWorkflowRecoveryV2 = serde_json::from_slice(&serde_json::to_vec(&prepared)?)?;
2805 assert_eq!(index_preparation(&restarted)?.1, wire_u64(AUTHORITY_LOGICAL_EXPIRY));
2806 assert!(may_start_catalog_submission(
2807 &restarted,
2808 CLIENT_NOW + 5_000,
2809 EFFECT_DEADLINE,
2810 ));
2811 assert!(!may_start_catalog_submission(
2812 &restarted,
2813 EFFECT_DEADLINE,
2814 EFFECT_DEADLINE,
2815 ));
2816
2817 let mut uncertain = restarted.clone();
2818 reduce_transition(&mut uncertain, &ExactMutationTransition::SubmissionStarted)?;
2819 reduce_transition(
2820 &mut uncertain,
2821 &ExactMutationTransition::SubmissionObserved {
2822 disposition: ExactSubmissionDispositionV1::Indeterminate,
2823 },
2824 )?;
2825 assert!(!may_start_catalog_submission(
2826 &uncertain,
2827 CLIENT_NOW + 5_000,
2828 EFFECT_DEADLINE,
2829 ));
2830
2831 let mut proven_not_submitted = restarted;
2832 reduce_transition(&mut proven_not_submitted, &ExactMutationTransition::SubmissionStarted)?;
2833 reduce_transition(
2834 &mut proven_not_submitted,
2835 &ExactMutationTransition::SubmissionObserved {
2836 disposition: ExactSubmissionDispositionV1::NotSubmitted,
2837 },
2838 )?;
2839 assert!(may_start_catalog_submission(
2840 &proven_not_submitted,
2841 CLIENT_NOW + 5_000,
2842 EFFECT_DEADLINE,
2843 ));
2844 Ok(())
2845 }
2846
2847 #[test]
2848 fn recovery_refuses_changed_request_or_scope_and_retains_first_deadline() -> Result<(), Box<dyn Error>> {
2849 let intent = exact_intent(
2850 ExactMutationKindV1::Append,
2851 digest('b'),
2852 identity('c'),
2853 identity('d'),
2854 Some(resource()),
2855 digest('e'),
2856 100,
2857 );
2858 let recovery = recovery_from_intent(&intent);
2859 validate_recovery_matches(&recovery, &intent)?;
2860 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
2861 let mut changed = intent.clone();
2862 changed.collection_id = identity('f');
2863 assert!(matches!(
2864 validate_recovery_matches(&recovery, &changed),
2865 Err(ExactWorkflowError::InvalidRecovery)
2866 ));
2867 changed = intent.clone();
2868 changed.request_sha256 = digest('f');
2869 assert!(matches!(
2870 validate_recovery_matches(&recovery, &changed),
2871 Err(ExactWorkflowError::InvalidRecovery)
2872 ));
2873 Ok(())
2874 }
2875
2876 #[test]
2877 fn reducer_never_regresses_clock_or_replaces_possible_submission() -> Result<(), Box<dyn Error>> {
2878 let intent = exact_intent(
2879 ExactMutationKindV1::Append,
2880 digest('b'),
2881 identity('c'),
2882 identity('d'),
2883 Some(resource()),
2884 digest('e'),
2885 100,
2886 );
2887 let mut state = recovery_from_intent(&intent);
2888 reduce_transition(
2889 &mut state,
2890 &ExactMutationTransition::ClockObserved {
2891 unix_millis: wire_u64(10),
2892 },
2893 )?;
2894 assert!(
2895 reduce_transition(
2896 &mut state,
2897 &ExactMutationTransition::ClockObserved {
2898 unix_millis: wire_u64(9),
2899 },
2900 )
2901 .is_err()
2902 );
2903 let preparation = ExactPreparationV1::Append(RetainedAppendBulkOutcome {
2904 batch_id: digest('e'),
2905 index: resource(),
2906 items: vec![RetainedAppendItemOutcome {
2907 item_action_id: digest('f'),
2908 locator: Some(Locator {
2909 authority: "retained_append".to_owned(),
2910 value: "01".to_owned(),
2911 }),
2912 semantic_digest: Some(digest('1')),
2913 state: RetainedAppendState::Prepared,
2914 retry_safety: RetainedAppendRetrySafety::Safe,
2915 sequence: None,
2916 primary_term: None,
2917 error: None,
2918 }],
2919 complete: true,
2920 achievement: None,
2921 });
2922 reduce_transition(
2923 &mut state,
2924 &ExactMutationTransition::Prepared {
2925 preparation: preparation.clone(),
2926 },
2927 )?;
2928 reduce_transition(&mut state, &ExactMutationTransition::SubmissionStarted)?;
2929 reduce_transition(
2930 &mut state,
2931 &ExactMutationTransition::SubmissionObserved {
2932 disposition: ExactSubmissionDispositionV1::Indeterminate,
2933 },
2934 )?;
2935 assert!(reduce_transition(&mut state, &ExactMutationTransition::Prepared { preparation },).is_err());
2936 Ok(())
2937 }
2938
2939 #[test]
2940 fn reducer_refuses_revision_overflow_without_changing_state() {
2941 let intent = exact_intent(
2942 ExactMutationKindV1::Append,
2943 digest('b'),
2944 identity('c'),
2945 identity('d'),
2946 Some(resource()),
2947 digest('e'),
2948 100,
2949 );
2950 let mut state = recovery_from_intent(&intent);
2951 state.revision = MAX_SAFE_REVISION;
2952 let unchanged = state.clone();
2953 assert!(
2954 reduce_transition(
2955 &mut state,
2956 &ExactMutationTransition::ClockObserved {
2957 unix_millis: wire_u64(1),
2958 },
2959 )
2960 .is_err()
2961 );
2962 assert_eq!(state, unchanged);
2963 }
2964
2965 #[test]
2966 fn not_submitted_append_outcome_remains_recovery_resumable() -> Result<(), Box<dyn Error>> {
2967 let request = AppendExactRequestV1 {
2968 batch_id: digest('a'),
2969 index: resource(),
2970 items: vec![RetainedAppendItem {
2971 item_action_id: digest('b'),
2972 document_id: "event-1".to_owned(),
2973 record: lodestone_wire::NativeRecord::Document {
2974 source: lodestone_wire::RawJson {
2975 bytes_base64: "e30=".to_owned(),
2976 },
2977 },
2978 }],
2979 observe_committed: true,
2980 };
2981 let outcome = RetainedAppendBulkOutcome {
2982 batch_id: request.batch_id.clone(),
2983 index: request.index.clone(),
2984 items: vec![RetainedAppendItemOutcome {
2985 item_action_id: request.items[0].item_action_id.clone(),
2986 locator: Some(Locator {
2987 authority: "retained_append".to_owned(),
2988 value: "01".to_owned(),
2989 }),
2990 semantic_digest: Some(digest('c')),
2991 state: RetainedAppendState::NotSubmittedRetryable,
2992 retry_safety: RetainedAppendRetrySafety::Safe,
2993 sequence: None,
2994 primary_term: None,
2995 error: None,
2996 }],
2997 complete: false,
2998 achievement: None,
2999 };
3000
3001 let result = append_terminal_result(&request, &outcome, false).ok_or("not-submitted observation absent")?;
3002 assert_eq!(result.outcome, ExactMutationOutcomeV1::NotSubmitted);
3003 assert!(!terminal_outcome(result.outcome));
3004 Ok(())
3005 }
3006
3007 #[test]
3008 fn append_recovery_redacts_server_error_messages() {
3009 let outcome = RetainedAppendBulkOutcome {
3010 batch_id: digest('a'),
3011 index: resource(),
3012 items: vec![RetainedAppendItemOutcome {
3013 item_action_id: digest('b'),
3014 locator: Some(Locator {
3015 authority: "retained_append".to_owned(),
3016 value: "01".to_owned(),
3017 }),
3018 semantic_digest: Some(digest('c')),
3019 state: RetainedAppendState::Prepared,
3020 retry_safety: RetainedAppendRetrySafety::Safe,
3021 sequence: None,
3022 primary_term: None,
3023 error: None,
3024 }],
3025 complete: true,
3026 achievement: None,
3027 };
3028 assert!(redact_append_outcome(outcome).items[0].error.is_none());
3029 }
3030
3031 #[tokio::test]
3032 async fn caller_owned_cas_converges_two_replicas_across_every_phase() -> Result<(), Box<dyn Error>> {
3033 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
3034 let intent = exact_intent(
3035 ExactMutationKindV1::Append,
3036 digest('b'),
3037 identity('c'),
3038 identity('d'),
3039 Some(resource()),
3040 digest('e'),
3041 10_000,
3042 );
3043 let options = ExactWorkflowOptions::new(10_000)
3044 .with_observation_deadline_unix_millis(20_000)
3045 .with_recovery_store(Arc::clone(&store));
3046 let (left_session, mut left) = open_session(&intent, &options).await?;
3047 let (right_session, mut right) = open_session(&intent, &options).await?;
3048 assert_eq!(left.revision, 0);
3049 assert_eq!(right.revision, 0);
3050
3051 assert!(
3052 left_session
3053 .record(
3054 &mut left,
3055 ExactMutationTransition::ClockObserved {
3056 unix_millis: wire_u64(1),
3057 },
3058 )
3059 .await?
3060 );
3061 assert!(
3062 !right_session
3063 .record(
3064 &mut right,
3065 ExactMutationTransition::ClockObserved {
3066 unix_millis: wire_u64(1),
3067 },
3068 )
3069 .await?
3070 );
3071
3072 let preparation = append_preparation_value();
3073 assert!(
3074 left_session
3075 .record(
3076 &mut left,
3077 ExactMutationTransition::Prepared {
3078 preparation: preparation.clone(),
3079 },
3080 )
3081 .await?
3082 );
3083 assert!(
3084 !right_session
3085 .record(&mut right, ExactMutationTransition::Prepared { preparation },)
3086 .await?
3087 );
3088
3089 assert!(
3090 left_session
3091 .record(&mut left, ExactMutationTransition::SubmissionStarted)
3092 .await?
3093 );
3094 assert!(
3095 !right_session
3096 .record(&mut right, ExactMutationTransition::SubmissionStarted)
3097 .await?
3098 );
3099
3100 let observation = ExactMutationTransition::SubmissionObserved {
3101 disposition: ExactSubmissionDispositionV1::Resolved,
3102 };
3103 assert!(left_session.record(&mut left, observation.clone()).await?);
3104 assert!(!right_session.record(&mut right, observation).await?);
3105
3106 let result = ExactTerminalResultV1::Append(AppendExactResultV1 {
3107 outcome: ExactMutationOutcomeV1::Resolved,
3108 batch_id: digest('e'),
3109 index: resource(),
3110 items: Vec::new(),
3111 visibility: Vec::new(),
3112 });
3113 assert!(
3114 left_session
3115 .record(&mut left, ExactMutationTransition::Terminal { result: result.clone() },)
3116 .await?
3117 );
3118 let losing = AppendExactResultV1 {
3119 outcome: ExactMutationOutcomeV1::Conflict,
3120 batch_id: digest('e'),
3121 index: resource(),
3122 items: Vec::new(),
3123 visibility: Vec::new(),
3124 };
3125 let adopted = finish_append(&right_session, &mut right, losing).await?;
3126 assert_eq!(adopted.result.outcome, ExactMutationOutcomeV1::Resolved);
3127 assert_eq!(left, right);
3128 assert_eq!(left.phase, ExactWorkflowPhaseV2::Terminal);
3129 assert_eq!(left.revision, 5);
3130 assert_eq!(store.load().await?, Some(left));
3131 Ok(())
3132 }
3133
3134 #[test]
3135 fn rust_recovery_v2_matches_the_cross_language_fixture() -> Result<(), Box<dyn Error>> {
3136 let fixture: serde_json::Value = serde_json::from_str(include_str!(
3137 "../../../clients/native/fixtures/lodestone-exact-workflow-recovery-v2.json"
3138 ))?;
3139 if fixture.get("format_version").and_then(serde_json::Value::as_u64) != Some(2) {
3140 return Err("exact recovery fixture format differs".into());
3141 }
3142 let values = fixture
3143 .get("states")
3144 .and_then(serde_json::Value::as_array)
3145 .ok_or("exact recovery fixture states are absent")?;
3146 let states: Vec<ExactWorkflowRecoveryV2> = values
3147 .iter()
3148 .cloned()
3149 .map(serde_json::from_value)
3150 .collect::<Result<_, _>>()?;
3151 assert_eq!(states.len(), 6);
3152 for state in &states {
3153 validate_recovery_shape(state)?;
3154 }
3155 for pair in states.windows(2) {
3156 let [current, next] = pair else {
3157 return Err("exact recovery fixture window differs".into());
3158 };
3159 let transition = transition_between(current, next)?;
3160 let mut reduced = current.clone();
3161 reduce_transition(&mut reduced, &transition)?;
3162 assert_eq!(&reduced, next);
3163 }
3164 assert_eq!(
3165 states.last().map(|value| value.phase),
3166 Some(ExactWorkflowPhaseV2::Terminal)
3167 );
3168 Ok(())
3169 }
3170
3171 #[tokio::test]
3172 async fn store_values_fail_closed_by_format_shape_and_intent() -> Result<(), Box<dyn Error>> {
3173 let intent = exact_intent(
3174 ExactMutationKindV1::Append,
3175 digest('b'),
3176 identity('c'),
3177 identity('d'),
3178 Some(resource()),
3179 digest('e'),
3180 10_000,
3181 );
3182 let initial = recovery_from_intent(&intent);
3183
3184 let mut future = initial.clone();
3185 future.format_version = EXACT_MUTATION_FORMAT_VERSION.saturating_add(1);
3186 let future_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3187 row: Mutex::new(Some(future)),
3188 });
3189 let future_result = open_session(
3190 &intent,
3191 &ExactWorkflowOptions::new(10_000).with_recovery_store(future_store),
3192 )
3193 .await;
3194 assert!(matches!(
3195 future_result,
3196 Err(ExactWorkflowError::Store {
3197 source: ExactWorkflowStoreError::UnsupportedFormat { .. },
3198 ..
3199 })
3200 ));
3201
3202 let mut malformed = initial.clone();
3203 malformed.phase = ExactWorkflowPhaseV2::Prepared;
3204 let malformed_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3205 row: Mutex::new(Some(malformed)),
3206 });
3207 let malformed_result = open_session(
3208 &intent,
3209 &ExactWorkflowOptions::new(10_000).with_recovery_store(malformed_store),
3210 )
3211 .await;
3212 assert!(matches!(
3213 malformed_result,
3214 Err(ExactWorkflowError::Store {
3215 source: ExactWorkflowStoreError::Malformed,
3216 ..
3217 })
3218 ));
3219
3220 let mut mismatched = initial;
3221 mismatched.request_sha256 = digest('9');
3222 let mismatch_store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore {
3223 row: Mutex::new(Some(mismatched)),
3224 });
3225 let mismatch_result = open_session(
3226 &intent,
3227 &ExactWorkflowOptions::new(10_000).with_recovery_store(mismatch_store),
3228 )
3229 .await;
3230 assert!(matches!(
3231 mismatch_result,
3232 Err(ExactWorkflowError::Store {
3233 source: ExactWorkflowStoreError::IntentMismatch,
3234 ..
3235 })
3236 ));
3237
3238 let unavailable: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(UnavailableRecoveryStore);
3239 let unavailable_result = open_session(
3240 &intent,
3241 &ExactWorkflowOptions::new(10_000).with_recovery_store(unavailable),
3242 )
3243 .await;
3244 assert!(matches!(
3245 unavailable_result,
3246 Err(ExactWorkflowError::Store {
3247 source: ExactWorkflowStoreError::Unavailable,
3248 last_durable_recovery: None,
3249 })
3250 ));
3251 Ok(())
3252 }
3253
3254 #[tokio::test]
3255 async fn store_conflicts_and_cancellation_are_bounded_with_recovery() -> Result<(), Box<dyn Error>> {
3256 let intent = exact_intent(
3257 ExactMutationKindV1::Append,
3258 digest('b'),
3259 identity('c'),
3260 identity('d'),
3261 Some(resource()),
3262 digest('e'),
3263 u64::MAX,
3264 );
3265 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(AdvancingConflictStore {
3266 row: Mutex::new(recovery_from_intent(&intent)),
3267 });
3268 let options = ExactWorkflowOptions::new(u64::MAX)
3269 .with_observation_deadline_unix_millis(u64::MAX)
3270 .with_store_conflict_limit(0)
3271 .with_recovery_store(store);
3272 let (session, mut recovery) = open_session(&intent, &options).await?;
3273 let conflict = session
3274 .record(
3275 &mut recovery,
3276 ExactMutationTransition::Prepared {
3277 preparation: append_preparation_value(),
3278 },
3279 )
3280 .await;
3281 assert!(matches!(
3282 conflict,
3283 Err(ExactWorkflowError::StoreConflictBudgetExhausted {
3284 last_durable_recovery
3285 }) if last_durable_recovery.revision == 1
3286 ));
3287
3288 let cancellation = CallCancellation::new();
3289 cancellation.cancel();
3290 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
3291 let options = ExactWorkflowOptions::new(u64::MAX)
3292 .with_cancellation(cancellation)
3293 .with_recovery_store(store);
3294 let (session, mut recovery) = open_session(&intent, &options).await?;
3295 let cancelled = session
3296 .record(
3297 &mut recovery,
3298 ExactMutationTransition::ClockObserved {
3299 unix_millis: wire_u64(1),
3300 },
3301 )
3302 .await;
3303 assert!(matches!(
3304 cancelled,
3305 Err(ExactWorkflowError::Cancelled {
3306 last_durable_recovery
3307 }) if last_durable_recovery.revision == 0
3308 ));
3309 Ok(())
3310 }
3311
3312 #[tokio::test]
3313 async fn durable_visibility_polling_does_not_exhaust_recovery_records() -> Result<(), Box<dyn Error>> {
3314 let intent = exact_intent(
3315 ExactMutationKindV1::Append,
3316 digest('b'),
3317 identity('c'),
3318 identity('d'),
3319 Some(resource()),
3320 digest('e'),
3321 u64::MAX,
3322 );
3323 let store: Arc<dyn ExactWorkflowRecoveryStore> = Arc::new(PostgresShapedRecoveryStore::default());
3324 let clock: Arc<dyn ExactWorkflowClock> = Arc::new(AdvancingClock { next: Mutex::new(1) });
3325 let options = ExactWorkflowOptions::new(u64::MAX)
3326 .with_clock(clock)
3327 .with_recovery_store(Arc::clone(&store));
3328 let (session, mut recovery) = open_session(&intent, &options).await?;
3329
3330 assert_eq!(observe_clock(&session, &mut recovery, &options).await?, 1);
3331 assert!(
3332 session
3333 .record(
3334 &mut recovery,
3335 ExactMutationTransition::Prepared {
3336 preparation: append_preparation_value(),
3337 },
3338 )
3339 .await?
3340 );
3341 assert!(
3342 session
3343 .record(&mut recovery, ExactMutationTransition::SubmissionStarted)
3344 .await?
3345 );
3346 assert!(
3347 session
3348 .record(
3349 &mut recovery,
3350 ExactMutationTransition::SubmissionObserved {
3351 disposition: ExactSubmissionDispositionV1::Resolved,
3352 },
3353 )
3354 .await?
3355 );
3356 assert_eq!(recovery.revision, 4);
3357
3358 for expected_now in 2..=301 {
3359 assert_eq!(observe_clock(&session, &mut recovery, &options).await?, expected_now);
3360 record_resolution_observation(&session, &mut recovery, ExactSubmissionDispositionV1::Resolved).await?;
3361 }
3362 assert_eq!(recovery.revision, 4);
3363 assert_eq!(store.load().await?.map(|stored| stored.revision), Some(4));
3364 Ok(())
3365 }
3366
3367 #[tokio::test]
3368 async fn durable_resume_retains_the_first_effect_deadline() -> Result<(), Box<dyn Error>> {
3369 let mut suffix = [0_u8; 8];
3370 getrandom::fill(&mut suffix)?;
3371 let parent =
3372 std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-{}", lower_hex(&suffix)));
3373 std::fs::create_dir(&parent)?;
3374 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
3375 let path = parent.join("journal");
3376 let first_intent = exact_intent(
3377 ExactMutationKindV1::Append,
3378 digest('b'),
3379 identity('c'),
3380 identity('d'),
3381 Some(resource()),
3382 digest('e'),
3383 100,
3384 );
3385 {
3386 let store = Arc::new(FilesystemExactWorkflowRecoveryStore::new(path.clone()));
3387 let first = ExactWorkflowOptions::new(100).with_recovery_store(store);
3388 let (session, recovery) = open_session(&first_intent, &first).await?;
3389 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
3390 drop(session);
3391 }
3392
3393 let resumed_intent = ExactMutationIntent {
3394 effect_deadline_unix_millis: wire_u64(999),
3395 ..first_intent
3396 };
3397 let store = Arc::new(FilesystemExactWorkflowRecoveryStore::new(path));
3398 let resumed = ExactWorkflowOptions::new(999).with_recovery_store(store);
3399 let (session, recovery) = open_session(&resumed_intent, &resumed).await?;
3400 assert_eq!(recovery_effect_deadline(&recovery)?, 100);
3401 drop(session);
3402 std::fs::remove_dir_all(&parent)?;
3403 Ok(())
3404 }
3405
3406 #[tokio::test]
3407 async fn filesystem_store_restarts_at_each_v2_durability_boundary() -> Result<(), Box<dyn Error>> {
3408 let mut suffix = [0_u8; 8];
3409 getrandom::fill(&mut suffix)?;
3410 let parent =
3411 std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-exact-v2-{}", lower_hex(&suffix)));
3412 std::fs::create_dir(&parent)?;
3413 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))?;
3414 let path = parent.join("journal");
3415 let intent = exact_intent(
3416 ExactMutationKindV1::Append,
3417 digest('b'),
3418 identity('c'),
3419 identity('d'),
3420 Some(resource()),
3421 digest('e'),
3422 10_000,
3423 );
3424 let mut state = recovery_from_intent(&intent);
3425 let store = FilesystemExactWorkflowRecoveryStore::new(path.clone());
3426 store.compare_exchange(None, state.clone()).await?;
3427 drop(store);
3428
3429 let transitions = [
3430 ExactMutationTransition::Prepared {
3431 preparation: append_preparation_value(),
3432 },
3433 ExactMutationTransition::SubmissionStarted,
3434 ExactMutationTransition::SubmissionObserved {
3435 disposition: ExactSubmissionDispositionV1::Indeterminate,
3436 },
3437 ExactMutationTransition::SubmissionObserved {
3438 disposition: ExactSubmissionDispositionV1::Resolved,
3439 },
3440 ExactMutationTransition::Terminal {
3441 result: ExactTerminalResultV1::Append(AppendExactResultV1 {
3442 outcome: ExactMutationOutcomeV1::Resolved,
3443 batch_id: digest('e'),
3444 index: resource(),
3445 items: Vec::new(),
3446 visibility: Vec::new(),
3447 }),
3448 },
3449 ];
3450 for transition in transitions {
3451 let store = FilesystemExactWorkflowRecoveryStore::new(path.clone());
3452 assert_eq!(store.load().await?, Some(state.clone()));
3453 let mut next = state.clone();
3454 reduce_transition(&mut next, &transition)?;
3455 store.compare_exchange(Some(state.revision), next.clone()).await?;
3456 state = next;
3457 drop(store);
3458 }
3459 let store = FilesystemExactWorkflowRecoveryStore::new(path);
3460 let loaded = store.load().await?.ok_or("filesystem recovery disappeared")?;
3461 assert_eq!(loaded.phase, ExactWorkflowPhaseV2::Terminal);
3462 assert_eq!(loaded.caller_action_id, digest('e'));
3463 assert_eq!(
3464 append_preparation(&loaded)?.items[0]
3465 .locator
3466 .as_ref()
3467 .map(|value| value.value.as_str()),
3468 Some("01")
3469 );
3470 drop(store);
3471 std::fs::remove_dir_all(&parent)?;
3472 Ok(())
3473 }
3474}