Conversation
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src/poetry/utils/authenticator.py" line_range="162-164" />
<code_context>
+ def set_body(self, key: str, body: bytes) -> None:
+ super().set_body(key, body)
+
+ metadata = self._pending_metadata.pop(key, None)
+ if metadata is not None:
+ super().set(key, metadata)
+
+ def get(self, key: str) -> bytes | None:
</code_context>
<issue_to_address>
**issue (bug_risk):** If the final metadata write raises after the body has been written, `_pending_metadata.pop()` has already discarded the metadata, so the completed body is left orphaned and a retry of `set_body()` cannot publish the entry.
**Triggers:** When the metadata file write fails after the body file write succeeds.
**Suggested fix:** Only remove the pending metadata after `super().set()` succeeds, or restore it if publication fails.
```suggestion
metadata = self._pending_metadata.get(key)
if metadata is not None:
super().set(key, metadata)
self._pending_metadata.pop(key, None)
```
</issue_to_address>
### Comment 2
<location path="tests/utils/test_atomic_separate_body_file_cache.py" line_range="44-64" />
<code_context>
+ cache = AtomicSeparateBodyFileCache(tmp_path)
+ reader = AtomicSeparateBodyFileCache(tmp_path)
+
+ metadata_published = threading.Event()
+ publish_body = threading.Event()
+
+ def writer() -> None:
+ cache.set(KEY, METADATA)
+ metadata_published.set()
+ publish_body.wait(timeout=10)
+ cache.set_body(KEY, BODY)
+
+ thread = threading.Thread(target=writer)
+ thread.start()
+ assert metadata_published.wait(timeout=10)
+
+ assert reader.get(KEY) is None
+ assert reader.get_body(KEY) is None
+
+ publish_body.set()
+ thread.join(timeout=10)
+ assert not thread.is_alive()
+
+ assert reader.get(KEY) == METADATA
+
+
</code_context>
<issue_to_address>
**issue (testing):** The concurrency test never reads during the actual publication window: it blocks before `set_body()`, then joins the writer before asserting the final value. It therefore passes even if `set_body()` publishes metadata before the body or exposes an intermediate state.
**Triggers:** When the implementation has a race between the body write and the delayed metadata write.
**Suggested fix:** Add a barrier or monkeypatch around the base body/metadata writes so the reader runs after one publication step and before the other.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 2 findings to address first, and if the publication logic is wrong, cache metadata or bodies can be left inconsistent on disk, causing bounded cache misses or stale entries after a revert. Clearing or rebuilding the affected cache repairs the state; no permanent data or access decision is altered.
Blocking findings: src/poetry/utils/authenticator.py:164, tests/utils/test_atomic_separate_body_file_cache.py:64
| metadata = self._pending_metadata.pop(key, None) | ||
| if metadata is not None: | ||
| super().set(key, metadata) |
There was a problem hiding this comment.
issue (bug_risk): If the final metadata write raises after the body has been written, _pending_metadata.pop() has already discarded the metadata, so the completed body is left orphaned and a retry of set_body() cannot publish the entry.
Triggers: When the metadata file write fails after the body file write succeeds.
Suggested fix: Only remove the pending metadata after super().set() succeeds, or restore it if publication fails.
| metadata = self._pending_metadata.pop(key, None) | |
| if metadata is not None: | |
| super().set(key, metadata) | |
| metadata = self._pending_metadata.get(key) | |
| if metadata is not None: | |
| super().set(key, metadata) | |
| self._pending_metadata.pop(key, None) |
| metadata_published = threading.Event() | ||
| publish_body = threading.Event() | ||
|
|
||
| def writer() -> None: | ||
| cache.set(KEY, METADATA) | ||
| metadata_published.set() | ||
| publish_body.wait(timeout=10) | ||
| cache.set_body(KEY, BODY) | ||
|
|
||
| thread = threading.Thread(target=writer) | ||
| thread.start() | ||
| assert metadata_published.wait(timeout=10) | ||
|
|
||
| assert reader.get(KEY) is None | ||
| assert reader.get_body(KEY) is None | ||
|
|
||
| publish_body.set() | ||
| thread.join(timeout=10) | ||
| assert not thread.is_alive() | ||
|
|
||
| assert reader.get(KEY) == METADATA |
There was a problem hiding this comment.
issue (testing): The concurrency test never reads during the actual publication window: it blocks before set_body(), then joins the writer before asserting the final value. It therefore passes even if set_body() publishes metadata before the body or exposes an intermediate state.
Triggers: When the implementation has a race between the body write and the delayed metadata write.
Suggested fix: Add a barrier or monkeypatch around the base body/metadata writes so the reader runs after one publication step and before the other.
…rate-body-cache-race
Fixes #11048
Problem
A
SeparateBodyFileCacheentry is stored in two files: metadata inself._fn(key)and thebody in
self._fn(key) + ".body".CacheController._cache_set()publishes them one after theother, metadata first:
Each file is written atomically on its own (
tempfile.mkstemp+os.replaceunder aFileLock), but the pair is not published atomically. A lookup that runs between the two callsin
CacheController._load_from_cache()reads:The entry deserializes into a response with an empty body. For a package index that reads as
"this package has no releases at all", so a parallel install concludes that a dependency does
not exist — the
Package setuptools (...) not foundfailure reported in #11048.The same state is left behind permanently when a write is interrupted between the two calls:
every later lookup finds the metadata and no body, so the entry never recovers on its own.
Fix
AtomicSeparateBodyFileCachepublishes the body first and the metadata last, so an entry onlybecomes readable once it is complete:
set()holds the metadata back instead of writing it, unless the key already has a storedbody. The existing-body case is a revalidation that only refreshes headers, where no
set_body()follows and the metadata must go out right away.set_body()writes the body, then publishes the held-back metadata.get()reports a miss when the body file is absent, which covers entries that are still inflight and entries a crashed write left behind.
delete()drops the held-back metadata so a purged entry cannot come back.The overridden methods keep the signatures of the base class, so callers in CacheControl,
including
update_cached_response()which never callsset_body(), keep working.Tests
tests/utils/test_atomic_separate_body_file_cache.py:metadata without a body;
SeparateBodyFileCacheis asserted as well, so the premiseof the fix is covered by a test rather than only by the description;
delete()removes a held-back entry.Note on the upstream fix
psf/cachecontrol#465 addresses the same race by locking once around the metadata and the body
and by routing the controller through new combined
get_with_body()/set_with_body()calls.That change is not released, so the
>=0.14.0,<0.15.0constraint cannot be tightened yet. Thisfix covers the reported failure on Poetry's side and stays independent of the upstream
interface: the sub-class only uses
set(),set_body(),get()anddelete(). If #465 landsand ships, the follow-up is to raise the lower bound and drop this sub-class.
This fix does not address mixed generations (metadata of one update paired with the body of
another). Closing that needs a shared lock across the controller's read and write, which is the
part #465 changes.