Skip to main content

soma_provider_adapters/python/
materializer_repair.rs

1//! Atomic repair of one exact planned Python environment.
2
3use std::{
4    fs, io,
5    path::{Path, PathBuf},
6    sync::atomic::{AtomicU64, Ordering},
7};
8
9use serde::Serialize;
10use thiserror::Error;
11
12use super::{
13    PreparedPythonEnvironment, PythonEnvironmentMaterializer, PythonMaterializationError,
14    PythonMaterializationRequest, UvRunner,
15};
16use crate::python::environment::PythonEnvironmentPlan;
17
18static REPAIR_SEQUENCE: AtomicU64 = AtomicU64::new(0);
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum PythonEnvironmentRepairOutcome {
23    Healthy,
24    Prepared,
25    Rebuilt,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct PythonEnvironmentRepairReport {
30    pub outcome: PythonEnvironmentRepairOutcome,
31    pub environment: PreparedPythonEnvironment,
32    pub replaced_error: Option<String>,
33    pub cleanup_pending: Option<PathBuf>,
34}
35
36#[derive(Debug, Error)]
37pub enum PythonEnvironmentRepairError {
38    #[error(transparent)]
39    Materialization(#[from] PythonMaterializationError),
40    #[error("failed to quarantine Python environment {}: {source}", path.display())]
41    Quarantine {
42        path: PathBuf,
43        #[source]
44        source: io::Error,
45    },
46    #[error(
47        "Python environment rebuild failed ({build_error}) and the original cache could not be restored from {} to {}: {source}",
48        quarantine.display(),
49        original.display()
50    )]
51    Restore {
52        original: PathBuf,
53        quarantine: PathBuf,
54        build_error: String,
55        #[source]
56        source: io::Error,
57    },
58    #[error(
59        "Python environment rebuild failed ({build_error}); recovery is preserved at {} because {} is occupied",
60        quarantine.display(),
61        original.display()
62    )]
63    RestoreBlocked {
64        original: PathBuf,
65        quarantine: PathBuf,
66        build_error: String,
67    },
68}
69
70impl<R: UvRunner> PythonEnvironmentMaterializer<R> {
71    pub fn repair(
72        &self,
73        plan: &PythonEnvironmentPlan,
74        request: PythonMaterializationRequest<'_>,
75    ) -> Result<PythonEnvironmentRepairReport, PythonEnvironmentRepairError> {
76        let replaced_error = match self.open_verified(plan) {
77            Ok(Some(environment)) => {
78                return Ok(PythonEnvironmentRepairReport {
79                    outcome: PythonEnvironmentRepairOutcome::Healthy,
80                    environment,
81                    replaced_error: None,
82                    cleanup_pending: None,
83                });
84            }
85            Ok(None) => {
86                let environment = self.prepare(plan, request)?;
87                return Ok(PythonEnvironmentRepairReport {
88                    outcome: PythonEnvironmentRepairOutcome::Prepared,
89                    environment,
90                    replaced_error: None,
91                    cleanup_pending: None,
92                });
93            }
94            Err(error @ PythonMaterializationError::IncompleteCache(_))
95            | Err(error @ PythonMaterializationError::InvalidMarker(_)) => error.to_string(),
96            Err(error) => return Err(error.into()),
97        };
98
99        if request.offline {
100            return Err(PythonMaterializationError::OfflineCacheMiss(plan.key.clone()).into());
101        }
102        let quarantine = repair_quarantine_path(&plan.directory)?;
103        match fs::rename(&plan.directory, &quarantine) {
104            Ok(()) => {}
105            Err(error) if error.kind() == io::ErrorKind::NotFound => {
106                let environment = self.prepare(plan, request)?;
107                return Ok(PythonEnvironmentRepairReport {
108                    outcome: PythonEnvironmentRepairOutcome::Prepared,
109                    environment,
110                    replaced_error: None,
111                    cleanup_pending: None,
112                });
113            }
114            Err(source) => {
115                return Err(PythonEnvironmentRepairError::Quarantine {
116                    path: plan.directory.clone(),
117                    source,
118                });
119            }
120        }
121
122        match self.prepare(plan, request) {
123            Ok(environment) => {
124                let cleanup_pending = remove_repair_quarantine(&quarantine)
125                    .err()
126                    .map(|_| quarantine.clone());
127                Ok(PythonEnvironmentRepairReport {
128                    outcome: PythonEnvironmentRepairOutcome::Rebuilt,
129                    environment,
130                    replaced_error: Some(replaced_error),
131                    cleanup_pending,
132                })
133            }
134            Err(build_error) => {
135                restore_after_failed_rebuild(&plan.directory, &quarantine, build_error)
136            }
137        }
138    }
139}
140
141fn restore_after_failed_rebuild(
142    original: &Path,
143    quarantine: &Path,
144    build_error: PythonMaterializationError,
145) -> Result<PythonEnvironmentRepairReport, PythonEnvironmentRepairError> {
146    let build_message = build_error.to_string();
147    match fs::symlink_metadata(original) {
148        Err(error) if error.kind() == io::ErrorKind::NotFound => {
149            fs::rename(quarantine, original).map_err(|source| {
150                PythonEnvironmentRepairError::Restore {
151                    original: original.to_path_buf(),
152                    quarantine: quarantine.to_path_buf(),
153                    build_error: build_message,
154                    source,
155                }
156            })?;
157            Err(PythonEnvironmentRepairError::Materialization(build_error))
158        }
159        Ok(_) => Err(PythonEnvironmentRepairError::RestoreBlocked {
160            original: original.to_path_buf(),
161            quarantine: quarantine.to_path_buf(),
162            build_error: build_message,
163        }),
164        Err(source) => Err(PythonEnvironmentRepairError::Restore {
165            original: original.to_path_buf(),
166            quarantine: quarantine.to_path_buf(),
167            build_error: build_message,
168            source,
169        }),
170    }
171}
172
173fn repair_quarantine_path(path: &Path) -> Result<PathBuf, PythonEnvironmentRepairError> {
174    let parent = path
175        .parent()
176        .ok_or_else(|| PythonEnvironmentRepairError::Quarantine {
177            path: path.to_path_buf(),
178            source: io::Error::new(io::ErrorKind::InvalidInput, "cache plan has no parent"),
179        })?;
180    let name = path
181        .file_name()
182        .and_then(|name| name.to_str())
183        .unwrap_or("environment");
184    loop {
185        let sequence = REPAIR_SEQUENCE.fetch_add(1, Ordering::Relaxed);
186        let candidate = parent.join(format!(".{name}.repair-{}-{sequence}", std::process::id()));
187        match fs::symlink_metadata(&candidate) {
188            Ok(_) => continue,
189            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(candidate),
190            Err(source) => {
191                return Err(PythonEnvironmentRepairError::Quarantine {
192                    path: candidate,
193                    source,
194                });
195            }
196        }
197    }
198}
199
200fn remove_repair_quarantine(path: &Path) -> io::Result<()> {
201    let metadata = fs::symlink_metadata(path)?;
202    if metadata.file_type().is_symlink() || !metadata.is_dir() {
203        fs::remove_file(path)
204    } else {
205        fs::remove_dir_all(path)
206    }
207}