Skip to content

Commit 24b358a

Browse files
committed
Bump bundled c-blosc2 to 87a3af7d: b2nd_set_slice atomicity bracket
87a3af7d brackets b2nd_set_slice_cbuffer() in the exclusive frame lock (like b2nd_resize() already does), so a slice write spanning multiple chunks is atomic to other locked handles instead of being N independently-locked chunk updates. On top of fa742207's open-vs-growth race fix and 3cd3bfe5's stale-writer append/insert counter fix. Add test_cross_process_overlapping_slice_atomic: two writer processes repeatedly overwrite the same multi-chunk NDArray region with distinguishable constant values via __setitem__; a locked reader sampling under holding_lock() must only ever see one writer's complete pass, never a chunk-wise mix. Confirms the fix reaches Python through __setitem__ with no code changes on this side (verified against a build without the c-blosc2 fix too, where it reproduces the mix reliably).
1 parent d58a2c0 commit 24b358a

3 files changed

Lines changed: 257 additions & 7 deletions

File tree

CMakeLists.txt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,8 @@ project(python-blosc2)
2020
# libblosc2 would pass this check and then fail with a confusing undefined
2121
# symbol at link time instead of a clear version error.
2222
set(BLOSC2_MIN_VERSION 3.2.0)
23-
# set(BLOSC2_BUNDLED_VERSION v3.1.5)
24-
# fa742207 includes blosc2_schunk_lock (fab03bda), the stale-writer
25-
# append/insert counter fix (3cd3bfe5), and the open-vs-growth race fix in
26-
# blosc2_schunk_open_offset_udio() (fa742207, found by this repo's NDArray
27-
# multi-writer hammer test, tests/test_locking.py); move this to the v3.2.0
28-
# tag once released.
29-
set(BLOSC2_BUNDLED_VERSION fa742207bddd6a477c5b0cbeb8237661560d962a) # SWMR support
23+
# set(BLOSC2_BUNDLED_VERSION v3.2.0)
24+
set(BLOSC2_BUNDLED_VERSION 87a3af7d20a224a226f093039142504b6b315e0c) # MWMR support
3025

3126
if(WIN32 AND NOT CMAKE_C_COMPILER_ID STREQUAL "Clang")
3227
message(FATAL_ERROR "Windows builds require clang-cl. Set CC/CXX to clang-cl or configure CMake with -T ClangCL.")

tests/test_locking.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,72 @@ def test_cross_process_multiwriter_ndarray(tmp_path):
437437
blosc2.remove_urlpath(urlpath)
438438

439439

440+
OVERLAPPING_SLICE_WRITER_SCRIPT = """
441+
import sys
442+
import numpy as np
443+
import blosc2
444+
445+
urlpath, value, iters = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
446+
w = blosc2.open(urlpath, mode="a", locking=True)
447+
buf = np.full(w.shape, value, dtype=np.int64)
448+
for i in range(iters):
449+
w[:, :] = buf
450+
sys.exit(0)
451+
"""
452+
453+
454+
def test_cross_process_overlapping_slice_atomic(tmp_path):
455+
# Two writer processes repeatedly overwrite the *same* (multi-chunk)
456+
# NDArray region with their own distinguishable constant value via
457+
# __setitem__ (b2nd_set_slice_cbuffer at the C level); a locked reader
458+
# sampling the whole region under holding_lock() must only ever see one
459+
# writer's complete pass, never a chunk-wise mix of both (the atomicity
460+
# b2nd_set_slice_cbuffer's exclusive-lock bracket provides -- python
461+
# inherits it through __setitem__ with no code changes of its own).
462+
urlpath = str(tmp_path / "array-overlap.b2nd")
463+
nrows, ncols = 200, 50
464+
iters = 150
465+
466+
a = blosc2.zeros(
467+
(nrows, ncols),
468+
dtype=np.int64,
469+
chunks=(nrows // 4, ncols),
470+
blocks=(nrows // 4, ncols),
471+
urlpath=urlpath,
472+
mode="w",
473+
locking=True,
474+
)
475+
del a
476+
477+
writers = [
478+
subprocess.Popen(
479+
[sys.executable, "-c", OVERLAPPING_SLICE_WRITER_SCRIPT, urlpath, str(v), str(iters)]
480+
)
481+
for v in (1, 2)
482+
]
483+
try:
484+
reader = blosc2.open(urlpath, mode="r", locking=True)
485+
nreads = 0
486+
deadline = time.monotonic() + 180
487+
while any(w.poll() is None for w in writers):
488+
assert time.monotonic() < deadline, "writer processes did not finish in time"
489+
with reader.schunk.holding_lock():
490+
data = reader[:, :]
491+
first = data.flat[0]
492+
assert first in (0, 1, 2), f"unexpected value {first} in the array"
493+
assert np.all(data == first), f"observed a mixed (half-applied) slice write: {np.unique(data)}"
494+
nreads += 1
495+
finally:
496+
for w in writers:
497+
if w.poll() is None:
498+
w.kill()
499+
w.wait()
500+
501+
assert all(w.returncode == 0 for w in writers), "a writer process failed"
502+
assert nreads > 0
503+
blosc2.remove_urlpath(urlpath)
504+
505+
440506
# ---------------------------------------------------------------------------
441507
# EmbedStore under locking: transactional writes + key-map re-sync
442508
# ---------------------------------------------------------------------------

todo/locking-mwmr.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# MWMR (Multiple Writer, Multiple Reader) — steps to get there
2+
3+
## Context
4+
5+
Status as of 2026-07-08, after the locking/SWMR push landed in both repos
6+
(c-blosc2 `plans/todo-locking-swmr.md` has the mirror view; design details in
7+
`plans/file-locking.md` and c-blosc2's `plans/high-level-formats-locking.md`).
8+
9+
Key realization from the 2026-07-08 review: **at coarse granularity, locking
10+
mode already is MWMR** — and the docs quietly claim it ("so several processes
11+
can safely write", `doc/getting_started/sharing_across_processes.rst`). The
12+
naming split is: SWMR = the non-locking contract (single writer, readers
13+
follow); locking = the multi-writer contract. Evidence in place today:
14+
15+
- Every mutating frame op takes the exclusive sidecar lock; the generation
16+
counter forces an exact re-sync of stale handles.
17+
- Append/insert re-sync the cached nbytes/cbytes/nchunks counters under the
18+
lock before applying deltas (c-blosc2 `3cd3bfe5`, 2026-07-08); update/delete
19+
refresh via their chunk read; `b2nd_resize` holds the exclusive lock across
20+
its whole metalayer+chunks sequence.
21+
- `blosc2_schunk_lock()`/`SChunk.holding_lock()` give callers multi-op
22+
transactions.
23+
- Store-level cross-process multi-writer support exists **and is tested**
24+
(`test_embed_store_cross_process_writers`, `test_dict_store_cross_process_writers`).
25+
26+
What separates "it basically works" from "we support MWMR" is the list below.
27+
Items 1–4 are roughly a week of work combined; after them MWMR can be
28+
advertised honestly. Items 5–6 are documented limitations / non-goals.
29+
30+
The driving external use case is multi-worker Caterva2 (several server
31+
processes fetching into one shared peercache pool) — see item 7.
32+
33+
---
34+
35+
## 1. Cross-process multi-writer hammer tests — DONE (2026-07-08, both repos)
36+
37+
The real gap was: current frame-level multi-writer evidence was same-process
38+
only (two handles, `test_stale_append_resync` in c-blosc2's
39+
`tests/test_frame_lock.c`); the fork hammer was one writer vs readers; the
40+
cross-process *writer* tests existed only at the store level.
41+
42+
Landed:
43+
44+
- c-blosc2 `tests/test_frame_lock.c`: `test_fork_two_appenders` (two child
45+
processes append through their own handle; on-disk header counters and
46+
chunk parity re-read from a fresh open must equal the union of both
47+
appends — pins the `3cd3bfe5` counter-resync fix cross-process).
48+
- python-blosc2 `tests/test_locking.py`: `test_cross_process_multiwriter_append`
49+
(N writers append disjoint-signature chunks), `test_cross_process_multiwriter_update`
50+
(N writers update disjoint chunks while a reader samples for torn/mixed
51+
content), `test_cross_process_multiwriter_ndarray` (N writers `resize()` +
52+
fill disjoint row regions via `holding_lock()`, exercising the b2nd
53+
metalayer path).
54+
55+
**Found a real bug while writing the NDArray hammer test** (not present in
56+
the append/update SChunk tests — needed the tighter growth loop of several
57+
concurrent `resize()`s plus several concurrent fresh opens racing from time
58+
zero): `blosc2_schunk_open_offset_udio()` could return `NULL` under
59+
concurrent frame growth. Root cause: `frame_from_file_offset()`'s bootstrap
60+
read (`stat()` for the file size, then the header) runs before any lock is
61+
taken; a writer growing the frame between the `stat()` and the header read
62+
can leave the header advertising a `frame_len` larger than the now-stale
63+
`file_size` snapshot, which was treated as a hard "frame length exceeds file
64+
boundary" error instead of the transient race it is. Fixed in c-blosc2
65+
(`blosc/frame.c`, `blosc/schunk.c`): bounded retry (50 attempts, 1ms backoff)
66+
around the bootstrap read whenever locking is requested, via a new
67+
`frame_locking_requested()` helper. Pinned by a new fork-based regression
68+
test, `test_fork_open_race` (4 concurrent appenders vs. 4 concurrent
69+
openers) — reproduces the `NULL` return reliably without the fix (4/5
70+
trials), clean across 20+ trials with it. This needs to land in c-blosc2
71+
before the 3.2.0 tag alongside the rest of this feature set (see Release
72+
coupling below); until then `BLOSC2_BUNDLED_VERSION` should move past this
73+
fix's commit.
74+
75+
## 2. Bracket `b2nd_set_slice` in the exclusive lock — DONE (2026-07-08, c-blosc2)
76+
77+
A slice write spanning multiple chunks was N independently-locked chunk
78+
updates. Two writers on overlapping slices interleaved at chunk granularity —
79+
no corruption, but a locked reader could observe a half-applied write and the
80+
merged result was chunk-wise last-writer-wins. `b2nd_resize` already held the
81+
lock across its whole sequence; `b2nd_set_slice_cbuffer` (the actual exported
82+
function behind "set_slice") now does the same, wrapping its call to
83+
`get_set_slice()` in `frame_lock(frame, true)`/`frame_unlock(frame)` (the
84+
bracket nests via `lock_depth`, so the inner per-chunk locks are free, and is
85+
a no-op for unlocked handles). python-blosc2 inherits it through
86+
`__setitem__` with no code changes — confirmed directly, see below.
87+
88+
Landed as `blosc/b2nd.c` (5-line change). Tests:
89+
90+
- c-blosc2 `tests/test_b2nd_set_slice_lock.c` (new file, GLOB-picked-up):
91+
two writer processes repeatedly overwrite the whole multi-chunk array with
92+
their own constant value; a reader wrapped in `blosc2_schunk_lock()`/
93+
`unlock()` samples the whole array and asserts it is never a mix.
94+
Reproduces the mix in 10/10 trials without the fix, clean across 15+
95+
trials with it.
96+
- python-blosc2 `tests/test_locking.py::test_cross_process_overlapping_slice_atomic`:
97+
same shape, through `arr[:, :] = value` and `holding_lock()` — confirms the
98+
fix reaches Python with zero code changes on this side (also reproduced
99+
the mix reliably without the c-blosc2 fix during verification, then
100+
confirmed clean with it).
101+
102+
## 3. Audit remaining mutating paths for RMW-under-stale gaps — MEDIUM (c-blosc2)
103+
104+
Sweep every exported mutating entry point and confirm it either (a) re-syncs
105+
under the exclusive lock before trusting cached state, or (b) is documented
106+
as out of the MWMR contract:
107+
108+
- vlmeta add/update/delete: locked; `blosc2_vlmeta_get`/`_exists` poll — OK.
109+
- Fixed metalayers: `blosc2_meta_update` from one writer is invisible to
110+
other handles' `blosc2_meta_exists`/`_get` (static-inline, stale-blind —
111+
known, blocked by the plugin no-link design decision; c-blosc2 todo item 6).
112+
For MWMR, document: fixed-meta RMW across handles is out of contract.
113+
- `blosc2_schunk_fill_special`, cframe import paths, and anything else that
114+
writes header/index without going through the locked chunk-op wrappers.
115+
116+
## 4. Documentation: promote and pin the MWMR contract — MEDIUM (python-blosc2)
117+
118+
Extend the locking section of `sharing_across_processes.rst` from a sentence
119+
to a stated contract:
120+
121+
- Multiple writers are supported with `locking=True` on **every** handle
122+
(advisory; one non-locking handle voids it).
123+
- Atomicity is per operation; a slice write is atomic after item 2 lands.
124+
- Read-modify-write (`arr[i] += 1`, append-position-dependent logic) races
125+
between writers unless wrapped in `holding_lock()` — show the idiom with a
126+
two-process example. Per-op locks give serialization, not transactions.
127+
- Concurrent writes to the same region: last-writer-wins (chunk-wise today,
128+
slice-wise after item 2).
129+
- Crash caveat from item 5, NFS/mmap caveats as already documented.
130+
- Stores: point at the existing EmbedStore/DictStore cross-process guarantees
131+
and accepted races; `.b2z` stays snapshot-only.
132+
133+
## 5. Crash robustness under multiple writers — LOW (document now, build later)
134+
135+
flock auto-releases when a process dies — good for liveness, but a writer
136+
crashing mid-mutation hands the next lock holder a possibly torn frame, with
137+
no journal to recover from. The stores document this as an accepted race; the
138+
frame level currently doesn't. Action now: document it (item 4). A real fix
139+
(shadow-write / atomic-commit / per-chunk journaling) is a genuine project —
140+
parked until a use case demands it; do not start it speculatively.
141+
142+
## 6. Explicit non-goals (record, do not do)
143+
144+
- **High-concurrency MWMR** (chunk-level locking, MVCC/snapshot isolation):
145+
one exclusive lock per frame serializes writers and excludes readers during
146+
writes. Correct, not concurrent. Fine for coordination workloads (peer
147+
caches, occasional multi-writer stores); parallel-write throughput is a
148+
different project and a different design.
149+
- **Lock fairness**: flock has no FIFO ordering; writer starvation under
150+
read-heavy load is possible. Document if it ever bites.
151+
- **NFS**: unchanged, unsupported.
152+
153+
## 7. Downstream consumer: multi-worker Caterva2 — (caterva2 repo)
154+
155+
The convergence point. Several gunicorn workers sharing one peercache pool is
156+
exactly the MWMR use case: the blosc2 layer already makes the cache frames
157+
safe for it (`locking=True` is on every peer-cache handle since caterva2
158+
`2f8eacb`), but Caterva2's fetch→read→touch critical section is an
159+
**asyncio** lock (process-local), and the atime sidecars + budget accounting
160+
are process-local too. The cross-process critical section maps naturally onto
161+
`holding_lock()`. Tracked in caterva2 `plans/c2cache-decoupling.md` §8.1 and
162+
the out-of-scope note of `plans/peercache-locking.md`; listed here because
163+
items 1–4 are its prerequisites.
164+
165+
## Release coupling
166+
167+
All of this rides on the pending release train (c-blosc2 3.2.0 tag →
168+
python-blosc2 4.8.0, whose bundled pin is already at c-blosc2 `3cd3bfe5`
169+
caterva2 `blosc2>=` floor bump); see item 3 of c-blosc2's
170+
`plans/todo-locking-swmr.md`. The minor-version bumps (3.2.0/4.8.0 rather
171+
than 3.1.6/4.7.1) reflect the significant API additions of this feature set
172+
(decided 2026-07-08). Items 1–3 above should land before the tag if
173+
practical, so 3.2.0/4.8.0 ship the tested multi-writer story rather than a
174+
half-claimed one.
175+
176+
## Suggested order
177+
178+
1 (hammer tests) first — it either pins the claim or finds the bugs (it found
179+
one: the open-race fix above); 2 (set_slice bracket, done) next; 3 (audit)
180+
and 4 (docs) together before the release pair; 5–6 are documentation lines
181+
inside 4; 7 lives in the caterva2 repo.
182+
183+
Item 1's open-race fix is committed upstream as `fa742207`, and
184+
python-blosc2's `BLOSC2_BUNDLED_VERSION` already pins to it. Item 2's
185+
set_slice bracket (`blosc/b2nd.c`, `tests/test_b2nd_set_slice_lock.c`) is
186+
still a local, uncommitted change on top of `fa742207` as of this writing —
187+
it needs its own commit, and the bundled pin needs to move past it before
188+
python-blosc2's overlapping-slice test is green against a non-local
189+
c-blosc2.

0 commit comments

Comments
 (0)