1use crate::workspace_context::{
10 WorkspaceContextChildren, context_children, discard_context_subtree,
11 plan_context_subtree_discard, update_context_children,
12};
13use crate::{
14 GitCompatState, GitCompatStore, LazyOverlay, LazyOverlayId, LazyWorkspaceState,
15 LazyWorkspaceStore, MaterializationJournal, MaterializationJournalStore, MultiRootPublication,
16 MultiRootPublicationStore, OperationId, WorkspaceContext, WorkspaceContextDiscardOutcome,
17 WorkspaceContextId, WorkspaceContextStore, WorkspaceId, WorkspaceLineageRecord,
18 WorkspaceLineageStore,
19};
20use fs2::FileExt;
21use serde::{Deserialize, Serialize, de::DeserializeOwned};
22use std::collections::{BTreeMap, BTreeSet};
23use std::fs::{File, OpenOptions};
24use std::io::{Read, Write};
25use std::path::{Path, PathBuf};
26use std::time::{Duration, Instant};
27use thiserror::Error;
28
29const RECORD_LOCK_DEADLINE: Duration = Duration::from_secs(5);
30const RECORD_LOCK_RETRY: Duration = Duration::from_millis(5);
31const GIT_COMPAT_NAMESPACE: &str = "git-compat-v9";
32
33async fn run_local_transaction<T: Send + 'static>(
37 operation: impl FnOnce() -> Result<T, LocalCoreStateStoreError> + Send + 'static,
38) -> Result<T, LocalCoreStateStoreError> {
39 acyclic_native_runtime::run_blocking_io(operation).await?
40}
41
42#[derive(Clone, Debug)]
44pub struct LocalCoreStateStore {
45 root: PathBuf,
46}
47
48#[derive(Clone, Debug, Serialize, Deserialize)]
49struct MultiRootParentClaim {
50 revision: u64,
51 operation_id: OperationId,
52}
53
54impl LocalCoreStateStore {
55 #[must_use]
59 pub fn new(root: impl Into<PathBuf>) -> Self {
60 Self { root: root.into() }
61 }
62
63 #[must_use]
65 pub fn root(&self) -> &Path {
66 &self.root
67 }
68
69 pub(crate) fn materialization_operations(
74 &self,
75 ) -> Result<Vec<OperationId>, LocalCoreStateStoreError> {
76 self.operation_records("materialization")
77 }
78
79 pub async fn materialization_operations_async(
81 &self,
82 ) -> Result<Vec<OperationId>, LocalCoreStateStoreError> {
83 let store = self.clone();
84 run_local_transaction(move || store.materialization_operations()).await
85 }
86
87 pub(crate) fn multi_root_publication_operations(
89 &self,
90 ) -> Result<Vec<OperationId>, LocalCoreStateStoreError> {
91 self.operation_records("multi-root-publications")
92 }
93
94 fn operation_records(
95 &self,
96 family: &str,
97 ) -> Result<Vec<OperationId>, LocalCoreStateStoreError> {
98 let directory = self.root.join(family);
99 let entries = match std::fs::read_dir(&directory) {
100 Ok(entries) => entries,
101 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
102 Err(error) => return Err(error.into()),
103 };
104 let mut operations = std::collections::BTreeSet::new();
105 for entry in entries {
106 let entry = entry?;
107 let name = entry.file_name();
108 let Some(name) = name.to_str() else { continue };
109 let Some(stem) = name.strip_suffix(".json") else {
110 continue;
111 };
112 let stem = stem.strip_suffix(".previous").unwrap_or(stem);
113 if stem.ends_with(".next") {
114 continue;
115 }
116 let Ok(bytes) = hex::decode(stem) else {
117 continue;
118 };
119 let Ok(bytes) = <[u8; 16]>::try_from(bytes) else {
120 continue;
121 };
122 operations.insert(OperationId::from_bytes(bytes));
123 }
124 Ok(operations.into_iter().collect())
125 }
126
127 pub(crate) fn remove_materialization(
129 &self,
130 operation_id: OperationId,
131 ) -> Result<(), LocalCoreStateStoreError> {
132 let paths = RecordPaths::new_key(&self.root, "materialization", &operation_id.into_bytes());
133 with_lock(&paths, || {
134 let journal: Option<MaterializationJournal> = read_recoverable(&paths)?;
135 if journal.as_ref().is_some_and(|journal| {
136 !matches!(
137 journal.phase,
138 crate::MaterializationPhase::Applied | crate::MaterializationPhase::RolledBack
139 )
140 }) {
141 return Err(LocalCoreStateStoreError::MaterializationInProgress);
142 }
143 remove_if_present(&paths.current)?;
144 remove_if_present(&paths.previous)?;
145 remove_if_present(&paths.temporary)?;
146 sync_directory(&paths.directory)?;
147 Ok(())
148 })
149 }
150
151 pub async fn remove_materialization_async(
153 &self,
154 operation_id: OperationId,
155 ) -> Result<(), LocalCoreStateStoreError> {
156 let store = self.clone();
157 run_local_transaction(move || store.remove_materialization(operation_id)).await
158 }
159
160 fn load_record<T: DeserializeOwned>(
161 &self,
162 family: &str,
163 workspace_id: WorkspaceId,
164 ) -> Result<Option<T>, LocalCoreStateStoreError> {
165 let paths = RecordPaths::new(&self.root, family, workspace_id);
166 with_lock(&paths, || read_recoverable(&paths))
167 }
168
169 fn compare_and_swap_record<T: DeserializeOwned + Serialize>(
170 &self,
171 family: &str,
172 workspace_id: WorkspaceId,
173 expected_revision: u64,
174 replacement: &T,
175 revision: impl Fn(&T) -> u64,
176 ) -> Result<bool, LocalCoreStateStoreError> {
177 let paths = RecordPaths::new(&self.root, family, workspace_id);
178 with_lock(&paths, || {
179 let current: Option<T> = read_recoverable(&paths)?;
180 if current.as_ref().map_or(0, &revision) != expected_revision {
181 return Ok(false);
182 }
183 write_journaled(&paths, replacement)?;
184 Ok(true)
185 })
186 }
187
188 fn compare_and_delete_record<T: DeserializeOwned>(
189 &self,
190 family: &str,
191 workspace_id: WorkspaceId,
192 expected_revision: u64,
193 revision: impl Fn(&T) -> u64,
194 ) -> Result<bool, LocalCoreStateStoreError> {
195 let paths = RecordPaths::new(&self.root, family, workspace_id);
196 with_lock(&paths, || {
197 let current: Option<T> = read_recoverable(&paths)?;
198 if current.as_ref().map_or(0, &revision) != expected_revision {
199 return Ok(false);
200 }
201 remove_if_present(&paths.temporary)?;
202 remove_if_present(&paths.previous)?;
203 remove_if_present(&paths.current)?;
204 sync_directory(&paths.directory)?;
205 Ok(true)
206 })
207 }
208
209 fn load_operation_record<T: DeserializeOwned>(
210 &self,
211 family: &str,
212 operation_id: OperationId,
213 ) -> Result<Option<T>, LocalCoreStateStoreError> {
214 let paths = RecordPaths::new_key(&self.root, family, &operation_id.into_bytes());
215 with_lock(&paths, || read_recoverable(&paths))
216 }
217
218 fn compare_and_swap_operation_record<T: DeserializeOwned + Serialize>(
219 &self,
220 family: &str,
221 operation_id: OperationId,
222 expected_revision: u64,
223 replacement: &T,
224 revision: impl Fn(&T) -> u64,
225 ) -> Result<bool, LocalCoreStateStoreError> {
226 let paths = RecordPaths::new_key(&self.root, family, &operation_id.into_bytes());
227 with_lock(&paths, || {
228 let current: Option<T> = read_recoverable(&paths)?;
229 if current.as_ref().map_or(0, &revision) != expected_revision {
230 return Ok(false);
231 }
232 write_journaled(&paths, replacement)?;
233 Ok(true)
234 })
235 }
236
237 fn compare_and_delete_operation_record<T: DeserializeOwned>(
238 &self,
239 family: &str,
240 operation_id: OperationId,
241 expected_revision: u64,
242 revision: impl Fn(&T) -> u64,
243 ) -> Result<bool, LocalCoreStateStoreError> {
244 let paths = RecordPaths::new_key(&self.root, family, &operation_id.into_bytes());
245 with_lock(&paths, || {
246 let current: Option<T> = read_recoverable(&paths)?;
247 if current.as_ref().map_or(0, &revision) != expected_revision {
248 return Ok(false);
249 }
250 remove_if_present(&paths.temporary)?;
251 remove_if_present(&paths.previous)?;
252 remove_if_present(&paths.current)?;
253 sync_directory(&paths.directory)?;
254 Ok(true)
255 })
256 }
257}
258
259#[derive(Debug)]
260struct RecordPaths {
261 directory: PathBuf,
262 current: PathBuf,
263 previous: PathBuf,
264 temporary: PathBuf,
265 lock: PathBuf,
266}
267
268impl RecordPaths {
269 fn new(root: &Path, family: &str, workspace_id: WorkspaceId) -> Self {
270 Self::new_key(root, family, &workspace_id.into_bytes())
271 }
272
273 fn new_key(root: &Path, family: &str, key: &[u8]) -> Self {
274 let directory = root.join(family);
275 let stem = hex::encode(key);
276 Self {
277 current: directory.join(format!("{stem}.json")),
278 previous: directory.join(format!("{stem}.previous.json")),
279 temporary: directory.join(format!("{stem}.next.json")),
280 lock: directory.join(format!("{stem}.lock")),
281 directory,
282 }
283 }
284}
285
286fn with_lock<T>(
287 paths: &RecordPaths,
288 action: impl FnOnce() -> Result<T, LocalCoreStateStoreError>,
289) -> Result<T, LocalCoreStateStoreError> {
290 std::fs::create_dir_all(&paths.directory)?;
291 let lock = OpenOptions::new()
292 .create(true)
293 .truncate(false)
294 .read(true)
295 .write(true)
296 .open(&paths.lock)?;
297 let deadline = Instant::now() + RECORD_LOCK_DEADLINE;
298 loop {
299 match lock.try_lock_exclusive() {
300 Ok(()) => break,
301 Err(error) if acyclic_native_runtime::is_exclusive_lock_contention(&error) => {
302 if Instant::now() >= deadline {
303 return Err(LocalCoreStateStoreError::LockTimeout(paths.lock.clone()));
304 }
305 std::thread::sleep(RECORD_LOCK_RETRY);
306 }
307 Err(error) => return Err(error.into()),
308 }
309 }
310 let result = action();
311 FileExt::unlock(&lock)?;
312 result
313}
314
315fn read_recoverable<T: DeserializeOwned>(
316 paths: &RecordPaths,
317) -> Result<Option<T>, LocalCoreStateStoreError> {
318 match read_json(&paths.current) {
319 Ok(Some(value)) => Ok(Some(value)),
320 Ok(None) => match read_json(&paths.previous) {
321 Ok(Some(value)) => {
322 promote_previous(paths)?;
323 Ok(Some(value))
324 }
325 other => other,
326 },
327 Err(current_error) => match read_json(&paths.previous) {
328 Ok(Some(value)) => {
329 promote_previous(paths)?;
330 Ok(Some(value))
331 }
332 _ => Err(current_error),
333 },
334 }
335}
336
337fn promote_previous(paths: &RecordPaths) -> Result<(), LocalCoreStateStoreError> {
338 std::fs::copy(&paths.previous, &paths.current)?;
339 OpenOptions::new()
340 .write(true)
341 .open(&paths.current)?
342 .sync_all()?;
343 sync_directory(&paths.directory)?;
344 Ok(())
345}
346
347fn read_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, LocalCoreStateStoreError> {
348 let mut file = match File::open(path) {
349 Ok(file) => file,
350 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
351 Err(error) => return Err(error.into()),
352 };
353 let mut bytes = Vec::new();
354 file.read_to_end(&mut bytes)?;
355 serde_json::from_slice(&bytes)
356 .map(Some)
357 .map_err(LocalCoreStateStoreError::Json)
358}
359
360fn read_json_bounded<T: DeserializeOwned>(
361 path: &Path,
362 maximum_bytes: u64,
363) -> Result<Option<T>, LocalCoreStateStoreError> {
364 let file = match File::open(path) {
365 Ok(file) => file,
366 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
367 Err(error) => return Err(error.into()),
368 };
369 if file.metadata()?.len() > maximum_bytes {
370 return Err(LocalCoreStateStoreError::ContextTransaction(
371 "workspace-context child bucket exceeds its declared bound".to_owned(),
372 ));
373 }
374 let mut bytes = Vec::new();
375 file.take(maximum_bytes.saturating_add(1))
376 .read_to_end(&mut bytes)?;
377 let length = u64::try_from(bytes.len()).map_err(|_| {
378 LocalCoreStateStoreError::ContextTransaction(
379 "workspace-context child bucket length is not representable".to_owned(),
380 )
381 })?;
382 if length > maximum_bytes {
383 return Err(LocalCoreStateStoreError::ContextTransaction(
384 "workspace-context child bucket grew beyond its declared bound".to_owned(),
385 ));
386 }
387 serde_json::from_slice(&bytes)
388 .map(Some)
389 .map_err(LocalCoreStateStoreError::Json)
390}
391
392fn read_recoverable_bounded<T: DeserializeOwned>(
393 paths: &RecordPaths,
394 maximum_bytes: u64,
395) -> Result<Option<T>, LocalCoreStateStoreError> {
396 match read_json_bounded(&paths.current, maximum_bytes) {
397 Ok(Some(value)) => Ok(Some(value)),
398 Ok(None) => match read_json_bounded(&paths.previous, maximum_bytes) {
399 Ok(Some(value)) => {
400 promote_previous(paths)?;
401 Ok(Some(value))
402 }
403 other => other,
404 },
405 Err(current_error) => match read_json_bounded(&paths.previous, maximum_bytes) {
406 Ok(Some(value)) => {
407 promote_previous(paths)?;
408 Ok(Some(value))
409 }
410 _ => Err(current_error),
411 },
412 }
413}
414
415fn write_journaled<T: Serialize>(
416 paths: &RecordPaths,
417 value: &T,
418) -> Result<(), LocalCoreStateStoreError> {
419 let bytes = serde_json::to_vec(value)?;
420 let mut temporary = OpenOptions::new()
421 .create(true)
422 .truncate(true)
423 .write(true)
424 .open(&paths.temporary)?;
425 temporary.write_all(&bytes)?;
426 temporary.sync_all()?;
427 drop(temporary);
428
429 #[cfg(windows)]
430 {
431 remove_if_present(&paths.previous)?;
437 if paths.current.exists() {
438 std::fs::copy(&paths.current, &paths.previous)?;
439 } else {
440 std::fs::copy(&paths.temporary, &paths.previous)?;
441 }
442 OpenOptions::new()
443 .write(true)
444 .open(&paths.previous)?
445 .sync_all()?;
446 std::fs::copy(&paths.temporary, &paths.current)?;
447 OpenOptions::new()
448 .write(true)
449 .open(&paths.current)?
450 .sync_all()?;
451 remove_if_present(&paths.temporary)?;
452 remove_if_present(&paths.previous)?;
453 Ok(())
454 }
455
456 #[cfg(not(windows))]
457 {
458 remove_if_present(&paths.previous)?;
459 if paths.current.exists() {
460 std::fs::rename(&paths.current, &paths.previous)?;
461 }
462 if let Err(error) = std::fs::rename(&paths.temporary, &paths.current) {
463 if paths.previous.exists() && !paths.current.exists() {
464 let _ = std::fs::rename(&paths.previous, &paths.current);
465 }
466 return Err(error.into());
467 }
468 sync_directory(&paths.directory)?;
469 remove_if_present(&paths.previous)?;
470 Ok(())
471 }
472}
473
474fn remove_if_present(path: &Path) -> Result<(), std::io::Error> {
475 match std::fs::remove_file(path) {
476 Ok(()) => Ok(()),
477 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
478 Err(error) => Err(error),
479 }
480}
481
482fn sync_directory(path: &Path) -> Result<(), std::io::Error> {
483 #[cfg(unix)]
484 {
485 File::open(path)?.sync_all()
486 }
487 #[cfg(not(unix))]
488 {
489 let _ = path;
490 Ok(())
491 }
492}
493
494#[derive(Debug, Error)]
496pub enum LocalCoreStateStoreError {
497 #[error("core state I/O failed: {0}")]
499 Io(#[from] std::io::Error),
500 #[error("core state record lock timed out: {}", .0.display())]
502 LockTimeout(PathBuf),
503 #[error("core state serialization failed: {0}")]
505 Json(#[from] serde_json::Error),
506 #[error("materialization is still in progress")]
508 MaterializationInProgress,
509 #[error("core state content address does not match its payload")]
511 Integrity,
512 #[error("workspace-context transaction is incompatible: {0}")]
514 ContextTransaction(String),
515}
516
517impl WorkspaceLineageStore for LocalCoreStateStore {
518 type Error = LocalCoreStateStoreError;
519
520 async fn load(
521 &self,
522 workspace_id: WorkspaceId,
523 ) -> Result<Option<WorkspaceLineageRecord>, Self::Error> {
524 let store = self.clone();
525 run_local_transaction(move || store.load_record("lineage", workspace_id)).await
526 }
527
528 async fn compare_and_swap(
529 &self,
530 workspace_id: WorkspaceId,
531 expected_revision: u64,
532 replacement: WorkspaceLineageRecord,
533 ) -> Result<bool, Self::Error> {
534 let store = self.clone();
535 run_local_transaction(move || {
536 store.compare_and_swap_record(
537 "lineage",
538 workspace_id,
539 expected_revision,
540 &replacement,
541 |record| record.revision,
542 )
543 })
544 .await
545 }
546}
547
548impl WorkspaceContextStore for LocalCoreStateStore {
549 type Error = LocalCoreStateStoreError;
550
551 async fn load(
552 &self,
553 context_id: WorkspaceContextId,
554 ) -> Result<Option<WorkspaceContext>, Self::Error> {
555 let root = self.root.clone();
556 run_local_transaction(move || {
557 let transaction = context_transaction_paths(&root);
558 with_lock(&transaction, || {
559 recover_context_transaction(&root, &transaction)?;
560 read_recoverable(&context_record_paths(&root, context_id))
561 })
562 })
563 .await
564 }
565
566 async fn list(&self) -> Result<Vec<WorkspaceContext>, Self::Error> {
567 let root = self.root.clone();
568 run_local_transaction(move || {
569 let transaction = context_transaction_paths(&root);
570 with_lock(&transaction, || {
571 recover_context_transaction(&root, &transaction)?;
572 let contexts = load_all_contexts(&root)?;
573 if !context_children_index_ready(&root)? {
574 install_context_children_index(&root, contexts.iter().cloned())?;
575 }
576 Ok(contexts)
577 })
578 })
579 .await
580 }
581
582 async fn compare_and_swap(
583 &self,
584 context_id: WorkspaceContextId,
585 expected_revision: u64,
586 replacement: WorkspaceContext,
587 ) -> Result<bool, Self::Error> {
588 self.compare_and_swap_many(
589 BTreeMap::from([(context_id, expected_revision)]),
590 vec![replacement],
591 false,
592 )
593 .await
594 }
595
596 async fn compare_and_swap_many(
597 &self,
598 expected_revisions: BTreeMap<WorkspaceContextId, u64>,
599 replacements: Vec<WorkspaceContext>,
600 require_exact_set: bool,
601 ) -> Result<bool, Self::Error> {
602 let root = self.root.clone();
603 run_local_transaction(move || {
604 let transaction = context_transaction_paths(&root);
605 with_lock(&transaction, || {
606 recover_context_transaction(&root, &transaction)?;
607 if require_exact_set && context_record_ids(&root)?.len() != expected_revisions.len()
608 {
609 return Ok(false);
610 }
611 let mut before = BTreeMap::new();
612 for (context_id, expected) in &expected_revisions {
613 let current: Option<WorkspaceContext> =
614 read_recoverable(&context_record_paths(&root, *context_id))?;
615 if current.as_ref().map_or(0, |record| record.revision) != *expected {
616 return Ok(false);
617 }
618 before.insert(*context_id, current);
619 }
620 let after = replacements
621 .into_iter()
622 .map(|replacement| (replacement.context_id, replacement))
623 .collect::<BTreeMap<_, _>>();
624 if after
625 .keys()
626 .any(|context_id| !expected_revisions.contains_key(context_id))
627 {
628 return Ok(false);
629 }
630 ensure_context_children_index(&root)?;
631 let affected_parents = before
632 .values()
633 .filter_map(|record| record.as_ref()?.parent_context_id)
634 .chain(after.values().filter_map(|record| record.parent_context_id))
635 .collect::<BTreeSet<_>>();
636 let mut before_children = BTreeMap::new();
637 let mut after_children = WorkspaceContextChildren::new();
638 for parent in affected_parents {
639 let current = read_recoverable(&context_children_paths(&root, parent))?;
640 after_children.insert(parent, current.clone().unwrap_or_default());
641 before_children.insert(parent, current);
642 }
643 for (context_id, replacement) in &after {
644 update_context_children(
645 &mut after_children,
646 before.get(context_id).and_then(Option::as_ref),
647 Some(replacement),
648 );
649 }
650 let journal = ContextTransaction {
651 version: 2,
652 phase: ContextTransactionPhase::Prepared,
653 before: before
654 .into_iter()
655 .filter(|(context_id, _)| after.contains_key(context_id))
656 .collect(),
657 after,
658 before_children,
659 after_children,
660 };
661 write_journaled(&transaction, &journal)?;
662 apply_context_transaction(&root, &journal)?;
663 write_journaled(
664 &transaction,
665 &ContextTransaction {
666 phase: ContextTransactionPhase::Committed,
667 ..journal
668 },
669 )?;
670 recover_context_transaction(&root, &transaction)?;
671 Ok(true)
672 })
673 })
674 .await
675 }
676
677 async fn discard_subtree(
678 &self,
679 parent: WorkspaceContextId,
680 child: WorkspaceContextId,
681 maximum: u32,
682 ) -> Result<WorkspaceContextDiscardOutcome, Self::Error> {
683 let root_path = self.root.clone();
684 run_local_transaction(move || {
685 let transaction = context_transaction_paths(&root_path);
686 with_lock(&transaction, || {
687 recover_context_transaction(&root_path, &transaction)?;
688 if !context_children_index_ready(&root_path)? {
689 return Ok(WorkspaceContextDiscardOutcome::IncompatibleState);
690 }
691 let root: Option<WorkspaceContext> =
692 read_recoverable(&context_record_paths(&root_path, child))?;
693 let discarded = match plan_local_context_subtree_discard(
694 &root_path,
695 root.as_ref(),
696 parent,
697 child,
698 maximum,
699 )? {
700 Ok(discarded) => discarded,
701 Err(outcome) => return Ok(outcome),
702 };
703 let mut records = BTreeMap::from_iter(root.map(|record| (child, record)));
704 for context_id in discarded.iter().copied().filter(|id| *id != child) {
705 let Some(record) =
706 read_recoverable(&context_record_paths(&root_path, context_id))?
707 else {
708 return Ok(WorkspaceContextDiscardOutcome::IncompatibleState);
709 };
710 records.insert(context_id, record);
711 }
712 let selected_children = context_children(records.values().cloned());
713 let outcome = discard_context_subtree(
714 &mut records,
715 &selected_children,
716 parent,
717 child,
718 maximum,
719 );
720 if !matches!(
721 &outcome,
722 WorkspaceContextDiscardOutcome::Discarded(actual) if actual == &discarded
723 ) {
724 return Ok(match outcome {
725 WorkspaceContextDiscardOutcome::Discarded(_) => {
726 WorkspaceContextDiscardOutcome::IncompatibleState
727 }
728 outcome => outcome,
729 });
730 }
731 let WorkspaceContextDiscardOutcome::Discarded(discarded) = &outcome else {
732 return Ok(outcome);
733 };
734 let mut before = BTreeMap::new();
735 let mut after = BTreeMap::new();
736 for context_id in discarded {
737 let current: Option<WorkspaceContext> =
738 read_recoverable(&context_record_paths(&root_path, *context_id))?;
739 before.insert(*context_id, current);
740 let replacement = records.get(context_id).cloned().ok_or_else(|| {
741 LocalCoreStateStoreError::ContextTransaction(
742 "discarded context replacement is absent".to_owned(),
743 )
744 })?;
745 after.insert(*context_id, replacement);
746 }
747 let journal = ContextTransaction {
748 version: 1,
749 phase: ContextTransactionPhase::Prepared,
750 before,
751 after,
752 before_children: BTreeMap::new(),
753 after_children: WorkspaceContextChildren::new(),
754 };
755 write_journaled(&transaction, &journal)?;
756 apply_context_records(&root_path, &journal.after)?;
757 write_journaled(
758 &transaction,
759 &ContextTransaction {
760 phase: ContextTransactionPhase::Committed,
761 ..journal
762 },
763 )?;
764 recover_context_transaction(&root_path, &transaction)?;
765 Ok(outcome)
766 })
767 })
768 .await
769 }
770}
771
772const WORKSPACE_CONTEXT_FAMILY: &str = "workspace-contexts-v2";
773
774#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
775enum ContextTransactionPhase {
776 Prepared,
777 Committed,
778}
779
780#[derive(Clone, Debug, Serialize, Deserialize)]
781struct ContextTransaction {
782 version: u16,
783 phase: ContextTransactionPhase,
784 before: BTreeMap<WorkspaceContextId, Option<WorkspaceContext>>,
785 after: BTreeMap<WorkspaceContextId, WorkspaceContext>,
786 #[serde(default)]
787 before_children: BTreeMap<WorkspaceContextId, Option<BTreeSet<WorkspaceContextId>>>,
788 #[serde(default)]
789 after_children: WorkspaceContextChildren,
790}
791
792fn context_transaction_paths(root: &Path) -> RecordPaths {
793 RecordPaths::new_key(root, "workspace-context-transactions-v2", &[0; 16])
794}
795
796fn context_record_paths(root: &Path, context_id: WorkspaceContextId) -> RecordPaths {
797 RecordPaths::new_key(root, WORKSPACE_CONTEXT_FAMILY, &context_id.into_bytes())
798}
799
800fn context_children_paths(root: &Path, parent: WorkspaceContextId) -> RecordPaths {
801 RecordPaths::new_key(root, "workspace-context-children-v1", &parent.into_bytes())
802}
803
804fn context_children_marker_paths(root: &Path) -> RecordPaths {
805 RecordPaths::new_key(root, "workspace-context-children-index-v1", &[0; 16])
806}
807
808fn context_children_count_paths(root: &Path, parent: WorkspaceContextId) -> RecordPaths {
809 RecordPaths::new_key(
810 root,
811 "workspace-context-child-counts-v1",
812 &parent.into_bytes(),
813 )
814}
815
816fn context_children_index_ready(root: &Path) -> Result<bool, LocalCoreStateStoreError> {
817 Ok(read_recoverable::<u16>(&context_children_marker_paths(root))?.is_some())
818}
819
820fn write_context_children(
821 root: &Path,
822 parent: WorkspaceContextId,
823 descendants: &BTreeSet<WorkspaceContextId>,
824) -> Result<(), LocalCoreStateStoreError> {
825 let paths = context_children_paths(root, parent);
826 std::fs::create_dir_all(&paths.directory)?;
827 write_journaled(&paths, descendants)?;
828 let count = u64::try_from(descendants.len()).map_err(|_| {
829 LocalCoreStateStoreError::ContextTransaction(
830 "workspace-context child count exceeds durable representation".to_owned(),
831 )
832 })?;
833 let count_paths = context_children_count_paths(root, parent);
834 std::fs::create_dir_all(&count_paths.directory)?;
835 write_journaled(&count_paths, &count)
836}
837
838fn ensure_context_children_index(root: &Path) -> Result<(), LocalCoreStateStoreError> {
839 if context_children_index_ready(root)? {
840 return Ok(());
841 }
842 install_context_children_index(root, load_all_contexts(root)?)
843}
844
845fn install_context_children_index(
846 root: &Path,
847 records: impl IntoIterator<Item = WorkspaceContext>,
848) -> Result<(), LocalCoreStateStoreError> {
849 let marker = context_children_marker_paths(root);
850 let children = context_children(records);
851 for (parent, descendants) in children {
852 write_context_children(root, parent, &descendants)?;
853 }
854 std::fs::create_dir_all(&marker.directory)?;
855 write_journaled(&marker, &1_u16)
856}
857
858fn plan_local_context_subtree_discard(
859 root_path: &Path,
860 root: Option<&WorkspaceContext>,
861 parent: WorkspaceContextId,
862 child: WorkspaceContextId,
863 maximum: u32,
864) -> Result<Result<Vec<WorkspaceContextId>, WorkspaceContextDiscardOutcome>, LocalCoreStateStoreError>
865{
866 let mut selected = match plan_context_subtree_discard(
867 root,
868 &WorkspaceContextChildren::new(),
869 parent,
870 child,
871 maximum,
872 ) {
873 Ok(selected) => selected,
874 Err(outcome) => return Ok(Err(outcome)),
875 };
876 let maximum = maximum as usize;
877 let mut pending = vec![child];
878 let mut seen = BTreeSet::new();
879 while let Some(context_id) = pending.pop() {
880 if !seen.insert(context_id) || seen.len() > maximum {
881 return Ok(Err(WorkspaceContextDiscardOutcome::TraversalLimit));
882 }
883 let count = read_recoverable::<u64>(&context_children_count_paths(root_path, context_id))?
884 .unwrap_or(0);
885 let remaining = maximum.saturating_sub(seen.len() + pending.len());
886 if count > remaining as u64 {
887 return Ok(Err(WorkspaceContextDiscardOutcome::TraversalLimit));
888 }
889 let maximum_bucket_bytes = count
893 .checked_mul(64)
894 .and_then(|bytes| bytes.checked_add(2))
895 .ok_or_else(|| {
896 LocalCoreStateStoreError::ContextTransaction(
897 "workspace-context child bucket bound overflowed".to_owned(),
898 )
899 })?;
900 let descendants: BTreeSet<WorkspaceContextId> = read_recoverable_bounded(
901 &context_children_paths(root_path, context_id),
902 maximum_bucket_bytes,
903 )?
904 .unwrap_or_default();
905 if usize::try_from(count) != Ok(descendants.len()) {
906 return Ok(Err(WorkspaceContextDiscardOutcome::IncompatibleState));
907 }
908 pending.extend(descendants);
909 }
910 selected.clear();
911 selected.extend(seen);
912 Ok(Ok(selected))
913}
914
915fn context_record_ids(root: &Path) -> Result<Vec<WorkspaceContextId>, LocalCoreStateStoreError> {
916 let directory = root.join(WORKSPACE_CONTEXT_FAMILY);
917 let entries = match std::fs::read_dir(directory) {
918 Ok(entries) => entries,
919 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
920 Err(error) => return Err(error.into()),
921 };
922 let mut ids = Vec::new();
923 for entry in entries {
924 let name = entry?.file_name();
925 let Some(name) = name.to_str() else { continue };
926 let Some(stem) = name.strip_suffix(".json") else {
927 continue;
928 };
929 if stem.ends_with(".previous") || stem.ends_with(".next") {
930 continue;
931 }
932 let Ok(bytes) = hex::decode(stem) else {
933 continue;
934 };
935 let Ok(bytes) = <[u8; 16]>::try_from(bytes) else {
936 continue;
937 };
938 ids.push(WorkspaceContextId::from_bytes(bytes));
939 }
940 ids.sort_unstable_by_key(|id| id.into_bytes());
941 Ok(ids)
942}
943
944fn load_all_contexts(root: &Path) -> Result<Vec<WorkspaceContext>, LocalCoreStateStoreError> {
945 context_record_ids(root)?
946 .into_iter()
947 .map(|id| {
948 read_recoverable(&context_record_paths(root, id))?.ok_or_else(|| {
949 LocalCoreStateStoreError::ContextTransaction(
950 "workspace context disappeared during locked discovery".to_owned(),
951 )
952 })
953 })
954 .collect()
955}
956
957fn apply_context_records(
958 root: &Path,
959 records: &BTreeMap<WorkspaceContextId, WorkspaceContext>,
960) -> Result<(), LocalCoreStateStoreError> {
961 for (context_id, record) in records {
962 let paths = context_record_paths(root, *context_id);
963 std::fs::create_dir_all(&paths.directory)?;
964 write_journaled(&paths, record)?;
965 }
966 Ok(())
967}
968
969fn apply_context_transaction(
970 root: &Path,
971 journal: &ContextTransaction,
972) -> Result<(), LocalCoreStateStoreError> {
973 apply_context_records(root, &journal.after)?;
974 if journal.version >= 2 {
975 for (parent, descendants) in &journal.after_children {
976 write_context_children(root, *parent, descendants)?;
977 }
978 }
979 Ok(())
980}
981
982fn restore_context_transaction(
983 root: &Path,
984 journal: &ContextTransaction,
985) -> Result<(), LocalCoreStateStoreError> {
986 restore_context_records(root, &journal.before)?;
987 if journal.version >= 2 {
988 for (parent, descendants) in &journal.before_children {
989 let paths = context_children_paths(root, *parent);
990 if let Some(descendants) = descendants {
991 write_context_children(root, *parent, descendants)?;
992 } else {
993 std::fs::create_dir_all(&paths.directory)?;
994 remove_if_present(&paths.temporary)?;
995 remove_if_present(&paths.previous)?;
996 remove_if_present(&paths.current)?;
997 sync_directory(&paths.directory)?;
998 let count_paths = context_children_count_paths(root, *parent);
999 std::fs::create_dir_all(&count_paths.directory)?;
1000 remove_if_present(&count_paths.temporary)?;
1001 remove_if_present(&count_paths.previous)?;
1002 remove_if_present(&count_paths.current)?;
1003 sync_directory(&count_paths.directory)?;
1004 }
1005 }
1006 }
1007 Ok(())
1008}
1009
1010fn restore_context_records(
1011 root: &Path,
1012 records: &BTreeMap<WorkspaceContextId, Option<WorkspaceContext>>,
1013) -> Result<(), LocalCoreStateStoreError> {
1014 for (context_id, record) in records {
1015 let paths = context_record_paths(root, *context_id);
1016 std::fs::create_dir_all(&paths.directory)?;
1017 if let Some(record) = record {
1018 write_journaled(&paths, record)?;
1019 } else {
1020 remove_if_present(&paths.temporary)?;
1021 remove_if_present(&paths.previous)?;
1022 remove_if_present(&paths.current)?;
1023 sync_directory(&paths.directory)?;
1024 }
1025 }
1026 Ok(())
1027}
1028
1029fn recover_context_transaction(
1030 root: &Path,
1031 paths: &RecordPaths,
1032) -> Result<(), LocalCoreStateStoreError> {
1033 let Some(journal) = read_recoverable::<ContextTransaction>(paths)? else {
1034 return Ok(());
1035 };
1036 if !matches!(journal.version, 1 | 2) {
1037 return Err(LocalCoreStateStoreError::ContextTransaction(
1038 "unsupported workspace-context transaction version".to_owned(),
1039 ));
1040 }
1041 match journal.phase {
1042 ContextTransactionPhase::Prepared => restore_context_transaction(root, &journal)?,
1043 ContextTransactionPhase::Committed => apply_context_transaction(root, &journal)?,
1044 }
1045 remove_if_present(&paths.temporary)?;
1046 remove_if_present(&paths.previous)?;
1047 remove_if_present(&paths.current)?;
1048 sync_directory(&paths.directory)?;
1049 Ok(())
1050}
1051
1052impl GitCompatStore for LocalCoreStateStore {
1053 type Error = LocalCoreStateStoreError;
1054
1055 async fn load(&self, workspace_id: WorkspaceId) -> Result<Option<GitCompatState>, Self::Error> {
1056 let store = self.clone();
1057 run_local_transaction(move || store.load_record(GIT_COMPAT_NAMESPACE, workspace_id)).await
1058 }
1059
1060 async fn compare_and_swap(
1061 &self,
1062 workspace_id: WorkspaceId,
1063 expected_revision: u64,
1064 replacement: GitCompatState,
1065 ) -> Result<bool, Self::Error> {
1066 let store = self.clone();
1067 run_local_transaction(move || {
1068 store.compare_and_swap_record(
1069 GIT_COMPAT_NAMESPACE,
1070 workspace_id,
1071 expected_revision,
1072 &replacement,
1073 |record| record.revision,
1074 )
1075 })
1076 .await
1077 }
1078
1079 async fn compare_and_delete(
1080 &self,
1081 workspace_id: WorkspaceId,
1082 expected_revision: u64,
1083 ) -> Result<bool, Self::Error> {
1084 let store = self.clone();
1085 run_local_transaction(move || {
1086 store.compare_and_delete_record::<GitCompatState>(
1087 GIT_COMPAT_NAMESPACE,
1088 workspace_id,
1089 expected_revision,
1090 |record| record.revision,
1091 )
1092 })
1093 .await
1094 }
1095}
1096
1097impl MaterializationJournalStore for LocalCoreStateStore {
1098 type Error = LocalCoreStateStoreError;
1099
1100 async fn load(
1101 &self,
1102 operation_id: OperationId,
1103 ) -> Result<Option<MaterializationJournal>, Self::Error> {
1104 let store = self.clone();
1105 run_local_transaction(move || store.load_operation_record("materialization", operation_id))
1106 .await
1107 }
1108
1109 async fn compare_and_swap(
1110 &self,
1111 operation_id: OperationId,
1112 expected_revision: u64,
1113 replacement: MaterializationJournal,
1114 ) -> Result<bool, Self::Error> {
1115 let store = self.clone();
1116 run_local_transaction(move || {
1117 store.compare_and_swap_operation_record(
1118 "materialization",
1119 operation_id,
1120 expected_revision,
1121 &replacement,
1122 |journal| journal.revision,
1123 )
1124 })
1125 .await
1126 }
1127}
1128
1129impl MultiRootPublicationStore for LocalCoreStateStore {
1130 type Error = LocalCoreStateStoreError;
1131
1132 async fn load(
1133 &self,
1134 operation_id: OperationId,
1135 ) -> Result<Option<MultiRootPublication>, Self::Error> {
1136 let store = self.clone();
1137 run_local_transaction(move || {
1138 store.load_operation_record("multi-root-publications", operation_id)
1139 })
1140 .await
1141 }
1142
1143 async fn compare_and_swap(
1144 &self,
1145 operation_id: OperationId,
1146 expected_revision: u64,
1147 replacement: MultiRootPublication,
1148 ) -> Result<bool, Self::Error> {
1149 let store = self.clone();
1150 run_local_transaction(move || {
1151 store.compare_and_swap_operation_record(
1152 "multi-root-publications",
1153 operation_id,
1154 expected_revision,
1155 &replacement,
1156 |publication| publication.revision,
1157 )
1158 })
1159 .await
1160 }
1161
1162 async fn list_operations(&self) -> Result<Vec<OperationId>, Self::Error> {
1163 let store = self.clone();
1164 run_local_transaction(move || store.multi_root_publication_operations()).await
1165 }
1166
1167 async fn compare_and_delete(
1168 &self,
1169 operation_id: OperationId,
1170 expected_revision: u64,
1171 ) -> Result<bool, Self::Error> {
1172 let store = self.clone();
1173 run_local_transaction(move || {
1174 store.compare_and_delete_operation_record::<MultiRootPublication>(
1175 "multi-root-publications",
1176 operation_id,
1177 expected_revision,
1178 |publication| publication.revision,
1179 )
1180 })
1181 .await
1182 }
1183
1184 async fn claim_parent(
1185 &self,
1186 parent_context_id: WorkspaceContextId,
1187 operation_id: OperationId,
1188 ) -> Result<bool, Self::Error> {
1189 let store = self.clone();
1190 run_local_transaction(move || {
1191 let key = WorkspaceId::from_bytes(parent_context_id.into_bytes());
1192 for _ in 0..2 {
1193 if let Some(claim) =
1194 store.load_record::<MultiRootParentClaim>("multi-root-parent-claims", key)?
1195 {
1196 return Ok(claim.operation_id == operation_id);
1197 }
1198 if store.compare_and_swap_record(
1199 "multi-root-parent-claims",
1200 key,
1201 0,
1202 &MultiRootParentClaim {
1203 revision: 1,
1204 operation_id,
1205 },
1206 |claim| claim.revision,
1207 )? {
1208 return Ok(true);
1209 }
1210 }
1211 Ok(false)
1212 })
1213 .await
1214 }
1215
1216 async fn release_parent(
1217 &self,
1218 parent_context_id: WorkspaceContextId,
1219 operation_id: OperationId,
1220 ) -> Result<bool, Self::Error> {
1221 let store = self.clone();
1222 run_local_transaction(move || {
1223 let key = WorkspaceId::from_bytes(parent_context_id.into_bytes());
1224 let Some(claim) =
1225 store.load_record::<MultiRootParentClaim>("multi-root-parent-claims", key)?
1226 else {
1227 return Ok(true);
1228 };
1229 if claim.operation_id != operation_id {
1230 return Ok(true);
1231 }
1232 store.compare_and_delete_record::<MultiRootParentClaim>(
1233 "multi-root-parent-claims",
1234 key,
1235 claim.revision,
1236 |claim| claim.revision,
1237 )
1238 })
1239 .await
1240 }
1241}
1242
1243#[async_trait::async_trait]
1244impl LazyWorkspaceStore for LocalCoreStateStore {
1245 type Error = LocalCoreStateStoreError;
1246
1247 async fn load_lazy_workspace(
1248 &self,
1249 workspace_id: WorkspaceId,
1250 ) -> Result<Option<LazyWorkspaceState>, Self::Error> {
1251 let store = self.clone();
1252 run_local_transaction(move || store.load_record("lazy-workspaces", workspace_id)).await
1253 }
1254
1255 async fn compare_and_swap_lazy_workspace(
1256 &self,
1257 workspace_id: WorkspaceId,
1258 expected_revision: u64,
1259 replacement: LazyWorkspaceState,
1260 ) -> Result<bool, Self::Error> {
1261 let store = self.clone();
1262 run_local_transaction(move || {
1263 store.compare_and_swap_record(
1264 "lazy-workspaces",
1265 workspace_id,
1266 expected_revision,
1267 &replacement,
1268 |state| state.revision,
1269 )
1270 })
1271 .await
1272 }
1273
1274 async fn load_lazy_overlay(
1275 &self,
1276 overlay: LazyOverlayId,
1277 ) -> Result<Option<LazyOverlay>, Self::Error> {
1278 let root = self.root.clone();
1279 run_local_transaction(move || {
1280 let paths = RecordPaths::new_key(&root, "lazy-overlays", &overlay.into_bytes());
1281 with_lock(&paths, || {
1282 let Some(value) = read_recoverable(&paths)? else {
1283 return Ok(None);
1284 };
1285 let encoded = serde_json::to_vec(&value)?;
1286 if blake3::hash(&encoded).as_bytes() != &overlay.into_bytes() {
1287 return Err(LocalCoreStateStoreError::Integrity);
1288 }
1289 Ok(Some(value))
1290 })
1291 })
1292 .await
1293 }
1294
1295 async fn put_lazy_overlay(
1296 &self,
1297 overlay: LazyOverlayId,
1298 value: LazyOverlay,
1299 ) -> Result<(), Self::Error> {
1300 let root = self.root.clone();
1301 run_local_transaction(move || {
1302 let encoded = serde_json::to_vec(&value)?;
1303 if blake3::hash(&encoded).as_bytes() != &overlay.into_bytes() {
1304 return Err(LocalCoreStateStoreError::Integrity);
1305 }
1306 let paths = RecordPaths::new_key(&root, "lazy-overlays", &overlay.into_bytes());
1307 with_lock(&paths, || {
1308 if let Some(existing) = read_recoverable::<LazyOverlay>(&paths)? {
1309 return if existing == value {
1310 Ok(())
1311 } else {
1312 Err(LocalCoreStateStoreError::Integrity)
1313 };
1314 }
1315 write_journaled(&paths, &value)
1316 })
1317 })
1318 .await
1319 }
1320
1321 async fn load_lazy_shadow(
1322 &self,
1323 shadow: crate::LazyShadowId,
1324 ) -> Result<Option<crate::LazyShadow>, Self::Error> {
1325 let root = self.root.clone();
1326 run_local_transaction(move || {
1327 let paths = RecordPaths::new_key(&root, "lazy-shadows-v1", &shadow.into_bytes());
1328 with_lock(&paths, || {
1329 let Some(value) = read_recoverable(&paths)? else {
1330 return Ok(None);
1331 };
1332 let encoded = serde_json::to_vec(&value)?;
1333 if blake3::hash(&encoded).as_bytes() != &shadow.into_bytes() {
1334 return Err(LocalCoreStateStoreError::Integrity);
1335 }
1336 Ok(Some(value))
1337 })
1338 })
1339 .await
1340 }
1341
1342 async fn put_lazy_shadow(
1343 &self,
1344 shadow: crate::LazyShadowId,
1345 value: crate::LazyShadow,
1346 ) -> Result<(), Self::Error> {
1347 let root = self.root.clone();
1348 run_local_transaction(move || {
1349 let encoded = serde_json::to_vec(&value)?;
1350 if blake3::hash(&encoded).as_bytes() != &shadow.into_bytes() {
1351 return Err(LocalCoreStateStoreError::Integrity);
1352 }
1353 let paths = RecordPaths::new_key(&root, "lazy-shadows-v1", &shadow.into_bytes());
1354 with_lock(&paths, || {
1355 if let Some(existing) = read_recoverable::<crate::LazyShadow>(&paths)? {
1356 return if existing == value {
1357 Ok(())
1358 } else {
1359 Err(LocalCoreStateStoreError::Integrity)
1360 };
1361 }
1362 write_journaled(&paths, &value)
1363 })
1364 })
1365 .await
1366 }
1367}
1368
1369#[cfg(test)]
1370#[allow(clippy::expect_used)]
1371mod tests {
1372 use super::*;
1373 use crate::{
1374 Digest, WorkspaceContextRoot, WorkspaceContextState, WorkspaceName, WorkspaceRootId,
1375 };
1376
1377 #[test]
1378 fn local_transactions_work_without_a_tokio_runtime() {
1379 let directory = tempfile::tempdir().expect("local state directory");
1380 let store = LocalCoreStateStore::new(directory.path());
1381 let result = futures::executor::block_on(WorkspaceLineageStore::load(&store, workspace()));
1382 assert!(result.expect("runtime-independent transaction").is_none());
1383 }
1384
1385 #[tokio::test(flavor = "current_thread")]
1386 async fn held_record_lock_is_bounded_without_stalling_unrelated_records() {
1387 let directory = tempfile::tempdir().expect("local state directory");
1388 let store = LocalCoreStateStore::new(directory.path());
1389 let held_id = workspace();
1390 let other_id = WorkspaceId::from_bytes([0x55; 16]);
1391 let paths = RecordPaths::new(directory.path(), "lineage", held_id);
1392 std::fs::create_dir_all(&paths.directory).expect("lock directory");
1393 let held = OpenOptions::new()
1394 .create(true)
1395 .truncate(false)
1396 .read(true)
1397 .write(true)
1398 .open(&paths.lock)
1399 .expect("held record lock");
1400 held.lock_exclusive().expect("hold record lock");
1401
1402 let contender =
1403 tokio::spawn(async move { WorkspaceLineageStore::load(&store, held_id).await });
1404 let unrelated = LocalCoreStateStore::new(directory.path());
1405 tokio::time::timeout(
1406 Duration::from_secs(2),
1407 WorkspaceLineageStore::load(&unrelated, other_id),
1408 )
1409 .await
1410 .expect("unrelated record must not stall")
1411 .expect("unrelated record load");
1412 assert!(matches!(
1413 contender.await.expect("contender task"),
1414 Err(LocalCoreStateStoreError::LockTimeout(path)) if path == paths.lock
1415 ));
1416 FileExt::unlock(&held).expect("release held lock");
1417 }
1418
1419 #[tokio::test(flavor = "current_thread")]
1420 async fn admitted_local_transaction_does_not_block_executor_or_stop_on_observer_drop() {
1421 use std::sync::mpsc;
1422 use std::time::{Duration, Instant};
1423
1424 let (entered_tx, entered_rx) = mpsc::channel();
1425 let (release_tx, release_rx) = mpsc::channel();
1426 let (finished_tx, finished_rx) = mpsc::channel();
1427 let observer = tokio::spawn(async move {
1428 run_local_transaction(move || {
1429 entered_tx.send(()).expect("test observer is alive");
1430 release_rx.recv().expect("test permits completion");
1431 finished_tx
1432 .send(())
1433 .expect("test completion observer is alive");
1434 Ok(())
1435 })
1436 .await
1437 });
1438 let deadline = Instant::now() + Duration::from_secs(2);
1439 while entered_rx.try_recv().is_err() {
1440 assert!(Instant::now() < deadline, "transaction did not enter");
1441 tokio::task::yield_now().await;
1442 }
1443 tokio::task::yield_now().await;
1444 observer.abort();
1445 release_tx.send(()).expect("transaction remains admitted");
1446 while finished_rx.try_recv().is_err() {
1447 assert!(Instant::now() < deadline, "admitted transaction was lost");
1448 tokio::task::yield_now().await;
1449 }
1450 }
1451
1452 fn workspace() -> WorkspaceId {
1453 WorkspaceId::derive(
1454 [4; 16],
1455 &WorkspaceName::new("durable").expect("valid workspace"),
1456 )
1457 }
1458
1459 fn workspace_context(
1460 directory: &Path,
1461 context_id: WorkspaceContextId,
1462 revision: u64,
1463 state: WorkspaceContextState,
1464 ) -> WorkspaceContext {
1465 let root_id = WorkspaceRootId::from_bytes([9; 16]);
1466 WorkspaceContext {
1467 version: 1,
1468 revision,
1469 context_id,
1470 parent_context_id: None,
1471 roots: [(
1472 root_id,
1473 WorkspaceContextRoot {
1474 root_id,
1475 source_path: directory.to_path_buf(),
1476 workspace_id: workspace(),
1477 workspace_name: "durable".to_owned(),
1478 parent_workspace_id: None,
1479 mount_path: None,
1480 },
1481 )]
1482 .into_iter()
1483 .collect(),
1484 state,
1485 }
1486 }
1487
1488 #[tokio::test]
1489 async fn state_survives_reopen_and_recovers_previous_record() {
1490 let directory = tempfile::tempdir().expect("temporary directory");
1491 let store = LocalCoreStateStore::new(directory.path());
1492 let state = WorkspaceLineageRecord {
1493 version: 1,
1494 revision: 1,
1495 ready: true,
1496 workspace_id: workspace(),
1497 workspace_name: "durable".to_owned(),
1498 parent_workspace_id: None,
1499 parent_workspace_name: None,
1500 fork_generation: crate::GenerationId::new(Digest::from_bytes([3; 32])),
1501 initial_generation: crate::GenerationId::new(Digest::from_bytes([3; 32])),
1502 };
1503 assert!(
1504 WorkspaceLineageStore::compare_and_swap(&store, workspace(), 0, state.clone())
1505 .await
1506 .expect("initial write")
1507 );
1508 let paths = RecordPaths::new_key(directory.path(), "lineage", &workspace().into_bytes());
1509 std::fs::copy(&paths.current, &paths.previous).expect("preserve previous record");
1510 std::fs::write(&paths.current, b"torn").expect("simulate torn current record");
1511 std::fs::write(&paths.temporary, b"uncommitted").expect("simulate temporary record");
1512 let reopened = LocalCoreStateStore::new(directory.path());
1513 assert_eq!(
1514 WorkspaceLineageStore::load(&reopened, workspace())
1515 .await
1516 .expect("reopen"),
1517 Some(state)
1518 );
1519 assert!(
1520 read_json::<WorkspaceLineageRecord>(&paths.current)
1521 .expect("repaired current")
1522 .is_some()
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn lazy_overlay_load_rejects_content_address_mismatch() {
1528 let directory = tempfile::tempdir().expect("temporary directory");
1529 let store = LocalCoreStateStore::new(directory.path());
1530 let empty = LazyOverlay::default();
1531 let encoded = serde_json::to_vec(&empty).expect("serialize overlay");
1532 let overlay = LazyOverlayId::from_bytes(*blake3::hash(&encoded).as_bytes());
1533 LazyWorkspaceStore::put_lazy_overlay(&store, overlay, empty)
1534 .await
1535 .expect("persist overlay");
1536
1537 let paths = RecordPaths::new_key(directory.path(), "lazy-overlays", &overlay.into_bytes());
1538 let different = serde_json::json!({
1539 "Node": {
1540 "path": "/tampered",
1541 "priority": 0,
1542 "change": "Tombstone",
1543 "left": vec![0_u8; 32],
1544 "right": vec![0_u8; 32]
1545 }
1546 });
1547 std::fs::write(
1548 &paths.current,
1549 serde_json::to_vec(&different).expect("serialize tampered overlay"),
1550 )
1551 .expect("tamper overlay");
1552 assert!(matches!(
1553 LazyWorkspaceStore::load_lazy_overlay(&store, overlay).await,
1554 Err(LocalCoreStateStoreError::Integrity)
1555 ));
1556 }
1557
1558 #[tokio::test]
1559 async fn workspace_contexts_are_durable_and_discoverable() {
1560 let directory = tempfile::tempdir().expect("temporary directory");
1561 let store = LocalCoreStateStore::new(directory.path());
1562 let context_id = WorkspaceContextId::from_bytes([8; 16]);
1563 let root_id = WorkspaceRootId::from_bytes([9; 16]);
1564 let context = WorkspaceContext {
1565 version: 1,
1566 revision: 1,
1567 context_id,
1568 parent_context_id: None,
1569 roots: [(
1570 root_id,
1571 WorkspaceContextRoot {
1572 root_id,
1573 source_path: directory.path().to_path_buf(),
1574 workspace_id: workspace(),
1575 workspace_name: "durable".to_owned(),
1576 parent_workspace_id: None,
1577 mount_path: None,
1578 },
1579 )]
1580 .into_iter()
1581 .collect(),
1582 state: WorkspaceContextState::Active,
1583 };
1584 assert!(
1585 WorkspaceContextStore::compare_and_swap(&store, context_id, 0, context.clone())
1586 .await
1587 .expect("write context")
1588 );
1589 let reopened = LocalCoreStateStore::new(directory.path());
1590 assert_eq!(
1591 WorkspaceContextStore::list(&reopened)
1592 .await
1593 .expect("list contexts"),
1594 [context]
1595 );
1596 }
1597
1598 #[tokio::test]
1599 async fn context_discard_reads_only_the_bounded_subtree() {
1600 let directory = tempfile::tempdir().expect("temporary directory");
1601 let store = LocalCoreStateStore::new(directory.path());
1602 let parent_id = WorkspaceContextId::from_bytes([20; 16]);
1603 let child_id = WorkspaceContextId::from_bytes([21; 16]);
1604 let unrelated_id = WorkspaceContextId::from_bytes([22; 16]);
1605 let parent = workspace_context(
1606 directory.path(),
1607 parent_id,
1608 1,
1609 WorkspaceContextState::Active,
1610 );
1611 let mut child =
1612 workspace_context(directory.path(), child_id, 1, WorkspaceContextState::Active);
1613 child.parent_context_id = Some(parent_id);
1614 let unrelated = workspace_context(
1615 directory.path(),
1616 unrelated_id,
1617 1,
1618 WorkspaceContextState::Active,
1619 );
1620 for context in [parent, child, unrelated] {
1621 assert!(
1622 WorkspaceContextStore::compare_and_swap(&store, context.context_id, 0, context,)
1623 .await
1624 .expect("register context")
1625 );
1626 }
1627
1628 std::fs::write(
1629 context_record_paths(directory.path(), unrelated_id).current,
1630 b"not-json",
1631 )
1632 .expect("corrupt unrelated record");
1633
1634 assert_eq!(
1635 WorkspaceContextStore::discard_subtree(&store, parent_id, child_id, 1)
1636 .await
1637 .expect("discard bounded subtree"),
1638 WorkspaceContextDiscardOutcome::Discarded(vec![child_id])
1639 );
1640 assert_eq!(
1641 WorkspaceContextStore::load(&store, child_id)
1642 .await
1643 .expect("load discarded child")
1644 .expect("child remains durable")
1645 .state,
1646 WorkspaceContextState::Discarded
1647 );
1648 }
1649
1650 #[tokio::test]
1651 async fn context_discard_checks_child_count_before_reading_bucket() {
1652 let directory = tempfile::tempdir().expect("temporary directory");
1653 let store = LocalCoreStateStore::new(directory.path());
1654 let parent_id = WorkspaceContextId::from_bytes([25; 16]);
1655 let child_id = WorkspaceContextId::from_bytes([26; 16]);
1656 let parent = workspace_context(
1657 directory.path(),
1658 parent_id,
1659 1,
1660 WorkspaceContextState::Active,
1661 );
1662 let mut child =
1663 workspace_context(directory.path(), child_id, 1, WorkspaceContextState::Active);
1664 child.parent_context_id = Some(parent_id);
1665 for context in [parent, child] {
1666 assert!(
1667 WorkspaceContextStore::compare_and_swap(&store, context.context_id, 0, context,)
1668 .await
1669 .expect("register context")
1670 );
1671 }
1672 write_journaled(
1673 &context_children_count_paths(directory.path(), child_id),
1674 &10_000_u64,
1675 )
1676 .expect("write oversized child count");
1677 let bucket = context_children_paths(directory.path(), child_id);
1678 std::fs::create_dir_all(&bucket.directory).expect("child bucket directory");
1679 std::fs::write(&bucket.current, b"not-json").expect("corrupt oversized child bucket");
1680
1681 assert_eq!(
1682 WorkspaceContextStore::discard_subtree(&store, parent_id, child_id, 1)
1683 .await
1684 .expect("reject before child bucket read"),
1685 WorkspaceContextDiscardOutcome::TraversalLimit
1686 );
1687 }
1688
1689 #[tokio::test]
1690 async fn context_discard_bounds_bucket_bytes_before_deserialization() {
1691 let directory = tempfile::tempdir().expect("temporary directory");
1692 let store = LocalCoreStateStore::new(directory.path());
1693 let parent_id = WorkspaceContextId::from_bytes([30; 16]);
1694 let child_id = WorkspaceContextId::from_bytes([31; 16]);
1695 let parent = workspace_context(
1696 directory.path(),
1697 parent_id,
1698 1,
1699 WorkspaceContextState::Active,
1700 );
1701 let mut child =
1702 workspace_context(directory.path(), child_id, 1, WorkspaceContextState::Active);
1703 child.parent_context_id = Some(parent_id);
1704 for context in [parent, child] {
1705 assert!(
1706 WorkspaceContextStore::compare_and_swap(&store, context.context_id, 0, context,)
1707 .await
1708 .expect("register context")
1709 );
1710 }
1711 write_journaled(
1712 &context_children_count_paths(directory.path(), child_id),
1713 &1_u64,
1714 )
1715 .expect("write understated child count");
1716 let bucket = context_children_paths(directory.path(), child_id);
1717 std::fs::create_dir_all(&bucket.directory).expect("child bucket directory");
1718 std::fs::write(&bucket.current, vec![b' '; 1_000_000])
1719 .expect("write oversized child bucket");
1720
1721 assert!(matches!(
1722 WorkspaceContextStore::discard_subtree(&store, parent_id, child_id, 2).await,
1723 Err(LocalCoreStateStoreError::ContextTransaction(message))
1724 if message.contains("exceeds its declared bound")
1725 ));
1726 }
1727
1728 #[tokio::test]
1729 async fn context_discard_never_rebuilds_a_missing_index() {
1730 let directory = tempfile::tempdir().expect("temporary directory");
1731 let store = LocalCoreStateStore::new(directory.path());
1732 let parent_id = WorkspaceContextId::from_bytes([27; 16]);
1733 let child_id = WorkspaceContextId::from_bytes([28; 16]);
1734 let unrelated_id = WorkspaceContextId::from_bytes([29; 16]);
1735 let mut child =
1736 workspace_context(directory.path(), child_id, 1, WorkspaceContextState::Active);
1737 child.parent_context_id = Some(parent_id);
1738 for context in [
1739 workspace_context(
1740 directory.path(),
1741 parent_id,
1742 1,
1743 WorkspaceContextState::Active,
1744 ),
1745 child,
1746 workspace_context(
1747 directory.path(),
1748 unrelated_id,
1749 1,
1750 WorkspaceContextState::Active,
1751 ),
1752 ] {
1753 let paths = context_record_paths(directory.path(), context.context_id);
1754 std::fs::create_dir_all(&paths.directory).expect("context directory");
1755 write_journaled(&paths, &context).expect("write legacy context");
1756 }
1757 std::fs::write(
1758 context_record_paths(directory.path(), unrelated_id).current,
1759 b"not-json",
1760 )
1761 .expect("corrupt unrelated legacy record");
1762
1763 assert_eq!(
1764 WorkspaceContextStore::discard_subtree(&store, parent_id, child_id, 1)
1765 .await
1766 .expect("fail closed without rebuilding index"),
1767 WorkspaceContextDiscardOutcome::IncompatibleState
1768 );
1769 }
1770
1771 #[tokio::test]
1772 async fn prepared_context_transaction_rolls_back_partial_record_application() {
1773 let directory = tempfile::tempdir().expect("temporary directory");
1774 let store = LocalCoreStateStore::new(directory.path());
1775 let context_id = WorkspaceContextId::from_bytes([10; 16]);
1776 let before = workspace_context(
1777 directory.path(),
1778 context_id,
1779 1,
1780 WorkspaceContextState::Active,
1781 );
1782 let after = workspace_context(
1783 directory.path(),
1784 context_id,
1785 2,
1786 WorkspaceContextState::Frozen,
1787 );
1788 std::fs::create_dir_all(directory.path().join(WORKSPACE_CONTEXT_FAMILY))
1789 .expect("context directory");
1790 std::fs::create_dir_all(directory.path().join("workspace-context-transactions-v2"))
1791 .expect("transaction directory");
1792 write_journaled(&context_record_paths(directory.path(), context_id), &before)
1793 .expect("initial context");
1794 write_journaled(
1795 &context_transaction_paths(directory.path()),
1796 &ContextTransaction {
1797 version: 1,
1798 phase: ContextTransactionPhase::Prepared,
1799 before: [(context_id, Some(before.clone()))].into_iter().collect(),
1800 after: [(context_id, after.clone())].into_iter().collect(),
1801 before_children: BTreeMap::new(),
1802 after_children: WorkspaceContextChildren::new(),
1803 },
1804 )
1805 .expect("prepared transaction");
1806 write_journaled(&context_record_paths(directory.path(), context_id), &after)
1807 .expect("partial application");
1808
1809 assert_eq!(
1810 WorkspaceContextStore::load(&store, context_id)
1811 .await
1812 .expect("recover prepared transaction"),
1813 Some(before)
1814 );
1815 }
1816
1817 #[tokio::test]
1818 async fn prepared_context_transaction_rolls_back_child_index_with_record() {
1819 let directory = tempfile::tempdir().expect("temporary directory");
1820 let store = LocalCoreStateStore::new(directory.path());
1821 let parent_id = WorkspaceContextId::from_bytes([23; 16]);
1822 let child_id = WorkspaceContextId::from_bytes([24; 16]);
1823 let mut child =
1824 workspace_context(directory.path(), child_id, 1, WorkspaceContextState::Active);
1825 child.parent_context_id = Some(parent_id);
1826 ensure_context_children_index(directory.path()).expect("initialize child index");
1827 let journal = ContextTransaction {
1828 version: 2,
1829 phase: ContextTransactionPhase::Prepared,
1830 before: [(child_id, None)].into_iter().collect(),
1831 after: [(child_id, child.clone())].into_iter().collect(),
1832 before_children: [(parent_id, None)].into_iter().collect(),
1833 after_children: [(parent_id, BTreeSet::from([child_id]))]
1834 .into_iter()
1835 .collect(),
1836 };
1837 std::fs::create_dir_all(directory.path().join("workspace-context-transactions-v2"))
1838 .expect("transaction directory");
1839 write_journaled(&context_transaction_paths(directory.path()), &journal)
1840 .expect("prepared transaction");
1841 apply_context_transaction(directory.path(), &journal).expect("partial application");
1842
1843 assert_eq!(
1844 WorkspaceContextStore::load(&store, child_id)
1845 .await
1846 .expect("recover prepared transaction"),
1847 None
1848 );
1849 assert_eq!(
1850 read_recoverable::<BTreeSet<WorkspaceContextId>>(&context_children_paths(
1851 directory.path(),
1852 parent_id,
1853 ))
1854 .expect("read recovered child bucket"),
1855 None
1856 );
1857 }
1858
1859 #[tokio::test]
1860 async fn committed_context_transaction_rolls_forward_missing_record_application() {
1861 let directory = tempfile::tempdir().expect("temporary directory");
1862 let store = LocalCoreStateStore::new(directory.path());
1863 let context_id = WorkspaceContextId::from_bytes([11; 16]);
1864 let before = workspace_context(
1865 directory.path(),
1866 context_id,
1867 1,
1868 WorkspaceContextState::Active,
1869 );
1870 let after = workspace_context(
1871 directory.path(),
1872 context_id,
1873 2,
1874 WorkspaceContextState::Frozen,
1875 );
1876 std::fs::create_dir_all(directory.path().join(WORKSPACE_CONTEXT_FAMILY))
1877 .expect("context directory");
1878 std::fs::create_dir_all(directory.path().join("workspace-context-transactions-v2"))
1879 .expect("transaction directory");
1880 write_journaled(&context_record_paths(directory.path(), context_id), &before)
1881 .expect("initial context");
1882 write_journaled(
1883 &context_transaction_paths(directory.path()),
1884 &ContextTransaction {
1885 version: 1,
1886 phase: ContextTransactionPhase::Committed,
1887 before: [(context_id, Some(before))].into_iter().collect(),
1888 after: [(context_id, after.clone())].into_iter().collect(),
1889 before_children: BTreeMap::new(),
1890 after_children: WorkspaceContextChildren::new(),
1891 },
1892 )
1893 .expect("committed transaction");
1894
1895 assert_eq!(
1896 WorkspaceContextStore::load(&store, context_id)
1897 .await
1898 .expect("recover committed transaction"),
1899 Some(after)
1900 );
1901 }
1902
1903 #[tokio::test]
1904 async fn git_compatibility_delete_is_revision_fenced_and_durable() {
1905 let directory = tempfile::tempdir().expect("temporary directory");
1906 let store = LocalCoreStateStore::new(directory.path());
1907 let old_paths = RecordPaths::new(directory.path(), "git-compat", workspace());
1908 std::fs::create_dir_all(&old_paths.directory).expect("legacy state directory");
1909 write_journaled(&old_paths, &GitCompatState::new("main", workspace()))
1910 .expect("legacy state remains isolated");
1911 assert!(
1912 GitCompatStore::load(&store, workspace())
1913 .await
1914 .expect("new namespace load")
1915 .is_none()
1916 );
1917 let mut state = GitCompatState::new("main", workspace());
1918 state.revision = 1;
1919 assert!(
1920 GitCompatStore::compare_and_swap(&store, workspace(), 0, state)
1921 .await
1922 .expect("write Git state")
1923 );
1924 assert!(
1925 !GitCompatStore::compare_and_delete(&store, workspace(), 0)
1926 .await
1927 .expect("reject stale delete")
1928 );
1929 assert!(
1930 GitCompatStore::compare_and_delete(&store, workspace(), 1)
1931 .await
1932 .expect("delete Git state")
1933 );
1934 let reopened = LocalCoreStateStore::new(directory.path());
1935 assert!(
1936 GitCompatStore::load(&reopened, workspace())
1937 .await
1938 .expect("load deleted Git state")
1939 .is_none()
1940 );
1941 assert!(old_paths.current.exists(), "legacy state is left untouched");
1942 }
1943
1944 #[tokio::test]
1945 async fn materialization_operations_are_discoverable_and_terminal_cleanup_is_safe() {
1946 let directory = tempfile::tempdir().expect("temporary directory");
1947 let store = LocalCoreStateStore::new(directory.path());
1948 let operation_id = OperationId::new();
1949 let journal = MaterializationJournal {
1950 version: 1,
1951 revision: 1,
1952 plan: crate::MaterializationPlan {
1953 operation_id,
1954 from: crate::GenerationId::new(Digest::from_bytes([1; 32])),
1955 to: crate::GenerationId::new(Digest::from_bytes([2; 32])),
1956 edits: Vec::new(),
1957 },
1958 preimages: Vec::new(),
1959 phase: crate::MaterializationPhase::Prepared,
1960 applied: 0,
1961 restored: 0,
1962 };
1963 assert!(
1964 MaterializationJournalStore::compare_and_swap(&store, operation_id, 0, journal.clone())
1965 .await
1966 .expect("write journal")
1967 );
1968 assert_eq!(
1969 store
1970 .materialization_operations_async()
1971 .await
1972 .expect("list materializations"),
1973 [operation_id]
1974 );
1975 assert!(matches!(
1976 store.remove_materialization_async(operation_id).await,
1977 Err(LocalCoreStateStoreError::MaterializationInProgress)
1978 ));
1979 let mut terminal = journal;
1980 terminal.revision = 2;
1981 terminal.phase = crate::MaterializationPhase::Applied;
1982 assert!(
1983 MaterializationJournalStore::compare_and_swap(&store, operation_id, 1, terminal)
1984 .await
1985 .expect("finish journal")
1986 );
1987 store
1988 .remove_materialization_async(operation_id)
1989 .await
1990 .expect("remove terminal journal");
1991 assert!(
1992 store
1993 .materialization_operations_async()
1994 .await
1995 .expect("list cleaned materializations")
1996 .is_empty()
1997 );
1998 }
1999
2000 #[tokio::test]
2001 async fn previous_only_materialization_journal_is_discoverable_and_recovered() {
2002 let directory = tempfile::tempdir().expect("temporary directory");
2003 let store = LocalCoreStateStore::new(directory.path());
2004 let operation_id = OperationId::from_bytes([0x73; 16]);
2005 let journal = MaterializationJournal {
2006 version: 1,
2007 revision: 1,
2008 plan: crate::MaterializationPlan {
2009 operation_id,
2010 from: crate::GenerationId::new(Digest::from_bytes([1; 32])),
2011 to: crate::GenerationId::new(Digest::from_bytes([2; 32])),
2012 edits: Vec::new(),
2013 },
2014 preimages: Vec::new(),
2015 phase: crate::MaterializationPhase::Prepared,
2016 applied: 0,
2017 restored: 0,
2018 };
2019 let paths = RecordPaths::new_key(
2020 directory.path(),
2021 "materialization",
2022 &operation_id.into_bytes(),
2023 );
2024 std::fs::create_dir_all(&paths.directory).expect("journal directory");
2025 std::fs::write(
2026 &paths.previous,
2027 serde_json::to_vec(&journal).expect("serialize journal"),
2028 )
2029 .expect("write previous journal");
2030
2031 assert_eq!(
2032 store
2033 .materialization_operations()
2034 .expect("discover journal"),
2035 [operation_id]
2036 );
2037 assert_eq!(
2038 MaterializationJournalStore::load(&store, operation_id)
2039 .await
2040 .expect("recover journal"),
2041 Some(journal)
2042 );
2043 assert!(paths.current.exists());
2044 }
2045}