Skip to content

feat: tell the job how large its claim is - #4

Open
stephenc wants to merge 4 commits into
mainfrom
feat/claim-env
Open

feat: tell the job how large its claim is#4
stephenc wants to merge 4 commits into
mainfrom
feat/claim-env

Conversation

@stephenc

@stephenc stephenc commented Aug 6, 2026

Copy link
Copy Markdown
Owner

A claim controls the queue, and it does not control the job. A job that asks
the machine how many cores it has receives the number of the MACHINE, so a job
with a claim of 2 cores on a machine of 16 starts 16 threads. It then takes the
capacity that qex gave to the other jobs, and it breaks the promise that it
made.

Most runtimes read a variable in place of the machine. qex now writes those
variables from the resolved claim:

$ qex submit --cpu 2 --mem 2GB -- go run main.go
NumCPU=16 GOMAXPROCS=2

An agent that queues a build, a test suite or a training run thus receives the
share that it asked for, and the other users of the machine keep theirs.

QEX_CPU, QEX_MEM and QEX_MEM_MB hold the claim for your own script, so it
needs no other tool: make -j"$QEX_CPU". The runtimes receive GOMAXPROCS,
GOMEMLIMIT, OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS,
NUMEXPR_NUM_THREADS, VECLIB_MAXIMUM_THREADS, RAYON_NUM_THREADS,
CARGO_BUILD_JOBS, JULIA_NUM_THREADS, DOTNET_PROCESSOR_COUNT,
POLARS_MAX_THREADS, and NODE_OPTIONS with a heap of three quarters of the
claim.

This is the nearest thing to a limit that operates on macOS as well as on
Linux, and it needs no cgroup and no privilege. It stays a promise: a program
that asks the operating system directly still sees the whole machine.

The change of behaviour

A job that gives BOTH --cpu and --mem receives these variables. Every other
job receives none of them:

  • A job with no claim receives nothing. The default claim is one core, and a
    job that heard that number would run single-threaded on a large machine.
  • A job with a learned claim receives nothing. qex would measure the job that
    it made single-threaded, learn one core, and make the fault permanent.
  • A job that gives one half of the claim receives nothing. The other half comes
    from the defaults or from the learning, and that half is not a decision.

qex never replaces a value that is already there. A value from your shell,
from the job file or from --env is a decision that somebody made, and qex
fills the values that nobody chose.

--env-capture none receives no claim either. That mode promises [env] and
--env only, and none means none.

How to turn it off

Use qex submit --no-limit-env-hints for one job. Write
no_limit_env_hints = true in a job file or in a stage of a pipeline. Write
[claims] export_env = false for every job of the machine.

Two variables go the other direction: [claims] also = ["java", "make"] adds
JAVA_TOOL_OPTIONS and MAKEFLAGS. Each of the two has a cost, so neither is
a default. Every JVM writes Picked up JAVA_TOOL_OPTIONS: ... to its standard
error, and that line lands in the log of the job. MAKEFLAGS replaces the -j
of a Makefile that gives one.

A limit for the JVM needs -Xmx, and not -XX:MaxRAMPercentage: the JVM takes
that percentage of the memory of the MACHINE, so a claim of 2GB on a machine of
28GB still gave a heap of 7GB. node has no variable for the number of cores, so
node receives the memory only.

claude added 4 commits August 7, 2026 19:52
A claim controls the queue, and it does not control the job. A job that
asks the machine how many cores it has receives the number of the
MACHINE, so a job with a claim of two cores on a machine of sixteen
starts sixteen threads. It then takes the capacity that qex gave to the
other jobs, and the promise of the queue is broken by the job that made
it.

Most runtimes read a variable in place of the machine, so qex now writes
those variables from the resolved claim:

    $ qex submit --cpu 2 --mem 2GB -- go run main.go
    go: NumCPU=16 GOMAXPROCS=2

GOMAXPROCS, GOMEMLIMIT, OMP_NUM_THREADS, OPENBLAS_NUM_THREADS,
MKL_NUM_THREADS, NUMEXPR_NUM_THREADS, VECLIB_MAXIMUM_THREADS,
RAYON_NUM_THREADS, CARGO_BUILD_JOBS, JULIA_NUM_THREADS,
DOTNET_PROCESSOR_COUNT, POLARS_MAX_THREADS and NODE_OPTIONS. And
QEX_CPU, QEX_MEM and QEX_MEM_MB, so a script needs no other tool:
`make -j"$QEX_CPU"`.

This is the nearest thing to a limit that operates on macOS as well as on
Linux, and it needs no cgroup and no privilege. It stays a promise: a
program that asks the operating system directly still sees the whole
machine.

QEX NEVER REPLACES A VALUE THAT IS ALREADY THERE. A value from the shell,
from the job file or from `--env` is a decision that somebody made, and
this code fills the values that nobody chose.

`--env-capture none` receives nothing either. That mode says that the job
starts with an empty environment and receives `[env]` and `--env` ONLY,
and sixteen variables that qex chose would break that promise. A test
that already existed caught this, and it now holds the decision.

`--no-limit-env-hints` turns it off for one job, and
`[claims] export_env = false` for every job.

MEASURED, AND NOT ASSUMED. Three runtimes were installed and read their
own values, and two results changed the list:

  -XX:MaxRAMPercentage does NOT limit the heap to the claim, because the
  JVM takes that percentage of the memory of the MACHINE: a claim of 2GB
  on a machine of 28GB still gave a heap of 7GB. A limit needs -Xmx.

  node gives no variable for the number of cores. availableParallelism()
  stays at the number of the machine.

JAVA_TOOL_OPTIONS and MAKEFLAGS need `[claims] also = ["java", "make"]`,
because each has a cost: every JVM writes `Picked up JAVA_TOOL_OPTIONS:`
to its standard error and that line lands in the log of the job, and
MAKEFLAGS replaces the -j of a Makefile that gives one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
`--no-limit-env-hints` existed on the command line only. A job file that
gave the same name was REFUSED, because a job file rejects a field that
qex does not know — so the option could not be written down beside the
command that needs it.

The field now exists on a job file and on a pipeline stage, and the order
is the order of every other value: the command line replaces the file,
and the file replaces the configuration.

    no_limit_env_hints = true

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
An independent review of this branch found that it would break the jobs
of every user who had not given a claim, and it was right.

THE DEFAULT CLAIM IS ONE CORE. This branch wrote that number into the
environment, so `qex submit -- cargo test` on a machine of sixteen cores
became `GOMAXPROCS=1` and `CARGO_BUILD_JOBS=1`: sixteen times slower,
with no error and no warning. A node job met something worse. The default
memory claim is the memory of the machine divided by its cores, which
gave a heap far below the heap that node takes by itself, and the job
stopped with `FATAL ERROR: Reached heap limit`. The review made both
faults happen.

The learn loop then made the fault permanent: qex measured the job that
it had capped at one core, learned one core, and capped it again on the
next run.

The rule of this feature is that A VALUE THAT SOMEBODY CHOSE IS A
DECISION. A claim that qex invented is not such a decision, and neither
is a claim that qex learned. qex now writes these variables only when the
claim came from the person: `--cpu` and `--mem`, a job file, or a
pipeline stage.

THREE MORE FROM THE SAME REVIEW.

`[claims] export_env = false` did not turn the feature off. A job file
that said `no_limit_env_hints = false` — the line a user writes believing
it repeats the default — turned it on again. The configuration now comes
first.

`-Xmx0m` makes the JVM refuse to start, and qex accepts a claim small
enough to give that number. A claim below one megabyte of heap now gives
the count of the cores and no heap.

The pages said that a value from the shell always wins. That is true for
the whole environment and it is false for `--env-capture minimal`, where
the value does not survive the capture. The pages now say which.

The version moves to 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
`source` said "explicit" when ONE half was explicit, so `--mem 4GB` with
no `--cpu` gave the job GOMAXPROCS=1 on a machine of 16 cores. Measured
before the change: `qex submit --mem 4GB -- sh -c 'echo $GOMAXPROCS'`
printed `1`.

Two tests passed for the wrong reason and now hold the rule. Add the
coverage that the mutation test found missing: the config file, a
pipeline stage, the whole list of variables, and a claim below one
megabyte.
Copilot AI lite review requested due to automatic review settings August 7, 2026 20:05
@stephenc

stephenc commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Pre-merge checklist

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR ensures jobs are informed of their resolved CPU/memory claim via environment variables so language runtimes and build tools size their thread pools and heaps to the claimed share rather than the full machine.

Changes:

  • Export claim-derived env vars (e.g., QEX_CPU, QEX_MEM(_MB), GOMAXPROCS, GOMEMLIMIT, OMP_NUM_THREADS, NODE_OPTIONS, etc.) only when both --cpu and --mem were explicitly provided (and not when --env-capture none is used).
  • Add opt-out controls via CLI (--no-limit-env-hints), job files / pipeline stages (no_limit_env_hints = true), and global config ([claims] export_env = false, plus [claims] also = ["java","make"]).
  • Add/extend unit + e2e tests and update help/reference documentation to describe the behavior and configuration.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/e2e.rs Adds end-to-end coverage for when claim env vars are exported, preserved, or suppressed across CLI/job-file/pipeline/config scenarios.
src/spec.rs Implements claim env export during job resolution, adds the no_limit_env_hints controls, and introduces export_claim with targeted runtime env vars.
src/pipeline.rs Plumbs no_limit_env_hints from pipeline stages into the generated JobFile spec.
src/help.rs Documents no_limit_env_hints in job-file help output and introduces [claims] config fields in config help.
src/config.rs Introduces [claims] configuration (export_env, also) and wires it into the global Config.
src/commands.rs Passes the new no_limit_env_hints option through submit/run command resolution.
src/cli.rs Adds the --no-limit-env-hints CLI flag with user-facing help text.
README.md Updates the README to describe that claims are exported to job environments and never override user-provided values.
docs/reference.md Adds a reference section documenting exported variables, the “both cpu+mem” rule, and all opt-out / opt-in controls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/spec.rs
Comment on lines +642 to +646
// This replaces the `-j` of a Makefile that gives one.
set("MAKEFLAGS", &format!("-j{cores}"));
}
_ => {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants