Skip to content

Commit ff47bae

Browse files
FrancescAltedclaude
andcommitted
Track fetched chunks explicitly in the Proxy cache
Whether a chunk is already cached could not be read off the cache itself: a fetched chunk that is a run of a single value is stored as a special chunk, exactly like the empty ones blosc2.empty() leaves behind. Every such chunk was therefore refetched on every access and on every run, which for a full() array behind an fsspec URL meant a range request per chunk, forever. Keep a bitmap of the chunks brought over instead, persisted in the cache's vlmeta so a later run picks up where this one stopped. It also replaces the per-call scan over the chunk infos in fetch() and afetch(). Two smaller fixes on the way: - Say in the Proxy docstring what adopting a cache actually checks: geometry only, unless the source can name the bytes it reads as FsspecNDSource does. - Shut the fetch pool down with cancel_futures: map() queues every chunk up front, so an error (or a Ctrl-C) waited for thousands of pending requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent bf87015 commit ff47bae

2 files changed

Lines changed: 92 additions & 24 deletions

File tree

‎src/blosc2/proxy.py‎

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,14 @@ def __init__(
232232
With "a" and an existing :paramref:`urlpath`, the cache written by an
233233
earlier run is adopted as is, so whatever it already holds is not
234234
fetched from the source again. It must be a cache from a proxy over a
235-
source of the same shape and dtype; anything else raises rather than
236-
being silently reused or overwritten.
235+
source of the same geometry (shape, dtype and partitioning); anything
236+
else raises rather than being silently reused or overwritten.
237+
238+
Geometry is all that is checked unless the source can name the exact
239+
bytes it reads, as :ref:`FsspecNDSource` does with its ``stamp``: a
240+
source whose contents changed underneath while its geometry did not
241+
is adopted, and the cache keeps serving what the earlier run fetched.
242+
Pass ``mode="w"`` when the source may have been rewritten.
237243
kwargs: dict, optional
238244
Keyword arguments supported:
239245
@@ -270,7 +276,8 @@ def __init__(
270276
)
271277
self._cache = self._reopen_cache(urlpath)
272278

273-
if self._cache is None:
279+
fresh = self._cache is None
280+
if fresh:
274281
meta_val = {
275282
"local_abspath": None,
276283
"urlpath": None,
@@ -305,6 +312,12 @@ def __init__(
305312
)
306313
self._cache.fill_special(self.src.nbytes // self.src.typesize, blosc2.SpecialValue.UNINIT)
307314
self._schunk_cache = getattr(self._cache, "schunk", self._cache)
315+
# What is already cached cannot be read off the cache itself: a chunk that
316+
# is a run of a single value (zeros, NaNs, whatever blosc2.full() writes)
317+
# is stored as a special chunk once fetched, telling it apart from the
318+
# empty ones the cache starts life with. Hence an explicit bitmap.
319+
nchunks = self._schunk_cache.nchunks
320+
self._fetched = bytearray((nchunks + 7) // 8) if fresh else self._load_fetched(nchunks)
308321
if self.urlpath is None:
309322
self.urlpath = getattr(self._schunk_cache, "urlpath", None)
310323
# Geometry alone cannot tell a replaced source from the one the cache was
@@ -320,6 +333,30 @@ def __enter__(self) -> "Proxy":
320333
"""Enter a context manager and return this proxy."""
321334
return self
322335

336+
def _load_fetched(self, nchunks: int) -> bytearray:
337+
"""The bitmap of already fetched chunks that a previous run left behind."""
338+
stored = self._schunk_cache.vlmeta.get("proxy-fetched")
339+
if stored is not None and len(stored) == (nchunks + 7) // 8:
340+
return bytearray(stored)
341+
# A cache filled before the bitmap existed, or one handed over through
342+
# `_cache=`: everything that is not a special chunk was surely fetched
343+
fetched = bytearray((nchunks + 7) // 8)
344+
for info in self._schunk_cache.iterchunks_info():
345+
if info.special == blosc2.SpecialValue.NOT_SPECIAL:
346+
fetched[info.nchunk // 8] |= 1 << (info.nchunk % 8)
347+
return fetched
348+
349+
def _missing_chunks(self, item) -> list[int]:
350+
"""The chunks *item* touches, minus those already in the cache."""
351+
nchunks = self._schunk_cache.nchunks
352+
# Full realization when item is (), else only the chunks it intersects
353+
wanted = range(nchunks) if item == () else sorted(set(blosc2.get_slice_nchunks(self._cache, item)))
354+
return [int(n) for n in wanted if not self._fetched[n // 8] >> (n % 8) & 1]
355+
356+
def _save_fetched(self) -> None:
357+
"""Persist the bitmap, so a later run does not fetch these chunks again."""
358+
self._schunk_cache.vlmeta["proxy-fetched"] = bytes(self._fetched)
359+
323360
def _reopen_cache(self, urlpath: str):
324361
"""Adopt the cache container stored at *urlpath*, checking it fits the source."""
325362
from blosc2.schunk import _set_default_dparams
@@ -418,16 +455,15 @@ def fetch(
418455
[2 3]
419456
[4 5]]
420457
"""
421-
# Full realization when item is (), else only the chunks it intersects
422-
wanted = None if item == () else set(blosc2.get_slice_nchunks(self._cache, item))
423-
missing = [
424-
info.nchunk
425-
for info in self._schunk_cache.iterchunks_info()
426-
if info.special != blosc2.SpecialValue.NOT_SPECIAL and (wanted is None or info.nchunk in wanted)
427-
]
428-
429-
for nchunk, chunk in self._get_chunks(missing, max_concurrency):
430-
self._schunk_cache.update_chunk(nchunk, chunk)
458+
missing = self._missing_chunks(item)
459+
try:
460+
for nchunk, chunk in self._get_chunks(missing, max_concurrency):
461+
self._schunk_cache.update_chunk(nchunk, chunk)
462+
self._fetched[nchunk // 8] |= 1 << (nchunk % 8)
463+
finally:
464+
# Keep hold of whatever did arrive, even if a later chunk blew up
465+
if missing:
466+
self._save_fetched()
431467

432468
return self._cache
433469

@@ -440,8 +476,14 @@ def _get_chunks(self, nchunks: list[int], max_concurrency: int | None):
440476
yield nchunk, self.src.get_chunk(nchunk)
441477
return
442478
# Writing to the cache stays on this thread; only the fetches fan out
443-
with ThreadPoolExecutor(max_workers=min(max_concurrency, len(nchunks))) as pool:
479+
pool = ThreadPoolExecutor(max_workers=min(max_concurrency, len(nchunks)))
480+
try:
444481
yield from zip(nchunks, pool.map(self.src.get_chunk, nchunks), strict=True)
482+
finally:
483+
# map() queues every chunk up front, so without cancel_futures an error
484+
# here (or a Ctrl-C) would first sit through thousands of pending
485+
# requests; only the handful already running are waited for
486+
pool.shutdown(cancel_futures=True)
445487

446488
async def afetch(
447489
self, item: slice | list[slice] | None = (), max_concurrency: int | None = None
@@ -529,15 +571,7 @@ async def afetch(
529571
if not callable(getattr(self.src, "aget_chunk", None)):
530572
raise NotImplementedError("afetch is only available if the source has an aget_chunk method")
531573

532-
if item == ():
533-
wanted = None # every missing chunk
534-
else:
535-
wanted = set(blosc2.get_slice_nchunks(self._cache, item))
536-
to_fetch = [
537-
info.nchunk
538-
for info in self._schunk_cache.iterchunks_info()
539-
if info.special != blosc2.SpecialValue.NOT_SPECIAL and (wanted is None or info.nchunk in wanted)
540-
]
574+
to_fetch = self._missing_chunks(item)
541575

542576
if max_concurrency is None:
543577
max_concurrency = getattr(
@@ -552,9 +586,14 @@ async def _fetch_one(nchunk):
552586
chunk = await self.src.aget_chunk(nchunk)
553587
# Runs to completion between awaits, so concurrent writers can't interleave.
554588
self._schunk_cache.update_chunk(nchunk, chunk)
589+
self._fetched[nchunk // 8] |= 1 << (nchunk % 8)
555590

556591
if to_fetch:
557-
await asyncio.gather(*(_fetch_one(nchunk) for nchunk in to_fetch))
592+
try:
593+
await asyncio.gather(*(_fetch_one(nchunk) for nchunk in to_fetch))
594+
finally:
595+
# Keep hold of whatever did arrive, even if a later chunk blew up
596+
self._save_fetched()
558597

559598
return self._cache
560599

‎tests/test_fsspec.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,35 @@ def test_lazy_persistent_proxy_cache(tmp_path, monkeypatch):
420420
assert fetched == [0, 5]
421421

422422

423+
def test_lazy_cache_converges_for_run_length_chunks(tmp_path, monkeypatch):
424+
# A fetched chunk that is a run of a single value is stored in the cache as a
425+
# special chunk, just like the empty ones it was created with, so whether it
426+
# is there cannot be read off the cache itself
427+
a = blosc2.full((1000,), 3.0, dtype="f8", chunks=(100,))
428+
url = _put("runlength.b2nd", a)
429+
cache = str(tmp_path / "runlength-cache.b2nd")
430+
431+
fetched = []
432+
orig = blosc2.FsspecNDSource.get_chunk
433+
monkeypatch.setattr(
434+
blosc2.FsspecNDSource,
435+
"get_chunk",
436+
lambda self, nchunk: (fetched.append(nchunk), orig(self, nchunk))[1],
437+
)
438+
439+
p = blosc2.Proxy(blosc2.FsspecNDSource(url), urlpath=cache, mode="a")
440+
assert np.array_equal(p[:], a[:])
441+
assert len(fetched) == 10
442+
assert np.array_equal(p[:], a[:])
443+
assert len(fetched) == 10
444+
del p
445+
446+
# And the same across runs, which is what the persistent cache promises
447+
p = blosc2.Proxy(blosc2.FsspecNDSource(url), urlpath=cache, mode="a")
448+
assert np.array_equal(p[:], a[:])
449+
assert len(fetched) == 10
450+
451+
423452
def test_lazy_needs_an_ndarray():
424453
schunk = blosc2.SChunk(chunksize=1000)
425454
schunk.append_data(np.arange(1000, dtype="u1"))

0 commit comments

Comments
 (0)