soma_provider_adapters/python/
materializer_repair.rs1use 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}