Skip to content

Commit 254ea5b

Browse files
committed
🐛 fix(pypi): address review on clear_caches concurrency fix
webknjaz's review left several requests on the parallel-rebuild fix. The renamed-aside directory variable moves from the generic `stale` to `old_downloads_dir` so the name says what it holds. The suppressed `OSError` now logs at `-v` rather than vanishing: a racing peer or an OS-level rename refusal is worth a breadcrumb, and the error codes that trigger it shift across platforms, so silently swallowing it hides a real signal. The changelog fragment moves out of imperative mood into the "X now does Y" form the towncrier convention expects, routes the second `pip-compile --rebuild` mention through the `{command}` role, and gains the `by` keyword in the byline. `changelog.d/2384.bugfix.md` is a symlink to `2393.bugfix.md` so the note renders under both the issue and the PR. The `pytest-mock` lower bound drops to match the unbounded style of the sibling test dependencies (`pytest-rerunfailures`, `pytest-xdist`, `tomli-w`); pinning one test helper to a single tested version while the rest float was inconsistent. `test_clear_caches_swallows_replace_ failure` now asserts the rename was attempted against the live download directory before the OSError short-circuits the rmtree.
1 parent 2a6030e commit 254ea5b

5 files changed

Lines changed: 22 additions & 12 deletions

File tree

changelog.d/2384.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2393.bugfix.md

changelog.d/2393.bugfix.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
Atomically rename the download directory before deleting it in
2-
{meth}`piptools.repositories.PyPIRepository.clear_caches` so a parallel
1+
{meth}`piptools.repositories.PyPIRepository.clear_caches` now atomically
2+
renames the download directory before deleting it, so a parallel
33
{command}`pip-compile --rebuild` no longer trips over a half-deleted
4-
tree. The patch narrows one observable symptom and does not fully
5-
serialise concurrent `--rebuild` runs; broader cache locking remains
6-
future work -- {user}`gaborbernat`.
4+
tree. The fix narrows one observable symptom; it does not fully
5+
serialise concurrent {command}`pip-compile --rebuild` runs, and broader
6+
cache locking remains future work -- by {user}`gaborbernat`.

piptools/repositories/pypi.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,17 @@ def clear_caches(self) -> None:
112112
# around the resolve, which is out of scope here.
113113
if not os.path.exists(self._download_dir):
114114
return
115-
stale = f"{self._download_dir}.stale-{os.getpid()}"
115+
old_downloads_dir = f"{self._download_dir}.stale-{os.getpid()}"
116116
try:
117-
os.replace(self._download_dir, stale)
118-
except OSError:
117+
os.replace(self._download_dir, old_downloads_dir)
118+
except OSError as exc:
119+
# A racing peer renamed the tree first, or the OS refused the
120+
# move (cross-device, permissions). The directory is no longer
121+
# ours to clear; log at -v since the suppressed OSError is
122+
# otherwise invisible and its error codes shift across platforms.
123+
log.debug(f"clear_caches skipped {self._download_dir!r}: {exc}")
119124
return
120-
rmtree(stale, ignore_errors=True)
125+
rmtree(old_downloads_dir, ignore_errors=True)
121126

122127
@property
123128
def options(self) -> optparse.Values:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ changelog = "https://github.com/jazzband/pip-tools/releases"
5858
[project.optional-dependencies]
5959
testing = [
6060
"pytest >= 7.2.0",
61-
"pytest-mock >= 3.15.1",
61+
"pytest-mock",
6262
"pytest-rerunfailures",
6363
"pytest-xdist",
6464
"tomli-w",

tests/test_repository_pypi.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,15 +201,19 @@ def test_clear_caches_when_download_dir_missing_is_a_no_op(
201201
def test_clear_caches_swallows_replace_failure(
202202
repo_with_cache_dir: PyPIRepository, mocker: MockerFixture
203203
) -> None:
204-
Path(repo_with_cache_dir._download_dir).mkdir(parents=True, exist_ok=True)
205-
mocker.patch(
204+
download_dir = Path(repo_with_cache_dir._download_dir)
205+
download_dir.mkdir(parents=True, exist_ok=True)
206+
replace = mocker.patch(
206207
"piptools.repositories.pypi.os.replace",
207208
side_effect=OSError("racing peer renamed first"),
208209
)
209210
rmtree = mocker.patch("piptools.repositories.pypi.rmtree")
210211

211212
repo_with_cache_dir.clear_caches()
212213

214+
# The rename targets the live download dir; the OSError short-circuits
215+
# before the rmtree so a racing peer keeps ownership of the tree.
216+
assert replace.call_args.args[0] == str(download_dir)
213217
rmtree.assert_not_called()
214218

215219

0 commit comments

Comments
 (0)