Skip to main content

krishiv_sql/
python_udf.rs

1//! Distributed Python UDF execution.
2//!
3//! The engine's executors are pure Rust with no embedded interpreter, so a
4//! Python-callable UDF cannot run in-process there. This module runs it in a
5//! persistent `python3` worker subprocess instead (the model PySpark uses):
6//! the client cloudpickles the callable and ships the bytes with the query; the
7//! executor spawns one worker per engine and applies the UDF to each Arrow
8//! batch over a length-framed stdin/stdout protocol. The worker caches each UDF
9//! by id after first use, so the pickle travels once.
10//!
11//! Requires `python3` on `PATH` with `pyarrow` and `cloudpickle` (plus whatever
12//! the UDF itself imports) available in the runtime environment.
13
14use 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
25/// The worker program, embedded in the binary and launched via `python3 -c`.
26const WORKER_SRC: &str = include_str!("udf_worker.py");
27
28/// Worker request mode: apply the callable per row (scalar UDF).
29const WORKER_MODE_SCALAR: u8 = 0;
30/// Worker request mode: apply the callable to the whole accumulated group
31/// (aggregate-UDF finalize), returning one scalar.
32const WORKER_MODE_AGGREGATE: u8 = 1;
33
34/// Process-global worker pool. One `python3` worker per process (executor or
35/// embedded engine) is spawned lazily on first Python-UDF use and shared by all
36/// engines/tasks; UDFs are distinguished by name, and access is serialized. This
37/// avoids one process-spawn per UDF and keeps hot imports (numpy, a model) loaded.
38pub 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
57/// A persistent `python3` worker that applies cloudpickled UDFs over Arrow IPC.
58/// One pool is shared by every Python UDF in an engine; access is serialized
59/// through the mutex (one in-flight batch at a time per worker).
60pub struct PythonWorkerPool {
61    io: Mutex<WorkerIo>,
62}
63
64struct WorkerIo {
65    child: Child,
66    stdin: ChildStdin,
67    stdout: ChildStdout,
68    /// UDF ids whose pickle has already been sent (and cached worker-side).
69    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    /// Spawn the worker process. Fails if `python3` is unavailable.
80    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    /// Apply `pickle` (the cloudpickled callable) over `batch` in scalar mode,
107    /// returning one output value per input row.
108    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    /// Apply `pickle` over the whole `batch` in aggregate-finalize mode: the
113    /// callable receives the accumulated group's column(s) and returns a single
114    /// scalar, delivered back as a one-row single-column array.
115    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    /// Shared request/response cycle for both worker modes. The pickle is sent
125    /// to the worker only the first time an `id` is seen; later calls reuse the
126    /// cached callable.
127    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            // Worker-side failure: drop the cached id so a re-register re-sends.
165            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        // Closing stdin makes the worker's read loop hit EOF and exit cleanly.
178        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
220/// A scalar UDF whose implementation is a cloudpickled Python callable executed
221/// in a [`PythonWorkerPool`]. Ships to and runs on the distributed executors.
222pub 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
275// ── Aggregate (GROUPED_AGG) Python UDF ──────────────────────────────────────
276
277use krishiv_plan::udf::{AggState, AggregateUdf, ScalarValue};
278
279/// An aggregate UDF whose implementation is a cloudpickled Python callable.
280///
281/// Semantics follow PySpark's `GROUPED_AGG` pandas UDF: the accumulated rows of
282/// a group are buffered (as Arrow IPC frames appended into [`AggState`]) and the
283/// callable is applied to the whole group exactly once at finalize. This makes
284/// the aggregate trivially mergeable across partitions and executors — `merge`
285/// is byte concatenation of two partial buffers — so it works in distributed
286/// two-phase aggregation (partial per map task, final after the shuffle) through
287/// the existing [`crate::udf`] `KrishivAggregateAccumulator` bridge.
288///
289/// The callable receives each input column of the group as a numpy array (one
290/// positional argument per input column) and returns a Python scalar; a callable
291/// marked `_krishiv_arrow_udf=True` instead receives the whole Arrow batch.
292pub 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
327/// Append one Arrow-IPC-encoded batch as a length-prefixed frame onto a state
328/// buffer. The buffer is a flat concatenation of `[u32 le len][ipc]` frames, so
329/// `accumulate` is O(batch) and `merge` is O(1) byte concatenation.
330fn 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
338/// Decode all length-prefixed IPC frames in a state buffer back into batches.
339fn 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        // Both buffers are already sequences of length-prefixed frames, so a
386        // merge is exactly their concatenation.
387        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
407/// Extract element 0 of a one-row worker result array as a [`ScalarValue`] of
408/// the declared output type, casting first so a Python `int` result satisfies a
409/// declared `float64` output (and similar widenings).
410fn 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    /// Ask python3 to cloudpickle a lambda and return the bytes, so the Rust
469    /// test exercises the real serialization path.
470    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        // First call sends the pickle; second reuses the cached callable.
505        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        // A vectorized (arrow-native) UDF using numpy inside — the "heavy Python"
515        // case that cannot be a SQL expression.
516        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        // Geometric mean = exp(mean(log(x))): a genuinely custom aggregate that
544        // is not a SQL SUM/AVG. Split the input across two partial states and
545        // merge them, exercising the distributed two-phase path.
546        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        // Partition A accumulates {1,2,4}; partition B accumulates {8,16}.
569        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        // A Python callable returning an int64 count, declared as int64 output.
589        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}