|
| 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