Skip to main content

somatize_worker/
env_manager.rs

1//! Python environment manager: creates and maintains isolated venvs/conda envs
2//! per pipeline, with incremental dependency updates.
3
4use crate::error::{Result, WorkerError};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Environment type preference.
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum EnvType {
15    /// Standard-library `python -m venv` (the default — no extra tooling).
16    #[default]
17    Venv,
18    /// A conda environment, for pipelines whose dependencies need conda's
19    /// binary packages.
20    Conda,
21}
22
23/// Lockfile: tracks what's installed in an environment.
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct EnvLockfile {
26    /// Installed packages, name → version.
27    pub packages: HashMap<String, String>,
28    /// SHA-256 of the normalized requirements the environment was built
29    /// from — a matching hash means the env can be reused as is.
30    pub requirements_hash: String,
31    /// How the environment was created (venv or conda).
32    pub env_type: EnvType,
33    /// The interpreter version the environment was created with.
34    pub python_version: String,
35}
36
37/// Manages isolated Python environments for pipeline execution.
38pub struct EnvManager {
39    base_dir: PathBuf,
40    env_type: EnvType,
41}
42
43impl EnvManager {
44    /// A manager that keeps its environments under `base_dir`, one per
45    /// pipeline. The directory is created eagerly; if that fails,
46    /// [`EnvManager::ensure_env`] reports the real error at first use.
47    pub fn new(base_dir: impl Into<PathBuf>, env_type: EnvType) -> Self {
48        let base = base_dir.into();
49        std::fs::create_dir_all(&base).ok();
50        Self {
51            base_dir: base,
52            env_type,
53        }
54    }
55
56    /// Get or create an environment for a pipeline.
57    /// Returns the path to the Python binary.
58    pub fn ensure_env(&self, pipeline_id: &str, requirements: &str) -> Result<PathBuf> {
59        let req_hash = Self::hash_requirements(requirements);
60        let env_dir = self.base_dir.join(format!("env-{pipeline_id}"));
61        let lockfile_path = env_dir.join("lockfile.json");
62
63        // Check if env exists and is up to date
64        if env_dir.exists()
65            && let Ok(lockfile) = self.read_lockfile(&lockfile_path)
66        {
67            if lockfile.requirements_hash == req_hash {
68                // Env is up to date, just return python path
69                tracing::info!("Reusing env for pipeline {pipeline_id} (hash match)");
70                return self.python_path(&env_dir);
71            }
72
73            // Requirements changed — do incremental update
74            tracing::info!("Updating env for pipeline {pipeline_id} (requirements changed)");
75            self.incremental_update(&env_dir, requirements, &lockfile)?;
76            self.write_lockfile(&lockfile_path, requirements, &req_hash)?;
77            return self.python_path(&env_dir);
78        }
79
80        // Create new environment
81        tracing::info!("Creating new env for pipeline {pipeline_id}");
82        self.create_env(&env_dir)?;
83        self.install_requirements(&env_dir, requirements)?;
84        self.write_lockfile(&lockfile_path, requirements, &req_hash)?;
85
86        self.python_path(&env_dir)
87    }
88
89    /// Remove unused environments older than max_age.
90    pub fn cleanup(&self, max_age: std::time::Duration) -> usize {
91        let mut removed = 0;
92        if let Ok(entries) = std::fs::read_dir(&self.base_dir) {
93            for entry in entries.flatten() {
94                if let Ok(meta) = entry.metadata()
95                    && let Ok(modified) = meta.modified()
96                    && modified.elapsed().unwrap_or_default() > max_age
97                {
98                    let _ = std::fs::remove_dir_all(entry.path());
99                    removed += 1;
100                }
101            }
102        }
103        removed
104    }
105
106    // ── Internal ──
107
108    /// Put `package_dir` on the venv's import path with a `.pth` file.
109    ///
110    /// The alternative — `pip install` of the source tree — would rebuild
111    /// the compiled extension for every pipeline that asks for a different
112    /// requirement set. A single path entry costs nothing and points at the
113    /// build that is already there.
114    ///
115    /// Nothing else is placed on the path, so the venv's own packages
116    /// (torch, numpy, whatever the requirements asked for) keep winning.
117    fn link_local_package(env_dir: &Path, package_dir: &str) -> Result<()> {
118        let lib = env_dir.join("lib");
119        let site = std::fs::read_dir(&lib)
120            .map_err(|e| WorkerError::Env(format!("reading {}: {e}", lib.display())))?
121            .filter_map(|e| e.ok())
122            .map(|e| e.path().join("site-packages"))
123            .find(|p| p.is_dir())
124            .ok_or_else(|| {
125                WorkerError::Env(format!(
126                    "no site-packages under {} to link the local soma package into",
127                    lib.display()
128                ))
129            })?;
130        std::fs::write(site.join("_soma_local.pth"), format!("{package_dir}\n"))
131            .map_err(|e| WorkerError::Env(format!("writing _soma_local.pth: {e}")))?;
132        tracing::info!(path = %package_dir, "worker venv uses the local soma package");
133        Ok(())
134    }
135
136    fn create_env(&self, env_dir: &Path) -> Result<()> {
137        match self.env_type {
138            EnvType::Venv => {
139                let output = Command::new("python3")
140                    .args(["-m", "venv", &env_dir.to_string_lossy()])
141                    .output()
142                    .map_err(|e| WorkerError::Env(format!("Failed to create venv: {e}")))?;
143                if !output.status.success() {
144                    return Err(WorkerError::Env(format!(
145                        "venv creation failed: {}",
146                        String::from_utf8_lossy(&output.stderr)
147                    )));
148                }
149            }
150            EnvType::Conda => {
151                let output = Command::new("conda")
152                    .args([
153                        "create",
154                        "-p",
155                        &env_dir.to_string_lossy(),
156                        "python=3.11",
157                        "-y",
158                        "-q",
159                    ])
160                    .output()
161                    .map_err(|e| WorkerError::Env(format!("Failed to create conda env: {e}")))?;
162                if !output.status.success() {
163                    return Err(WorkerError::Env(format!(
164                        "conda create failed: {}",
165                        String::from_utf8_lossy(&output.stderr)
166                    )));
167                }
168            }
169        }
170        Ok(())
171    }
172
173    fn install_requirements(&self, env_dir: &Path, requirements: &str) -> Result<()> {
174        let pip = self.pip_path(env_dir);
175
176        // Write requirements to temp file
177        let req_file = env_dir.join("requirements.txt");
178        std::fs::write(&req_file, requirements)
179            .map_err(|e| WorkerError::Env(format!("Failed to write requirements.txt: {e}")))?;
180
181        // The bootstrap in `python_process.rs` opens with
182        // `import json, sys, base64, cloudpickle, io, pickle`, so a venv
183        // without cloudpickle cannot load a single filter — the child dies
184        // on its first line and the worker reports "python process closed
185        // stdout (crashed?)", which names neither the module nor the venv.
186        //
187        // This used to install "soma", which is a DIFFERENT project on
188        // PyPI: this one publishes as `somatize`. The result was discarded
189        // with `let _`, so installing the wrong package, or failing to
190        // install anything, was indistinguishable from success.
191        // Pinned to THIS build's version where PyPI has it. An unpinned
192        // install put somatize 0.3.1 in the venv of a 0.4.0 worker, so the
193        // subprocess ran a months-old `_composite.py` against filters
194        // pickled by the current build — and the failure surfaced as
195        // `'NoneType' object has no attribute 'size'` inside the user's
196        // fit, with nothing anywhere mentioning a version.
197        //
198        // It falls back rather than refusing, because a build ahead of the
199        // last release is the normal state of a repository and a mismatch
200        // is harmless for a plain filter. It is not harmless for a
201        // differentiable one, so the fallback warns loudly.
202        // `$SOMA_LOCAL_PACKAGE` short-circuits all of that: it names the
203        // directory holding the `soma` package this build belongs to, and
204        // is how a working tree runs its OWN Python layer on a worker
205        // instead of whatever the last release put on PyPI. The Python
206        // `Worker` sets it automatically. It is added by a `.pth` rather
207        // than installed, because installing it would mean compiling the
208        // extension module once per venv.
209        let version = env!("CARGO_PKG_VERSION");
210        let local_package = std::env::var("SOMA_LOCAL_PACKAGE").ok().filter(|p| {
211            let ok = std::path::Path::new(p).join("soma").is_dir();
212            if !ok {
213                tracing::warn!(
214                    path = %p,
215                    "SOMA_LOCAL_PACKAGE does not contain a `soma` directory; \
216                     falling back to installing somatize from PyPI"
217                );
218            }
219            ok
220        });
221        let mut bootstrap = if let Some(dir) = &local_package {
222            let out = Command::new(&pip)
223                .args(["install", "-q", "cloudpickle"])
224                .output()
225                .map_err(|e| WorkerError::Env(format!("pip install cloudpickle failed: {e}")))?;
226            if out.status.success() {
227                Self::link_local_package(env_dir, dir)?;
228            }
229            out
230        } else {
231            Command::new(&pip)
232                .args([
233                    "install",
234                    "-q",
235                    "cloudpickle",
236                    &format!("somatize=={version}"),
237                ])
238                .output()
239                .map_err(|e| {
240                    WorkerError::Env(format!("pip install (bootstrap deps) failed: {e}"))
241                })?
242        };
243        if !bootstrap.status.success() && local_package.is_none() {
244            tracing::warn!(
245                version,
246                "PyPI has no somatize {version}; installing the latest instead. A \
247                 filter pickled by this build and unpickled against a different \
248                 somatize can fail deep inside its own fit, naming no version. \
249                 Set SOMA_LOCAL_PACKAGE to this build's Python package \
250                 directory to run the real thing instead",
251            );
252            bootstrap = Command::new(&pip)
253                .args(["install", "-q", "cloudpickle", "somatize"])
254                .output()
255                .map_err(|e| {
256                    WorkerError::Env(format!("pip install (bootstrap deps) failed: {e}"))
257                })?;
258        }
259        if !bootstrap.status.success() {
260            return Err(WorkerError::Env(format!(
261                "installing the bootstrap dependencies (cloudpickle, somatize) failed in {}:\n{}",
262                env_dir.display(),
263                String::from_utf8_lossy(&bootstrap.stderr)
264            )));
265        }
266
267        let output = Command::new(&pip)
268            .args(["install", "-r", &req_file.to_string_lossy(), "-q"])
269            .output()
270            .map_err(|e| WorkerError::Env(format!("pip install failed: {e}")))?;
271
272        if !output.status.success() {
273            return Err(WorkerError::Env(format!(
274                "pip install failed:\n{}",
275                String::from_utf8_lossy(&output.stderr)
276            )));
277        }
278
279        Ok(())
280    }
281
282    fn incremental_update(
283        &self,
284        env_dir: &Path,
285        new_requirements: &str,
286        old_lockfile: &EnvLockfile,
287    ) -> Result<()> {
288        let new_packages = Self::parse_requirements(new_requirements);
289        let pip = self.pip_path(env_dir);
290
291        // Find packages to install/upgrade
292        let mut to_install = Vec::new();
293        for (name, version) in &new_packages {
294            match old_lockfile.packages.get(name) {
295                None => {
296                    // New package
297                    tracing::info!("  + {name}=={version}");
298                    to_install.push(format!("{name}=={version}"));
299                }
300                Some(old_ver) if old_ver != version => {
301                    // Version changed
302                    tracing::info!("  ↑ {name}: {old_ver} → {version}");
303                    to_install.push(format!("{name}=={version}"));
304                }
305                _ => {} // Same version, skip
306            }
307        }
308
309        // Find packages to remove
310        for name in old_lockfile.packages.keys() {
311            if !new_packages.contains_key(name) {
312                tracing::info!("  - {name}");
313                let _ = Command::new(&pip)
314                    .args(["uninstall", name, "-y", "-q"])
315                    .output();
316            }
317        }
318
319        // Install new/updated packages
320        if !to_install.is_empty() {
321            let output = Command::new(&pip)
322                .args(["install"])
323                .args(&to_install)
324                .arg("-q")
325                .output()
326                .map_err(|e| WorkerError::Env(format!("pip install failed: {e}")))?;
327
328            if !output.status.success() {
329                return Err(WorkerError::Env(format!(
330                    "pip install failed:\n{}",
331                    String::from_utf8_lossy(&output.stderr)
332                )));
333            }
334        }
335
336        Ok(())
337    }
338
339    fn python_path(&self, env_dir: &Path) -> Result<PathBuf> {
340        let path = env_dir.join("bin").join("python");
341        if path.exists() {
342            Ok(path)
343        } else {
344            Err(WorkerError::Env(format!(
345                "Python not found at {}",
346                path.display()
347            )))
348        }
349    }
350
351    fn pip_path(&self, env_dir: &Path) -> PathBuf {
352        env_dir.join("bin").join("pip")
353    }
354
355    /// A stable environment id for a set of requirements.
356    ///
357    /// Callers with no durable pipeline identity — a one-off plan, whose id
358    /// is a fresh timestamp — must key on this instead. Keying on the plan
359    /// id gave every plan its own venv and its own `pip install`, which is
360    /// unbounded: one short test suite left 17 GB of near-identical
361    /// environments behind.
362    pub fn env_id_for(requirements: &str) -> String {
363        format!("reqs-{}", &Self::hash_requirements(requirements)[..16])
364    }
365
366    fn hash_requirements(requirements: &str) -> String {
367        let mut hasher = Sha256::new();
368        // Normalize: sort lines, trim whitespace, ignore comments
369        let mut lines: Vec<&str> = requirements
370            .lines()
371            .map(|l| l.trim())
372            .filter(|l| !l.is_empty() && !l.starts_with('#'))
373            .collect();
374        lines.sort();
375        for line in &lines {
376            hasher.update(line.as_bytes());
377            hasher.update(b"\n");
378        }
379        hex::encode(hasher.finalize())
380    }
381
382    fn parse_requirements(requirements: &str) -> HashMap<String, String> {
383        let mut packages = HashMap::new();
384        for line in requirements.lines() {
385            let line = line.trim();
386            if line.is_empty() || line.starts_with('#') {
387                continue;
388            }
389            // Parse "package==version", "package>=version", "package"
390            let (name, version) = if let Some((n, v)) = line.split_once("==") {
391                (n.trim().to_lowercase(), v.trim().to_string())
392            } else if let Some((n, v)) = line.split_once(">=") {
393                (n.trim().to_lowercase(), format!(">={v}"))
394            } else if let Some((n, v)) = line.split_once("<=") {
395                (n.trim().to_lowercase(), format!("<={v}"))
396            } else {
397                (line.to_lowercase(), "latest".to_string())
398            };
399            packages.insert(name, version);
400        }
401        packages
402    }
403
404    fn read_lockfile(&self, path: &Path) -> Result<EnvLockfile> {
405        let content = std::fs::read_to_string(path).map_err(|e| WorkerError::Env(e.to_string()))?;
406        serde_json::from_str(&content).map_err(|e| WorkerError::Encoding(e.to_string()))
407    }
408
409    fn write_lockfile(&self, path: &Path, requirements: &str, hash: &str) -> Result<()> {
410        let lockfile = EnvLockfile {
411            packages: Self::parse_requirements(requirements),
412            requirements_hash: hash.to_string(),
413            env_type: self.env_type.clone(),
414            python_version: "3.11".to_string(),
415        };
416        let json =
417            serde_json::to_string_pretty(&lockfile).map_err(|e| WorkerError::Env(e.to_string()))?;
418        std::fs::write(path, json)
419            .map_err(|e| WorkerError::Env(format!("Failed to write lockfile: {e}")))
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn hash_requirements_stable() {
429        let r1 = "numpy==1.26\nscikit-learn==1.4\n";
430        let r2 = "scikit-learn==1.4\nnumpy==1.26\n"; // different order
431        assert_eq!(
432            EnvManager::hash_requirements(r1),
433            EnvManager::hash_requirements(r2)
434        );
435    }
436
437    /// Two plans with the same dependencies share one environment.
438    ///
439    /// The env id used to be the plan id, which is a fresh timestamp per
440    /// plan: nothing was ever reused, every plan paid a full pip install,
441    /// and the environments accumulated without bound.
442    #[test]
443    fn the_env_id_follows_the_requirements_not_the_caller() {
444        let a = EnvManager::env_id_for("numpy==1.26\nscikit-learn==1.4\n");
445        let b = EnvManager::env_id_for("scikit-learn==1.4\n numpy==1.26\n");
446        assert_eq!(a, b, "the same dependency set must reuse one environment");
447
448        let other = EnvManager::env_id_for("numpy==1.26\n");
449        assert_ne!(a, other, "a different dependency set needs its own");
450    }
451
452    #[test]
453    fn hash_requirements_ignores_comments() {
454        let r1 = "numpy==1.26\n# comment\nscikit-learn==1.4\n";
455        let r2 = "numpy==1.26\nscikit-learn==1.4\n";
456        assert_eq!(
457            EnvManager::hash_requirements(r1),
458            EnvManager::hash_requirements(r2)
459        );
460    }
461
462    #[test]
463    fn hash_changes_on_version_change() {
464        let r1 = "numpy==1.26\n";
465        let r2 = "numpy==1.27\n";
466        assert_ne!(
467            EnvManager::hash_requirements(r1),
468            EnvManager::hash_requirements(r2)
469        );
470    }
471
472    #[test]
473    fn parse_requirements_formats() {
474        let pkgs = EnvManager::parse_requirements("numpy==1.26\nsklearn>=1.4\npandas\n");
475        assert_eq!(pkgs["numpy"], "1.26");
476        assert_eq!(pkgs["sklearn"], ">=1.4");
477        assert_eq!(pkgs["pandas"], "latest");
478    }
479}