mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
* 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>
119 lines
7.3 KiB
Markdown
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.
|