* 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>
7.3 KiB
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
ATTACHes, DuckLake and datatable attaches with their credentials inlined, workspace macros; - the job args, the job token,
base_internal_urland 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 enginestd::terminate(for example a remotetemp_directorythat 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
KillGroupOnDropkills the runner's whole process group. Killing the spawned process alone is not enough:unshare --forkleaves its child running. - The kernel OOM-kills the runner, not the worker.
JOB_OOM_SCORE_ADJis written inpre_exec, before nsjail orunshareis exec'd, so the runner inherits it. Written after spawn it would race their fork, and the jail's/procis read-only. Within the job, DuckDB bounds itself throughmemory_limitand 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'sfailed to offload data block … max_temp_directory_sizeerror 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/tmplives 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.