From 3e7da723e9f58a871de686c0e7bbffa8723967b2 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 3 Sep 2026 18:31:28 +0200 Subject: [PATCH] compress: treat an empty BORG_ZSTD_MT_WORKERS as unset An empty value made borg fail: $ BORG_ZSTD_MT_WORKERS= borg benchmark cpu --compressing Error: BORG_ZSTD_MT_WORKERS must be an integer, but is: '' That is unhelpful - an env var that is set to an empty string is usually meant to be "not configured", e.g. when a wrapper script or a systemd unit passes a variable through unconditionally. Use the defaults in that case (whitespace-only values are treated the same way). Co-Authored-By: Claude Opus 5 --- docs/usage/general/environment.rst.inc | 3 ++- src/borg/archiver/help_cmd.py | 3 ++- src/borg/compress.pyx | 7 ++++--- src/borg/testsuite/compress_test.py | 5 ++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/usage/general/environment.rst.inc b/docs/usage/general/environment.rst.inc index d2390f8c66..b0a341b99a 100644 --- a/docs/usage/general/environment.rst.inc +++ b/docs/usage/general/environment.rst.inc @@ -225,7 +225,8 @@ General: 0 means "always multi-threaded", a very large value effectively disables multi-threading. BORG_ZSTD_MT_WORKERS When set to a numeric value, use that many threads to zstd-compress a single chunk - (default: the cpu count, but at most 4). 0 or 1 means single-threaded compression. + (default: the cpu count, but at most 4). If it is unset or empty, the default is + used. 0 or 1 means single-threaded compression. Only relevant when compressing with ``zstd``. Chunks below 768KiB are always compressed single-threaded: libzstd will not use a compression job smaller than 512KiB, so a small chunk gets split very unevenly and diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index 0b4c766d08..acdcbfcf53 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -819,7 +819,8 @@ class HelpMixIn: 0 means "always multi-threaded", a very large value effectively disables multi-threading. BORG_ZSTD_MT_WORKERS When set to a numeric value, use that many threads to zstd-compress a single chunk - (default: the cpu count, but at most 4). 0 or 1 means single-threaded compression. + (default: the cpu count, but at most 4). If it is unset or empty, the default is + used. 0 or 1 means single-threaded compression. Only relevant when compressing with ``zstd``. Chunks below 768KiB are always compressed single-threaded: libzstd will not use a compression job smaller than 512KiB, so a small chunk gets split very unevenly and diff --git a/src/borg/compress.pyx b/src/borg/compress.pyx index 766b7ab756..f26b2d6ebd 100644 --- a/src/borg/compress.pyx +++ b/src/borg/compress.pyx @@ -59,7 +59,8 @@ _zstd_mt_workers = None # cached (chunk workers, stream workers) def get_zstd_mt_workers(stream=False): """How many threads libzstd may use to compress a single chunk (or stream). - BORG_ZSTD_MT_WORKERS overrides the defaults below (for chunks and streams alike). + BORG_ZSTD_MT_WORKERS overrides the defaults below (for chunks and streams alike); + if it is unset or empty, the defaults are used. 0 or 1 means single-threaded compression, which also avoids the small loss of compression ratio that splitting a chunk into jobs causes (measured at zstd,3: +0.05% for a 1MiB chunk, +0.64% for an 8MiB one; higher levels lose a bit more as @@ -84,9 +85,9 @@ def get_zstd_mt_workers(stream=False): """ global _zstd_mt_workers if _zstd_mt_workers is None: - value = os.environ.get("BORG_ZSTD_MT_WORKERS") + value = os.environ.get("BORG_ZSTD_MT_WORKERS", "").strip() cpus = os.cpu_count() or 1 - if value is None: + if not value: # unset or empty: use the defaults workers = (min(cpus, 4), cpus) else: try: diff --git a/src/borg/testsuite/compress_test.py b/src/borg/testsuite/compress_test.py index 73318fe0a9..3a86af5708 100644 --- a/src/borg/testsuite/compress_test.py +++ b/src/borg/testsuite/compress_test.py @@ -350,9 +350,12 @@ def workers_for(env_value, stream=False): cpus = os.cpu_count() or 1 assert workers_for(None) == min(cpus, 4) # per-chunk default is capped assert workers_for(None, stream=True) == cpus # stream default is not + for empty in ["", " "]: # an empty value is treated like an unset one + assert workers_for(empty) == min(cpus, 4) + assert workers_for(empty, stream=True) == cpus for value, expected in [("0", 0), ("1", 1), ("4", 4), ("12", 12)]: assert workers_for(value) == expected # the env var is not capped assert workers_for(value, stream=True) == expected - for invalid in ["", "yes", "4x", "1.5", "-1"]: + for invalid in ["yes", "4x", "1.5", "-1"]: with pytest.raises(Error): workers_for(invalid)