From 092064db4c33c04cf9b7546f234273ef8570fdd5 Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Fri, 5 Dec 2025 01:29:31 +0100 Subject: [PATCH] libstore/build: allow `preferLocalBuild = true` to bypass `max-jobs = 0` When `max-jobs = 0`, our async semaphores have null capacity and therefore no slot token can be acquired. In addition, a derivation with `preferLocalBuild = true` with `max-jobs = 0` which is not a built-in derivation is not considered suitable for being possible to build. Our documentation in `max-jobs` writes: > `0` is useful when using remote builders to prevent any local builds (except for preferLocalBuild derivation attribute which executes locally regardless). A valid interpretation is that `preferLocalBuild` derivations (all of them) will always execute even if `max-jobs = 0`. This CL achieves this promise by enabling `preferLocalBuild = true` derivations to draw in priority capacity from a `preferLocalBuild`-only pool and then fall back to the build pool (`maxBuildJobs`). If there's non-zero max-jobs, we make the `preferLocalBuild`-only pool empty. If there's zero max-jobs, we make the `preferLocalBuild`-only pool non-empty (capacity: 1). Remark 1: if `max-jobs = 0`, no non-`preferLocalBuild = true` derivations can be built without a remote builder and `preferLocalBuild = true` derivations can be built one at a time if `extra-local-jobs` is not overridden. Remark 2: if `max-jobs != 0`, no `preferLocalBuild = true` derivations can bypass capacity limits, i.e. `max-jobs` unless `extra-local-jobs` is overridden. Fixes #855. Change-Id: I00a0acac7375a881aec6281ebb13a4530b26e460 Signed-off-by: Raito Bezarius --- lix/libstore/build/local-derivation-goal.cc | 25 ++++++++++++++-- lix/libstore/build/worker.cc | 5 +++- lix/libstore/build/worker.hh | 2 +- lix/libstore/meson.build | 1 + lix/libstore/parsed-derivations.cc | 4 --- lix/libstore/settings/extra-local-jobs.md | 16 ++++++++++ tests/functional2/build/test_build_jobless.py | 30 +++++++++++++++++++ 7 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 lix/libstore/settings/extra-local-jobs.md diff --git a/lix/libstore/build/local-derivation-goal.cc b/lix/libstore/build/local-derivation-goal.cc index a31e50ca1..e1031aa9b 100644 --- a/lix/libstore/build/local-derivation-goal.cc +++ b/lix/libstore/build/local-derivation-goal.cc @@ -205,10 +205,31 @@ retry: if (!slotToken.valid()) { outputLocks.reset(); - if (worker.localBuilds.capacity() > 0) { - slotToken = co_await worker.localBuilds.acquire(); + + // NOTE: we prioritize preferLocalBuilds jobs on the localJobs slot pool. + // Once we cannot anymore, we eat towards the builds slot pool. + if (parsedDrv->willBuildLocally(worker.store)) { + if (auto token = worker.localJobs.tryAcquire()) { + slotToken = std::move(*token); + co_return co_await tryToBuild(); + } + + if (auto token = worker.builds.tryAcquire()) { + slotToken = std::move(*token); + co_return co_await tryToBuild(); + } + + if (worker.localJobs.capacity() > 0) { + slotToken = co_await worker.localJobs.acquire(); + co_return co_await tryToBuild(); + } + } + + if (worker.builds.capacity() > 0) { + slotToken = co_await worker.builds.acquire(); co_return co_await tryToBuild(); } + if (getMachines().empty()) { throw Error( "unable to start any build; either set '--max-jobs' to a non-zero value or enable " diff --git a/lix/libstore/build/worker.cc b/lix/libstore/build/worker.cc index 138bfd6e9..bd2f79286 100644 --- a/lix/libstore/build/worker.cc +++ b/lix/libstore/build/worker.cc @@ -35,7 +35,10 @@ Worker::Worker(Store & store, Store & evalStore, AvailableNamespaces namespaces) /* Make sure that we are always allowed to run at least one substitution. This prevents infinite waiting. */ , substitutions(std::max(1, settings.maxSubstitutionJobs)) - , localBuilds(settings.maxBuildJobs) + , builds(settings.maxBuildJobs) + // We allow local job to progress if there's no build job slot. + // See https://git.lix.systems/lix-project/lix/issues/855 for details. + , localJobs(settings.extraLocalJobs.get().value_or(settings.maxBuildJobs == 0U ? 1U : 0U)) , children(errorHandler) , namespaces(namespaces) { diff --git a/lix/libstore/build/worker.hh b/lix/libstore/build/worker.hh index 2d1a214a8..63210c707 100644 --- a/lix/libstore/build/worker.hh +++ b/lix/libstore/build/worker.hh @@ -188,7 +188,7 @@ public: Store & store; Store & evalStore; - AsyncSemaphore substitutions, localBuilds; + AsyncSemaphore substitutions, builds, localJobs; std::optional buildDirOverride; struct PlatformFeatures diff --git a/lix/libstore/meson.build b/lix/libstore/meson.build index 94eadf66c..fd73f5730 100644 --- a/lix/libstore/meson.build +++ b/lix/libstore/meson.build @@ -93,6 +93,7 @@ libstore_setting_definitions = files( 'settings/diff-hook.md', 'settings/download-speed.md', 'settings/enable-core-dumps.md', + 'settings/extra-local-jobs.md', 'settings/extra-platforms.md', 'settings/fallback.md', 'settings/fsync-metadata.md', diff --git a/lix/libstore/parsed-derivations.cc b/lix/libstore/parsed-derivations.cc index e86074450..273a73fae 100644 --- a/lix/libstore/parsed-derivations.cc +++ b/lix/libstore/parsed-derivations.cc @@ -106,10 +106,6 @@ bool ParsedDerivation::canBuildLocally(Store & localStore) const && !drv.isBuiltin()) return false; - if (settings.maxBuildJobs.get() == 0 - && !drv.isBuiltin()) - return false; - for (auto & feature : getRequiredSystemFeatures()) if (!localStore.config().systemFeatures.get().count(feature)) return false; diff --git a/lix/libstore/settings/extra-local-jobs.md b/lix/libstore/settings/extra-local-jobs.md new file mode 100644 index 000000000..267612d4d --- /dev/null +++ b/lix/libstore/settings/extra-local-jobs.md @@ -0,0 +1,16 @@ +--- +name: extra-local-jobs +internalName: extraLocalJobs +type: std::optional +defaultText: "1 if `max-jobs = 0` otherwise 0" +--- + +This option defines an additional amount of slots for local jobs. +`preferLocalBuild` derivations will prioritize acquiring from the local job pool first, +then fall back to a `max-jobs` pool. + +By default, `extra-local-jobs` is empty except if `max-jobs = 0`, in which +case, Lix will automatically set this value to `1` to allow local jobs to +progress as they are usually trivial. + +This option can be overridden using the `--extra-local-jobs` flag. diff --git a/tests/functional2/build/test_build_jobless.py b/tests/functional2/build/test_build_jobless.py index e972fde75..4a7d713dc 100644 --- a/tests/functional2/build/test_build_jobless.py +++ b/tests/functional2/build/test_build_jobless.py @@ -1,5 +1,7 @@ import pytest from testlib.fixtures.nix import Nix +from testlib.utils import get_global_asset +from testlib.fixtures.file_helper import with_files, File drv = """ @@ -11,6 +13,17 @@ drv = """ } """ +local_drv = """ + with import ./config.nix; mkDerivation { + name = "foo"; + buildCommand = '' + touch $out + ''; + preferLocalBuild = true; + requiredSystemFeatures = [ "glitter" ]; + } +""" + @pytest.fixture(autouse=True) def set_features(nix: Nix): @@ -32,3 +45,20 @@ def test_j0_with_mismatched_remotes_fails(nix: Nix): "error: unable to start any build; remote machines may not have all required system features." in result.stderr_s ) + + +@with_files({"config.nix": get_global_asset("config.nix"), "default.nix": File(local_drv)}) +def test_j0_without_local_jobs(nix: Nix): + result = nix.nix_build(["-j0", "--extra-local-jobs", "0"]).run().expect(1) + assert ( + "error: unable to start any build; either set '--max-jobs' to a non-zero value or enable remote builds." + in result.stderr_s + ) + + +@with_files({"config.nix": get_global_asset("config.nix"), "default.nix": File(local_drv)}) +def test_j0_with_local_jobs(nix: Nix): + nix.nix_build(["-j0"]).run().ok() + + +# TODO(Raito): test -j1 --extra-local-jobs=1 in another file and ensure that preferLocalBuild *AND* another build job can run properly at the same time.