Skip to content

Commit cdd56d0

Browse files
FrancescAltedclaude
andcommitted
Answer for the array a handle has itself written to
Review found three ways the write path could serve wrong data, and all three are fixed here with a test apiece. A handle reads api/info once when it opens the array, and `stamp` is built from exactly the fields a write moves -- so a handle used to fill an array kept its pre-fill stamp for good, and a Proxy given that handle adopted a cache built while the chunks were still empty. A write marks the metadata stale now, and whatever asks reads it again. `serves_blocks` weighs whether splitting a chunk into blocks would pay, which is nothing to do with whether the frame has an index worth reading -- but the two shared one memoized source, so touching the block path first (max_ranges is a public property, and Proxy reads it) cached None and left written_chunks raising NotRanged ever after. The judgement belongs at the call, not in the slot. A complete array stamps `n<nonce>:<cbytes>` and a filling one whose subscriber reports no mtime stamped the same string, which is the one collision the whole design exists to prevent. Both carry a marker now. Also from the review: invalidating the index no longer empties what index_state hands a cache, nor drops layouts a written chunk can never invalidate; _forget_index takes the lock a source is built under; the run-length tag is read in the host's byte order rather than always little-endian; and the fill bench built a C2Array per chunk rather than per writer, which charged every timed write an untallied api/info. The numbers move little -- 7.6x rather than 7.0x for eight writers over a simulated WAN -- and the plan is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 33aeb83 commit cdd56d0

6 files changed

Lines changed: 165 additions & 51 deletions

File tree

‎bench/ndarray/cat2-block-granularity.py‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def _dataset(self):
215215
return target
216216
return self.server.subscriber
217217

218-
def do_POST(self): # noqa: N802 (BaseHTTPRequestHandler's own spelling)
218+
def do_POST(self): # BaseHTTPRequestHandler's own spelling
219219
sub = self._dataset()
220220
endpoint = self.path.split("/")[2].split("?")[0]
221221
if endpoint != "chunk" or not sub.writable:
@@ -226,7 +226,7 @@ def do_POST(self): # noqa: N802 (BaseHTTPRequestHandler's own spelling)
226226
status, answer = sub.write_chunk(nchunk, body)
227227
self._send(status, json.dumps(answer).encode())
228228

229-
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler's own spelling)
229+
def do_GET(self): # BaseHTTPRequestHandler's own spelling
230230
sub = self._dataset()
231231
endpoint = self.path.split("/")[2]
232232
if endpoint == "info":
@@ -292,11 +292,16 @@ def _fetch(self, sub):
292292
)
293293

294294

295-
def stand_in(urlpath, streamed=False, target=None):
296-
"""Serve *urlpath* as ``@public/<name>``, and return (server, urlbase, path)."""
295+
def stand_in(urlpath, streamed=False):
296+
"""Serve *urlpath* as ``@public/<name>``, and return (server, urlbase, path).
297+
298+
The array a fill writes into is installed later, by `make_presize`, which
299+
lays out a fresh one per timed fill; until then there is only the one served
300+
here.
301+
"""
297302
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
298303
server.subscriber = Subscriber(urlpath, streamed)
299-
server.target = None if target is None else Subscriber(target, writable=True)
304+
server.target = None
300305
threading.Thread(target=server.serve_forever, daemon=True).start()
301306
urlbase = f"http://127.0.0.1:{server.server_address[1]}/"
302307
return server, urlbase, f"@public/{pathlib.Path(urlpath).name}"
@@ -476,33 +481,33 @@ def timed_fill(open_array, chunks, writers, latency, bandwidth):
476481
"""
477482
tally = {"requests": 0, "bytes": 0}
478483
tally_lock = threading.Lock()
484+
writers = max(writers, 1)
485+
# One array per writer, built before the clock starts: opening one is an
486+
# `api/info` of its own, and a writer opens once however many chunks it goes
487+
# on to send. Building them inside the timing would charge every chunk for a
488+
# round trip no writer actually makes
489+
arrays = [open_array() for _ in range(writers)]
490+
work = list(enumerate(chunks))
491+
shares = [work[index::writers] for index in range(writers)]
479492

480-
def write(nchunk_and_chunk):
481-
nchunk, chunk = nchunk_and_chunk
482-
array = open_array()
483-
original = array.update_chunk
484-
485-
def charged(n, payload):
493+
def run(assignment):
494+
array, share = assignment
495+
for nchunk, chunk in share:
486496
if latency:
487497
time.sleep(latency)
488498
if bandwidth:
489-
time.sleep(len(payload) / bandwidth)
490-
answer = original(n, payload)
499+
time.sleep(len(chunk) / bandwidth)
500+
array.update_chunk(nchunk, chunk)
491501
with tally_lock:
492502
tally["requests"] += 1
493-
tally["bytes"] += len(payload)
494-
return answer
495-
496-
return charged(nchunk, chunk)
503+
tally["bytes"] += len(chunk)
497504

498-
work = list(enumerate(chunks))
499505
start = time.perf_counter()
500-
if writers <= 1:
501-
for item in work:
502-
write(item)
506+
if writers == 1:
507+
run((arrays[0], work))
503508
else:
504509
with concurrent.futures.ThreadPoolExecutor(max_workers=writers) as pool:
505-
list(pool.map(write, work))
510+
list(pool.map(run, zip(arrays, shares, strict=True)))
506511
return time.perf_counter() - start, tally["requests"], tally["bytes"]
507512

508513

‎doc/reference/c2array.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ own offsets, which is one range read and no endpoint of its own.
4747
.. automethod:: __getitem__
4848

4949

50+
.. autoclass:: ChunkAlreadyWritten
51+
52+
5053
.. _C2NDSource:
5154

5255
C2NDSource class

‎plans/cat2-concurrent-writers.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,8 @@ of 1.76 MB:
464464

465465
| | | |
466466
|---|---|---|
467-
| fill, serial | 247.7 ms/chunk | one round trip apiece |
468-
| fill, 8 writers at once | **35.5 ms/chunk** | **7.0x** |
467+
| fill, serial | 244.0 ms/chunk | one round trip apiece |
468+
| fill, 8 writers at once | **32.2 ms/chunk** | **7.6x** |
469469
| store into an empty slot | 0.91 ms | appended; no other chunk moves |
470470
| store over a live chunk | 2.96 ms | 3.3x, rewriting the 5.29 MB after it |
471471
| `written_chunks()` over HTTP | 2.47 ms | one range read of the offsets |
@@ -475,7 +475,7 @@ of 1.76 MB:
475475
The concurrency figure is the one worth having: the subscriber serializes the
476476
writes themselves, since each takes the frame's exclusive lock, so what overlaps
477477
is the round trip — which over a WAN is nearly all of it, and over loopback is
478-
nearly none (1.2x there). The rewrite ratio is this dataset's and grows with
478+
none at all (1.0x there: 1.9 ms a chunk either way). The rewrite ratio is this dataset's and grows with
479479
whatever payload follows the chunk; the same measurement on a 110 MB frame ran
480480
21.2 ms against 0.5 ms.
481481

‎src/blosc2/c2array.py‎

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,9 @@ def __init__(self, path: str, /, urlbase: str | None = None, auth_token: str | N
565565
# dataset cannot be read in ranges) or a C2NDSource
566566
self._block_source = _UNTRIED
567567
self._block_lock = threading.Lock()
568+
# Set when this handle writes: `meta` describes the array as it was read,
569+
# and a write of its own moves what `stamp` and `vlmeta` are built from
570+
self._meta_stale = False
568571
# An index a `Proxy` handed over before the source existed; see adopt_index
569572
self._pending_index = None
570573

@@ -854,9 +857,25 @@ def written_chunks(self) -> np.ndarray:
854857

855858
def _forget_index(self) -> None:
856859
"""Drop what was read of a frame that has since been written to."""
857-
source = self._block_source
858-
if source is not _UNTRIED and source is not None:
859-
source.invalidate_index()
860+
# The metadata as well as the index: `meta` is read once when the array
861+
# is opened, so a handle that goes on to write would otherwise answer for
862+
# the array as it was before its own writes -- and `stamp` is built from
863+
# exactly the fields a write moves. Read again when something asks,
864+
# rather than here, so a writer that never asks pays no request
865+
self._meta_stale = True
866+
with self._block_lock:
867+
# Under the lock a source being built right now is invalidated after
868+
# it is built, rather than missed entirely for holding a header this
869+
# write has already moved
870+
source = self._block_source
871+
if source is not _UNTRIED and source is not None:
872+
source.invalidate_index()
873+
874+
def _refresh_meta(self) -> None:
875+
"""Read `api/info` again, if this handle has written since it last did."""
876+
if self._meta_stale:
877+
self.meta = info(self.path, self.urlbase, auth_token=self.auth_token)
878+
self._meta_stale = False
860879

861880
# -- Block-granular reads. A :ref:`Proxy` uses these to fetch the blocks a
862881
# slice touches instead of whole chunks, wherever that is the cheaper way
@@ -897,16 +916,21 @@ def stamp(self) -> str | None:
897916
which leaves the cache checked on its geometry alone, as every source
898917
without a stamp is.
899918
"""
919+
self._refresh_meta()
900920
vlmeta = self.meta.get("schunk", {}).get("vlmeta") or {}
901921
nonce = vlmeta.get("fill_nonce")
902922
cbytes = self.meta.get("schunk", {}).get("cbytes", "")
903-
if nonce is not None and vlmeta.get("fill_state", "filling") != "filling":
904-
# Complete: nothing can write to it again, so nothing here need move
905-
return f"n{nonce}:{cbytes}"
906923
mtime = self.meta.get("mtime")
907-
if mtime is None:
908-
return None if nonce is None else f"n{nonce}:{cbytes}"
909-
return f"{mtime}:{cbytes}" if nonce is None else f"n{nonce}:{mtime}:{cbytes}"
924+
if nonce is None:
925+
return None if mtime is None else f"{mtime}:{cbytes}"
926+
# `c` and `f` keep the two apart whatever the rest holds: a complete array
927+
# and a filling one must never stamp the same, or a cache of the second
928+
# is adopted against the first and serves the zeros it holds for the
929+
# chunks nobody had written yet
930+
if vlmeta.get("fill_state", "filling") != "filling":
931+
# Complete: nothing can write to it again, so nothing here need move
932+
return f"n{nonce}:c:{cbytes}"
933+
return f"n{nonce}:f:{cbytes}" if mtime is None else f"n{nonce}:f:{mtime}:{cbytes}"
910934

911935
@property
912936
def blocks_per_chunk(self) -> int:
@@ -964,8 +988,14 @@ def block_source(self) -> C2NDSource | None:
964988
be permanent: a subscriber that streams this dataset answers a range
965989
request with the whole body, so retrying would pay a full download to
966990
rediscover the same answer.
991+
992+
A frame whose chunks are too small to be worth taking apart says no here
993+
without building anything, and without remembering that it said so: the
994+
judgement is about *blocks*, and the same frame's index is still worth
995+
reading. Deciding it at the call rather than caching it is what keeps
996+
the two questions from answering each other.
967997
"""
968-
return self._source(require_blocks=True)
998+
return self._source() if self.serves_blocks else None
969999

9701000
def _index_source(self) -> C2NDSource | None:
9711001
"""The same reader, built for any stored frame however small its chunks.
@@ -975,29 +1005,29 @@ def _index_source(self) -> C2NDSource | None:
9751005
and they still say which chunks hold anything. Whatever is built here is
9761006
the source the block path uses too -- there is only ever one.
9771007
"""
978-
return self._source(require_blocks=False)
1008+
return self._source() if self._reports_geometry else None
9791009

980-
def _source(self, require_blocks: bool) -> C2NDSource | None:
1010+
def _source(self) -> C2NDSource | None:
1011+
"""The one source, built once, whichever question asked for it first."""
9811012
if self._block_source is _UNTRIED:
9821013
with self._block_lock:
9831014
if self._block_source is _UNTRIED:
984-
self._block_source = self._open_block_source(require_blocks)
1015+
self._block_source = self._open_block_source()
9851016
# A failure that says nothing about the dataset leaves it _UNTRIED, so the
9861017
# next fetch asks again; this one keeps to whole chunks either way
9871018
return None if self._block_source is _UNTRIED else self._block_source
9881019

989-
def _open_block_source(self, require_blocks: bool = True):
1020+
def _open_block_source(self):
9901021
"""Decide, at whatever cost it takes, whether this dataset serves ranges.
9911022
9921023
None for a dataset that does not serve ranges, which is an answer for
9931024
good; `_UNTRIED` for a subscriber that could not say, which is not.
9941025
"""
9951026
httpx = _httpx()
996-
# What `api/info` alone rules out -- a dataset the subscriber computes, a
997-
# frame of chunks too small to take apart -- costs no request to find out.
998-
# The second of those only bars the block path: the index is worth a read
999-
# whatever the chunks cost, which is what `require_blocks` says
1000-
if not (self.serves_blocks if require_blocks else self._reports_geometry):
1027+
# A dataset the subscriber computes has no frame to read at all, and
1028+
# `api/info` says so for free. Whether its chunks are worth taking apart
1029+
# is a separate judgement, made by whoever asks -- see `block_source`
1030+
if not self._reports_geometry:
10011031
return None
10021032
# Whether a dataset that reports a geometry is *served* from a file is
10031033
# something only the answer to a range request can say: an HDF5 leaf or a
@@ -1179,7 +1209,13 @@ def cratio(self) -> float:
11791209

11801210
@property
11811211
def vlmeta(self) -> dict:
1182-
"""The variable-length metadata f the remote array"""
1212+
"""The variable-length metadata of the remote array.
1213+
1214+
Read again where this handle has written since it last looked: a fill
1215+
records itself here, so a writer asking what it just did would otherwise
1216+
be told what was true before it started.
1217+
"""
1218+
self._refresh_meta()
11831219
return self.meta["schunk"]["vlmeta"]
11841220

11851221
@property

‎src/blosc2/proxy_source.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -684,10 +684,12 @@ def _frame_index(self) -> tuple[np.ndarray, np.ndarray]:
684684
with self._index_lock:
685685
if self._stale:
686686
# A write moved the frame's length and its payload extent, and the
687-
# offsets are found through both, so the header is read first
687+
# offsets are found through both, so the header is read first, and
688+
# the offsets it locates are read again after it
688689
raw, self._header, self._head = _read_frame_header(self.read_range)
689690
self._header_len = len(raw)
690691
self._chunksize = self._header[8]
692+
self._index = None
691693
self._stale = False
692694
if self._index is None:
693695
offsets = _read_frame_offsets(self.read_range, self._header, self._head, self._header_len)
@@ -722,8 +724,11 @@ def written_chunks(self) -> np.ndarray:
722724
"""
723725
offsets = self._offsets
724726
# A view, not a cast: the tag lives in the top byte of an offset whose
725-
# sign bit is what marks it as run-length in the first place
726-
kinds = (offsets.view("<u8") >> 56) & 0x7
727+
# sign bit is what marks it as run-length in the first place. Viewed as
728+
# the native unsigned type, not as a little-endian one -- the offsets are
729+
# read in the host's order, so naming a byte order here would read the
730+
# tag out of the wrong end of each word on a big-endian machine
731+
kinds = (offsets.view(np.uint64) >> np.uint64(56)) & np.uint64(0x7)
727732
return ~((offsets < 0) & (kinds == _SPECIAL_UNINIT))
728733

729734
def invalidate_index(self) -> None:
@@ -744,8 +749,14 @@ def invalidate_index(self) -> None:
744749
writing. A frame that nobody mutates never needs this.
745750
"""
746751
with self._index_lock:
747-
self._index = None
748-
self._layouts.clear()
752+
# What was read stays until something reads again: `index_state` hands
753+
# it to a cache that a stamp already guards, and emptying it here
754+
# would overwrite a good index with nothing at all.
755+
#
756+
# The layouts stay for good. A chunk gets one only once it has been
757+
# read, which under the write-once contract this exists for means it
758+
# holds content and can never be written again; a chunk that was
759+
# empty when the index was read has no layout to be wrong about.
749760
self._stale = True
750761

751762
@property
@@ -927,10 +938,12 @@ def _special_chunk(self, offset: int) -> bytes:
927938
nitems = self._chunksize // self._dtype.itemsize
928939
if kind == _SPECIAL_NAN:
929940
data = np.full(nitems, np.nan, dtype=self._dtype)
930-
else:
941+
elif kind in (_SPECIAL_ZERO, _SPECIAL_UNINIT):
931942
# A run of zeros; an uninitialized chunk has no defined content, and
932943
# zeros is what reading one locally hands back too
933944
data = np.zeros(nitems, dtype=self._dtype)
945+
else:
946+
raise NotImplementedError(f"chunk offset {offset} codes run-length value {kind}")
934947
# The blocksize has to be the container's: left to choose, blosc2 takes
935948
# the whole chunk, and the cache then rejects the chunk we hand it
936949
return blosc2.compress2(

‎tests/ndarray/test_c2array_writes.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,3 +474,60 @@ def test_a_cache_of_a_complete_array_survives_a_second_run(subscriber, tmp_path)
474474
proxy = blosc2.Proxy(blosc2.C2Array("run.b2nd", urlbase=array.urlbase), urlpath=cache, mode="a")
475475
np.testing.assert_array_equal(proxy[:], expected)
476476
assert not [entry for entry in sub.log if entry[0] in ("chunk", "fetch")]
477+
478+
479+
def test_a_handle_that_writes_stamps_what_it_wrote(subscriber):
480+
"""A writer's own view of the array has to move when the array does.
481+
482+
`meta` is read when the array is opened, and `stamp` is built from exactly
483+
the fields a write moves, so a handle that goes on to write would otherwise
484+
answer for the array as it was before its own writes -- and a `Proxy` given
485+
that handle would adopt a cache built against them.
486+
"""
487+
array, sub = subscriber
488+
before = array.stamp
489+
array.update_chunk(0, _chunk(0))
490+
assert array.stamp != before
491+
assert array.stamp == blosc2.C2Array("run.b2nd", urlbase=array.urlbase).stamp
492+
493+
494+
def test_asking_about_blocks_does_not_close_the_door_on_the_index(subscriber):
495+
"""Two questions, one source, and the answer to one must not answer the other.
496+
497+
`serves_blocks` weighs whether splitting a chunk into blocks would pay, which
498+
a frame of small chunks fails; reading the frame's index is worth doing
499+
anyway. Deciding that at the call rather than remembering it is what keeps
500+
the block path from shutting the index path down.
501+
"""
502+
array, sub = subscriber
503+
assert not array.serves_blocks # chunks here are far under BLOCK_MIN_CBYTES
504+
assert array.max_ranges == 1 # the block path, asked first, and declining
505+
assert array.block_source() is None
506+
assert list(array.written_chunks()) == [False] * NCHUNKS # still answerable
507+
508+
509+
def test_a_filling_stamp_can_never_read_as_a_complete_one(subscriber):
510+
"""The two branches must not be able to produce the same string.
511+
512+
A cache built while chunks were unwritten holds the zeros they read as; if
513+
the completed array stamped the same, that cache would be adopted against it
514+
and those zeros served as data.
515+
"""
516+
array, sub = subscriber
517+
array.update_chunk(0, _chunk(0))
518+
filling = blosc2.C2Array("run.b2nd", urlbase=array.urlbase).stamp
519+
assert ":f:" in filling
520+
for nchunk in range(1, NCHUNKS):
521+
array.update_chunk(nchunk, _chunk(nchunk))
522+
complete = blosc2.C2Array("run.b2nd", urlbase=array.urlbase).stamp
523+
assert ":c:" in complete
524+
assert complete != filling
525+
526+
# ... including when the subscriber reports no mtime at all, which is what
527+
# left the two able to collide
528+
handle = blosc2.C2Array("run.b2nd", urlbase=array.urlbase)
529+
handle.meta["mtime"] = None
530+
handle.meta["schunk"]["vlmeta"] = {"fill_nonce": "abc", "fill_state": "filling"}
531+
unfinished = handle.stamp
532+
handle.meta["schunk"]["vlmeta"] = {"fill_nonce": "abc", "fill_state": "complete"}
533+
assert handle.stamp != unfinished

0 commit comments

Comments
 (0)