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