Files
windmill/docs/duckdb-execution.md
T
f9513e82ee feat: run duckdb jobs out of process under job isolation, drop the engine fork, bump to 1.5.6 (#11780)
* feat: prototype running duckdb jobs out of process inside nsjail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: bound runner output, honor force install outside the jail, set oom score before exec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: gate the duckdb runner bin behind a feature and decode pre-install errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: allowlist-only policy inside the duckdb jail, with its own extension cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: run duckdb jobs out of process under job isolation and drop the engine fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: runner everywhere, stock duckdb engine, no sql fence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 24bdc6615b3f651141fb740e2cacf9c3c2d81252

This commit updates the EE repository reference after PR #884 was merged in windmill-ee-private.

Previous ee-repo-ref: 73452bbc280cc9ca33cf0fe325d26c2fbf3aace4

New ee-repo-ref: 24bdc6615b3f651141fb740e2cacf9c3c2d81252

Automated by sync-ee-ref workflow.

* fix: pass the windows system env and TZ to the duckdb runner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: cap a jailed duckdb job's disk use from the worker, 6GiB by default on cloud

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep a disk-backed jail tmp under the duckdb disk cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the benchmark scaffolding and bump duckdb to 1.5.6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-15 16:49:50 +02:00

119 lines
7.3 KiB
Markdown

# How DuckDB jobs run
A DuckDB job runs in `windmill_duckdb_runner`, a binary built from
`backend/windmill-duckdb-ffi-internal` (`src/bin`) and shipped next to the `windmill` binary.
The worker spawns one runner per DuckDB connection a job opens: the job itself, and the
`on_schema_change=sync` pre-pass. It never loads the engine into its own process.
The runner gets the same job isolation as every other language. `duckdb_runner::runner_mode`
decides it per job:
| worker isolates with | runner runs | what it can see |
|---|---|---|
| nsjail | inside `run.duckdb.config.proto` | read-only `/usr`, `/lib`, `/etc`; its own `/tmp`; its job dir read-only, except `duckdb/` (spill + result); a read-only extension cache |
| unshare | under `build_command_with_isolation` | the worker user's file system, its own PID namespace, a cleared environment |
| nothing | as a plain child process | the worker user's file system, a cleared environment |
There is no SQL-level policy on top. A script can read and write what the jail lets it and load
any extension DuckDB will sign, as a Python job can read the same files and load native code.
Under nsjail its disk use is capped from the worker (below). Network egress is open in every
mode, as it is for the other languages: the S3 proxy, metadata endpoints and internal hosts are
all reachable.
## What crosses to the runner
`spawn_runner` writes one JSON request to the runner's stdin, so nothing secret is put on argv or
into the environment:
- the blocks, already rewritten by the worker: resource `ATTACH`es, DuckLake and datatable
attaches with their credentials inlined, workspace macros;
- the job args, the job token, `base_internal_url` and the workspace id. The runner derives the
S3/GCS secrets for the workspace's S3 proxy from the token, which it reaches over the network;
- `memory_limit` (`DUCKDB_MEMORY_LIMIT`, or 80% of the worker's cgroup), the temp directory (the
job dir), and the result-size cap (`MAX_SQL_RESULT_SIZE`).
The environment is cleared except `PATH`, `HOME` (outside nsjail), `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`,
`GOOGLE_APPLICATION_CREDENTIALS` (BigQuery `ATTACH`, whose credentials file nsjail mounts) and the
proxy variables. In particular a script cannot use the worker's own `AWS_*` credential chain.
Under nsjail the job dir is mounted read-only at its host path, so every path the worker wrote
into the blocks (Postgres root certificates) resolves unchanged. The one writable place on the
host is `{job dir}/duckdb/`: the spill directory and the result file, which the worker opens
without following symlinks, since the script can write there.
The runner writes its answer to a file in that directory: a first line with the last statement's
column order, then either the collected results or `ERROR <json-encoded message>`.
`finish_run` / `finish_prepare` in `duckdb_executor.rs` turn that into the job result.
## Failure, cancellation and memory
- **A crash fails the job, never the worker.** A runner that exits without writing its result
becomes `The DuckDB engine process … The worker is unaffected.` with the tail of its stderr.
An engine `std::terminate` (for example a remote `temp_directory` that has to spill) and a
panic in result rendering both land here.
- **Cancellation and timeouts kill the engine.** The executor drops the job future, and
`KillGroupOnDrop` kills the runner's whole process group. Killing the spawned process alone is
not enough: `unshare --fork` leaves its child running.
- **The kernel OOM-kills the runner, not the worker.** `JOB_OOM_SCORE_ADJ` is written in
`pre_exec`, before nsjail or `unshare` is exec'd, so the runner inherits it. Written after spawn
it would race their fork, and the jail's `/proc` is read-only. Within the job, DuckDB bounds
itself through `memory_limit` and spills past it.
- **Runner output is bounded.** stdout/stderr are drained keeping only the last 4 KB: a script
controls how much the engine prints (`CALL enable_logging(storage='stdout')`).
## Extensions
Outside nsjail the runner uses the worker user's `~/.duckdb/extensions` and installs whatever a
script names.
Under nsjail it gets its own cache, `~/.duckdb/windmill_jail_extensions`, mounted read-only.
Only the worker writes to it: before a jailed run it starts the runner unjailed in `install`
mode, with only `INSTALL` statements it built itself, for `httpfs`, `azure`, `parquet`, `json`
and every extension a block installs by name. That runs no script SQL and loads nothing, so no
extension code runs outside the jail. It happens once per extension per worker process, except
for `FORCE INSTALL`, which is honored every time and becomes a plain `INSTALL` inside the jail.
A job can therefore neither add to nor replace what the next job loads from that cache. An
extension used without being named and outside those four (`icu` behind `SET TimeZone`, say)
fails on the read-only mount: name it with `INSTALL`.
A script can still point `extension_directory` somewhere writable and install into it. That only
affects its own connection, and is no more than a Python job could do.
## The engine
The runner links duckdb-rs from crates.io, pinned with `=` so `cargo update` cannot move the
engine. The engine loads the **prebuilt** extensions from `extensions.duckdb.org`, compiled for the
exact release it reports, so an engine bump is a version change in `Cargo.toml` and nothing else.
Confinement belongs to the job isolation, not to engine settings. DuckDB spills through the same
file system a query reads with, so `disabled_filesystems='LocalFileSystem'` also stops every query
that outgrows `memory_limit`, and no combination of stock settings gives "spill, but no other local
file access". Fencing the engine at the SQL level again would need a patched engine to keep
spilling.
## Bounding disk use
The cap is `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, or `6GiB` on a `CLOUD_HOSTED` worker that does not
set it. The cloud runs one worker per pod on nodes with no per-pod ephemeral-storage limit, and a
worker pod already holds several GiB of runtime caches, so the cap is sized against a mostly full
node; re-measure before raising it. Unset elsewhere, DuckDB's own default applies: 90% of the
temp volume's free space.
It is applied twice:
- **DuckDB's `max_temp_directory_size`**, set on every connection, so an ordinary query that
outgrows it fails with the engine's `failed to offload data block … max_temp_directory_size`
error and cleans up its temp files.
- **A worker-side watchdog**, under nsjail. A script can raise the engine setting, so on its own
it is only a default. The worker sums the allocated size of `{job dir}/duckdb/` once a second
and kills the runner when it passes the cap, whatever wrote the bytes: spill, `COPY … TO`, or
the result file. With a disk-backed jail `/tmp` (`NSJAIL_TMP_BACKING=disk`) that `/tmp` lives
inside the same directory, so it counts too; a RAM-backed one is bounded by its tmpfs size.
Enforcement lags by up to a second of writing. Outside nsjail the script can
write anywhere the worker user can, as other languages can, so there the cap stays a default.
The value is DuckDB's spelling, not Kubernetes': `KiB`/`MiB`/`GiB`/`TiB` for 1024-based units,
`KB`/`MB`/`GB`/`TB` for 1000-based. An unparseable value fails every DuckDB job on the worker
rather than being ignored. `DUCKDB_MEMORY_LIMIT` is spelled the same way, and the two are set in
separate statements so a parse error names the right variable.