1use crate::foundation::{Digest, Epoch, Head, OperationId, ProposedCommit, Sequence};
4use crate::kernel::{
5 DurableSourceMode, DurableSourceState, PublishGenerationRequest, RebaseDecision, SourceFact,
6 SourceInvalidation, contextual_publication_fingerprint, decode_published_generation,
7 decode_source_fact, encode_source_fact, source_authority_id, volume_authority_id,
8};
9use crate::model::{CheckoutMode, GenerationSelector};
10use crate::path::PortablePath;
11use crate::{
12 AppendOutcome, AsyncAuthorityStore, AsyncObjectStore, CancellationToken, CaptureOptions,
13 CapturePolicy, Checkout, CheckoutCommitOutcome, CreateAuthorityOutcome, Fs, Generation,
14 IdempotencyKey, NativeWatch, NativeWatchOptions, ReplayLimit, WatchBatch,
15 WatchInvalidationReason, WorkBudget, Workspace,
16};
17use bytes::Bytes;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use thiserror::Error;
21use tokio::sync::Mutex;
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SourceMode {
26 Pinned,
28 Tracking,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct SourceOptions {
35 pub mode: SourceMode,
37 pub maximum_paths: u32,
39 pub maximum_extent_spans: u32,
41 pub maximum_queued_changes: u32,
43 pub excluded_paths: Vec<PortablePath>,
45}
46
47impl Default for SourceOptions {
48 fn default() -> Self {
49 Self {
50 mode: SourceMode::Tracking,
51 maximum_paths: 262_144,
52 maximum_extent_spans: 65_536,
53 maximum_queued_changes: 65_536,
54 excluded_paths: Vec::new(),
55 }
56 }
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum SourceState {
62 Clean,
64 PendingCapture,
66 NeedsRescan(WatchInvalidationReason),
68 Conflict,
71 Sealed,
73}
74
75pub enum ReconcileOutcome<A, O> {
77 Clean(Generation<A, O>),
79 NeedsRescan(WatchInvalidationReason),
81 Conflict,
84}
85
86#[derive(Debug, Error)]
88pub enum SourceError {
89 #[error("source options are invalid")]
91 InvalidOptions,
92 #[error("pinned source requires an explicit rescan")]
94 Pinned,
95 #[error("source binding does not match the durable workspace source")]
97 BindingMismatch,
98 #[error("source state changed concurrently")]
100 Concurrent,
101 #[error("source conflicts with the workspace and must be reattached")]
103 Conflict,
104 #[error("source operation identity is already bound to another request")]
106 IdempotencyConflict,
107 #[error("source operation failed: {0}")]
109 Engine(String),
110}
111
112#[derive(Clone, Copy)]
113enum SourceOperation {
114 Reconcile,
115 Rescan,
116 Seal,
117}
118
119#[derive(Clone, Copy)]
120enum SourceOperationStage {
121 Begin,
122 Terminal,
123}
124
125struct RecoveredSourceOperation {
126 fact: SourceFact,
127 head: Head,
128}
129
130struct SourceSession<A, O> {
131 workspace: Workspace<A, O>,
132 checkout: Checkout<A, O>,
133 watcher: NativeWatch,
134 capture: CaptureOptions,
135 capture_policy: CapturePolicy,
136 mode: SourceMode,
137 maximum_queued_changes: u32,
138 state: SourceState,
139 authority_head: Head,
140}
141
142pub struct Source<A, O> {
144 inner: Arc<Mutex<SourceSession<A, O>>>,
145}
146
147impl<A, O> Clone for Source<A, O> {
148 fn clone(&self) -> Self {
149 Self {
150 inner: Arc::clone(&self.inner),
151 }
152 }
153}
154
155impl<A: AsyncAuthorityStore, O: AsyncObjectStore> Source<A, O> {
156 pub async fn state(&self) -> SourceState {
158 self.inner.lock().await.state
159 }
160
161 pub async fn acknowledge_materialization(
168 &self,
169 key: IdempotencyKey,
170 ) -> Result<ReconcileOutcome<A, O>, SourceError> {
171 let mut session = self.inner.lock().await;
172 refresh_source_authority(&mut session).await?;
173 session.checkout = session
174 .workspace
175 .volume
176 .checkout(
177 GenerationSelector::Head,
178 CheckoutMode::tracking_transaction(),
179 WorkBudget::UNBOUNDED,
180 &CancellationToken::new(),
181 )
182 .await
183 .map_err(engine)?
184 .value;
185 session.state = SourceState::NeedsRescan(WatchInvalidationReason::InitialSnapshotRequired);
186 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
187 rescan_session(
188 &mut session,
189 key,
190 SourceOperation::Rescan,
191 SourceState::Clean,
192 )
193 .await
194 }
195
196 pub async fn reconcile(&self) -> Result<ReconcileOutcome<A, O>, SourceError> {
204 self.reconcile_with_key(IdempotencyKey::new()).await
205 }
206
207 pub async fn reconcile_with_key(
218 &self,
219 key: IdempotencyKey,
220 ) -> Result<ReconcileOutcome<A, O>, SourceError> {
221 let mut session = self.inner.lock().await;
222 if let Some(outcome) =
223 replay_reconcile_operation(&mut session, key, SourceOperation::Reconcile).await?
224 {
225 return Ok(outcome);
226 }
227 if session.state == SourceState::Conflict {
228 return Ok(ReconcileOutcome::Conflict);
229 }
230 if session.mode == SourceMode::Pinned {
231 return Err(SourceError::Pinned);
232 }
233 begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
234 let maximum_paths = session.capture.maximum_paths;
235 let batch = session
236 .watcher
237 .poll(
238 maximum_paths,
239 WorkBudget::UNBOUNDED,
240 &CancellationToken::new(),
241 )
242 .map_err(engine)?
243 .value;
244 if let WatchBatch::RescanRequired { reason, .. } = batch {
245 let fact = persist_source_operation(
246 &mut session,
247 SourceState::NeedsRescan(reason),
248 key,
249 SourceOperation::Reconcile,
250 )
251 .await?;
252 return reconcile_outcome_from_fact(&session, fact);
253 }
254 let capture = session.capture.clone();
255 let policy = session.capture_policy.clone();
256 crate::capture_watch_batch_with_policy(
257 &mut session.checkout,
258 batch,
259 &capture,
260 &policy,
261 WorkBudget::UNBOUNDED,
262 &CancellationToken::new(),
263 )
264 .await
265 .map_err(engine)?;
266 publish_session(
267 &mut session,
268 key,
269 SourceOperation::Reconcile,
270 SourceState::Clean,
271 )
272 .await
273 }
274
275 pub async fn rescan(&self) -> Result<ReconcileOutcome<A, O>, SourceError> {
283 self.rescan_with_key(IdempotencyKey::new()).await
284 }
285
286 pub async fn rescan_with_key(
293 &self,
294 key: IdempotencyKey,
295 ) -> Result<ReconcileOutcome<A, O>, SourceError> {
296 let mut session = self.inner.lock().await;
297 if let Some(outcome) =
298 replay_reconcile_operation(&mut session, key, SourceOperation::Rescan).await?
299 {
300 return Ok(outcome);
301 }
302 if session.state == SourceState::Conflict {
303 return Ok(ReconcileOutcome::Conflict);
304 }
305 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
306 rescan_session(
307 &mut session,
308 key,
309 SourceOperation::Rescan,
310 SourceState::Clean,
311 )
312 .await
313 }
314
315 pub async fn seal(&self) -> Result<Generation<A, O>, SourceError> {
322 self.seal_with_key(IdempotencyKey::new()).await
323 }
324
325 pub async fn seal_with_key(
332 &self,
333 key: IdempotencyKey,
334 ) -> Result<Generation<A, O>, SourceError> {
335 let mut session = self.inner.lock().await;
336 if let Some(generation) = replay_seal_operation(&mut session, key).await? {
337 return Ok(generation);
338 }
339 if session.state == SourceState::Conflict {
340 return Err(SourceError::Conflict);
341 }
342 begin_source_operation(&mut session, key, SourceOperation::Seal).await?;
343 let generation = match rescan_session(
344 &mut session,
345 key,
346 SourceOperation::Seal,
347 SourceState::Sealed,
348 )
349 .await?
350 {
351 ReconcileOutcome::Clean(generation) => generation,
352 ReconcileOutcome::Conflict => return Err(SourceError::Conflict),
353 ReconcileOutcome::NeedsRescan(_) => {
354 return Err(SourceError::Engine(
355 "source could not reach a clean sealed state".to_owned(),
356 ));
357 }
358 };
359 Ok(generation)
360 }
361}
362
363async fn rescan_session<A: AsyncAuthorityStore, O: AsyncObjectStore>(
364 session: &mut SourceSession<A, O>,
365 key: IdempotencyKey,
366 operation: SourceOperation,
367 terminal_state: SourceState,
368) -> Result<ReconcileOutcome<A, O>, SourceError> {
369 let trailing = {
370 let mut attempt = 0;
371 loop {
372 session.watcher.begin_rescan().map_err(engine)?;
373 let policy = session.capture_policy.clone();
374 let capture = crate::capture_baseline_with_policy(
375 &mut session.checkout,
376 &session.capture,
377 &policy,
378 WorkBudget::UNBOUNDED,
379 &CancellationToken::new(),
380 )
381 .await;
382 if let Err(error) = capture {
383 let _ = session
384 .watcher
385 .abort_rescan(WatchInvalidationReason::BackendError);
386 let _ = persist_session_state(
387 session,
388 SourceState::NeedsRescan(WatchInvalidationReason::BackendError),
389 )
390 .await;
391 discard_unpublished_capture(session).await?;
392 return Err(engine(error));
393 }
394 let batch = session.watcher.finish_rescan().map_err(engine)?;
395 if matches!(
396 batch,
397 WatchBatch::RescanRequired {
398 reason: WatchInvalidationReason::NativeRescanRequired,
399 ..
400 }
401 ) && attempt < 3
402 {
403 discard_unpublished_capture(session).await?;
407 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
408 attempt += 1;
409 continue;
410 }
411 break batch;
412 }
413 };
414 if let WatchBatch::RescanRequired { reason, .. } = trailing {
415 let fact =
416 persist_source_operation(session, SourceState::NeedsRescan(reason), key, operation)
417 .await?;
418 discard_unpublished_capture(session).await?;
419 return reconcile_outcome_from_fact(session, fact);
420 }
421 let capture = session.capture.clone();
422 let policy = session.capture_policy.clone();
423 crate::capture_watch_batch_with_policy(
424 &mut session.checkout,
425 trailing,
426 &capture,
427 &policy,
428 WorkBudget::UNBOUNDED,
429 &CancellationToken::new(),
430 )
431 .await
432 .map_err(engine)?;
433 publish_session(session, key, operation, terminal_state).await
434}
435
436async fn discard_unpublished_capture<A: AsyncAuthorityStore, O: AsyncObjectStore>(
437 session: &mut SourceSession<A, O>,
438) -> Result<(), SourceError> {
439 let base = session.checkout.generation_id();
440 session.checkout = session
441 .workspace
442 .volume
443 .checkout(
444 GenerationSelector::Exact(base),
445 CheckoutMode::tracking_transaction(),
446 WorkBudget::UNBOUNDED,
447 &CancellationToken::new(),
448 )
449 .await
450 .map_err(engine)?
451 .value;
452 Ok(())
453}
454
455async fn publish_session<A: AsyncAuthorityStore, O: AsyncObjectStore>(
456 session: &mut SourceSession<A, O>,
457 key: IdempotencyKey,
458 operation: SourceOperation,
459 terminal_state: SourceState,
460) -> Result<ReconcileOutcome<A, O>, SourceError> {
461 let had_pending_mutations = session.checkout.has_pending_mutations();
462 let operation_id = source_volume_operation_id(key, operation);
463 let operation_context = source_volume_operation_context(key, operation);
464 let outcome = session
465 .checkout
466 .commit_with_context(
467 operation_id,
468 operation_context,
469 WorkBudget::UNBOUNDED,
470 &CancellationToken::new(),
471 )
472 .await
473 .map_err(engine)?
474 .value;
475 match outcome {
476 CheckoutCommitOutcome::Committed { generation_id, .. }
477 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => {
478 persist_generation_outcome(session, terminal_state, generation_id, key, operation).await
479 }
480 CheckoutCommitOutcome::Conflict { .. } if !had_pending_mutations => {
481 persist_state_outcome(session, SourceState::Conflict, key, operation).await
482 }
483 CheckoutCommitOutcome::Conflict { actual } => {
484 if conflict_head_reuses_checkout_base(
485 &session.workspace,
486 session.checkout.generation_id(),
487 actual,
488 )
489 .await?
490 {
491 return persist_state_outcome(session, SourceState::Conflict, key, operation).await;
492 }
493 let rebased = rebase_source_checkout(session).await?;
494 if matches!(rebased, RebaseDecision::Conflicted { .. }) {
495 return persist_state_outcome(session, SourceState::Conflict, key, operation).await;
496 }
497 let retried = session
498 .checkout
499 .commit_with_context(
500 operation_id,
501 operation_context,
502 WorkBudget::UNBOUNDED,
503 &CancellationToken::new(),
504 )
505 .await
506 .map_err(engine)?
507 .value;
508 match retried {
509 CheckoutCommitOutcome::Committed { generation_id, .. }
510 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => {
511 persist_generation_outcome(
512 session,
513 terminal_state,
514 generation_id,
515 key,
516 operation,
517 )
518 .await
519 }
520 CheckoutCommitOutcome::Conflict { .. } | CheckoutCommitOutcome::Fenced { .. } => {
521 persist_state_outcome(session, SourceState::Conflict, key, operation).await
522 }
523 CheckoutCommitOutcome::IdempotencyConflict { .. } => {
524 Err(SourceError::IdempotencyConflict)
525 }
526 }
527 }
528 CheckoutCommitOutcome::Fenced { .. } => {
529 persist_state_outcome(session, SourceState::Conflict, key, operation).await
530 }
531 CheckoutCommitOutcome::IdempotencyConflict { .. } => Err(SourceError::IdempotencyConflict),
532 }
533}
534
535async fn conflict_head_reuses_checkout_base<A: AsyncAuthorityStore, O: AsyncObjectStore>(
536 workspace: &Workspace<A, O>,
537 checkout_generation: crate::GenerationId,
538 actual: Head,
539) -> Result<bool, SourceError> {
540 let previous_sequence = actual.sequence.get().checked_sub(1).ok_or_else(|| {
541 SourceError::Engine("conflicting volume head has no durable record".to_owned())
542 })?;
543 let records = workspace
544 .volume
545 .fs
546 .authority()
547 .replay(
548 volume_authority_id(workspace.volume.id()),
549 Sequence::new(previous_sequence),
550 ReplayLimit {
551 records: 1,
552 payload_bytes: 4096,
553 },
554 WorkBudget::UNBOUNDED,
555 &CancellationToken::new(),
556 )
557 .await
558 .map_err(engine)?
559 .value;
560 let record = records.first().ok_or_else(|| {
561 SourceError::Engine("conflicting volume head has no durable record".to_owned())
562 })?;
563 if record.epoch != actual.epoch
564 || record.sequence != actual.sequence
565 || record.digest != actual.digest
566 {
567 return Err(SourceError::Engine(
568 "conflicting volume head does not match durable history".to_owned(),
569 ));
570 }
571 let published = decode_published_generation(&record.payload, 4096).map_err(engine)?;
572 if published.volume_id != workspace.volume.id() {
573 return Err(SourceError::Engine(
574 "conflicting volume publication belongs to another volume".to_owned(),
575 ));
576 }
577 Ok(published.generation_root.digest == checkout_generation.digest())
578}
579
580async fn rebase_source_checkout<A: AsyncAuthorityStore, O: AsyncObjectStore>(
581 session: &mut SourceSession<A, O>,
582) -> Result<RebaseDecision, SourceError> {
583 session
584 .checkout
585 .rebase_head(
586 session
587 .workspace
588 .volume
589 .config()
590 .limits
591 .maximum_checkout_dependencies,
592 WorkBudget::UNBOUNDED,
593 &CancellationToken::new(),
594 )
595 .await
596 .map(|receipt| receipt.value)
597 .map_err(engine)
598}
599
600async fn persist_generation_outcome<A: AsyncAuthorityStore, O: AsyncObjectStore>(
601 session: &mut SourceSession<A, O>,
602 state: SourceState,
603 generation_id: crate::GenerationId,
604 key: IdempotencyKey,
605 operation: SourceOperation,
606) -> Result<ReconcileOutcome<A, O>, SourceError> {
607 let fact =
608 persist_source_operation_with_generation(session, state, generation_id, key, operation)
609 .await?;
610 reconcile_outcome_from_fact(session, fact)
611}
612
613async fn persist_state_outcome<A: AsyncAuthorityStore, O: AsyncObjectStore>(
614 session: &mut SourceSession<A, O>,
615 state: SourceState,
616 key: IdempotencyKey,
617 operation: SourceOperation,
618) -> Result<ReconcileOutcome<A, O>, SourceError> {
619 let fact = persist_source_operation(session, state, key, operation).await?;
620 reconcile_outcome_from_fact(session, fact)
621}
622
623fn reconcile_outcome_from_fact<A, O>(
624 session: &SourceSession<A, O>,
625 fact: SourceFact,
626) -> Result<ReconcileOutcome<A, O>, SourceError> {
627 match fact.state {
628 DurableSourceState::Clean | DurableSourceState::Sealed => {
629 Ok(ReconcileOutcome::Clean(Generation {
630 workspace: session.workspace.clone(),
631 id: fact.generation_id,
632 }))
633 }
634 DurableSourceState::NeedsRescan(reason) => {
635 Ok(ReconcileOutcome::NeedsRescan(watch_invalidation(reason)))
636 }
637 DurableSourceState::Conflict => Ok(ReconcileOutcome::Conflict),
638 DurableSourceState::PendingCapture => Err(SourceError::Engine(
639 "source operation has no terminal outcome".to_owned(),
640 )),
641 }
642}
643
644impl<A: AsyncAuthorityStore, O: AsyncObjectStore> Fs<A, O> {
645 pub async fn attach_directory(
652 &self,
653 name: impl AsRef<str>,
654 path: impl AsRef<Path>,
655 options: SourceOptions,
656 ) -> Result<Workspace<A, O>, SourceError> {
657 if options.maximum_paths == 0
658 || options.maximum_extent_spans == 0
659 || options.maximum_queued_changes == 0
660 {
661 return Err(SourceError::InvalidOptions);
662 }
663 let workspace = self.create_workspace(name).await.map_err(engine)?;
664 let watcher = NativeWatch::open_with_profile(
665 path.as_ref(),
666 workspace.volume.config().profile,
667 NativeWatchOptions {
668 limits: workspace.volume.config().limits,
669 maximum_queued_changes: options.maximum_queued_changes,
670 recursive: true,
671 },
672 )
673 .map_err(engine)?;
674 let capture = CaptureOptions {
675 source_root: PathBuf::from(path.as_ref()),
676 expected_root_identity: watcher.root_identity(),
677 maximum_paths: options.maximum_paths,
678 maximum_extent_spans: options.maximum_extent_spans,
679 };
680 let capture_policy = CapturePolicy::excluding(
681 options
682 .excluded_paths
683 .iter()
684 .map(|path| {
685 crate::kernel::NamespacePath::from_portable_in_profile(
686 path,
687 workspace.volume.config().profile,
688 workspace.volume.config().limits,
689 )
690 .map_err(engine)
691 })
692 .collect::<Result<Vec<_>, _>>()?,
693 )
694 .map_err(engine)?;
695 let generation = workspace.head().await.map_err(engine)?;
696 let authority_head = attach_source_authority(
697 &workspace,
698 watcher.root_identity(),
699 &options,
700 capture_policy.fingerprint(),
701 generation.id,
702 )
703 .await?;
704 let checkout = workspace
705 .volume
706 .checkout(
707 GenerationSelector::Head,
708 CheckoutMode::tracking_transaction(),
709 WorkBudget::UNBOUNDED,
710 &CancellationToken::new(),
711 )
712 .await
713 .map_err(engine)?
714 .value;
715 let source = Source {
716 inner: Arc::new(Mutex::new(SourceSession {
717 workspace: workspace.clone(),
718 checkout,
719 watcher,
720 capture,
721 capture_policy,
722 mode: options.mode,
723 maximum_queued_changes: options.maximum_queued_changes,
724 state: SourceState::NeedsRescan(WatchInvalidationReason::InitialSnapshotRequired),
725 authority_head,
726 })),
727 };
728 if !matches!(source.rescan().await?, ReconcileOutcome::Clean(_)) {
729 return Err(SourceError::Engine(
730 "initial source baseline did not become clean".to_owned(),
731 ));
732 }
733 Ok(Workspace {
734 source: Some(source),
735 ..workspace
736 })
737 }
738}
739
740async fn attach_source_authority<A: AsyncAuthorityStore, O: AsyncObjectStore>(
741 workspace: &Workspace<A, O>,
742 root_identity: crate::NativeRootIdentity,
743 options: &SourceOptions,
744 capture_policy: Digest,
745 generation_id: crate::GenerationId,
746) -> Result<Head, SourceError> {
747 let authority_id = source_authority_id(workspace.volume.id());
748 let created = workspace
749 .volume
750 .fs
751 .authority()
752 .create_authority(
753 authority_id,
754 Epoch::GENESIS,
755 WorkBudget::UNBOUNDED,
756 &CancellationToken::new(),
757 )
758 .await
759 .map_err(engine)?
760 .value;
761 let head = match created {
762 CreateAuthorityOutcome::Created(head) | CreateAuthorityOutcome::Existing(head) => head,
763 };
764 if head.sequence != Sequence::GENESIS {
765 let latest = replay_source_fact(workspace, head).await?;
766 if latest.volume_id != workspace.volume.id()
767 || latest.root_identity != root_identity.to_bytes()
768 || latest.mode != durable_mode(options.mode)
769 || latest.maximum_paths != options.maximum_paths
770 || latest.maximum_extent_spans != options.maximum_extent_spans
771 || latest.maximum_queued_changes != options.maximum_queued_changes
772 || (latest.schema_version == 1 && !options.excluded_paths.is_empty())
773 || (latest.schema_version >= 2 && latest.capture_policy != capture_policy)
774 {
775 return Err(SourceError::BindingMismatch);
776 }
777 }
778 append_source_fact(
779 workspace,
780 head,
781 SourceFact {
782 schema_version: 2,
783 volume_id: workspace.volume.id(),
784 root_identity: root_identity.to_bytes(),
785 mode: durable_mode(options.mode),
786 maximum_paths: options.maximum_paths,
787 maximum_extent_spans: options.maximum_extent_spans,
788 maximum_queued_changes: options.maximum_queued_changes,
789 capture_policy,
790 state: DurableSourceState::NeedsRescan(SourceInvalidation::InitialSnapshotRequired),
791 generation_id,
792 },
793 )
794 .await
795}
796
797async fn replay_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
798 workspace: &Workspace<A, O>,
799 head: Head,
800) -> Result<SourceFact, SourceError> {
801 let after = Sequence::new(head.sequence.get().saturating_sub(1));
802 let records = workspace
803 .volume
804 .fs
805 .authority()
806 .replay(
807 source_authority_id(workspace.volume.id()),
808 after,
809 ReplayLimit {
810 records: 1,
811 payload_bytes: 4096,
812 },
813 WorkBudget::UNBOUNDED,
814 &CancellationToken::new(),
815 )
816 .await
817 .map_err(engine)?
818 .value;
819 let record = records.first().ok_or_else(|| {
820 SourceError::Engine("source authority head has no terminal fact".to_owned())
821 })?;
822 decode_source_fact(&record.payload, 4096).map_err(engine)
823}
824
825async fn refresh_source_authority<A: AsyncAuthorityStore, O: AsyncObjectStore>(
826 session: &mut SourceSession<A, O>,
827) -> Result<(), SourceError> {
828 let head = session
829 .workspace
830 .volume
831 .fs
832 .authority()
833 .head(
834 source_authority_id(session.workspace.volume.id()),
835 WorkBudget::UNBOUNDED,
836 &CancellationToken::new(),
837 )
838 .await
839 .map_err(engine)?
840 .value;
841 if head == session.authority_head {
842 return Ok(());
843 }
844 let fact = replay_source_fact(&session.workspace, head).await?;
845 validate_source_fact_binding(session, &fact)?;
846 session.authority_head = head;
847 let state = source_state(fact.state);
848 session.state = if fact.generation_id == session.checkout.generation_id() {
849 state
850 } else {
851 SourceState::Conflict
852 };
853 Ok(())
854}
855
856async fn recover_published_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
857 session: &mut SourceSession<A, O>,
858 state: SourceState,
859 generation_id: crate::GenerationId,
860 begin_head: Head,
861 key: IdempotencyKey,
862 operation: SourceOperation,
863) -> Result<SourceFact, SourceError> {
864 refresh_source_authority(session).await?;
865 let operation_still_current = (session.state == SourceState::PendingCapture
866 && session.authority_head == begin_head)
867 || (session.state == SourceState::Clean
868 && session.checkout.generation_id() == generation_id);
869 let recovered_state = if operation_still_current {
870 state
871 } else {
872 SourceState::Conflict
873 };
874 persist_source_operation_with_generation(
875 session,
876 recovered_state,
877 generation_id,
878 key,
879 operation,
880 )
881 .await
882}
883
884async fn replay_reconcile_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
885 session: &mut SourceSession<A, O>,
886 key: IdempotencyKey,
887 operation: SourceOperation,
888) -> Result<Option<ReconcileOutcome<A, O>>, SourceError> {
889 let fact = if let Some(recovered) =
890 find_source_operation(session, key, operation, SourceOperationStage::Terminal).await?
891 {
892 let fact = recovered.fact;
893 refresh_source_authority(session).await?;
894 fact
895 } else {
896 let Some(begin) =
897 find_source_operation(session, key, operation, SourceOperationStage::Begin).await?
898 else {
899 if published_generation_for_key(session, key, operation)
900 .await?
901 .is_some()
902 {
903 return Err(SourceError::IdempotencyConflict);
904 }
905 return Ok(None);
906 };
907 let Some(generation_id) = published_generation_for_key(session, key, operation).await?
908 else {
909 return Ok(None);
910 };
911 recover_published_operation(
912 session,
913 SourceState::Clean,
914 generation_id,
915 begin.head,
916 key,
917 operation,
918 )
919 .await?
920 };
921 let outcome = match fact.state {
922 DurableSourceState::Clean => ReconcileOutcome::Clean(Generation {
923 workspace: session.workspace.clone(),
924 id: fact.generation_id,
925 }),
926 DurableSourceState::NeedsRescan(reason) => {
927 ReconcileOutcome::NeedsRescan(watch_invalidation(reason))
928 }
929 DurableSourceState::Conflict => ReconcileOutcome::Conflict,
930 DurableSourceState::PendingCapture | DurableSourceState::Sealed => {
931 return Err(SourceError::Engine(
932 "durable source operation has an invalid terminal state".to_owned(),
933 ));
934 }
935 };
936 Ok(Some(outcome))
937}
938
939async fn replay_seal_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
940 session: &mut SourceSession<A, O>,
941 key: IdempotencyKey,
942) -> Result<Option<Generation<A, O>>, SourceError> {
943 let fact = if let Some(recovered) = find_source_operation(
944 session,
945 key,
946 SourceOperation::Seal,
947 SourceOperationStage::Terminal,
948 )
949 .await?
950 {
951 let fact = recovered.fact;
952 refresh_source_authority(session).await?;
953 fact
954 } else {
955 let Some(begin) = find_source_operation(
956 session,
957 key,
958 SourceOperation::Seal,
959 SourceOperationStage::Begin,
960 )
961 .await?
962 else {
963 if published_generation_for_key(session, key, SourceOperation::Seal)
964 .await?
965 .is_some()
966 {
967 return Err(SourceError::IdempotencyConflict);
968 }
969 return Ok(None);
970 };
971 let Some(generation_id) =
972 published_generation_for_key(session, key, SourceOperation::Seal).await?
973 else {
974 return Ok(None);
975 };
976 recover_published_operation(
977 session,
978 SourceState::Sealed,
979 generation_id,
980 begin.head,
981 key,
982 SourceOperation::Seal,
983 )
984 .await?
985 };
986 match fact.state {
987 DurableSourceState::Sealed => Ok(Some(Generation {
988 workspace: session.workspace.clone(),
989 id: fact.generation_id,
990 })),
991 DurableSourceState::Conflict => Err(SourceError::Conflict),
992 DurableSourceState::NeedsRescan(_) => Err(SourceError::Engine(
993 "source could not reach a clean sealed state".to_owned(),
994 )),
995 DurableSourceState::Clean | DurableSourceState::PendingCapture => Err(SourceError::Engine(
996 "durable seal operation has an invalid terminal state".to_owned(),
997 )),
998 }
999}
1000
1001async fn find_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1002 session: &mut SourceSession<A, O>,
1003 key: IdempotencyKey,
1004 operation: SourceOperation,
1005 stage: SourceOperationStage,
1006) -> Result<Option<RecoveredSourceOperation>, SourceError> {
1007 let authority_id = source_authority_id(session.workspace.volume.id());
1008 let recovered = session
1009 .workspace
1010 .volume
1011 .fs
1012 .authority()
1013 .find_operation(
1014 authority_id,
1015 source_stage_operation_id(key, stage),
1016 WorkBudget::UNBOUNDED,
1017 &CancellationToken::new(),
1018 )
1019 .await
1020 .map_err(engine)?
1021 .value;
1022 let Some(commit) = recovered else {
1023 return Ok(None);
1024 };
1025 let fact = decode_source_fact(&commit.payload, 4096).map_err(engine)?;
1026 if commit.fingerprint != source_operation_fingerprint(operation, &fact)? {
1027 return Err(SourceError::IdempotencyConflict);
1028 }
1029 validate_source_fact_binding(session, &fact)?;
1030 let head = Head {
1031 epoch: commit.epoch,
1032 sequence: commit.sequence,
1033 digest: commit.digest,
1034 };
1035 if head.epoch == session.authority_head.epoch
1036 && head.sequence >= session.authority_head.sequence
1037 {
1038 session.authority_head = head;
1039 session.state = source_state(fact.state);
1040 }
1041 Ok(Some(RecoveredSourceOperation { fact, head }))
1042}
1043
1044fn validate_source_fact_binding<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1045 session: &SourceSession<A, O>,
1046 fact: &SourceFact,
1047) -> Result<(), SourceError> {
1048 if fact.volume_id != session.workspace.volume.id()
1049 || fact.root_identity != session.watcher.root_identity().to_bytes()
1050 || fact.mode != durable_mode(session.mode)
1051 || fact.maximum_paths != session.capture.maximum_paths
1052 || fact.maximum_extent_spans != session.capture.maximum_extent_spans
1053 || fact.maximum_queued_changes != session.maximum_queued_changes
1054 || (fact.schema_version == 1 && session.capture_policy != CapturePolicy::allow_all())
1055 || (fact.schema_version >= 2 && fact.capture_policy != session.capture_policy.fingerprint())
1056 {
1057 return Err(SourceError::BindingMismatch);
1058 }
1059 Ok(())
1060}
1061
1062async fn published_generation_for_key<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1063 session: &mut SourceSession<A, O>,
1064 key: IdempotencyKey,
1065 operation: SourceOperation,
1066) -> Result<Option<crate::GenerationId>, SourceError> {
1067 let receipt = session
1068 .workspace
1069 .volume
1070 .fs
1071 .observe_volume_operation(
1072 session.workspace.volume.id(),
1073 source_volume_operation_id(key, operation),
1074 WorkBudget::UNBOUNDED,
1075 &CancellationToken::new(),
1076 )
1077 .await
1078 .map_err(engine)?;
1079 let Some(commit) = receipt.value else {
1080 return Ok(None);
1081 };
1082 let published = decode_published_generation(&commit.payload, 4096).map_err(engine)?;
1083 if published.volume_id != session.workspace.volume.id() {
1084 return Err(SourceError::IdempotencyConflict);
1085 }
1086 let Some(previous_sequence) = commit.sequence.get().checked_sub(1) else {
1087 return Err(SourceError::IdempotencyConflict);
1088 };
1089 let expected = Head {
1090 epoch: commit.epoch,
1091 sequence: Sequence::new(previous_sequence),
1092 digest: commit.previous_digest,
1093 };
1094 let request = PublishGenerationRequest {
1095 authority_id: volume_authority_id(session.workspace.volume.id()),
1096 volume_id: session.workspace.volume.id(),
1097 epoch: commit.epoch,
1098 expected,
1099 operation_id: source_volume_operation_id(key, operation),
1100 generation_root: published.generation_root,
1101 };
1102 if commit.fingerprint
1103 != contextual_publication_fingerprint(
1104 request,
1105 source_volume_operation_context(key, operation),
1106 )
1107 {
1108 return Err(SourceError::IdempotencyConflict);
1109 }
1110 Ok(Some(crate::GenerationId::new(
1111 published.generation_root.digest,
1112 )))
1113}
1114
1115async fn begin_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1116 session: &mut SourceSession<A, O>,
1117 key: IdempotencyKey,
1118 operation: SourceOperation,
1119) -> Result<(), SourceError> {
1120 if find_source_operation(session, key, operation, SourceOperationStage::Begin)
1121 .await?
1122 .is_some()
1123 {
1124 return Ok(());
1125 }
1126 if published_generation_for_key(session, key, operation)
1127 .await?
1128 .is_some()
1129 {
1130 return Err(SourceError::IdempotencyConflict);
1131 }
1132 let fact = source_fact(
1133 session,
1134 SourceState::PendingCapture,
1135 session.checkout.generation_id(),
1136 );
1137 let fingerprint = source_operation_fingerprint(operation, &fact)?;
1138 session.authority_head = append_source_fact_with_identity(
1139 &session.workspace,
1140 session.authority_head,
1141 fact,
1142 source_stage_operation_id(key, SourceOperationStage::Begin),
1143 fingerprint,
1144 )
1145 .await?;
1146 session.state = SourceState::PendingCapture;
1147 Ok(())
1148}
1149
1150async fn persist_session_state<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1151 session: &mut SourceSession<A, O>,
1152 state: SourceState,
1153) -> Result<(), SourceError> {
1154 let generation_id = session.checkout.generation_id();
1155 persist_source_fact(session, state, generation_id).await
1156}
1157
1158async fn persist_source_operation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1159 session: &mut SourceSession<A, O>,
1160 state: SourceState,
1161 key: IdempotencyKey,
1162 operation: SourceOperation,
1163) -> Result<SourceFact, SourceError> {
1164 let generation_id = session.checkout.generation_id();
1165 persist_source_operation_with_generation(session, state, generation_id, key, operation).await
1166}
1167
1168async fn persist_source_operation_with_generation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1169 session: &mut SourceSession<A, O>,
1170 state: SourceState,
1171 generation_id: crate::GenerationId,
1172 key: IdempotencyKey,
1173 operation: SourceOperation,
1174) -> Result<SourceFact, SourceError> {
1175 let fact = source_fact(session, state, generation_id);
1176 let fingerprint = source_operation_fingerprint(operation, &fact)?;
1177 let appended = append_source_fact_with_identity(
1178 &session.workspace,
1179 session.authority_head,
1180 fact,
1181 source_stage_operation_id(key, SourceOperationStage::Terminal),
1182 fingerprint,
1183 )
1184 .await;
1185 match appended {
1186 Ok(head) => {
1187 session.authority_head = head;
1188 session.state = state;
1189 Ok(fact)
1190 }
1191 Err(SourceError::IdempotencyConflict) => {
1192 let recovered =
1193 find_source_operation(session, key, operation, SourceOperationStage::Terminal)
1194 .await?
1195 .ok_or(SourceError::IdempotencyConflict)?;
1196 Ok(recovered.fact)
1197 }
1198 Err(error) => Err(error),
1199 }
1200}
1201
1202async fn persist_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1203 session: &mut SourceSession<A, O>,
1204 state: SourceState,
1205 generation_id: crate::GenerationId,
1206) -> Result<(), SourceError> {
1207 let fact = source_fact(session, state, generation_id);
1208 session.authority_head =
1209 append_source_fact(&session.workspace, session.authority_head, fact).await?;
1210 session.state = state;
1211 Ok(())
1212}
1213
1214fn source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1215 session: &SourceSession<A, O>,
1216 state: SourceState,
1217 generation_id: crate::GenerationId,
1218) -> SourceFact {
1219 SourceFact {
1220 schema_version: 2,
1221 volume_id: session.workspace.volume.id(),
1222 root_identity: session.watcher.root_identity().to_bytes(),
1223 mode: durable_mode(session.mode),
1224 maximum_paths: session.capture.maximum_paths,
1225 maximum_extent_spans: session.capture.maximum_extent_spans,
1226 maximum_queued_changes: session.maximum_queued_changes,
1227 capture_policy: session.capture_policy.fingerprint(),
1228 state: durable_state(state),
1229 generation_id,
1230 }
1231}
1232
1233async fn append_source_fact<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1234 workspace: &Workspace<A, O>,
1235 expected: Head,
1236 fact: SourceFact,
1237) -> Result<Head, SourceError> {
1238 let authority_id = source_authority_id(workspace.volume.id());
1239 let payload = encode_source_fact(fact).map_err(engine)?;
1240 let mut fingerprint_hasher = blake3::Hasher::new();
1241 fingerprint_hasher.update(b"acyclic-fs-source-fingerprint-v1\0");
1242 fingerprint_hasher.update(&payload);
1243 let fingerprint = Digest::from_bytes(*fingerprint_hasher.finalize().as_bytes());
1244 let mut operation_hasher = blake3::Hasher::new();
1245 operation_hasher.update(b"acyclic-fs-source-operation-v1\0");
1246 operation_hasher.update(&authority_id.into_bytes());
1247 operation_hasher.update(&expected.epoch.get().to_le_bytes());
1248 operation_hasher.update(&expected.sequence.get().to_le_bytes());
1249 operation_hasher.update(expected.digest.as_bytes());
1250 operation_hasher.update(fingerprint.as_bytes());
1251 let mut operation_bytes = [0_u8; 16];
1252 operation_bytes.copy_from_slice(&operation_hasher.finalize().as_bytes()[..16]);
1253 append_source_fact_with_identity(
1254 workspace,
1255 expected,
1256 fact,
1257 OperationId::from_bytes(operation_bytes),
1258 fingerprint,
1259 )
1260 .await
1261}
1262
1263async fn append_source_fact_with_identity<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1264 workspace: &Workspace<A, O>,
1265 expected: Head,
1266 fact: SourceFact,
1267 operation_id: OperationId,
1268 fingerprint: Digest,
1269) -> Result<Head, SourceError> {
1270 let authority_id = source_authority_id(workspace.volume.id());
1271 let payload = encode_source_fact(fact).map_err(engine)?;
1272 let outcome = workspace
1273 .volume
1274 .fs
1275 .authority()
1276 .compare_and_append(
1277 authority_id,
1278 expected.epoch,
1279 expected,
1280 ProposedCommit {
1281 operation_id,
1282 fingerprint,
1283 payload: Bytes::from(payload),
1284 },
1285 WorkBudget::UNBOUNDED,
1286 &CancellationToken::new(),
1287 )
1288 .await
1289 .map_err(engine)?
1290 .value;
1291 match outcome {
1292 AppendOutcome::Committed(commit) | AppendOutcome::AlreadyCommitted(commit) => Ok(Head {
1293 epoch: commit.epoch,
1294 sequence: commit.sequence,
1295 digest: commit.digest,
1296 }),
1297 AppendOutcome::Conflict { .. } | AppendOutcome::Fenced { .. } => {
1298 Err(SourceError::Concurrent)
1299 }
1300 AppendOutcome::IdempotencyConflict { .. } => Err(SourceError::IdempotencyConflict),
1301 }
1302}
1303
1304fn source_operation_fingerprint(
1305 operation: SourceOperation,
1306 fact: &SourceFact,
1307) -> Result<Digest, SourceError> {
1308 let mut hasher = blake3::Hasher::new();
1309 hasher.update(b"acyclic-fs-source-command-v1\0");
1310 hasher.update(&[match operation {
1311 SourceOperation::Reconcile => 1,
1312 SourceOperation::Rescan => 2,
1313 SourceOperation::Seal => 3,
1314 }]);
1315 hasher.update(&encode_source_fact(*fact).map_err(engine)?);
1316 Ok(Digest::from_bytes(*hasher.finalize().as_bytes()))
1317}
1318
1319fn source_stage_operation_id(key: IdempotencyKey, stage: SourceOperationStage) -> OperationId {
1320 let mut hasher = blake3::Hasher::new();
1321 hasher.update(b"acyclic-fs-source-stage-v1\0");
1322 hasher.update(&key.into_bytes());
1323 hasher.update(&[match stage {
1324 SourceOperationStage::Begin => 1,
1325 SourceOperationStage::Terminal => 2,
1326 }]);
1327 let mut bytes = [0_u8; 16];
1328 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1329 OperationId::from_bytes(bytes)
1330}
1331
1332fn source_volume_operation_id(key: IdempotencyKey, operation: SourceOperation) -> OperationId {
1333 let mut hasher = blake3::Hasher::new();
1334 hasher.update(b"acyclic-fs-source-volume-publication-v1\0");
1335 hasher.update(&key.into_bytes());
1336 hasher.update(&[match operation {
1337 SourceOperation::Reconcile => 1,
1338 SourceOperation::Rescan => 2,
1339 SourceOperation::Seal => 3,
1340 }]);
1341 let mut bytes = [0_u8; 16];
1342 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1343 OperationId::from_bytes(bytes)
1344}
1345
1346fn source_volume_operation_context(key: IdempotencyKey, operation: SourceOperation) -> Digest {
1347 let mut hasher = blake3::Hasher::new();
1348 hasher.update(b"acyclic-fs-source-volume-context-v1\0");
1349 hasher.update(&key.into_bytes());
1350 hasher.update(&[match operation {
1351 SourceOperation::Reconcile => 1,
1352 SourceOperation::Rescan => 2,
1353 SourceOperation::Seal => 3,
1354 }]);
1355 Digest::from_bytes(*hasher.finalize().as_bytes())
1356}
1357
1358const fn durable_mode(mode: SourceMode) -> DurableSourceMode {
1359 match mode {
1360 SourceMode::Pinned => DurableSourceMode::Pinned,
1361 SourceMode::Tracking => DurableSourceMode::Tracking,
1362 }
1363}
1364
1365const fn durable_state(state: SourceState) -> DurableSourceState {
1366 match state {
1367 SourceState::Clean => DurableSourceState::Clean,
1368 SourceState::PendingCapture => DurableSourceState::PendingCapture,
1369 SourceState::NeedsRescan(reason) => DurableSourceState::NeedsRescan(match reason {
1370 WatchInvalidationReason::InitialSnapshotRequired => {
1371 SourceInvalidation::InitialSnapshotRequired
1372 }
1373 WatchInvalidationReason::QueueOverflow => SourceInvalidation::QueueOverflow,
1374 WatchInvalidationReason::NativeRescanRequired => {
1375 SourceInvalidation::NativeRescanRequired
1376 }
1377 WatchInvalidationReason::BackendError => SourceInvalidation::BackendError,
1378 WatchInvalidationReason::UnrepresentablePath => SourceInvalidation::UnrepresentablePath,
1379 WatchInvalidationReason::AmbiguousRename => SourceInvalidation::AmbiguousRename,
1380 WatchInvalidationReason::RootChanged => SourceInvalidation::RootChanged,
1381 }),
1382 SourceState::Conflict => DurableSourceState::Conflict,
1383 SourceState::Sealed => DurableSourceState::Sealed,
1384 }
1385}
1386
1387const fn source_state(state: DurableSourceState) -> SourceState {
1388 match state {
1389 DurableSourceState::Clean => SourceState::Clean,
1390 DurableSourceState::PendingCapture => SourceState::PendingCapture,
1391 DurableSourceState::NeedsRescan(reason) => {
1392 SourceState::NeedsRescan(watch_invalidation(reason))
1393 }
1394 DurableSourceState::Conflict => SourceState::Conflict,
1395 DurableSourceState::Sealed => SourceState::Sealed,
1396 }
1397}
1398
1399const fn watch_invalidation(reason: SourceInvalidation) -> WatchInvalidationReason {
1400 match reason {
1401 SourceInvalidation::InitialSnapshotRequired => {
1402 WatchInvalidationReason::InitialSnapshotRequired
1403 }
1404 SourceInvalidation::QueueOverflow => WatchInvalidationReason::QueueOverflow,
1405 SourceInvalidation::NativeRescanRequired => WatchInvalidationReason::NativeRescanRequired,
1406 SourceInvalidation::BackendError => WatchInvalidationReason::BackendError,
1407 SourceInvalidation::UnrepresentablePath => WatchInvalidationReason::UnrepresentablePath,
1408 SourceInvalidation::AmbiguousRename => WatchInvalidationReason::AmbiguousRename,
1409 SourceInvalidation::RootChanged => WatchInvalidationReason::RootChanged,
1410 }
1411}
1412
1413fn engine(error: impl std::fmt::Display) -> SourceError {
1414 SourceError::Engine(error.to_string())
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use bytes::Bytes;
1421 use std::sync::atomic::{AtomicU8, Ordering};
1422
1423 #[derive(Clone, Copy, Eq, PartialEq)]
1424 enum AppendGatePhase {
1425 Before = 1,
1426 After = 2,
1427 }
1428
1429 struct AppendGate {
1430 target: std::sync::Mutex<Option<OperationId>>,
1431 phase: AtomicU8,
1432 arrived: tokio::sync::watch::Sender<usize>,
1433 open: tokio::sync::watch::Sender<bool>,
1434 }
1435
1436 impl AppendGate {
1437 fn new() -> Self {
1438 let (arrived, _) = tokio::sync::watch::channel(0);
1439 let (open, _) = tokio::sync::watch::channel(true);
1440 Self {
1441 target: std::sync::Mutex::new(None),
1442 phase: AtomicU8::new(0),
1443 arrived,
1444 open,
1445 }
1446 }
1447
1448 fn arm(&self, target: OperationId, phase: AppendGatePhase) {
1449 let mut configured = match self.target.lock() {
1450 Ok(configured) => configured,
1451 Err(poisoned) => poisoned.into_inner(),
1452 };
1453 *configured = Some(target);
1454 self.phase.store(phase as u8, Ordering::Release);
1455 self.arrived.send_replace(0);
1456 self.open.send_replace(false);
1457 }
1458
1459 fn matches(&self, operation_id: OperationId, phase: AppendGatePhase) -> bool {
1460 let target = match self.target.lock() {
1461 Ok(target) => target,
1462 Err(poisoned) => poisoned.into_inner(),
1463 };
1464 !*self.open.borrow()
1465 && self.phase.load(Ordering::Acquire) == phase as u8
1466 && *target == Some(operation_id)
1467 }
1468
1469 async fn wait(&self) {
1470 let mut open = self.open.subscribe();
1471 self.arrived.send_modify(|arrived| *arrived += 1);
1472 while !*open.borrow_and_update() {
1473 if open.changed().await.is_err() {
1474 return;
1475 }
1476 }
1477 }
1478
1479 async fn wait_for_arrivals(&self, expected: usize) {
1480 let mut arrived = self.arrived.subscribe();
1481 while *arrived.borrow_and_update() < expected {
1482 if arrived.changed().await.is_err() {
1483 return;
1484 }
1485 }
1486 }
1487
1488 fn release(&self) {
1489 self.open.send_replace(true);
1490 }
1491 }
1492
1493 #[derive(Clone)]
1494 struct GatedAuthorityStore {
1495 inner: Arc<crate::memory::MemoryAuthorityStore>,
1496 gate: Arc<AppendGate>,
1497 }
1498
1499 impl AsyncAuthorityStore for GatedAuthorityStore {
1500 async fn fork_generation_authority(
1501 &self,
1502 source: crate::GenerationForkSource,
1503 destination_authority: crate::foundation::AuthorityId,
1504 operation_id: crate::foundation::OperationId,
1505 budget: WorkBudget,
1506 cancellation: &CancellationToken,
1507 ) -> crate::AuthorityResult<CreateAuthorityOutcome> {
1508 self.inner
1509 .fork_generation_authority(
1510 source,
1511 destination_authority,
1512 operation_id,
1513 budget,
1514 cancellation,
1515 )
1516 .await
1517 }
1518
1519 async fn create_authority(
1520 &self,
1521 authority_id: crate::foundation::AuthorityId,
1522 genesis_epoch: Epoch,
1523 budget: WorkBudget,
1524 cancellation: &CancellationToken,
1525 ) -> crate::AuthorityResult<CreateAuthorityOutcome> {
1526 self.inner
1527 .create_authority(authority_id, genesis_epoch, budget, cancellation)
1528 .await
1529 }
1530
1531 async fn head(
1532 &self,
1533 authority_id: crate::foundation::AuthorityId,
1534 budget: WorkBudget,
1535 cancellation: &CancellationToken,
1536 ) -> crate::AuthorityResult<Head> {
1537 self.inner.head(authority_id, budget, cancellation).await
1538 }
1539
1540 async fn compare_and_append(
1541 &self,
1542 authority_id: crate::foundation::AuthorityId,
1543 epoch: Epoch,
1544 expected: Head,
1545 commit: ProposedCommit,
1546 budget: WorkBudget,
1547 cancellation: &CancellationToken,
1548 ) -> crate::AuthorityResult<AppendOutcome> {
1549 if self
1550 .gate
1551 .matches(commit.operation_id, AppendGatePhase::Before)
1552 {
1553 self.gate.wait().await;
1554 }
1555 let operation_id = commit.operation_id;
1556 let result = self
1557 .inner
1558 .compare_and_append(authority_id, epoch, expected, commit, budget, cancellation)
1559 .await;
1560 if self.gate.matches(operation_id, AppendGatePhase::After) {
1561 self.gate.wait().await;
1562 }
1563 result
1564 }
1565
1566 async fn replay(
1567 &self,
1568 authority_id: crate::foundation::AuthorityId,
1569 after: Sequence,
1570 limit: ReplayLimit,
1571 budget: WorkBudget,
1572 cancellation: &CancellationToken,
1573 ) -> crate::AuthorityResult<Vec<crate::foundation::DurableCommit>> {
1574 self.inner
1575 .replay(authority_id, after, limit, budget, cancellation)
1576 .await
1577 }
1578
1579 async fn fence(
1580 &self,
1581 authority_id: crate::foundation::AuthorityId,
1582 expected: Head,
1583 budget: WorkBudget,
1584 cancellation: &CancellationToken,
1585 ) -> crate::AuthorityResult<crate::FenceOutcome> {
1586 self.inner
1587 .fence(authority_id, expected, budget, cancellation)
1588 .await
1589 }
1590
1591 async fn find_operation(
1592 &self,
1593 authority_id: crate::foundation::AuthorityId,
1594 operation_id: OperationId,
1595 budget: WorkBudget,
1596 cancellation: &CancellationToken,
1597 ) -> crate::AuthorityResult<Option<crate::foundation::DurableCommit>> {
1598 self.inner
1599 .find_operation(authority_id, operation_id, budget, cancellation)
1600 .await
1601 }
1602 }
1603
1604 fn next_authority_append<A, O>(
1605 simulation: &crate::Simulation<A, O>,
1606 ) -> Result<u64, crate::SimulationError> {
1607 Ok(simulation
1608 .trace()?
1609 .iter()
1610 .filter(|entry| entry.operation == crate::SimulationOperation::AuthorityAppend)
1611 .map(|entry| entry.occurrence)
1612 .max()
1613 .unwrap_or(0)
1614 .saturating_add(1))
1615 }
1616
1617 fn clean_generation<A, O>(
1618 outcome: ReconcileOutcome<A, O>,
1619 ) -> Result<Generation<A, O>, &'static str> {
1620 match outcome {
1621 ReconcileOutcome::Clean(generation) => Ok(generation),
1622 ReconcileOutcome::NeedsRescan(_) | ReconcileOutcome::Conflict => {
1623 Err("source did not become clean")
1624 }
1625 }
1626 }
1627
1628 async fn publish_without_terminal<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1629 source: &Source<A, O>,
1630 key: IdempotencyKey,
1631 operation: SourceOperation,
1632 ) -> Result<crate::GenerationId, Box<dyn std::error::Error>> {
1633 let mut session = source.inner.lock().await;
1634 begin_source_operation(&mut session, key, operation).await?;
1635 session.watcher.begin_rescan()?;
1636 let capture = session.capture.clone();
1637 let policy = session.capture_policy.clone();
1638 crate::capture_baseline_with_policy(
1639 &mut session.checkout,
1640 &capture,
1641 &policy,
1642 WorkBudget::UNBOUNDED,
1643 &CancellationToken::new(),
1644 )
1645 .await?;
1646 let trailing = session.watcher.finish_rescan()?;
1647 crate::capture_watch_batch_with_policy(
1648 &mut session.checkout,
1649 trailing,
1650 &capture,
1651 &policy,
1652 WorkBudget::UNBOUNDED,
1653 &CancellationToken::new(),
1654 )
1655 .await?;
1656 match session
1657 .checkout
1658 .commit_with_context(
1659 source_volume_operation_id(key, operation),
1660 source_volume_operation_context(key, operation),
1661 WorkBudget::UNBOUNDED,
1662 &CancellationToken::new(),
1663 )
1664 .await?
1665 .value
1666 {
1667 CheckoutCommitOutcome::Committed { generation_id, .. }
1668 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => Ok(generation_id),
1669 _ => Err("source publication did not commit".into()),
1670 }
1671 }
1672
1673 #[tokio::test]
1674 async fn attached_directory_rescans_and_seals_through_one_workspace()
1675 -> Result<(), Box<dyn std::error::Error>> {
1676 let root = tempfile::tempdir()?;
1677 std::fs::write(root.path().join("before.txt"), b"before")?;
1678 let workspace =
1679 Box::pin(Fs::memory().attach_directory("repo", root.path(), SourceOptions::default()))
1680 .await?;
1681 assert_eq!(
1682 workspace.read("/before.txt", 16).await?,
1683 Bytes::from_static(b"before")
1684 );
1685 let source = workspace.source().ok_or("source handle missing")?;
1686 assert_eq!(source.state().await, SourceState::Clean);
1687
1688 std::fs::write(root.path().join("before.txt"), b"after")?;
1689 std::fs::write(root.path().join("added.txt"), b"added")?;
1690 assert!(matches!(
1691 Box::pin(source.rescan()).await?,
1692 ReconcileOutcome::Clean(_)
1693 ));
1694 assert_eq!(
1695 workspace.read("/before.txt", 16).await?,
1696 Bytes::from_static(b"after")
1697 );
1698 assert_eq!(
1699 workspace.read("/added.txt", 16).await?,
1700 Bytes::from_static(b"added")
1701 );
1702 let sealed = Box::pin(workspace.seal()).await?;
1703 assert_eq!(source.state().await, SourceState::Sealed);
1704 std::fs::remove_dir_all(root.path())?;
1705 assert_eq!(
1706 sealed.read("/before.txt", 16).await?,
1707 Bytes::from_static(b"after")
1708 );
1709 Ok(())
1710 }
1711
1712 #[tokio::test]
1713 async fn attached_directory_excludes_private_prefixes_from_all_rescans()
1714 -> Result<(), Box<dyn std::error::Error>> {
1715 let root = tempfile::tempdir()?;
1716 std::fs::create_dir(root.path().join(".git"))?;
1717 std::fs::write(root.path().join(".git").join("config"), b"private")?;
1718 std::fs::write(root.path().join("visible.txt"), b"visible")?;
1719 let options = SourceOptions {
1720 excluded_paths: vec![PortablePath::parse(
1721 "/.git",
1722 crate::model::VolumeLimits::default(),
1723 )?],
1724 ..SourceOptions::default()
1725 };
1726 let workspace =
1727 Box::pin(Fs::memory().attach_directory("filtered", root.path(), options)).await?;
1728 assert_eq!(
1729 workspace.read("/visible.txt", 16).await?,
1730 Bytes::from_static(b"visible")
1731 );
1732 assert!(workspace.read("/.git/config", 16).await.is_err());
1733
1734 std::fs::write(root.path().join(".git").join("config"), b"changed")?;
1735 let source = workspace.source().ok_or("source handle missing")?;
1736 assert!(matches!(
1737 Box::pin(source.rescan()).await?,
1738 ReconcileOutcome::Clean(_)
1739 ));
1740 assert!(workspace.read("/.git/config", 16).await.is_err());
1741 Ok(())
1742 }
1743
1744 #[cfg(feature = "local")]
1745 #[tokio::test]
1746 async fn durable_source_restarts_fail_closed_and_remains_gc_classifiable()
1747 -> Result<(), Box<dyn std::error::Error>> {
1748 let storage = tempfile::tempdir()?;
1749 let source_root = tempfile::tempdir()?;
1750 std::fs::write(source_root.path().join("durable.txt"), b"one")?;
1751 let local_options = crate::LocalOptions::new(storage.path());
1752
1753 {
1754 let fs = Fs::local(local_options.clone()).await?;
1755 let workspace = Box::pin(fs.attach_directory(
1756 "durable",
1757 source_root.path(),
1758 SourceOptions::default(),
1759 ))
1760 .await?;
1761 let source = workspace.source().ok_or("source missing")?;
1762 assert_eq!(source.state().await, SourceState::Clean);
1763 }
1764
1765 std::fs::write(source_root.path().join("durable.txt"), b"two")?;
1766 {
1767 let fs = Fs::local(local_options.clone()).await?;
1768 let workspace = Box::pin(fs.attach_directory(
1769 "durable",
1770 source_root.path(),
1771 SourceOptions::default(),
1772 ))
1773 .await?;
1774 assert_eq!(
1775 Box::pin(workspace.read("/durable.txt", 8)).await?,
1776 Bytes::from_static(b"two")
1777 );
1778 let wrong_root = tempfile::tempdir()?;
1779 assert!(matches!(
1780 Box::pin(fs.attach_directory(
1781 "durable",
1782 wrong_root.path(),
1783 SourceOptions::default()
1784 ))
1785 .await,
1786 Err(SourceError::BindingMismatch)
1787 ));
1788 }
1789
1790 Ok(())
1791 }
1792
1793 #[tokio::test]
1794 async fn keyed_source_retry_recovers_exact_outcome_and_rejects_another_kind()
1795 -> Result<(), Box<dyn std::error::Error>> {
1796 let root = tempfile::tempdir()?;
1797 std::fs::write(root.path().join("value.txt"), b"one")?;
1798 let workspace =
1799 Box::pin(Fs::memory().attach_directory("keyed", root.path(), SourceOptions::default()))
1800 .await?;
1801 let source = workspace.source().ok_or("source missing")?;
1802 std::fs::write(root.path().join("value.txt"), b"two")?;
1803 let key = IdempotencyKey::from_bytes([41; 16]);
1804 let first = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
1805 std::fs::write(root.path().join("value.txt"), b"three")?;
1806 let retried = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
1807 assert_eq!(retried.id(), first.id());
1808 assert_eq!(
1809 retried.read("/value.txt", 8).await?,
1810 Bytes::from_static(b"two")
1811 );
1812 assert!(matches!(
1813 Box::pin(source.seal_with_key(key)).await,
1814 Err(SourceError::IdempotencyConflict)
1815 ));
1816 Ok(())
1817 }
1818
1819 #[tokio::test]
1820 async fn no_op_reconcile_conflicts_after_an_independent_workspace_advance()
1821 -> Result<(), Box<dyn std::error::Error>> {
1822 let root = tempfile::tempdir()?;
1823 let workspace = Box::pin(Fs::memory().attach_directory(
1824 "no-op-stale",
1825 root.path(),
1826 SourceOptions::default(),
1827 ))
1828 .await?;
1829 let source = workspace.source().ok_or("source missing")?;
1830 let acknowledged = source.inner.lock().await.checkout.generation_id();
1831
1832 let mut unrelated = workspace
1833 .volume
1834 .checkout(
1835 GenerationSelector::Head,
1836 CheckoutMode::tracking_transaction(),
1837 WorkBudget::UNBOUNDED,
1838 &CancellationToken::new(),
1839 )
1840 .await?
1841 .value;
1842 let advanced = match unrelated
1843 .commit_with_context(
1844 IdempotencyKey::from_bytes([62; 16]).operation_id(),
1845 Digest::from_bytes([62; 32]),
1846 WorkBudget::UNBOUNDED,
1847 &CancellationToken::new(),
1848 )
1849 .await?
1850 .value
1851 {
1852 CheckoutCommitOutcome::Committed { generation_id, .. }
1853 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
1854 CheckoutCommitOutcome::Conflict { .. }
1855 | CheckoutCommitOutcome::Fenced { .. }
1856 | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
1857 return Err("independent workspace authority advance was rejected".into());
1858 }
1859 };
1860 assert_eq!(advanced, acknowledged);
1861
1862 let key = IdempotencyKey::from_bytes([63; 16]);
1863 match Box::pin(source.reconcile_with_key(key)).await? {
1864 ReconcileOutcome::Conflict => assert!(matches!(
1865 Box::pin(source.reconcile_with_key(key)).await?,
1866 ReconcileOutcome::Conflict
1867 )),
1868 ReconcileOutcome::NeedsRescan(WatchInvalidationReason::NativeRescanRequired)
1869 if cfg!(target_os = "macos") =>
1870 {
1871 assert!(matches!(
1872 Box::pin(source.reconcile_with_key(key)).await?,
1873 ReconcileOutcome::NeedsRescan(WatchInvalidationReason::NativeRescanRequired)
1874 ));
1875 assert!(matches!(
1876 Box::pin(source.rescan_with_key(IdempotencyKey::from_bytes([71; 16]))).await?,
1877 ReconcileOutcome::Conflict
1878 ));
1879 }
1880 _ => return Err("source did not preserve conflict or rescan state".into()),
1881 }
1882 assert!(matches!(
1883 Box::pin(source.reconcile_with_key(IdempotencyKey::from_bytes([72; 16]))).await?,
1884 ReconcileOutcome::Conflict
1885 ));
1886 assert!(matches!(
1887 Box::pin(source.rescan_with_key(IdempotencyKey::from_bytes([73; 16]))).await?,
1888 ReconcileOutcome::Conflict
1889 ));
1890 assert!(matches!(
1891 Box::pin(source.seal_with_key(IdempotencyKey::from_bytes([74; 16]))).await,
1892 Err(SourceError::Conflict)
1893 ));
1894 assert_eq!(source.state().await, SourceState::Conflict);
1895 assert_eq!(workspace.head().await?.id(), advanced);
1896 Ok(())
1897 }
1898
1899 #[tokio::test]
1900 async fn pending_source_capture_conflicts_after_an_authority_only_advance()
1901 -> Result<(), Box<dyn std::error::Error>> {
1902 let root = tempfile::tempdir()?;
1903 std::fs::write(root.path().join("source.txt"), b"source")?;
1904 let workspace = Box::pin(Fs::memory().attach_directory(
1905 "pending-source-authority-fence",
1906 root.path(),
1907 SourceOptions::default(),
1908 ))
1909 .await?;
1910 let source = workspace.source().ok_or("source missing")?;
1911 let key = IdempotencyKey::from_bytes([77; 16]);
1912 {
1913 let mut session = source.inner.lock().await;
1914 begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
1915 let limits = session.checkout.volume_config().limits;
1916 let portable = crate::path::PortablePath::parse("/source.txt", limits)?;
1917 let path = crate::kernel::NamespacePath::from_portable(&portable, limits)?;
1918 session
1919 .checkout
1920 .write_file(
1921 path,
1922 0,
1923 Bytes::from_static(b"captured"),
1924 WorkBudget::UNBOUNDED,
1925 &CancellationToken::new(),
1926 )
1927 .await?;
1928 assert!(session.checkout.has_pending_mutations());
1929 }
1930
1931 let mut unrelated = workspace
1932 .volume
1933 .checkout(
1934 GenerationSelector::Head,
1935 CheckoutMode::tracking_transaction(),
1936 WorkBudget::UNBOUNDED,
1937 &CancellationToken::new(),
1938 )
1939 .await?
1940 .value;
1941 let advanced = match unrelated
1942 .commit_with_context(
1943 IdempotencyKey::from_bytes([78; 16]).operation_id(),
1944 Digest::from_bytes([78; 32]),
1945 WorkBudget::UNBOUNDED,
1946 &CancellationToken::new(),
1947 )
1948 .await?
1949 .value
1950 {
1951 CheckoutCommitOutcome::Committed { generation_id, .. }
1952 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
1953 CheckoutCommitOutcome::Conflict { .. }
1954 | CheckoutCommitOutcome::Fenced { .. }
1955 | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
1956 return Err("independent workspace authority advance was rejected".into());
1957 }
1958 };
1959
1960 let outcome = {
1961 let mut session = source.inner.lock().await;
1962 assert_eq!(advanced, session.checkout.generation_id());
1963 publish_session(
1964 &mut session,
1965 key,
1966 SourceOperation::Reconcile,
1967 SourceState::Clean,
1968 )
1969 .await?
1970 };
1971 assert!(matches!(outcome, ReconcileOutcome::Conflict));
1972 assert!(matches!(
1973 Box::pin(source.reconcile_with_key(key)).await?,
1974 ReconcileOutcome::Conflict
1975 ));
1976 assert_eq!(source.state().await, SourceState::Conflict);
1977 assert_eq!(workspace.head().await?.id(), advanced);
1978 Ok(())
1979 }
1980
1981 #[tokio::test]
1982 async fn pending_source_capture_rebases_over_a_disjoint_generation()
1983 -> Result<(), Box<dyn std::error::Error>> {
1984 let root = tempfile::tempdir()?;
1985 std::fs::write(root.path().join("source.txt"), b"source")?;
1986 let workspace = Box::pin(Fs::memory().attach_directory(
1987 "pending-source-disjoint-rebase",
1988 root.path(),
1989 SourceOptions::default(),
1990 ))
1991 .await?;
1992 let source = workspace.source().ok_or("source missing")?;
1993 let key = IdempotencyKey::from_bytes([79; 16]);
1994 let acknowledged = {
1995 let mut session = source.inner.lock().await;
1996 begin_source_operation(&mut session, key, SourceOperation::Reconcile).await?;
1997 let acknowledged = session.checkout.generation_id();
1998 let limits = session.checkout.volume_config().limits;
1999 let portable = crate::path::PortablePath::parse("/source.txt", limits)?;
2000 let path = crate::kernel::NamespacePath::from_portable(&portable, limits)?;
2001 session
2002 .checkout
2003 .write_file(
2004 path,
2005 0,
2006 Bytes::from_static(b"captured"),
2007 WorkBudget::UNBOUNDED,
2008 &CancellationToken::new(),
2009 )
2010 .await?;
2011 assert!(session.checkout.has_pending_mutations());
2012 acknowledged
2013 };
2014
2015 let mut unrelated = workspace
2016 .begin_transaction(IdempotencyKey::from_bytes([80; 16]))
2017 .await?;
2018 unrelated
2019 .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2020 .await?;
2021 let advanced = match unrelated.commit().await? {
2022 crate::TransactionCommit::Committed(generation)
2023 | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2024 crate::TransactionCommit::Conflict { .. }
2025 | crate::TransactionCommit::Fenced
2026 | crate::TransactionCommit::IdempotencyConflict => {
2027 return Err("disjoint workspace advance was rejected".into());
2028 }
2029 };
2030 assert_ne!(advanced.id(), acknowledged);
2031
2032 let reconciled = {
2033 let mut session = source.inner.lock().await;
2034 clean_generation(
2035 publish_session(
2036 &mut session,
2037 key,
2038 SourceOperation::Reconcile,
2039 SourceState::Clean,
2040 )
2041 .await?,
2042 )?
2043 };
2044 assert_eq!(
2045 reconciled.read("/source.txt", 16).await?,
2046 Bytes::from_static(b"captured")
2047 );
2048 assert_eq!(
2049 reconciled.read("/unrelated.txt", 16).await?,
2050 Bytes::from_static(b"unrelated")
2051 );
2052 assert_eq!(
2053 clean_generation(Box::pin(source.reconcile_with_key(key)).await?)?.id(),
2054 reconciled.id()
2055 );
2056 assert_eq!(source.state().await, SourceState::Clean);
2057 Ok(())
2058 }
2059
2060 #[tokio::test]
2061 async fn no_op_reconcile_conflicts_when_an_advance_wins_before_its_fence()
2062 -> Result<(), Box<dyn std::error::Error>> {
2063 let root = tempfile::tempdir()?;
2064 let gate = Arc::new(AppendGate::new());
2065 let authority = GatedAuthorityStore {
2066 inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2067 gate: Arc::clone(&gate),
2068 };
2069 let fs = Fs::new(
2070 authority,
2071 Arc::new(crate::memory::MemoryObjectStore::default()),
2072 crate::EmbeddedCapabilities::MEMORY,
2073 );
2074 let workspace = Box::pin(fs.attach_directory(
2075 "no-op-before-fence",
2076 root.path(),
2077 SourceOptions::default(),
2078 ))
2079 .await?;
2080 let source_key = IdempotencyKey::from_bytes([66; 16]);
2081 let acknowledged = workspace
2082 .source()
2083 .ok_or("source missing")?
2084 .inner
2085 .lock()
2086 .await
2087 .checkout
2088 .generation_id();
2089 gate.arm(
2090 source_volume_operation_id(source_key, SourceOperation::Reconcile),
2091 AppendGatePhase::Before,
2092 );
2093 let source = workspace.source().ok_or("source missing")?.clone();
2094 let operation = tokio::spawn(async move { source.reconcile_with_key(source_key).await });
2095 gate.wait_for_arrivals(1).await;
2096
2097 let mut unrelated = workspace
2098 .begin_transaction(IdempotencyKey::from_bytes([67; 16]))
2099 .await?;
2100 unrelated
2101 .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2102 .await?;
2103 let advanced = match unrelated.commit().await? {
2104 crate::TransactionCommit::Committed(generation)
2105 | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2106 crate::TransactionCommit::Conflict { .. }
2107 | crate::TransactionCommit::Fenced
2108 | crate::TransactionCommit::IdempotencyConflict => {
2109 return Err("concurrent workspace advance was rejected".into());
2110 }
2111 };
2112 assert_ne!(advanced.id(), acknowledged);
2113 gate.release();
2114
2115 assert!(matches!(operation.await??, ReconcileOutcome::Conflict));
2116 let source = workspace.source().ok_or("source missing")?;
2117 assert!(matches!(
2118 Box::pin(source.reconcile_with_key(source_key)).await?,
2119 ReconcileOutcome::Conflict
2120 ));
2121 assert_eq!(source.state().await, SourceState::Conflict);
2122 assert_eq!(workspace.head().await?.id(), advanced.id());
2123 Ok(())
2124 }
2125
2126 #[tokio::test]
2127 async fn no_op_reconcile_linearizes_before_an_advance_between_fence_and_terminal()
2128 -> Result<(), Box<dyn std::error::Error>> {
2129 let root = tempfile::tempdir()?;
2130 let gate = Arc::new(AppendGate::new());
2131 let authority = GatedAuthorityStore {
2132 inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2133 gate: Arc::clone(&gate),
2134 };
2135 let fs = Fs::new(
2136 authority,
2137 Arc::new(crate::memory::MemoryObjectStore::default()),
2138 crate::EmbeddedCapabilities::MEMORY,
2139 );
2140 let workspace = Box::pin(fs.attach_directory(
2141 "no-op-concurrent",
2142 root.path(),
2143 SourceOptions::default(),
2144 ))
2145 .await?;
2146 let source_key = IdempotencyKey::from_bytes([64; 16]);
2147 let acknowledged = workspace
2148 .source()
2149 .ok_or("source missing")?
2150 .inner
2151 .lock()
2152 .await
2153 .checkout
2154 .generation_id();
2155 gate.arm(
2156 source_stage_operation_id(source_key, SourceOperationStage::Terminal),
2157 AppendGatePhase::Before,
2158 );
2159 let source = workspace.source().ok_or("source missing")?.clone();
2160 let operation = tokio::spawn(async move { source.reconcile_with_key(source_key).await });
2161 gate.wait_for_arrivals(1).await;
2162
2163 let mut unrelated = workspace
2164 .begin_transaction(IdempotencyKey::from_bytes([65; 16]))
2165 .await?;
2166 unrelated
2167 .write("/unrelated.txt", Bytes::from_static(b"unrelated"))
2168 .await?;
2169 let advanced = match unrelated.commit().await? {
2170 crate::TransactionCommit::Committed(generation)
2171 | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2172 crate::TransactionCommit::Conflict { .. }
2173 | crate::TransactionCommit::Fenced
2174 | crate::TransactionCommit::IdempotencyConflict => {
2175 return Err("concurrent workspace advance was rejected".into());
2176 }
2177 };
2178 assert_ne!(advanced.id(), acknowledged);
2179 gate.release();
2180
2181 let reconciled = clean_generation(operation.await??)?;
2182 assert_eq!(reconciled.id(), acknowledged);
2183 let source = workspace.source().ok_or("source missing")?;
2184 assert_eq!(
2185 clean_generation(Box::pin(source.reconcile_with_key(source_key)).await?)?.id(),
2186 acknowledged
2187 );
2188 assert_eq!(source.state().await, SourceState::Clean);
2189 assert_eq!(workspace.head().await?.id(), advanced.id());
2190 Ok(())
2191 }
2192
2193 #[tokio::test]
2194 async fn published_retry_preserves_a_later_fail_closed_state()
2195 -> Result<(), Box<dyn std::error::Error>> {
2196 let root = tempfile::tempdir()?;
2197 std::fs::write(root.path().join("source.txt"), b"source")?;
2198 let workspace = Box::pin(Fs::memory().attach_directory(
2199 "published-retry-terminal-race",
2200 root.path(),
2201 SourceOptions::default(),
2202 ))
2203 .await?;
2204 let source = workspace.source().ok_or("source missing")?;
2205 let published_key = IdempotencyKey::from_bytes([68; 16]);
2206 let other_key = IdempotencyKey::from_bytes([69; 16]);
2207
2208 let (published_generation, published_begin_head, other_begin, other_terminal) = {
2209 let mut session = source.inner.lock().await;
2210 begin_source_operation(&mut session, published_key, SourceOperation::Reconcile).await?;
2211 let published_begin_head = session.authority_head;
2212 let publication = session
2213 .checkout
2214 .commit_with_context(
2215 source_volume_operation_id(published_key, SourceOperation::Reconcile),
2216 source_volume_operation_context(published_key, SourceOperation::Reconcile),
2217 WorkBudget::UNBOUNDED,
2218 &CancellationToken::new(),
2219 )
2220 .await?
2221 .value;
2222 let published_generation = match publication {
2223 CheckoutCommitOutcome::Committed { generation_id, .. }
2224 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
2225 CheckoutCommitOutcome::Conflict { .. }
2226 | CheckoutCommitOutcome::Fenced { .. }
2227 | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
2228 return Err("source volume publication was rejected".into());
2229 }
2230 };
2231 (
2232 published_generation,
2233 published_begin_head,
2234 source_fact(&session, SourceState::PendingCapture, published_generation),
2235 source_fact(
2236 &session,
2237 SourceState::NeedsRescan(WatchInvalidationReason::BackendError),
2238 published_generation,
2239 ),
2240 )
2241 };
2242
2243 let other_begin_head = append_source_fact_with_identity(
2244 &workspace,
2245 published_begin_head,
2246 other_begin,
2247 source_stage_operation_id(other_key, SourceOperationStage::Begin),
2248 source_operation_fingerprint(SourceOperation::Reconcile, &other_begin)?,
2249 )
2250 .await?;
2251 let other_terminal_head = append_source_fact_with_identity(
2252 &workspace,
2253 other_begin_head,
2254 other_terminal,
2255 source_stage_operation_id(other_key, SourceOperationStage::Terminal),
2256 source_operation_fingerprint(SourceOperation::Reconcile, &other_terminal)?,
2257 )
2258 .await?;
2259
2260 assert!(matches!(
2261 Box::pin(source.reconcile_with_key(published_key)).await?,
2262 ReconcileOutcome::Conflict
2263 ));
2264 assert!(matches!(
2265 Box::pin(source.reconcile_with_key(published_key)).await?,
2266 ReconcileOutcome::Conflict
2267 ));
2268 let mut session = source.inner.lock().await;
2269 let recovered = find_source_operation(
2270 &mut session,
2271 published_key,
2272 SourceOperation::Reconcile,
2273 SourceOperationStage::Terminal,
2274 )
2275 .await?
2276 .ok_or("recovered terminal missing")?;
2277 let recovered_head = session.authority_head;
2278 assert_eq!(recovered.fact.generation_id, published_generation);
2279 assert_eq!(recovered.fact.state, DurableSourceState::Conflict);
2280 assert!(recovered_head.sequence > other_terminal_head.sequence);
2281 assert_eq!(session.state, SourceState::Conflict);
2282 Ok(())
2283 }
2284
2285 #[tokio::test]
2286 async fn published_retry_conflicts_after_another_source_generation()
2287 -> Result<(), Box<dyn std::error::Error>> {
2288 let root = tempfile::tempdir()?;
2289 std::fs::write(root.path().join("source.txt"), b"one")?;
2290 let fs = Fs::memory();
2291 let first_workspace = Box::pin(fs.attach_directory(
2292 "published-retry-generation-race",
2293 root.path(),
2294 SourceOptions::default(),
2295 ))
2296 .await?;
2297 let first = first_workspace.source().ok_or("first source missing")?;
2298 let published_key = IdempotencyKey::from_bytes([75; 16]);
2299 std::fs::write(root.path().join("source.txt"), b"two")?;
2300 let published_generation =
2301 publish_without_terminal(first, published_key, SourceOperation::Rescan).await?;
2302
2303 std::fs::write(root.path().join("source.txt"), b"three")?;
2304 let second_workspace = Box::pin(fs.attach_directory(
2305 "published-retry-generation-race",
2306 root.path(),
2307 SourceOptions::default(),
2308 ))
2309 .await?;
2310 let second = second_workspace.source().ok_or("second source missing")?;
2311 let advanced = second_workspace.head().await?;
2312 assert_ne!(advanced.id(), published_generation);
2313 assert_eq!(second.state().await, SourceState::Clean);
2314
2315 assert!(matches!(
2316 Box::pin(first.rescan_with_key(published_key)).await?,
2317 ReconcileOutcome::Conflict
2318 ));
2319 {
2320 let mut session = first.inner.lock().await;
2321 let recovered = find_source_operation(
2322 &mut session,
2323 published_key,
2324 SourceOperation::Rescan,
2325 SourceOperationStage::Terminal,
2326 )
2327 .await?
2328 .ok_or("conflict terminal missing")?;
2329 assert_eq!(recovered.fact.generation_id, published_generation);
2330 assert_eq!(recovered.fact.state, DurableSourceState::Conflict);
2331 assert_eq!(session.state, SourceState::Conflict);
2332 }
2333 assert_eq!(first.state().await, SourceState::Conflict);
2334 assert!(matches!(
2335 Box::pin(first.seal_with_key(IdempotencyKey::from_bytes([76; 16]))).await,
2336 Err(SourceError::Conflict)
2337 ));
2338 Ok(())
2339 }
2340
2341 #[tokio::test]
2342 async fn terminal_retry_refreshes_a_later_source_state()
2343 -> Result<(), Box<dyn std::error::Error>> {
2344 let root = tempfile::tempdir()?;
2345 std::fs::write(root.path().join("source.txt"), b"source")?;
2346 let workspace = Box::pin(Fs::memory().attach_directory(
2347 "terminal-retry-refresh",
2348 root.path(),
2349 SourceOptions::default(),
2350 ))
2351 .await?;
2352 let source = workspace.source().ok_or("source missing")?;
2353 let completed_key = IdempotencyKey::from_bytes([70; 16]);
2354 let later_key = IdempotencyKey::from_bytes([71; 16]);
2355
2356 let (completed_generation, completed_terminal_head, later_begin, later_terminal) = {
2357 let mut session = source.inner.lock().await;
2358 begin_source_operation(&mut session, completed_key, SourceOperation::Reconcile).await?;
2359 let publication = session
2360 .checkout
2361 .commit_with_context(
2362 source_volume_operation_id(completed_key, SourceOperation::Reconcile),
2363 source_volume_operation_context(completed_key, SourceOperation::Reconcile),
2364 WorkBudget::UNBOUNDED,
2365 &CancellationToken::new(),
2366 )
2367 .await?
2368 .value;
2369 let completed_generation = match publication {
2370 CheckoutCommitOutcome::Committed { generation_id, .. }
2371 | CheckoutCommitOutcome::AlreadyCommitted { generation_id, .. } => generation_id,
2372 CheckoutCommitOutcome::Conflict { .. }
2373 | CheckoutCommitOutcome::Fenced { .. }
2374 | CheckoutCommitOutcome::IdempotencyConflict { .. } => {
2375 return Err("source volume publication was rejected".into());
2376 }
2377 };
2378 persist_source_operation_with_generation(
2379 &mut session,
2380 SourceState::Clean,
2381 completed_generation,
2382 completed_key,
2383 SourceOperation::Reconcile,
2384 )
2385 .await?;
2386 (
2387 completed_generation,
2388 session.authority_head,
2389 source_fact(&session, SourceState::PendingCapture, completed_generation),
2390 source_fact(&session, SourceState::Conflict, completed_generation),
2391 )
2392 };
2393
2394 let later_begin_head = append_source_fact_with_identity(
2395 &workspace,
2396 completed_terminal_head,
2397 later_begin,
2398 source_stage_operation_id(later_key, SourceOperationStage::Begin),
2399 source_operation_fingerprint(SourceOperation::Reconcile, &later_begin)?,
2400 )
2401 .await?;
2402 let later_terminal_head = append_source_fact_with_identity(
2403 &workspace,
2404 later_begin_head,
2405 later_terminal,
2406 source_stage_operation_id(later_key, SourceOperationStage::Terminal),
2407 source_operation_fingerprint(SourceOperation::Reconcile, &later_terminal)?,
2408 )
2409 .await?;
2410
2411 assert_eq!(
2412 clean_generation(Box::pin(source.reconcile_with_key(completed_key)).await?)?.id(),
2413 completed_generation
2414 );
2415 let session = source.inner.lock().await;
2416 assert_eq!(session.authority_head, later_terminal_head);
2417 assert_eq!(session.state, SourceState::Conflict);
2418 Ok(())
2419 }
2420
2421 #[tokio::test]
2422 async fn two_source_sessions_replay_one_canonical_terminal_outcome()
2423 -> Result<(), Box<dyn std::error::Error>> {
2424 let root = tempfile::tempdir()?;
2425 std::fs::write(root.path().join("value.txt"), b"one")?;
2426 let gate = Arc::new(AppendGate::new());
2427 let authority = GatedAuthorityStore {
2428 inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2429 gate: Arc::clone(&gate),
2430 };
2431 let fs = Fs::new(
2432 authority,
2433 Arc::new(crate::memory::MemoryObjectStore::default()),
2434 crate::EmbeddedCapabilities::MEMORY,
2435 );
2436 let first_workspace =
2437 Box::pin(fs.attach_directory("two-sessions", root.path(), SourceOptions::default()))
2438 .await?;
2439 let second_workspace =
2440 Box::pin(fs.attach_directory("two-sessions", root.path(), SourceOptions::default()))
2441 .await?;
2442 let first = first_workspace.source().ok_or("first source missing")?;
2443 let second = second_workspace.source().ok_or("second source missing")?;
2444 let key = IdempotencyKey::from_bytes([52; 16]);
2445 {
2446 let mut session = second.inner.lock().await;
2447 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2448 }
2449 {
2450 let mut session = first.inner.lock().await;
2451 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2452 }
2453 gate.arm(
2454 source_stage_operation_id(key, SourceOperationStage::Terminal),
2455 AppendGatePhase::Before,
2456 );
2457 let first = first.clone();
2458 let first_terminal = tokio::spawn(async move {
2459 let mut session = first.inner.lock().await;
2460 let generation_id = session.checkout.generation_id();
2461 persist_source_operation_with_generation(
2462 &mut session,
2463 SourceState::Clean,
2464 generation_id,
2465 key,
2466 SourceOperation::Rescan,
2467 )
2468 .await
2469 });
2470 let second = second.clone();
2471 let second_terminal = tokio::spawn(async move {
2472 let mut session = second.inner.lock().await;
2473 let generation_id = session.checkout.generation_id();
2474 persist_source_operation_with_generation(
2475 &mut session,
2476 SourceState::Conflict,
2477 generation_id,
2478 key,
2479 SourceOperation::Rescan,
2480 )
2481 .await
2482 });
2483 gate.wait_for_arrivals(2).await;
2484 gate.release();
2485 let first_fact = first_terminal.await??;
2486 let second_fact = second_terminal.await??;
2487 assert_eq!(first_fact, second_fact);
2488 let first_session = first_workspace
2489 .source()
2490 .ok_or("first source missing")?
2491 .inner
2492 .lock()
2493 .await;
2494 let second_session = second_workspace
2495 .source()
2496 .ok_or("second source missing")?
2497 .inner
2498 .lock()
2499 .await;
2500 assert_eq!(first_session.authority_head, second_session.authority_head);
2501 assert_eq!(first_session.state, second_session.state);
2502 assert_eq!(first_session.state, source_state(first_fact.state));
2503 Ok(())
2504 }
2505
2506 #[tokio::test]
2507 async fn dropped_source_future_recovers_after_each_durable_append_boundary()
2508 -> Result<(), Box<dyn std::error::Error>> {
2509 {
2510 let root = tempfile::tempdir()?;
2511 std::fs::write(root.path().join("value.txt"), b"one")?;
2512 let gate = Arc::new(AppendGate::new());
2513 let authority = GatedAuthorityStore {
2514 inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2515 gate: Arc::clone(&gate),
2516 };
2517 let fs = Fs::new(
2518 authority.clone(),
2519 Arc::new(crate::memory::MemoryObjectStore::default()),
2520 crate::EmbeddedCapabilities::MEMORY,
2521 );
2522 let workspace = Box::pin(fs.attach_directory(
2523 "drop-after-begin",
2524 root.path(),
2525 SourceOptions::default(),
2526 ))
2527 .await?;
2528 let key = IdempotencyKey::from_bytes([59; 16]);
2529 gate.arm(
2530 source_stage_operation_id(key, SourceOperationStage::Begin),
2531 AppendGatePhase::After,
2532 );
2533 std::fs::write(root.path().join("value.txt"), b"two")?;
2534 let source = workspace.source().ok_or("source missing")?.clone();
2535 let operation = tokio::spawn(async move { source.rescan_with_key(key).await });
2536 gate.wait_for_arrivals(1).await;
2537 operation.abort();
2538 assert!(operation.await.is_err_and(|error| error.is_cancelled()));
2539 gate.release();
2540
2541 let source = workspace.source().ok_or("source missing")?;
2542 let recovered = clean_generation(Box::pin(source.rescan_with_key(key)).await?)?;
2543 assert_eq!(
2544 recovered.read("/value.txt", 8).await?,
2545 Bytes::from_static(b"two")
2546 );
2547 std::fs::write(root.path().join("value.txt"), b"three")?;
2548 let current = clean_generation(Box::pin(source.rescan()).await?)?;
2549 let current_head = workspace.head().await?;
2550 assert_eq!(current_head.id(), current.id());
2551 assert_eq!(
2552 current.read("/value.txt", 8).await?,
2553 Bytes::from_static(b"three")
2554 );
2555 assert_eq!(
2556 clean_generation(Box::pin(source.rescan_with_key(key)).await?)?.id(),
2557 recovered.id()
2558 );
2559 assert_eq!(workspace.head().await?.id(), current.id());
2560 assert_eq!(source.state().await, SourceState::Clean);
2561 let session = source.inner.lock().await;
2562 let durable_head = authority
2563 .inner
2564 .head(
2565 source_authority_id(workspace.volume.id()),
2566 WorkBudget::UNBOUNDED,
2567 &CancellationToken::new(),
2568 )
2569 .await?
2570 .value;
2571 assert_eq!(session.authority_head, durable_head);
2572 }
2573
2574 for terminal_append in [false, true] {
2575 let root = tempfile::tempdir()?;
2576 std::fs::write(root.path().join("value.txt"), b"one")?;
2577 let gate = Arc::new(AppendGate::new());
2578 let authority = GatedAuthorityStore {
2579 inner: Arc::new(crate::memory::MemoryAuthorityStore::default()),
2580 gate: Arc::clone(&gate),
2581 };
2582 let objects = Arc::new(crate::memory::MemoryObjectStore::default());
2583 let name = if terminal_append {
2584 "drop-after-terminal"
2585 } else {
2586 "drop-after-volume"
2587 };
2588 let fs = Fs::new(
2589 authority.clone(),
2590 Arc::clone(&objects),
2591 crate::EmbeddedCapabilities::MEMORY,
2592 );
2593 let workspace =
2594 Box::pin(fs.attach_directory(name, root.path(), SourceOptions::default())).await?;
2595 let key = IdempotencyKey::from_bytes([60 + u8::from(terminal_append); 16]);
2596 let target = if terminal_append {
2597 source_stage_operation_id(key, SourceOperationStage::Terminal)
2598 } else {
2599 source_volume_operation_id(key, SourceOperation::Rescan)
2600 };
2601 gate.arm(target, AppendGatePhase::After);
2602 std::fs::write(root.path().join("value.txt"), b"two")?;
2603 let source = workspace.source().ok_or("source missing")?.clone();
2604 let operation = tokio::spawn(async move { source.rescan_with_key(key).await });
2605 gate.wait_for_arrivals(1).await;
2606 operation.abort();
2607 assert!(operation.await.is_err_and(|error| error.is_cancelled()));
2608 gate.release();
2609
2610 std::fs::write(root.path().join("value.txt"), b"three")?;
2611 drop(workspace);
2612 drop(fs);
2613 let fs = Fs::new(
2614 authority.clone(),
2615 objects,
2616 crate::EmbeddedCapabilities::MEMORY,
2617 );
2618 let workspace =
2619 Box::pin(fs.attach_directory(name, root.path(), SourceOptions::default())).await?;
2620 let source = workspace.source().ok_or("source missing")?;
2621 let current = workspace.head().await?;
2622 assert_eq!(
2623 current.read("/value.txt", 8).await?,
2624 Bytes::from_static(b"three")
2625 );
2626 let outcome = Box::pin(source.rescan_with_key(key)).await?;
2627 if terminal_append {
2628 assert_eq!(
2629 clean_generation(outcome)?.read("/value.txt", 8).await?,
2630 Bytes::from_static(b"two")
2631 );
2632 assert_eq!(source.state().await, SourceState::Clean);
2633 } else {
2634 assert!(matches!(outcome, ReconcileOutcome::Conflict));
2635 assert_eq!(source.state().await, SourceState::Conflict);
2636 }
2637 assert_eq!(workspace.head().await?.id(), current.id());
2638 let session = source.inner.lock().await;
2639 let durable_head = authority
2640 .inner
2641 .head(
2642 source_authority_id(workspace.volume.id()),
2643 WorkBudget::UNBOUNDED,
2644 &CancellationToken::new(),
2645 )
2646 .await?
2647 .value;
2648 assert_eq!(session.authority_head, durable_head);
2649 }
2650 Ok(())
2651 }
2652
2653 #[tokio::test]
2654 async fn source_volume_identity_rejects_a_deliberately_aliased_generic_operation()
2655 -> Result<(), Box<dyn std::error::Error>> {
2656 let root = tempfile::tempdir()?;
2657 std::fs::write(root.path().join("source.txt"), b"one")?;
2658 let fs = Fs::memory();
2659 let workspace = Box::pin(fs.attach_directory(
2660 "domain-separated",
2661 root.path(),
2662 SourceOptions::default(),
2663 ))
2664 .await?;
2665 let source = workspace.source().ok_or("source missing")?;
2666 let key = IdempotencyKey::from_bytes([42; 16]);
2667 {
2668 let mut session = source.inner.lock().await;
2669 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2670 }
2671 let forged_key = IdempotencyKey::from_bytes(
2672 source_volume_operation_id(key, SourceOperation::Rescan).into_bytes(),
2673 );
2674 let mut unrelated = workspace.begin_transaction(forged_key).await?;
2675 unrelated
2676 .write("/unrelated.txt", Bytes::from_static(b"generic"))
2677 .await?;
2678 let unrelated = match unrelated.commit().await? {
2679 crate::TransactionCommit::Committed(generation)
2680 | crate::TransactionCommit::AlreadyCommitted(generation) => generation,
2681 crate::TransactionCommit::Conflict { .. }
2682 | crate::TransactionCommit::Fenced
2683 | crate::TransactionCommit::IdempotencyConflict => {
2684 return Err("unexpected generic transaction rejection".into());
2685 }
2686 };
2687 std::fs::write(root.path().join("source.txt"), b"two")?;
2688 assert!(matches!(
2689 Box::pin(source.rescan_with_key(key)).await,
2690 Err(SourceError::IdempotencyConflict)
2691 ));
2692 assert_eq!(workspace.head().await?.id(), unrelated.id());
2693 assert_eq!(source.state().await, SourceState::PendingCapture);
2694 Ok(())
2695 }
2696
2697 #[tokio::test]
2698 async fn ambiguous_volume_and_terminal_appends_recover_the_exact_source_generation()
2699 -> Result<(), Box<dyn std::error::Error>> {
2700 for terminal_fault in [false, true] {
2701 let simulation = crate::Simulation::default();
2702 let (authority, objects) = simulation.stores();
2703 let fs = Fs::new(
2704 authority.clone(),
2705 objects.clone(),
2706 crate::EmbeddedCapabilities::MEMORY,
2707 );
2708 let root = tempfile::tempdir()?;
2709 std::fs::write(root.path().join("value.txt"), b"one")?;
2710 let workspace = Box::pin(fs.attach_directory(
2711 if terminal_fault {
2712 "ambiguous-terminal"
2713 } else {
2714 "ambiguous-volume"
2715 },
2716 root.path(),
2717 SourceOptions::default(),
2718 ))
2719 .await?;
2720 let key = IdempotencyKey::from_bytes([50 + u8::from(terminal_fault); 16]);
2721 {
2722 let source = workspace.source().ok_or("source missing")?;
2723 let mut session = source.inner.lock().await;
2724 begin_source_operation(&mut session, key, SourceOperation::Rescan).await?;
2725 }
2726 std::fs::write(root.path().join("value.txt"), b"two")?;
2727 simulation.schedule(crate::ScheduledSimulationFault {
2728 operation: crate::SimulationOperation::AuthorityAppend,
2729 occurrence: next_authority_append(&simulation)? + u64::from(terminal_fault),
2730 fault: crate::SimulationFault::AmbiguousAuthorityAppend,
2731 })?;
2732 {
2733 let source = workspace.source().ok_or("source missing")?;
2734 assert!(matches!(
2735 Box::pin(source.rescan_with_key(key)).await,
2736 Err(SourceError::Engine(_))
2737 ));
2738 }
2739 std::fs::write(root.path().join("value.txt"), b"three")?;
2740 drop(workspace);
2741 drop(fs);
2742 let fs = Fs::new(authority, objects, crate::EmbeddedCapabilities::MEMORY);
2743 let workspace = Box::pin(fs.attach_directory(
2744 if terminal_fault {
2745 "ambiguous-terminal"
2746 } else {
2747 "ambiguous-volume"
2748 },
2749 root.path(),
2750 SourceOptions::default(),
2751 ))
2752 .await?;
2753 let source = workspace.source().ok_or("source missing")?;
2754 let current = workspace.head().await?;
2755 assert_eq!(
2756 current.read("/value.txt", 8).await?,
2757 Bytes::from_static(b"three")
2758 );
2759 let outcome = Box::pin(source.rescan_with_key(key)).await?;
2760 if terminal_fault {
2761 assert_eq!(
2762 clean_generation(outcome)?.read("/value.txt", 8).await?,
2763 Bytes::from_static(b"two")
2764 );
2765 assert_eq!(source.state().await, SourceState::Clean);
2766 } else {
2767 assert!(matches!(outcome, ReconcileOutcome::Conflict));
2768 assert_eq!(source.state().await, SourceState::Conflict);
2769 }
2770 assert_eq!(workspace.head().await?.id(), current.id());
2771 }
2772 Ok(())
2773 }
2774
2775 #[cfg(feature = "local")]
2776 #[tokio::test]
2777 async fn restart_recovers_publication_missing_its_terminal_source_record()
2778 -> Result<(), Box<dyn std::error::Error>> {
2779 let storage = tempfile::tempdir()?;
2780 let root = tempfile::tempdir()?;
2781 std::fs::write(root.path().join("value.txt"), b"one")?;
2782 let options = crate::LocalOptions::new(storage.path());
2783 let key = IdempotencyKey::from_bytes([43; 16]);
2784 {
2785 let fs = Fs::local(options.clone()).await?;
2786 let workspace =
2787 Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2788 .await?;
2789 let source = workspace.source().ok_or("source missing")?;
2790 std::fs::write(root.path().join("value.txt"), b"two")?;
2791 publish_without_terminal(source, key, SourceOperation::Rescan).await?;
2792 }
2793
2794 std::fs::write(root.path().join("value.txt"), b"three")?;
2795 {
2796 let fs = Fs::local(options.clone()).await?;
2797 let workspace =
2798 Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2799 .await?;
2800 let source = workspace.source().ok_or("source missing")?;
2801 let current = workspace.head().await?;
2802 assert_eq!(
2803 current.read("/value.txt", 8).await?,
2804 Bytes::from_static(b"three")
2805 );
2806 assert!(matches!(
2807 Box::pin(source.rescan_with_key(key)).await?,
2808 ReconcileOutcome::Conflict
2809 ));
2810 assert_eq!(workspace.head().await?.id(), current.id());
2811 assert_eq!(source.state().await, SourceState::Conflict);
2812 }
2813
2814 let seal_key = IdempotencyKey::from_bytes([44; 16]);
2815 {
2816 let fs = Fs::local(options.clone()).await?;
2817 let workspace =
2818 Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2819 .await?;
2820 let source = workspace.source().ok_or("source missing")?;
2821 std::fs::write(root.path().join("value.txt"), b"four")?;
2822 publish_without_terminal(source, seal_key, SourceOperation::Seal).await?;
2823 }
2824
2825 std::fs::write(root.path().join("value.txt"), b"five")?;
2826 {
2827 let fs = Fs::local(options.clone()).await?;
2828 let workspace =
2829 Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default()))
2830 .await?;
2831 let source = workspace.source().ok_or("source missing")?;
2832 let current = workspace.head().await?;
2833 assert!(matches!(
2834 Box::pin(source.seal_with_key(seal_key)).await,
2835 Err(SourceError::Conflict)
2836 ));
2837 assert_eq!(workspace.head().await?.id(), current.id());
2838 assert_eq!(source.state().await, SourceState::Conflict);
2839 }
2840
2841 let fs = Fs::local(options).await?;
2842 let workspace =
2843 Box::pin(fs.attach_directory("recover", root.path(), SourceOptions::default())).await?;
2844 let source = workspace.source().ok_or("source missing")?;
2845 assert_eq!(
2846 workspace.read("/value.txt", 8).await?,
2847 Bytes::from_static(b"five")
2848 );
2849 assert!(matches!(
2850 Box::pin(source.rescan_with_key(key)).await?,
2851 ReconcileOutcome::Conflict
2852 ));
2853 assert!(matches!(
2854 Box::pin(source.seal_with_key(seal_key)).await,
2855 Err(SourceError::Conflict)
2856 ));
2857 assert_eq!(
2858 workspace.read("/value.txt", 8).await?,
2859 Bytes::from_static(b"five")
2860 );
2861 assert_eq!(source.state().await, SourceState::Clean);
2862 Ok(())
2863 }
2864}