Skip to content

feat(query): sandbox Python UDF workers and bound script UDF resources - #20830

Merged
sundy-li merged 8 commits into
databendlabs:mainfrom
sundy-li:feat/python-udf-sandbox
Oct 28, 2026
Merged

sundy-li merged 8 commits into
databendlabs:mainfrom
sundy-li:feat/python-udf-sandbox

Conversation

@sundy-li

@sundy-li sundy-li commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

I hereby agree to the terms of the CLA available at: https://docs.databend.com/dev/policies/cla/

Summary

Python script UDF workers run arbitrary user code. Before this PR they inherited the query node's environment (cloud credentials), could read its config/data files and /proc/<query pid>/environ, connect to meta/storage/metadata endpoints, spawn programs, and pip install accepted option-like requirements (--index-url=..., -r /path) and ran sdist build scripts on the node. JavaScript/WebAssembly UDFs were isolated but unbounded (a while(true){} or a large allocation could hang or OOM the node).

Python worker sandbox (python_sandbox.rs)

After the interpreter starts and before any user code runs, each Linux worker enters an unprivileged sandbox. There is no switch: if it cannot be entered the worker fails with Python UDF sandbox is unavailable, refusing to run user code.

  • Landlock: read-only Python installation (sys.path, stdlib, site-packages), system libraries and a few device/proc entries; read-write only the worker's private directory (cwd, TMPDIR, deps, imports). On newer ABIs also TCP and signal/abstract-socket scoping.
  • seccomp (deny list): socket, execve, kill/tgkill outside the worker, ptrace, process_vm_*, io_uring_*, bpf, namespaces (clone with CLONE_NEW*, clone3 → ENOSYS), mounts, kernel modules, path-based chmod/chown/xattr, x32 syscalls.
  • macOS (local development only) runs workers unconfined and logs a warning.

Requirements: Linux 5.13+ with Landlock enabled, x86_64/aarch64, container seccomp profile permitting landlock_*.

Hardening

  • Workers: env_clear() + allowlist, no_new_privs, RLIMIT_CORE=0.
  • pip: requirements validated as name[extras]<specifiers>, passed after --, allowlisted environment (keeps PIP_*/proxy/CA for mirrors), --only-binary=:all: by default (python_udf_pip_only_binary = true).
  • Python UDAF: now checked against enable_udf_python_script at create/bind/execute, rejected when enable_udf_sandbox = true (that mode routes Python to an external sandbox with no aggregate API). WASM UDAF returns an error instead of panicking.

Python UDAF in worker processes

Python aggregates no longer run in the query process. They use the same query-owned worker pool, quotas and sandbox as scalar Python UDFs. Workers are stateless: states stay in the query process (serialized for spill/exchange as before) and travel with each request. accumulate_keys and merge_serialized send one batched request per block (distinct states + row-to-state index, applied in row order); merge_result is still one request per group. The initial state is created lazily so later Python runtimes of the same query can still register during pipeline build.

  • JS/WASM: udf_script_memory_limit_mb = 1024 (JS heap, WASM linear memory) and udf_script_execution_timeout_secs = 300 (JS per call and top-level module code).

Not covered (documented in script_udf_support/README.md)

Python worker memory/CPU limits, WASM execution timeout (needs arrow-udf-runtime change), query cancellation inside a JS/WASM call, batched finalization for Python UDAF, packages/imports for Python UDAF.

Tests

  • Unit Test

  • Logic Test

  • Benchmark Test

  • No Test - Explain why

  • python_worker_process: escape attempts (read secret file, /etc/passwd, parent environ, list /, write/unlink/chmod outside, TCP to a listening port, Unix socket, subprocess, signal parent, kill(-1)) all raise PermissionError; private temp files, threads, stdlib still work; Seccomp: 2, NoNewPrivs: 1, no env leak, pip injection rejected. Negative control: removing the sandbox call makes the test fail.

  • seccomp program evaluated by a BPF interpreter unit test; requirement validator, JS timeout/memory, config mapping unit tests.

  • sql type-check tests for UDAF language validation; system.configs golden updated.

  • python_worker_process UDAF cases: GROUP BY through batched accumulate_keys with interleaved places and NULLs, single-state accumulate, serialize + merge_serialized with a repeated target place, merge_states, empty state, scalar UDF registered after the UDAF, UDAF pid differs from the query process, open('/etc/passwd') in a UDAF raises PermissionError.

  • tests/suites/5_ee/08_udf_python/08_0004_hardening.sh, 08_0005_udaf.sh.

  • Manually: numpy + pandas import and compute inside the sandbox.

Type of change

  • Bug Fix (non-breaking change which fixes an issue)
  • New Feature (non-breaking change which adds functionality)
  • Breaking Change (fix or feature that could cause existing functionality not to work as expected)
  • Documentation Update
  • Refactoring
  • Performance Improvement
  • Other (please describe):

Breaking for Python UDFs that use subprocess, network, files outside their directory, multiprocessing locks, sdist-only dependencies, or run on Linux without Landlock; and for Python UDAFs when enable_udf_sandbox = true.

AI assistance

  • AI usage: An AI coding agent drafted the implementation, tests and docs under src/query/script_udf_support, the UDF validator and config; the responsible human reviews the final diff.
  • Responsible human: @sundy-li
  • The responsible human has read every line of this diff and can explain each change

This change is Reviewable

Python script UDF workers ran user code with the query node's environment,
files, network and processes in reach. Harden and confine them:

- Workers start with a cleared environment (allowlist only), no_new_privs
  and RLIMIT_CORE=0, and a private TMPDIR.
- Before user code runs, Linux workers enter an unprivileged Landlock +
  seccomp sandbox: read-only Python/system libraries, read-write only the
  private directory, no sockets, no execve, no signals outside the worker,
  no ptrace/io_uring/bpf/namespaces/mounts. Failure to enter it is fatal.
  macOS workers run unconfined for local development and log a warning.
- pip only accepts `name[extras]<specifiers>` requirements after `--`, runs
  with an environment allowlist and installs wheels only by default
  (`python_udf_pip_only_binary`).
- Python aggregate UDFs, which still run in-process, are validated like
  scalar ones and rejected when `enable_udf_sandbox` is on.
- JavaScript and WebAssembly UDFs get memory limits and JavaScript an
  execution timeout (`udf_script_memory_limit_mb`,
  `udf_script_execution_timeout_secs`).
@github-actions github-actions Bot added the pr-feature this PR introduces a new feature to the codebase label Oct 24, 2026
fork/vfork and clone without CLONE_THREAD are rejected, so user code cannot escape the per-worker process accounting or fork-bomb the node.
…on UDF sandbox

The query binary runs jemalloc with background_thread:true, so workers had purge threads when entering the sandbox and refused to run. Stop them first; the worker process test now uses the query allocator to cover this.
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

⏱ CI timing · Dev

run 40439332445 · 40.1 min (main p50 35.6 min, +4.5 min ⚠️)

stage this run main p50 Δ
total 40.1 min 35.6 min +4.5 min ⚠️
build (aarch64, ci profile) 26.1 min 16.6 min +9.5 min ⚠️
├ deps + crates (parallel) 13.4 min — —
├ databend-query codegen 2.7 min — —
├ databend-binaries link 7.6 min — —
└ sccache hit rate 94.5 % — —
test_unit 19.1 min 15.9 min +3.2 min ⚠️
mac_check — 13.2 min —
tests after build (49 jobs) 11.4 min 12.7 min −1.3 min
├ queue p50 / max 2.8 min / 6.4 min 2.1 min / 6.6 min +0.7 min
├ sqllogic standalone (8 jobs, max) 4.3 min 4.1 min +0.2 min
├ sqllogic cluster (8 jobs, max) 6.9 min 6.7 min +0.3 min
├ sqllogic stage (2 jobs, max) 4.7 min 4.9 min −0.2 min
├ sqllogic other (10 jobs, max) 6.2 min 5.5 min +0.7 min
├ stateless (2 jobs, max) 6.6 min 6.3 min +0.3 min
├ stateful (4 jobs, max) 4.8 min 4.8 min +0.0 min
├ compat (6 jobs, max) 5.3 min 4.9 min +0.4 min
├ meta / logs / lineage / udf (5 jobs, max) 4.5 min 4.4 min +0.0 min
├ ee (2 jobs, max) 1.4 min 1.3 min +0.1 min
├ private_tasks (1 jobs, max) 9.0 min 8.8 min +0.2 min
└ fuzz (1 jobs, max) 1.1 min 1.0 min +0.1 min

Minutes unless stated; durations exclude queue time unless stated; "max" = longest job in the group. Baseline = median of the last 10 successful runs (Dev: Main on main; Cloud: recent Cloud runs). Build breakdown has no baseline. Updated on each push.

Python UDAFs ran user code inside the query process. They now use the same
query-owned worker pool, quotas and Landlock/seccomp sandbox as scalar
Python UDFs:

- Workers load create_state/accumulate/merge/finish for an aggregate and
  serve stateless requests; states stay in the query process.
- accumulate_keys and merge_serialized send one batched request per block
  (distinct states + row-to-state index), applied in row order in the worker.
- The initial state is created lazily so other Python runtimes of the query
  can still register while the pipeline is built.
- Assignments replace AggrState::write on existing states, so replaced
  states are dropped instead of leaked.
Partial and final aggregation build separate UDAF instances; each registered its own Python runtime and spent the per-query worker minimum, so a UDAF plus a scalar Python UDF exceeded python_udf_max_workers_per_query = 2. Stateless aggregate workers can be shared, so reuse the query's runtime for an identical aggregate function.
@sundy-li
sundy-li marked this pull request as ready for review October 25, 2026 15:38

@sundy-li sundy-li left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted author-side review of 78b5a17. I recommend addressing the two inline findings before merge; this is not a human approval.

Validation: cargo test -p databend-query-script-udf-support --features python-udf --locked passed all 37 unit tests and the custom worker-process regression suite with Python 3.12; targeted cargo clippy -p databend-query-script-udf-support --features python-udf --all-targets --locked -- -D warnings, git diff --check, and bash -n on both new shell tests passed. An independent, bounded Arrow 58.4.0 probe confirmed the state-buffer retention described inline. The JS finding is based on tracing the pinned dependency, not an adversarial reproduction. SQL service/cluster suites were not rerun locally.

The authenticated account is also the PR author, so this is a comment review rather than a formal approval/request-changes review. A responsible human still needs to read the full diff and complete the PR's unchecked acknowledgement before merge.

- JavaScript: arrow-udf-runtime arms its deadline only around the user
  function call, so argument/result conversion (struct getters, toJSON) and
  async continuations ran unbounded. LimitedJsRuntime owns the interrupt
  handler and arms one deadline per whole operation, also covering
  top-level module code during initialization.
- Python UDAF: per-group states were 1-row slices of the worker response,
  so every state of a block retained the whole response buffer. Copy each
  group's row into its own buffers.
@sundy-li
sundy-li requested a review from forsaken628 October 26, 2026 03:43

@sundy-li sundy-li left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI-assisted follow-up review of 5da805c, focusing on the delta since the previous review.

The JS deadline finding is addressed: LimitedJsRuntime installs an independent handler and scopes the deadline across initialization and every scalar/aggregate engine operation, including output conversion. The new conversion regression passes.

The Python aggregate retention finding is only partially addressed: take copies ordinary Utf8/numeric buffers but still shares Utf8View payload buffers, and Utf8View is the production mapping for STRING state fields. One remaining P1 is detailed inline, so I do not recommend approval yet.

Validation with Python 3.12: all 39 crate unit tests and the custom python_worker_process suite passed; targeted clippy with --all-targets and -D warnings, git diff --check, and bash -n for both added shell tests passed. A bounded standalone probe against the exact pinned Arrow source confirms the remaining view-buffer sharing. The latest visible checks show 61 successful and 2 skipped checks. SQL service/cluster suites were not rerun locally. No repository files were changed.

This is an author-account comment review, not a formal independent approval or request-changes review.

@forsaken628

Copy link
Copy Markdown
Collaborator

I think the runtime-level changes in this PR would be better implemented in the arrow-udf repository, with this PR focused on integrating them into Databend. We already depend on the datafuse-extras/arrow-udf fork, so that seems like a practical place to develop these capabilities.

In particular, the operation-wide JavaScript deadline belongs in arrow-udf-runtime: callers should not need a wrapper to ensure that initialization and result conversion are covered by execution limits. Likewise, Python worker execution, Arrow IPC, process lifecycle management, and OS sandboxing are reusable execution-layer capabilities. These could live in a separate worker crate alongside the existing in-process runtime.

Databend should retain the application-specific pieces: configuration and defaults, SQL validation, query admission and scheduling, pipeline lifecycle integration, and aggregate-state ownership. The worker launch interface would also need to accommodate host-specific initialization rather than depending directly on the Databend executable or allocator setup.

Could we split the change along this boundary, implementing the reusable runtime/worker capabilities in the existing fork and keeping the Databend-side integration here? This would give those capabilities a clearer API and independent test coverage, while avoiding a substantial runtime implementation inside the query engine.

…fork

Per review, reusable execution-layer code now lives in
datafuse-extras/arrow-udf (datafuse-extras/arrow-udf#3), pinned at
ffd6f943:

- arrow-udf-python-worker: worker launch, IPC protocol, process lifecycle,
  environment allowlist, pre-exec hardening and the Landlock/seccomp
  sandbox. Removes python_sandbox.rs and the protocol/process code from
  python_worker.rs.
- arrow-udf-runtime: operation-wide JavaScript deadline and the
  set_memory_limit(None) fix. Removes LimitedJsRuntime; Databend sets the
  limits before adding functions.

Databend keeps configuration, SQL validation, admission/quotas/pooling,
pipeline lifecycle, aggregate-state ownership, dependency installation and
the worker entrypoint, which stops jemalloc background threads through
WorkerHooks::before_sandbox.

Tests: the worker-process suite checks that the worker installs its own
seccomp filter (containers already filter every process) and allows 5s for
startup in the timeout scenario (debug binary exec alone takes ~1s).
@sundy-li

Copy link
Copy Markdown
Member Author

@forsaken628 Thanks — split along that boundary.

datafuse-extras/arrow-udf#3 (pinned here at ffd6f943):

  • arrow-udf-runtime: set_timeout now bounds whole operations (argument/result conversion incl. getters and toJSON, async continuations, each table-function chunk, aggregate steps) and top-level module code in add_function/add_aggregate; also fixes set_memory_limit(None) setting a 0-byte limit. LimitedJsRuntime is gone from Databend.
  • New arrow-udf-python-worker crate: WorkerProcess (launch via a host-provided WorkerCommand, env allowlist, no_new_privs/no core dumps, Arrow IPC over a private socket, kill/reap of the process group with a host keep_alive held until exit), run_worker (scalar + batched stateless aggregate protocol) and the Landlock/seccomp sandbox. Host-specific init goes through WorkerHooks::before_sandbox instead of depending on the Databend executable or allocator. It has its own unit tests and a self-spawning integration suite (Linux + macOS in the fork's CI).

This PR keeps configuration and defaults, SQL validation, admission/quotas/pooling, pipeline lifecycle, aggregate-state ownership, dependency installation and the worker entrypoint (run_python_udf_worker → run_worker with a hook that stops jemalloc background threads). python_sandbox.rs is removed; python_worker.rs keeps only scheduling and a thin Worker wrapper around WorkerProcess.

Order: once arrow-udf#3 is merged I'll re-pin to its merge commit on the fork's main.

83f98e3 is the merge commit on the fork's main; same tree as the
previously pinned PR branch plus CI-only fixes.
@sundy-li

Copy link
Copy Markdown
Member Author

datafuse-extras/arrow-udf#3 is merged; arrow-udf-runtime and arrow-udf-python-worker are now pinned to its merge commit 83f98e3 on the fork's main.

@sundy-li
sundy-li enabled auto-merge October 28, 2026 06:56
@sundy-li
sundy-li added this pull request to the merge queue Oct 28, 2026
Merged via the queue into databendlabs:main with commit b0d1a19 Oct 28, 2026
63 checks passed
@sundy-li
sundy-li deleted the feat/python-udf-sandbox branch October 28, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-feature this PR introduces a new feature to the codebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants