Skip to main content

acyclic_fs/
core_state.rs

1//! Journaled local persistence for filesystem control-plane state.
2//!
3//! Distributed deployments can implement the small CAS traits over their
4//! authority stream. Embedded native deployments use this companion namespace:
5//! one lock and one recoverable JSON record per workspace and state family.
6//! Keeping the implementation here prevents adapters from inventing separate
7//! lineage, lease, and Git-compatibility databases.
8
9use 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
33// Journal locks, directory enumeration, and namespace mutations have no
34// portable native completion path. Bound entire transactions, not individual
35// syscalls, so cancellation cannot split a lock/CAS/durability sequence.
36async 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/// One journaled private namespace shared by core control-plane state.
43#[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    /// Opens a private companion namespace below `root`.
56    ///
57    /// Directories are created lazily on first mutation.
58    #[must_use]
59    pub fn new(root: impl Into<PathBuf>) -> Self {
60        Self { root: root.into() }
61    }
62
63    /// Returns the namespace root.
64    #[must_use]
65    pub fn root(&self) -> &Path {
66        &self.root
67    }
68
69    /// Lists materialization operation ids with durable recovery state.
70    ///
71    /// Only canonical journal names are returned. Temporary, previous, and
72    /// lock files remain internal recovery details.
73    pub(crate) fn materialization_operations(
74        &self,
75    ) -> Result<Vec<OperationId>, LocalCoreStateStoreError> {
76        self.operation_records("materialization")
77    }
78
79    /// Lists durable materialization journals without blocking an async executor.
80    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    /// Lists multi-root publications with durable recovery state.
88    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    /// Removes a terminal materialization journal and its recovery copies.
128    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    /// Removes a terminal journal without blocking an async executor.
152    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        // Windows has no safe, portable directory-fsync API. Preserve a
432        // fully flushed recovery copy before modifying the current file, then
433        // flush the current file itself. A crash can therefore expose either
434        // the prior record or the complete replacement, never only a torn
435        // current record.
436        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/// Journaled companion-state failure.
495#[derive(Debug, Error)]
496pub enum LocalCoreStateStoreError {
497    /// Native filesystem operation failed.
498    #[error("core state I/O failed: {0}")]
499    Io(#[from] std::io::Error),
500    /// Another owner held a record lock beyond the bounded transaction deadline.
501    #[error("core state record lock timed out: {}", .0.display())]
502    LockTimeout(PathBuf),
503    /// A persisted record was malformed or incompatible with its schema.
504    #[error("core state serialization failed: {0}")]
505    Json(#[from] serde_json::Error),
506    /// A nonterminal materialization journal cannot be discarded.
507    #[error("materialization is still in progress")]
508    MaterializationInProgress,
509    /// Content-addressed state did not match its claimed digest.
510    #[error("core state content address does not match its payload")]
511    Integrity,
512    /// A durable workspace-context transaction was malformed or incompatible.
513    #[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        // UUIDs serialize as quoted 36-byte strings. This deliberately leaves
890        // headroom for JSON delimiters while keeping allocation proportional
891        // to the already-checked child count.
892        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}