1use std::collections::HashSet;
15use std::io::{Read, Write};
16use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
17use std::sync::{Arc, Mutex};
18
19use arrow::array::{ArrayRef, RecordBatch};
20use arrow::datatypes::{Field, Schema};
21use arrow::ipc::reader::StreamReader;
22use arrow::ipc::writer::StreamWriter;
23use krishiv_plan::udf::{ScalarUdf, UdfError};
24
25const WORKER_SRC: &str = include_str!("udf_worker.py");
27
28const WORKER_MODE_SCALAR: u8 = 0;
30const WORKER_MODE_AGGREGATE: u8 = 1;
33
34pub fn global_pool() -> Result<Arc<PythonWorkerPool>, UdfError> {
39 use std::sync::OnceLock;
40 static POOL: OnceLock<Mutex<Option<Arc<PythonWorkerPool>>>> = OnceLock::new();
41 let cell = POOL.get_or_init(|| Mutex::new(None));
42 let mut guard = cell.lock().unwrap_or_else(|e| e.into_inner());
43 if let Some(pool) = guard.as_ref() {
44 return Ok(Arc::clone(pool));
45 }
46 let pool = PythonWorkerPool::spawn()?;
47 *guard = Some(Arc::clone(&pool));
48 Ok(pool)
49}
50
51fn exec_err(msg: impl Into<String>) -> UdfError {
52 UdfError::Execution {
53 message: msg.into(),
54 }
55}
56
57pub struct PythonWorkerPool {
61 io: Mutex<WorkerIo>,
62}
63
64struct WorkerIo {
65 child: Child,
66 stdin: ChildStdin,
67 stdout: ChildStdout,
68 sent: HashSet<String>,
70}
71
72impl std::fmt::Debug for PythonWorkerPool {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("PythonWorkerPool").finish_non_exhaustive()
75 }
76}
77
78impl PythonWorkerPool {
79 pub fn spawn() -> Result<Arc<Self>, UdfError> {
81 let mut child = Command::new("python3")
82 .arg("-c")
83 .arg(WORKER_SRC)
84 .stdin(Stdio::piped())
85 .stdout(Stdio::piped())
86 .spawn()
87 .map_err(|e| exec_err(format!("failed to spawn python3 UDF worker: {e}")))?;
88 let stdin = child
89 .stdin
90 .take()
91 .ok_or_else(|| exec_err("worker stdin unavailable"))?;
92 let stdout = child
93 .stdout
94 .take()
95 .ok_or_else(|| exec_err("worker stdout unavailable"))?;
96 Ok(Arc::new(Self {
97 io: Mutex::new(WorkerIo {
98 child,
99 stdin,
100 stdout,
101 sent: HashSet::new(),
102 }),
103 }))
104 }
105
106 fn eval(&self, id: &str, pickle: &[u8], batch: &RecordBatch) -> Result<ArrayRef, UdfError> {
109 self.eval_mode(WORKER_MODE_SCALAR, id, pickle, batch)
110 }
111
112 fn eval_aggregate(
116 &self,
117 id: &str,
118 pickle: &[u8],
119 batch: &RecordBatch,
120 ) -> Result<ArrayRef, UdfError> {
121 self.eval_mode(WORKER_MODE_AGGREGATE, id, pickle, batch)
122 }
123
124 fn eval_mode(
128 &self,
129 mode: u8,
130 id: &str,
131 pickle: &[u8],
132 batch: &RecordBatch,
133 ) -> Result<ArrayRef, UdfError> {
134 let ipc = write_ipc(batch)?;
135 let mut io = self.io.lock().unwrap_or_else(|e| e.into_inner());
136
137 let need_pickle = !io.sent.contains(id);
138 let pickle_frame: &[u8] = if need_pickle { pickle } else { &[] };
139 io.stdin
140 .write_all(&[mode])
141 .map_err(|e| exec_err(format!("worker mode write failed: {e}")))?;
142 write_frame(&mut io.stdin, id.as_bytes())?;
143 write_frame(&mut io.stdin, pickle_frame)?;
144 write_frame(&mut io.stdin, &ipc)?;
145 io.stdin
146 .flush()
147 .map_err(|e| exec_err(format!("worker write failed: {e}")))?;
148 if need_pickle {
149 io.sent.insert(id.to_string());
150 }
151
152 let mut hdr = [0u8; 5];
153 io.stdout
154 .read_exact(&mut hdr)
155 .map_err(|e| exec_err(format!("worker read failed (process died?): {e}")))?;
156 let status = hdr[0];
157 let n = u32::from_le_bytes([hdr[1], hdr[2], hdr[3], hdr[4]]) as usize;
158 let mut payload = vec![0u8; n];
159 io.stdout
160 .read_exact(&mut payload)
161 .map_err(|e| exec_err(format!("worker payload read failed: {e}")))?;
162
163 if status != 0 {
164 io.sent.remove(id);
166 return Err(exec_err(format!(
167 "python UDF '{id}': {}",
168 String::from_utf8_lossy(&payload)
169 )));
170 }
171 read_ipc_first_column(&payload)
172 }
173}
174
175impl Drop for WorkerIo {
176 fn drop(&mut self) {
177 let _ = self.child.kill();
179 let _ = self.child.wait();
180 }
181}
182
183fn write_frame(w: &mut impl Write, bytes: &[u8]) -> Result<(), UdfError> {
184 let len = u32::try_from(bytes.len())
185 .map_err(|_| exec_err("UDF frame exceeds 4 GiB"))?
186 .to_le_bytes();
187 w.write_all(&len)
188 .and_then(|()| w.write_all(bytes))
189 .map_err(|e| exec_err(format!("worker frame write failed: {e}")))
190}
191
192fn write_ipc(batch: &RecordBatch) -> Result<Vec<u8>, UdfError> {
193 let mut buf = Vec::new();
194 {
195 let mut writer = StreamWriter::try_new(&mut buf, &batch.schema())
196 .map_err(|e| exec_err(format!("arrow IPC writer: {e}")))?;
197 writer
198 .write(batch)
199 .map_err(|e| exec_err(format!("arrow IPC write: {e}")))?;
200 writer
201 .finish()
202 .map_err(|e| exec_err(format!("arrow IPC finish: {e}")))?;
203 }
204 Ok(buf)
205}
206
207fn read_ipc_first_column(bytes: &[u8]) -> Result<ArrayRef, UdfError> {
208 let mut reader = StreamReader::try_new(std::io::Cursor::new(bytes), None)
209 .map_err(|e| exec_err(format!("arrow IPC reader: {e}")))?;
210 let batch = reader
211 .next()
212 .ok_or_else(|| exec_err("worker returned no batch"))?
213 .map_err(|e| exec_err(format!("arrow IPC decode: {e}")))?;
214 if batch.num_columns() == 0 {
215 return Err(exec_err("worker returned a batch with no columns"));
216 }
217 Ok(Arc::clone(batch.column(0)))
218}
219
220pub struct PythonWorkerUdf {
223 name: String,
224 pickle: Vec<u8>,
225 input_schema: Schema,
226 output_field: Field,
227 pool: Arc<PythonWorkerPool>,
228}
229
230impl std::fmt::Debug for PythonWorkerUdf {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 f.debug_struct("PythonWorkerUdf")
233 .field("name", &self.name)
234 .field("pickle_len", &self.pickle.len())
235 .finish_non_exhaustive()
236 }
237}
238
239impl PythonWorkerUdf {
240 pub fn new(
241 name: impl Into<String>,
242 pickle: Vec<u8>,
243 input_schema: Schema,
244 output_field: Field,
245 pool: Arc<PythonWorkerPool>,
246 ) -> Self {
247 Self {
248 name: name.into(),
249 pickle,
250 input_schema,
251 output_field,
252 pool,
253 }
254 }
255}
256
257impl ScalarUdf for PythonWorkerUdf {
258 fn name(&self) -> &str {
259 &self.name
260 }
261
262 fn input_schema(&self) -> &Schema {
263 &self.input_schema
264 }
265
266 fn output_field(&self) -> &Field {
267 &self.output_field
268 }
269
270 fn call(&self, batch: &RecordBatch) -> Result<ArrayRef, UdfError> {
271 self.pool.eval(&self.name, &self.pickle, batch)
272 }
273}
274
275use krishiv_plan::udf::{AggState, AggregateUdf, ScalarValue};
278
279pub struct PythonWorkerAggregateUdf {
293 name: String,
294 pickle: Vec<u8>,
295 input_schema: Schema,
296 output_field: Field,
297 pool: Arc<PythonWorkerPool>,
298}
299
300impl std::fmt::Debug for PythonWorkerAggregateUdf {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 f.debug_struct("PythonWorkerAggregateUdf")
303 .field("name", &self.name)
304 .field("pickle_len", &self.pickle.len())
305 .finish_non_exhaustive()
306 }
307}
308
309impl PythonWorkerAggregateUdf {
310 pub fn new(
311 name: impl Into<String>,
312 pickle: Vec<u8>,
313 input_schema: Schema,
314 output_field: Field,
315 pool: Arc<PythonWorkerPool>,
316 ) -> Self {
317 Self {
318 name: name.into(),
319 pickle,
320 input_schema,
321 output_field,
322 pool,
323 }
324 }
325}
326
327fn push_state_frame(data: &mut Vec<u8>, ipc: &[u8]) -> Result<(), UdfError> {
331 let len =
332 u32::try_from(ipc.len()).map_err(|_| exec_err("aggregate state frame exceeds 4 GiB"))?;
333 data.extend_from_slice(&len.to_le_bytes());
334 data.extend_from_slice(ipc);
335 Ok(())
336}
337
338fn decode_state_frames(data: &[u8]) -> Result<Vec<RecordBatch>, UdfError> {
340 let mut batches = Vec::new();
341 let mut rest = data;
342 while !rest.is_empty() {
343 let (len_bytes, after_len) = rest
344 .split_at_checked(4)
345 .ok_or_else(|| exec_err("aggregate state truncated (length header)"))?;
346 let len_arr: [u8; 4] = len_bytes
347 .try_into()
348 .map_err(|_| exec_err("aggregate state length header not 4 bytes"))?;
349 let len = u32::from_le_bytes(len_arr) as usize;
350 let (frame, remainder) = after_len
351 .split_at_checked(len)
352 .ok_or_else(|| exec_err("aggregate state truncated (frame body)"))?;
353 rest = remainder;
354 let reader = StreamReader::try_new(std::io::Cursor::new(frame), None)
355 .map_err(|e| exec_err(format!("aggregate state IPC reader: {e}")))?;
356 for batch in reader {
357 batches.push(batch.map_err(|e| exec_err(format!("aggregate state IPC decode: {e}")))?);
358 }
359 }
360 Ok(batches)
361}
362
363impl AggregateUdf for PythonWorkerAggregateUdf {
364 fn name(&self) -> &str {
365 &self.name
366 }
367
368 fn input_schema(&self) -> &Schema {
369 &self.input_schema
370 }
371
372 fn output_field(&self) -> &Field {
373 &self.output_field
374 }
375
376 fn accumulate(&self, state: &mut AggState, batch: &RecordBatch) -> Result<(), UdfError> {
377 if batch.num_rows() == 0 {
378 return Ok(());
379 }
380 let ipc = write_ipc(batch)?;
381 push_state_frame(&mut state.data, &ipc)
382 }
383
384 fn merge(&self, mut a: AggState, b: AggState) -> Result<AggState, UdfError> {
385 a.data.extend_from_slice(&b.data);
388 Ok(a)
389 }
390
391 fn finalize(&self, state: AggState) -> Result<ScalarValue, UdfError> {
392 let schema = Arc::new(self.input_schema.clone());
393 let batches = decode_state_frames(&state.data)?;
394 let combined = if batches.is_empty() {
395 RecordBatch::new_empty(Arc::clone(&schema))
396 } else {
397 arrow::compute::concat_batches(&schema, &batches)
398 .map_err(|e| exec_err(format!("aggregate concat: {e}")))?
399 };
400 let array = self
401 .pool
402 .eval_aggregate(&self.name, &self.pickle, &combined)?;
403 scalar_from_array(&array, self.output_field.data_type())
404 }
405}
406
407fn scalar_from_array(
411 array: &ArrayRef,
412 want: &arrow::datatypes::DataType,
413) -> Result<ScalarValue, UdfError> {
414 use arrow::array::{BooleanArray, Float64Array, Int64Array, StringArray};
415 use arrow::datatypes::DataType;
416
417 if array.is_empty() || array.is_null(0) {
418 return Ok(ScalarValue::Null);
419 }
420 let casted = if array.data_type() == want {
421 Arc::clone(array)
422 } else {
423 arrow::compute::cast(array, want)
424 .map_err(|e| exec_err(format!("aggregate result cast to {want:?}: {e}")))?
425 };
426 let downcast_err = |t: &str| exec_err(format!("aggregate result not a {t} array"));
427 match want {
428 DataType::Float64 => {
429 let a = casted
430 .as_any()
431 .downcast_ref::<Float64Array>()
432 .ok_or_else(|| downcast_err("Float64"))?;
433 Ok(ScalarValue::Float64(a.value(0)))
434 }
435 DataType::Int64 => {
436 let a = casted
437 .as_any()
438 .downcast_ref::<Int64Array>()
439 .ok_or_else(|| downcast_err("Int64"))?;
440 Ok(ScalarValue::Int64(a.value(0)))
441 }
442 DataType::Boolean => {
443 let a = casted
444 .as_any()
445 .downcast_ref::<BooleanArray>()
446 .ok_or_else(|| downcast_err("Boolean"))?;
447 Ok(ScalarValue::Boolean(a.value(0)))
448 }
449 DataType::Utf8 => {
450 let a = casted
451 .as_any()
452 .downcast_ref::<StringArray>()
453 .ok_or_else(|| downcast_err("Utf8"))?;
454 Ok(ScalarValue::Utf8(a.value(0).to_string()))
455 }
456 other => Err(exec_err(format!(
457 "unsupported aggregate output type {other:?}"
458 ))),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use arrow::array::{Float64Array, Int64Array};
466 use arrow::datatypes::DataType;
467
468 fn cloudpickle(expr: &str) -> Option<Vec<u8>> {
471 let out = Command::new("python3")
472 .arg("-c")
473 .arg(format!(
474 "import sys,cloudpickle; sys.stdout.buffer.write(cloudpickle.dumps({expr}))"
475 ))
476 .output()
477 .ok()?;
478 if out.status.success() && !out.stdout.is_empty() {
479 Some(out.stdout)
480 } else {
481 None
482 }
483 }
484
485 #[test]
486 fn python_worker_runs_scalar_and_caches() {
487 let Some(pickle) = cloudpickle("lambda x: x + 1000") else {
488 eprintln!("skipping: python3/cloudpickle unavailable");
489 return;
490 };
491 let pool = PythonWorkerPool::spawn().expect("spawn worker");
492 let udf = PythonWorkerUdf::new(
493 "inc",
494 pickle,
495 Schema::new(vec![Field::new("a0", DataType::Int64, true)]),
496 Field::new("out", DataType::Int64, true),
497 pool,
498 );
499 let batch = RecordBatch::try_new(
500 Arc::new(udf.input_schema().clone()),
501 vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
502 )
503 .unwrap();
504 for _ in 0..2 {
506 let out = udf.call(&batch).expect("udf call");
507 let vals = out.as_any().downcast_ref::<Int64Array>().unwrap();
508 assert_eq!(vals.values(), &[1001, 1002, 1003]);
509 }
510 }
511
512 #[test]
513 fn python_worker_vectorized_numpy() {
514 let expr = "(lambda: (lambda f: (setattr(f, '_krishiv_arrow_udf', True), f)[1])(\
517 __import__('cloudpickle') and (lambda b: __import__('pyarrow').array(\
518 __import__('numpy').sqrt(b.column(0).to_numpy(zero_copy_only=False))))))()";
519 let Some(pickle) = cloudpickle(expr) else {
520 eprintln!("skipping: python3/numpy/cloudpickle unavailable");
521 return;
522 };
523 let pool = PythonWorkerPool::spawn().expect("spawn worker");
524 let udf = PythonWorkerUdf::new(
525 "vsqrt",
526 pickle,
527 Schema::new(vec![Field::new("a0", DataType::Float64, true)]),
528 Field::new("out", DataType::Float64, true),
529 pool,
530 );
531 let batch = RecordBatch::try_new(
532 Arc::new(udf.input_schema().clone()),
533 vec![Arc::new(Float64Array::from(vec![4.0, 9.0, 16.0]))],
534 )
535 .unwrap();
536 let out = udf.call(&batch).expect("udf call");
537 let vals = out.as_any().downcast_ref::<Float64Array>().unwrap();
538 assert_eq!(vals.values(), &[2.0, 3.0, 4.0]);
539 }
540
541 #[test]
542 fn python_aggregate_merges_partial_states() {
543 let expr = "lambda a: float(__import__('numpy').exp(__import__('numpy').log(a).mean()))";
547 let Some(pickle) = cloudpickle(expr) else {
548 eprintln!("skipping: python3/numpy/cloudpickle unavailable");
549 return;
550 };
551 let pool = PythonWorkerPool::spawn().expect("spawn worker");
552 let udf = PythonWorkerAggregateUdf::new(
553 "geomean",
554 pickle,
555 Schema::new(vec![Field::new("a0", DataType::Float64, true)]),
556 Field::new("out", DataType::Float64, true),
557 pool,
558 );
559 let schema = Arc::new(udf.input_schema().clone());
560 let mk = |vals: Vec<f64>| {
561 RecordBatch::try_new(
562 Arc::clone(&schema),
563 vec![Arc::new(Float64Array::from(vals))],
564 )
565 .unwrap()
566 };
567
568 let mut state_a = AggState::default();
570 udf.accumulate(&mut state_a, &mk(vec![1.0, 2.0, 4.0]))
571 .unwrap();
572 let mut state_b = AggState::default();
573 udf.accumulate(&mut state_b, &mk(vec![8.0, 16.0])).unwrap();
574
575 let merged = udf.merge(state_a, state_b).unwrap();
576 let result = udf.finalize(merged).expect("finalize");
577 match result {
578 ScalarValue::Float64(v) => assert!(
579 (v - 4.0).abs() < 1e-9,
580 "geomean of 1,2,4,8,16 should be 4.0, got {v}"
581 ),
582 other => panic!("expected Float64, got {other:?}"),
583 }
584 }
585
586 #[test]
587 fn python_aggregate_int_result_casts_to_declared_type() {
588 let Some(pickle) = cloudpickle("lambda a: int(len(a))") else {
590 eprintln!("skipping: python3/cloudpickle unavailable");
591 return;
592 };
593 let pool = PythonWorkerPool::spawn().expect("spawn worker");
594 let udf = PythonWorkerAggregateUdf::new(
595 "cnt",
596 pickle,
597 Schema::new(vec![Field::new("a0", DataType::Int64, true)]),
598 Field::new("out", DataType::Int64, true),
599 pool,
600 );
601 let schema = Arc::new(udf.input_schema().clone());
602 let mut state = AggState::default();
603 udf.accumulate(
604 &mut state,
605 &RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![5, 6, 7, 8]))])
606 .unwrap(),
607 )
608 .unwrap();
609 match udf.finalize(state).expect("finalize") {
610 ScalarValue::Int64(v) => assert_eq!(v, 4),
611 other => panic!("expected Int64, got {other:?}"),
612 }
613 }
614}