Skip to main content

laminar_db/process_function/remote/
local_python.rs

1use std::ffi::OsString;
2use std::io::Read;
3use std::path::PathBuf;
4use std::process::Stdio;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7use std::time::Duration;
8
9use tokio::io::AsyncReadExt;
10use tokio::process::{Child, Command};
11use tokio::sync::oneshot;
12use tokio_util::sync::CancellationToken;
13
14use super::python_environment::{self, FileGuards, VerifiedEnvironment};
15use super::{RemoteProcessClient, MAX_IN_FLIGHT};
16use crate::error::DbError;
17use crate::process_function::descriptor::{valid_python_identifier, MAX_MANIFEST_BYTES};
18use crate::process_function::{ProcessDeterminism, ProcessFunctionDescriptor, ProcessRuntime};
19
20/// Explicit local Python worker launch. The handler file is the direct digest-bound artifact;
21/// an optional environment binding checks deployment drift. A replay-safe descriptor additionally
22/// requires Linux, an unprivileged child, and a package on the read-only root filesystem.
23/// Bound paths reject links in their ancestry before canonicalization. On Windows, bound launches
24/// also retain read-share handles to inventoried files and configured path ancestors.
25/// Bound launches compile filesystem source modules without reading their bytecode caches.
26#[derive(Clone)]
27pub struct LocalPythonWorkerConfig {
28    /// Python executable or a trusted executable name resolved by the host environment.
29    /// Environment-bound launches require a file path inside `runtime_root`.
30    pub python: PathBuf,
31    /// Complete interpreter installation to check against the descriptor's environment binding.
32    /// Supply this together with `python_environment`; exclude the function manifest from it.
33    /// On Windows, existing files and configured path ancestors remain guarded during supervision.
34    pub runtime_root: Option<PathBuf>,
35    /// Canonical descriptor manifest consumed by the worker.
36    pub manifest: PathBuf,
37    /// Top-level Python module file containing the handler function.
38    pub handler_file: PathBuf,
39    /// Function name exported by `handler_file`.
40    pub function: String,
41    /// Explicit import roots for the SDK and handler dependencies. The child does not inherit
42    /// the host's `PYTHONPATH` or Python user site.
43    pub python_paths: Vec<PathBuf>,
44    /// Maximum simultaneous worker calls. The process and client use the same limit.
45    /// The launcher sets `OMP_NUM_THREADS=1` and `OPENBLAS_NUM_THREADS=1` before imports.
46    pub max_in_flight: usize,
47    /// Worker readiness and per-call deadline, between 1 millisecond and 30 seconds.
48    /// Filesystem binding verification precedes the readiness deadline.
49    pub timeout: Duration,
50}
51
52/// One child process with an explicit shutdown owner. Unexpected exit is observed by the
53/// supervisor and outstanding calls fail through the connected transport.
54/// Cancelling startup signals the same supervisor to stop and reap the child.
55pub struct LocalPythonWorker {
56    client: Arc<RemoteProcessClient>,
57    cancel: CancellationToken,
58    exited: CancellationToken,
59    alive: Arc<AtomicBool>,
60    supervisor: Option<tokio::task::JoinHandle<Result<(), DbError>>>,
61    #[cfg(test)]
62    process_id: u32,
63    #[cfg(test)]
64    endpoint: String,
65}
66
67struct VerifiedBinding {
68    manifest: PathBuf,
69    handler_file: PathBuf,
70    module: String,
71    descriptor: ProcessFunctionDescriptor,
72    environment: VerifiedEnvironment,
73}
74
75type StartupResult = Result<(Arc<RemoteProcessClient>, u16), DbError>;
76
77impl VerifiedBinding {
78    fn verify(config: &LocalPythonWorkerConfig) -> Result<Self, DbError> {
79        if config.max_in_flight == 0
80            || config.max_in_flight > MAX_IN_FLIGHT
81            || config.timeout < Duration::from_millis(1)
82            || config.timeout > Duration::from_secs(30)
83            || !valid_python_identifier(&config.function)
84        {
85            return Err(DbError::InvalidOperation(
86                "invalid local process worker limits or handler function".into(),
87            ));
88        }
89        let mut guards = FileGuards::default();
90        let (manifest, handler_file) =
91            if config.runtime_root.is_some() {
92                (
93                    guards.canonical_file(&config.manifest)?,
94                    guards.canonical_file(&config.handler_file)?,
95                )
96            } else {
97                let manifest = config.manifest.canonicalize().map_err(|error| {
98                    DbError::Config(format!("resolve process manifest: {error}"))
99                })?;
100                let handler_file = config.handler_file.canonicalize().map_err(|error| {
101                    DbError::Config(format!("resolve process handler: {error}"))
102                })?;
103                (manifest, handler_file)
104            };
105        let module = handler_file
106            .file_stem()
107            .and_then(|name| name.to_str())
108            .filter(|name| valid_python_identifier(name))
109            .ok_or_else(|| DbError::InvalidOperation("invalid Python handler filename".into()))?;
110        if handler_file.extension().and_then(|ext| ext.to_str()) != Some("py") {
111            return Err(DbError::InvalidOperation(
112                "local Python handler must be a .py file".into(),
113            ));
114        }
115        let mut manifest_bytes = Vec::new();
116        let manifest_limit = u64::try_from(MAX_MANIFEST_BYTES)
117            .map_err(|error| DbError::Config(format!("process manifest size limit: {error}")))?;
118        let mut manifest_file = FileGuards::open_file(&manifest)?;
119        (&mut manifest_file)
120            .take(manifest_limit + 3)
121            .read_to_end(&mut manifest_bytes)
122            .map_err(|error| DbError::Config(format!("read process manifest: {error}")))?;
123        let canonical = manifest_bytes
124            .strip_suffix(b"\r\n")
125            .or_else(|| manifest_bytes.strip_suffix(b"\n"))
126            .unwrap_or(&manifest_bytes);
127        let descriptor = ProcessFunctionDescriptor::from_manifest_json(canonical)?;
128        if descriptor.to_manifest_json()? != canonical {
129            return Err(DbError::InvalidOperation(
130                "Python worker manifest is not canonical".into(),
131            ));
132        }
133        if descriptor.runtime != ProcessRuntime::RemotePython {
134            return Err(DbError::Unsupported(
135                "local Python worker requires a Python process descriptor".into(),
136            ));
137        }
138        if descriptor.determinism == ProcessDeterminism::ReplaySafe {
139            guards = FileGuards::for_replay()?;
140            // Reopen under the stronger filesystem contract before using the binding.
141            guards.canonical_file(&manifest)?;
142            guards.canonical_file(&handler_file)?;
143        }
144        if python_environment::file_sha256(&handler_file)? != descriptor.implementation_digest {
145            return Err(DbError::InvalidOperation(
146                "Python handler file digest differs from its manifest".into(),
147            ));
148        }
149        let module = module.to_string();
150        let handler_directory = handler_file
151            .parent()
152            .ok_or_else(|| DbError::InvalidOperation("Python handler has no parent".into()))?;
153        let environment = python_environment::verify(
154            config,
155            &descriptor,
156            handler_directory,
157            &manifest,
158            &module,
159            guards,
160        )?;
161        Ok(Self {
162            manifest,
163            handler_file,
164            module,
165            descriptor,
166            environment,
167        })
168    }
169
170    fn command(&self, config: &LocalPythonWorkerConfig) -> Result<Command, DbError> {
171        let mut command = Command::new(&self.environment.python);
172        if let Some(root) = &self.environment.runtime_root {
173            let paths = serde_json::to_string(&self.environment.import_roots).map_err(|error| {
174                DbError::Config(format!("encode bound Python import paths: {error}"))
175            })?;
176            // A regular-file prefix makes source cache paths unresolvable, including imports
177            // before bootstrap. The inventoried executable is already guarded on Windows.
178            let mut cache_prefix = OsString::from("pycache_prefix=");
179            cache_prefix.push(&self.environment.python);
180            command.args(
181                if self.descriptor.determinism == ProcessDeterminism::ReplaySafe {
182                    ["-s", "-P", "-S", "-B"]
183                } else {
184                    ["-I", "-S", "-B", "-X"]
185                },
186            );
187            if self.descriptor.determinism == ProcessDeterminism::ReplaySafe {
188                command.env_clear().env("PYTHONHASHSEED", "0").arg("-X");
189                #[cfg(target_os = "linux")]
190                command.env("LD_LIBRARY_PATH", root.join("lib"));
191            }
192            command
193                .arg(cache_prefix)
194                .arg("-c")
195                .arg(include_str!("python_environment/bootstrap.py"))
196                .arg(root)
197                .arg(paths)
198                .arg("laminardb_process.worker")
199                .env_remove("PYTHONPATH");
200        } else {
201            let paths = std::env::join_paths(&self.environment.import_roots)
202                .map_err(|error| DbError::Config(format!("Python import path: {error}")))?;
203            command
204                .args(["-s", "-P", "-m", "laminardb_process.worker"])
205                .env("PYTHONPATH", paths);
206        }
207        command
208            .current_dir(&self.environment.import_roots[0])
209            .arg("--manifest")
210            .arg(&self.manifest)
211            .args([
212                "--handler",
213                &format!("{}:{}", self.module, config.function),
214                "--handler-file",
215            ])
216            .arg(&self.handler_file)
217            .args(["--bind", "127.0.0.1:0", "--max-in-flight"])
218            .arg(config.max_in_flight.to_string())
219            .env_remove("PYTHONHOME")
220            .env("OMP_NUM_THREADS", "1")
221            .env("OPENBLAS_NUM_THREADS", "1")
222            .stdout(Stdio::piped())
223            .stderr(Stdio::inherit())
224            .kill_on_drop(true);
225        #[cfg(target_os = "linux")]
226        if self.descriptor.determinism == ProcessDeterminism::ReplaySafe {
227            // SAFETY: the child hook performs only the async-signal-safe prctl syscall. All
228            // other replay checks run before spawn or in the initialized Python bootstrap.
229            unsafe {
230                command.pre_exec(|| {
231                    if libc::prctl(
232                        libc::PR_SET_NO_NEW_PRIVS,
233                        1 as libc::c_ulong,
234                        0 as libc::c_ulong,
235                        0 as libc::c_ulong,
236                        0 as libc::c_ulong,
237                    ) == 0
238                    {
239                        Ok(())
240                    } else {
241                        Err(std::io::Error::last_os_error())
242                    }
243                });
244            }
245        }
246        Ok(command)
247    }
248}
249
250impl LocalPythonWorker {
251    /// Verify the local binding, start Python without a shell, and connect over loopback.
252    ///
253    /// # Errors
254    /// Rejects invalid paths, mismatched artifacts, malformed readiness, or startup failure.
255    pub async fn start(config: LocalPythonWorkerConfig) -> Result<Self, DbError> {
256        let (config, binding) = tokio::task::spawn_blocking(move || {
257            let binding = VerifiedBinding::verify(&config)?;
258            Ok::<_, DbError>((config, binding))
259        })
260        .await
261        .map_err(|error| DbError::Pipeline(format!("verify Python process binding: {error}")))??;
262        let child = binding
263            .command(&config)?
264            .spawn()
265            .map_err(|error| DbError::Pipeline(format!("start Python process worker: {error}")))?;
266        let cancel = CancellationToken::new();
267        let startup_cancel = cancel.clone().drop_guard();
268        let exited = CancellationToken::new();
269        let alive = Arc::new(AtomicBool::new(true));
270        #[cfg(test)]
271        let process_id = child.id().unwrap_or(0);
272        let (ready_tx, ready_rx) = oneshot::channel();
273        // Transfer the child and file handles before the first await after spawn. Cancelling
274        // startup signals this same cleanup owner, which retains handles while reaping.
275        let supervisor = tokio::spawn(supervise(
276            child,
277            binding,
278            config,
279            ready_tx,
280            cancel.clone(),
281            exited.clone(),
282            Arc::clone(&alive),
283        ));
284        let (client, _port) = match ready_rx
285            .await
286            .map_err(|error| DbError::Pipeline(format!("Python worker startup channel: {error}")))
287            .and_then(|result| result)
288        {
289            Ok(ready) => ready,
290            Err(primary) => {
291                return match supervisor.await {
292                    Ok(Ok(())) => Err(primary),
293                    Ok(Err(cleanup)) => Err(DbError::Pipeline(format!(
294                        "{primary}; worker cleanup: {cleanup}"
295                    ))),
296                    Err(error) => Err(DbError::Pipeline(format!(
297                        "{primary}; worker supervisor: {error}"
298                    ))),
299                };
300            }
301        };
302        startup_cancel.disarm();
303        Ok(Self {
304            client,
305            cancel,
306            exited,
307            alive,
308            supervisor: Some(supervisor),
309            #[cfg(test)]
310            process_id,
311            #[cfg(test)]
312            endpoint: format!("http://127.0.0.1:{_port}"),
313        })
314    }
315
316    /// Connected client to register with the local database instance.
317    #[must_use]
318    pub fn client(&self) -> Arc<RemoteProcessClient> {
319        Arc::clone(&self.client)
320    }
321
322    /// Whether the supervisor still owns a live child process.
323    #[must_use]
324    pub fn is_alive(&self) -> bool {
325        self.alive.load(Ordering::Acquire)
326    }
327
328    #[cfg(test)]
329    pub(crate) const fn process_id(&self) -> u32 {
330        self.process_id
331    }
332
333    #[cfg(test)]
334    pub(crate) fn loopback_endpoint(&self) -> &str {
335        &self.endpoint
336    }
337
338    /// Wait until the supervisor has observed and reaped the worker process.
339    /// A caller that still owns the worker should treat this as a terminal event.
340    pub async fn wait_for_exit(&self) {
341        self.exited.cancelled().await;
342    }
343
344    /// Stop the worker and wait for process exit after the database pipeline has stopped.
345    ///
346    /// # Errors
347    /// Reports a process wait failure or an unexpected worker exit already observed.
348    pub async fn shutdown(mut self) -> Result<(), DbError> {
349        self.cancel.cancel();
350        let Some(supervisor) = self.supervisor.take() else {
351            return Ok(());
352        };
353        supervisor.await.map_err(|error| {
354            DbError::Pipeline(format!("Python process supervisor task: {error}"))
355        })?
356    }
357}
358
359impl Drop for LocalPythonWorker {
360    fn drop(&mut self) {
361        // The supervisor owns the child and always reaps it. Explicit `shutdown` also waits for
362        // completion and reports unexpected exits; this signal covers an abandoned local owner.
363        self.cancel.cancel();
364    }
365}
366
367async fn read_ready(child: &mut Child) -> Result<u16, DbError> {
368    let stdout = child
369        .stdout
370        .as_mut()
371        .ok_or_else(|| DbError::Pipeline("Python worker stdout is unavailable".into()))?;
372    let mut line = Vec::with_capacity(16);
373    for _ in 0..32 {
374        let mut byte = [0u8];
375        stdout.read_exact(&mut byte).await.map_err(|error| {
376            DbError::Pipeline(format!("Python worker closed before readiness: {error}"))
377        })?;
378        if byte[0] == b'\n' {
379            let value = std::str::from_utf8(&line)
380                .ok()
381                .map(|line| line.trim_end_matches('\r'))
382                .and_then(|line| line.strip_prefix("READY "))
383                .and_then(|port| port.parse::<u16>().ok())
384                .filter(|port| *port != 0)
385                .ok_or_else(|| DbError::Pipeline("invalid Python worker readiness".into()))?;
386            child.stdout.take();
387            return Ok(value);
388        }
389        line.push(byte[0]);
390    }
391    Err(DbError::Pipeline(
392        "Python worker readiness line exceeded 32 bytes".into(),
393    ))
394}
395
396async fn stop_child(child: &mut Child) -> Result<(), DbError> {
397    let _ = child.start_kill();
398    tokio::time::timeout(Duration::from_secs(5), child.wait())
399        .await
400        .map_err(|_| DbError::Pipeline("reap Python process worker timed out".into()))?
401        .map_err(|error| DbError::Pipeline(format!("reap Python process worker: {error}")))?;
402    Ok(())
403}
404
405async fn stop_after_start_error(child: &mut Child, primary: DbError) -> DbError {
406    match stop_child(child).await {
407        Ok(()) => primary,
408        Err(cleanup) => DbError::Pipeline(format!("{primary}; worker cleanup: {cleanup}")),
409    }
410}
411
412async fn connect_child(
413    child: &mut Child,
414    descriptor: ProcessFunctionDescriptor,
415    config: &LocalPythonWorkerConfig,
416    exited: CancellationToken,
417) -> StartupResult {
418    let port = tokio::time::timeout(config.timeout, read_ready(child))
419        .await
420        .map_err(|_| DbError::Pipeline("Python process worker readiness timed out".into()))??;
421    let replay_safe = descriptor.determinism == ProcessDeterminism::ReplaySafe;
422    let mut client = RemoteProcessClient::connect_loopback(
423        &format!("http://127.0.0.1:{port}"),
424        descriptor,
425        config.max_in_flight,
426        config.timeout,
427    )
428    .await?;
429    if replay_safe {
430        client.bind_python_replay_lifetime(exited);
431    }
432    Ok((Arc::new(client), port))
433}
434
435async fn supervise(
436    mut child: Child,
437    binding: VerifiedBinding,
438    config: LocalPythonWorkerConfig,
439    ready: oneshot::Sender<StartupResult>,
440    cancel: CancellationToken,
441    exited: CancellationToken,
442    alive: Arc<AtomicBool>,
443) -> Result<(), DbError> {
444    let startup = tokio::select! {
445        biased;
446        () = cancel.cancelled() => Err(DbError::Pipeline("Python worker startup cancelled".into())),
447        result = connect_child(&mut child, binding.descriptor, &config, exited.clone()) => result,
448    };
449    let outcome = match startup {
450        Ok(client) => {
451            if ready.send(Ok(client)).is_err() {
452                cancel.cancel();
453            }
454            observe_child(&mut child, &cancel).await
455        }
456        Err(primary) => {
457            let error = stop_after_start_error(&mut child, primary).await;
458            let _ = ready.send(Err(error));
459            Ok(())
460        }
461    };
462    drop(binding.environment);
463    alive.store(false, Ordering::Release);
464    exited.cancel();
465    outcome
466}
467
468async fn observe_child(child: &mut Child, cancel: &CancellationToken) -> Result<(), DbError> {
469    tokio::select! {
470        biased;
471        status = child.wait() => {
472            match status {
473                Ok(status) => Err(DbError::Pipeline(format!(
474                    "Python process worker exited unexpectedly with {status}"
475                ))),
476                Err(error) => Err(DbError::Pipeline(format!(
477                    "wait for Python process worker: {error}"
478                ))),
479            }
480        }
481        () = cancel.cancelled() => {
482            stop_child(child).await
483        }
484    }
485}