@@ -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
0 commit comments