laminar_db/process_function/remote/
local_python.rs1use 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#[derive(Clone)]
27pub struct LocalPythonWorkerConfig {
28 pub python: PathBuf,
31 pub runtime_root: Option<PathBuf>,
35 pub manifest: PathBuf,
37 pub handler_file: PathBuf,
39 pub function: String,
41 pub python_paths: Vec<PathBuf>,
44 pub max_in_flight: usize,
47 pub timeout: Duration,
50}
51
52pub 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 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 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 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 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 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 #[must_use]
318 pub fn client(&self) -> Arc<RemoteProcessClient> {
319 Arc::clone(&self.client)
320 }
321
322 #[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 pub async fn wait_for_exit(&self) {
341 self.exited.cancelled().await;
342 }
343
344 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 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}