Fix EmbedStore.__getitem__ race condition between index sync and data read - #691
Conversation
Co-authored-by: FrancescAlted <314521+FrancescAlted@users.noreply.github.com>
FrancescAlted
left a comment
There was a problem hiding this comment.
The core of this is right, and the mechanism is better than the issue gave you to work with.
holding_lock() calls self.refresh() inside the lock (schunk.py:479), so this is not merely serialising the reader against writers — it refreshes the stale extent. That is the root-cause fix, and it answers the question #690 left open: refresh() is the cached state the vlmeta poll was not updating. Removing the test scaffolding is right, and keeping from_cframe() outside the lock is right.
One defect to fix before merge, plus two smaller notes.
Blocking: HTTP round trip under the exclusive file lock
The urlbase branch now returns from inside the lock:
with self._backing_schunk.holding_lock():
...
if urlbase:
urlpath = blosc2.URLPath(node_info["path"], urlbase=urlbase)
return blosc2.open(urlpath, mode="r") # still holding the lockC2Array.__init__ calls info(self.path, self.urlbase, ...) (c2array.py:249), which is a synchronous HTTP request. So retrieving a C2Array-backed entry holds the exclusive frame lock across a network round trip: every other reader and writer of that store, in every process, blocks for its duration, and a slow or unreachable server hangs the store. holding_lock()'s own docstring says "keep the block short".
It is also unnecessary — that branch never touches self._store, it only needs the index entry. Something like:
def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray:
"""Retrieve a node from the embed store."""
with self._backing_schunk.holding_lock():
self._sync_metadata()
if key not in self._embed_map:
raise KeyError(f"Key '{key}' not found in the embed store.")
node_info = self._embed_map[key]
urlbase = node_info.get("urlbase", None)
if not urlbase:
offset = node_info["offset"]
length = node_info["length"]
serialized_data = bytes(self._store[offset : offset + length])
if urlbase:
# Outside the lock: opening a C2Array is an HTTP round trip
return blosc2.open(blosc2.URLPath(node_info["path"], urlbase=urlbase), mode="r")
# It is safer to copy data here, as the reference to the SChunk may disappear
# Use from_cframe so we can deserialize either an NDArray or an SChunk
return blosc2.from_cframe(serialized_data, copy=True)The performance note in the description is misleading
holding_lock()is a documented no-op when locking is disabled, so non-shared usage has zero overhead.
True, but beside the point: the cost lands precisely on shared usage, which is the only configuration in which this bug exists. Reads now take an exclusive lock, so readers serialise against each other, not only against writers — the docstring is explicit that this includes "plain reads through other locked handles" — plus a refresh() filesystem poll on every read.
That may well be the right trade for correctness, and I am not asking for it to be changed. But it is a real semantic change for a store whose sharing story is SWMR, and it should be stated rather than described as free.
If read-read serialisation turns out to matter, the narrower alternative is to call refresh() without taking the exclusive lock: the write path already appends the bytes before publishing the map entry and does both under the lock, so a visible index entry implies the bytes are present, and appends only grow. That would need someone to establish that a lockless reader cannot observe a torn frame mid-append — which is exactly what the lock is buying today, so this is a follow-up at best, not a change to make here.
Fixes #690 will close the issue with one part unexamined
The issue also notes DictStore's external-leaf path: _sync_store() re-syncs under holding_lock(), then opens the file after releasing it — possibly the same shape, explicitly not traced. This PR does not touch it. Either trace it, or drop the auto-close so it stays tracked.
Co-authored-by: FrancescAlted <314521+FrancescAlted@users.noreply.github.com>
Fixed in the latest commit: the On the performance note: agreed, the description was misleading — I've removed that claim. The real cost is reads serialising against each other on shared stores, plus a On the |
Adding a root conftest.py made ruff's isort classify `conftest` as first-party, so the two tests doing `from conftest import expected_nthreads` had to move into the same block as `import blosc2`. pre-commit.ci only runs on pull requests, so pushing this straight to main never triggered it and the failure first showed up on #691, where it looked like that PR's fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The The C2Array hoist in b45b2b9 addresses the blocking point — thanks. The two non-blocking notes (the "zero overhead" wording, and |
|
Correction to my previous comment: this will not go green on its own. The fix is on |
There was a problem hiding this comment.
Pull request overview
This PR fixes a cross-process race in EmbedStore.__getitem__ where the vlmeta index and backing storage bytes could be observed out of sync, causing intermittent slice read failures under concurrent writers (issue #690). It also tightens locking tests so they fail on regressions instead of silently retrying.
Changes:
- Serialize
EmbedStore.__getitem__metadata sync + offset/length lookup + backing-schunk slice read underholding_lock()to ensure a refreshed extent before bounds checks. - Keep remote (
C2Array) opens outside the lock to avoid holding an exclusive lock across HTTP I/O. - Remove
RuntimeError-swallowing scaffolding in cross-process writer tests so the race is reliably detected.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/blosc2/embed_store.py |
Wraps the embed index sync and embedded-bytes read in an exclusive holding_lock() to prevent stale-extent read failures under concurrent writes. |
tests/test_locking.py |
Removes retry/ignore logic that masked the failing window, so the test suite now asserts the fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
FrancescAlted
left a comment
There was a problem hiding this comment.
The C2Array hoist in b45b2b9 resolves my earlier blocking point, and the functional fix is right. But de453f7, added since, should be reworked before this goes in.
The finding behind de453f7 is fair
Copilot's comment is correct: holding_lock() calls refresh() unconditionally, so non-shared reads now pay for it. That is not theoretical either — refresh() is documented as "always False for in-memory super-chunks", but an on-disk non-shared store (urlpath set, locking=False) does a real filesystem re-sync on every read. Worth avoiding.
But duplicating the body is the wrong way to avoid it
de453f7 copies the whole read into two branches — one under the lock, one not — leaving ten near-identical lines in a concurrency-critical function. Two paths that have to stay in sync forever, and the next person to fix something in one of them will miss the other. That is a poor trade for skipping a refresh().
contextlib is already imported in this module, so the same behaviour is one line:
from contextlib import contextmanager, nullcontext def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray:
"""Retrieve a node from the embed store."""
# Only a shared store needs the lock: it is the only mode where
# _sync_metadata() does real work and where the index/data race exists.
# holding_lock() also refreshes, which costs a filesystem poll.
with self._backing_schunk.holding_lock() if self._shared else nullcontext():
self._sync_metadata()
if key not in self._embed_map:
raise KeyError(f"Key '{key}' not found in the embed store.")
node_info = self._embed_map[key]
urlbase = node_info.get("urlbase", None)
if not urlbase:
offset = node_info["offset"]
length = node_info["length"]
serialized_data = bytes(self._store[offset : offset + length])
if urlbase:
# Outside the lock: opening a C2Array involves an HTTP round trip
return blosc2.open(blosc2.URLPath(node_info["path"], urlbase=urlbase), mode="r")
# It is safer to copy data here, as the reference to the SChunk may disappear
# Use from_cframe so we can deserialize either an NDArray or an SChunk
return blosc2.from_cframe(serialized_data, copy=True)The conditional expression is evaluated before the with, so holding_lock() is only called when _shared — the non-shared path takes neither the lock nor the refresh, exactly as de453f7 intends.
I checked this out on top of this branch and ran it rather than only proposing it: 172 passed, 1 skipped across tests/test_locking.py, tests/test_embed_store.py, tests/test_dict_store.py and tests/test_tree_store.py. Against the current head it is a net 7 lines smaller (13 removed, 6 added).
Also still outstanding
- This branch is one commit behind
main, sopre-commit.cikeeps failing on an import-sort issue that was already fixed on main in b910699. It needs a merge or rebase to clear — it will not resolve on its own. Fixes #690will auto-close the issue while theDictStoreexternal-leaf path noted there is untraced (_sync_store()re-syncs underholding_lock(), then opens the file after releasing it). Either trace it or drop the auto-close so it stays tracked.
Follow-up to #691. Skipping holding_lock() for non-shared stores is worth doing -- it refreshes unconditionally, which is a filesystem poll on every read of an on-disk handle -- but the merged version bought that by copying the whole read into two branches, leaving ten near-identical lines in a function whose correctness depends on the locking being right. contextlib.nullcontext expresses the same thing in one line. The conditional is evaluated before the `with`, so holding_lock() is only called when _shared and the non-shared path still takes neither the lock nor the refresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The path was resolved under the lock but opened after releasing it, so a concurrent overwrite or delete of the same key — which removes and rewrites the leaf at that exact path — could leave the reader opening a file that is missing or half-written. Mirrors what #691 did for EmbedStore: the resolve, the existence check and the open now share one holding_lock(), free for the nested _sync_store() since the lock is re-entrant, and skipped entirely for non-shared stores. The natural window is microseconds wide, so the test widens it by delaying _sync_store(); without the fix that yields malformed cframes and spurious KeyErrors within a handful of reads. Fixes #692.
EmbedStore.__getitem__read the vlmeta index and the backing schunk bytes as two unsynchronized operations. A concurrent writer could update the index before the reader's cached schunk extent was refreshed, causingRuntimeError: Error while getting the sliceeven though the bytes were genuinely present on disk.Changes
src/blosc2/embed_store.py: Wrap_sync_metadata(), key lookup, andself._store[offset:offset+length]insideholding_lock(). This mirrors_write_bracket()on the write path and ensuresrefresh()is called on the schunk before the slice bounds-check, eliminating the staleness.from_cframe()stays outside the lock since bytes are already copied.holding_lock()is a documented no-op when locking is disabled, so non-shared usage has zero overhead.tests/test_locking.py: Remove theexcept RuntimeError: continuescaffolding fromtest_embed_store_cross_process_writersandtest_dict_store_cross_process_writers; these guards were placeholders that masked regressions of exactly this bug.