Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/fsspec/caching.py: 19%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

566 statements  

1from __future__ import annotations 

2 

3import asyncio 

4import collections 

5import functools 

6import logging 

7import math 

8import os 

9import threading 

10from collections import OrderedDict 

11from collections.abc import Callable 

12from concurrent.futures import Future, ThreadPoolExecutor 

13from itertools import groupby 

14from operator import itemgetter 

15from typing import TYPE_CHECKING, Any, ClassVar, Generic, NamedTuple, TypeVar 

16 

17if TYPE_CHECKING: 

18 import mmap 

19 

20 from typing_extensions import ParamSpec 

21 

22 P = ParamSpec("P") 

23else: 

24 P = TypeVar("P") 

25 

26T = TypeVar("T") 

27 

28 

29logger = logging.getLogger("fsspec.caching") 

30 

31Fetcher = Callable[[int, int], bytes] # Maps (start, end) to bytes 

32MultiFetcher = Callable[[list[int, int]], bytes] # Maps [(start, end)] to bytes 

33 

34 

35class BaseCache: 

36 """Pass-though cache: doesn't keep anything, calls every time 

37 

38 Acts as base class for other cachers 

39 

40 Parameters 

41 ---------- 

42 blocksize: int 

43 How far to read ahead in numbers of bytes 

44 fetcher: func 

45 Function of the form f(start, end) which gets bytes from remote as 

46 specified 

47 size: int 

48 How big this file is 

49 """ 

50 

51 name: ClassVar[str] = "none" 

52 

53 def __init__(self, blocksize: int, fetcher: Fetcher, size: int) -> None: 

54 self.blocksize = blocksize 

55 self.nblocks = 0 

56 self.fetcher = fetcher 

57 self.size = size 

58 self.hit_count = 0 

59 self.miss_count = 0 

60 # the bytes that we actually requested 

61 self.total_requested_bytes = 0 

62 

63 def _fetch(self, start: int | None, stop: int | None) -> bytes: 

64 if start is None: 

65 start = 0 

66 if stop is None: 

67 stop = self.size 

68 if start >= self.size or start >= stop: 

69 return b"" 

70 return self.fetcher(start, stop) 

71 

72 def _reset_stats(self) -> None: 

73 """Reset hit and miss counts for a more ganular report e.g. by file.""" 

74 self.hit_count = 0 

75 self.miss_count = 0 

76 self.total_requested_bytes = 0 

77 

78 def _log_stats(self) -> str: 

79 """Return a formatted string of the cache statistics.""" 

80 if self.hit_count == 0 and self.miss_count == 0: 

81 # a cache that does nothing, this is for logs only 

82 return "" 

83 return f" , {self.name}: {self.hit_count} hits, {self.miss_count} misses, {self.total_requested_bytes} total requested bytes" 

84 

85 def __repr__(self) -> str: 

86 # TODO: use rich for better formatting 

87 return f""" 

88 <{self.__class__.__name__}: 

89 block size : {self.blocksize} 

90 block count : {self.nblocks} 

91 file size : {self.size} 

92 cache hits : {self.hit_count} 

93 cache misses: {self.miss_count} 

94 total requested bytes: {self.total_requested_bytes}> 

95 """ 

96 

97 

98class MMapCache(BaseCache): 

99 """memory-mapped sparse file cache 

100 

101 Opens temporary file, which is filled blocks-wise when data is requested. 

102 Ensure there is enough disc space in the temporary location. 

103 

104 This cache method might only work on posix 

105 

106 Parameters 

107 ---------- 

108 blocksize: int 

109 How far to read ahead in numbers of bytes 

110 fetcher: Fetcher 

111 Function of the form f(start, end) which gets bytes from remote as 

112 specified 

113 size: int 

114 How big this file is 

115 location: str 

116 Where to create the temporary file. If None, a temporary file is 

117 created using tempfile.TemporaryFile(). 

118 blocks: set[int] 

119 Set of block numbers that have already been fetched. If None, an empty 

120 set is created. 

121 multi_fetcher: MultiFetcher 

122 Function of the form f([(start, end)]) which gets bytes from remote 

123 as specified. This function is used to fetch multiple blocks at once. 

124 If not specified, the fetcher function is used instead. 

125 """ 

126 

127 name = "mmap" 

128 

129 def __init__( 

130 self, 

131 blocksize: int, 

132 fetcher: Fetcher, 

133 size: int, 

134 location: str | None = None, 

135 blocks: set[int] | None = None, 

136 multi_fetcher: MultiFetcher | None = None, 

137 ) -> None: 

138 super().__init__(blocksize, fetcher, size) 

139 self.blocks = set() if blocks is None else blocks 

140 self.location = location 

141 self.multi_fetcher = multi_fetcher 

142 self.cache = self._makefile() 

143 

144 def _makefile(self) -> mmap.mmap | bytearray: 

145 import mmap 

146 import tempfile 

147 

148 if self.size == 0: 

149 return bytearray() 

150 

151 # posix version 

152 if self.location is None or not os.path.exists(self.location): 

153 if self.location is None: 

154 fd = tempfile.TemporaryFile() 

155 self.blocks = set() 

156 else: 

157 fd = open(self.location, "wb+") 

158 fd.seek(self.size - 1) 

159 fd.write(b"1") 

160 fd.flush() 

161 else: 

162 fd = open(self.location, "r+b") 

163 

164 return mmap.mmap(fd.fileno(), self.size) 

165 

166 def _fetch(self, start: int | None, end: int | None) -> bytes: 

167 logger.debug(f"MMap cache fetching {start}-{end}") 

168 if start is None: 

169 start = 0 

170 if end is None: 

171 end = self.size 

172 if start >= self.size or start >= end: 

173 return b"" 

174 start_block = start // self.blocksize 

175 end_block = end // self.blocksize 

176 block_range = range(start_block, end_block + 1) 

177 # Determine which blocks need to be fetched. This sequence is sorted by construction. 

178 need = (i for i in block_range if i not in self.blocks) 

179 # Count the number of blocks already cached 

180 self.hit_count += sum(1 for i in block_range if i in self.blocks) 

181 

182 ranges = [] 

183 

184 # Consolidate needed blocks. 

185 # Algorithm adapted from Python 2.x itertools documentation. 

186 # We are grouping an enumerated sequence of blocks. By comparing when the difference 

187 # between an ascending range (provided by enumerate) and the needed block numbers 

188 # we can detect when the block number skips values. The key computes this difference. 

189 # Whenever the difference changes, we know that we have previously cached block(s), 

190 # and a new group is started. In other words, this algorithm neatly groups 

191 # runs of consecutive block numbers so they can be fetched together. 

192 for _, _blocks in groupby(enumerate(need), key=lambda x: x[0] - x[1]): 

193 # Extract the blocks from the enumerated sequence 

194 _blocks = tuple(map(itemgetter(1), _blocks)) 

195 # Compute start of first block 

196 sstart = _blocks[0] * self.blocksize 

197 # Compute the end of the last block. Last block may not be full size. 

198 send = min(_blocks[-1] * self.blocksize + self.blocksize, self.size) 

199 

200 # Fetch bytes (could be multiple consecutive blocks) 

201 self.total_requested_bytes += send - sstart 

202 logger.debug( 

203 f"MMap get blocks {_blocks[0]}-{_blocks[-1]} ({sstart}-{send})" 

204 ) 

205 ranges.append((sstart, send)) 

206 

207 # Update set of cached blocks 

208 self.blocks.update(_blocks) 

209 # Update cache statistics with number of blocks we had to cache 

210 self.miss_count += len(_blocks) 

211 

212 if not ranges: 

213 return self.cache[start:end] 

214 

215 if self.multi_fetcher: 

216 logger.debug(f"MMap get blocks {ranges}") 

217 for idx, r in enumerate(self.multi_fetcher(ranges)): 

218 sstart, send = ranges[idx] 

219 logger.debug(f"MMap copy block ({sstart}-{send}") 

220 self.cache[sstart:send] = r 

221 else: 

222 for sstart, send in ranges: 

223 logger.debug(f"MMap get block ({sstart}-{send}") 

224 self.cache[sstart:send] = self.fetcher(sstart, send) 

225 

226 return self.cache[start:end] 

227 

228 def __getstate__(self) -> dict[str, Any]: 

229 state = self.__dict__.copy() 

230 # Remove the unpicklable entries. 

231 del state["cache"] 

232 return state 

233 

234 def __setstate__(self, state: dict[str, Any]) -> None: 

235 # Restore instance attributes 

236 self.__dict__.update(state) 

237 self.cache = self._makefile() 

238 

239 

240class ReadAheadCache(BaseCache): 

241 """Cache which reads only when we get beyond a block of data 

242 

243 This is a much simpler version of BytesCache, and does not attempt to 

244 fill holes in the cache or keep fragments alive. It is best suited to 

245 many small reads in a sequential order (e.g., reading lines from a file). 

246 """ 

247 

248 name = "readahead" 

249 

250 def __init__(self, blocksize: int, fetcher: Fetcher, size: int) -> None: 

251 super().__init__(blocksize, fetcher, size) 

252 self.cache = b"" 

253 self.start = 0 

254 self.end = 0 

255 

256 def _fetch(self, start: int | None, end: int | None) -> bytes: 

257 if start is None: 

258 start = 0 

259 if end is None or end > self.size: 

260 end = self.size 

261 if start >= self.size or start >= end: 

262 return b"" 

263 l = end - start 

264 if start >= self.start and end <= self.end: 

265 # cache hit 

266 self.hit_count += 1 

267 return self.cache[start - self.start : end - self.start] 

268 elif self.start <= start < self.end: 

269 # partial hit 

270 self.miss_count += 1 

271 part = self.cache[start - self.start :] 

272 l -= len(part) 

273 start = self.end 

274 else: 

275 # miss 

276 self.miss_count += 1 

277 part = b"" 

278 end = min(self.size, end + self.blocksize) 

279 self.total_requested_bytes += end - start 

280 self.cache = self.fetcher(start, end) # new block replaces old 

281 self.start = start 

282 self.end = self.start + len(self.cache) 

283 return part + self.cache[:l] 

284 

285 

286class AdaptiveReadaheadCache(BaseCache): 

287 """Cache with adaptive asynchronous prefetching. 

288 

289 Optimized for sequential and near-sequential reads by dynamically 

290 adjusting the amount of data prefetched in the background. The cache 

291 uses the generic prefetch engine when async loop support is available, 

292 and falls back to ``ReadAheadCache`` when it is not. 

293 

294 Parameters 

295 ---------- 

296 blocksize: int 

297 Nominal read size used by callers. 

298 fetcher: Fetcher 

299 Function of the form ``f(start, end)`` that returns bytes. 

300 size: int 

301 Total size of the file. 

302 concurrency: int 

303 Maximum number of concurrent background fetch tasks. 

304 max_prefetch_size: int | None 

305 Optional upper bound for adaptive prefetch size in bytes. 

306 """ 

307 

308 name = "adaptive" 

309 

310 def __init__( 

311 self, 

312 blocksize: int, 

313 fetcher: Fetcher, 

314 size: int, 

315 concurrency: int = 4, 

316 max_prefetch_size: int | None = None, 

317 ) -> None: 

318 super().__init__(blocksize, fetcher, size) 

319 self._fallback = ReadAheadCache(blocksize, fetcher, size) 

320 self._prefetcher = None 

321 

322 async def _default_fetcher_async( 

323 start_offset: int, 

324 total_size: int, 

325 split_factor: int = 1, 

326 ) -> bytes: 

327 del split_factor 

328 return await asyncio.to_thread( 

329 self.fetcher, start_offset, start_offset + total_size 

330 ) 

331 

332 try: 

333 from . import asyn as fsspec_asyn 

334 except ImportError as e: 

335 raise ImportError( 

336 "AdaptiveReadaheadCache requires fsspec.asyn to be available" 

337 ) from e 

338 

339 try: 

340 from .prefetcher import BackgroundPrefetcher 

341 

342 self._prefetcher = BackgroundPrefetcher( 

343 fetcher=_default_fetcher_async, 

344 size=size, 

345 concurrency=concurrency, 

346 max_prefetch_size=max_prefetch_size, 

347 loop=fsspec_asyn.get_loop(), 

348 ) 

349 logger.info( 

350 "AdaptiveReadaheadCache enabled (blocksize=%d, size=%d, concurrency=%d, max_prefetch_size=%s)", 

351 blocksize, 

352 size, 

353 concurrency, 

354 max_prefetch_size, 

355 ) 

356 except Exception as e: 

357 logger.info( 

358 "AdaptiveReadaheadCache fallback to ReadAheadCache: %s", 

359 e, 

360 exc_info=True, 

361 ) 

362 self._prefetcher = None 

363 

364 def _fetch(self, start: int | None, end: int | None) -> bytes: 

365 if self._prefetcher is None: 

366 out = self._fallback._fetch(start, end) 

367 self.hit_count = self._fallback.hit_count 

368 self.miss_count = self._fallback.miss_count 

369 self.total_requested_bytes = self._fallback.total_requested_bytes 

370 return out 

371 

372 out = self._prefetcher.fetch(start, end) 

373 self.miss_count += 1 

374 self.total_requested_bytes += len(out) 

375 return out 

376 

377 def close(self) -> None: 

378 if self._prefetcher is not None: 

379 self._prefetcher.close() 

380 self._prefetcher = None 

381 

382 def __getstate__(self) -> dict[str, Any]: 

383 # The prefetcher owns asyncio primitives that are not picklable. 

384 self.close() 

385 state = self.__dict__.copy() 

386 state["_prefetcher"] = None 

387 return state 

388 

389 def __setstate__(self, state: dict[str, Any]) -> None: 

390 self.__dict__.update(state) 

391 self._prefetcher = None 

392 

393 def __del__(self): 

394 try: 

395 self.close() 

396 except Exception: 

397 # Best-effort cleanup during GC. 

398 pass 

399 

400 

401class FirstChunkCache(BaseCache): 

402 """Caches the first block of a file only 

403 

404 This may be useful for file types where the metadata is stored in the header, 

405 but is randomly accessed. 

406 """ 

407 

408 name = "first" 

409 

410 def __init__(self, blocksize: int, fetcher: Fetcher, size: int) -> None: 

411 if blocksize > size: 

412 # this will buffer the whole thing 

413 blocksize = size 

414 super().__init__(blocksize, fetcher, size) 

415 self.cache: bytes | None = None 

416 

417 def _fetch(self, start: int | None, end: int | None) -> bytes: 

418 start = start or 0 

419 if start > self.size: 

420 logger.debug("FirstChunkCache: requested start > file size") 

421 return b"" 

422 

423 if end is None: 

424 end = self.size 

425 end = min(end, self.size) 

426 

427 if start < self.blocksize: 

428 if self.cache is None: 

429 self.miss_count += 1 

430 if end > self.blocksize: 

431 self.total_requested_bytes += end 

432 data = self.fetcher(0, end) 

433 self.cache = data[: self.blocksize] 

434 return data[start:] 

435 self.cache = self.fetcher(0, self.blocksize) 

436 self.total_requested_bytes += self.blocksize 

437 part = self.cache[start:end] 

438 if end > self.blocksize: 

439 self.total_requested_bytes += end - self.blocksize 

440 part += self.fetcher(self.blocksize, end) 

441 self.hit_count += 1 

442 return part 

443 else: 

444 self.miss_count += 1 

445 self.total_requested_bytes += end - start 

446 return self.fetcher(start, end) 

447 

448 

449class BlockCache(BaseCache): 

450 """ 

451 Cache holding memory as a set of blocks. 

452 

453 Requests are only ever made ``blocksize`` at a time, and are 

454 stored in an LRU cache. The least recently accessed block is 

455 discarded when more than ``maxblocks`` are stored. 

456 

457 Parameters 

458 ---------- 

459 blocksize : int 

460 The number of bytes to store in each block. 

461 Requests are only ever made for ``blocksize``, so this 

462 should balance the overhead of making a request against 

463 the granularity of the blocks. 

464 fetcher : Callable 

465 size : int 

466 The total size of the file being cached. 

467 maxblocks : int 

468 The maximum number of blocks to cache for. The maximum memory 

469 use for this cache is then ``blocksize * maxblocks``. 

470 """ 

471 

472 name = "blockcache" 

473 

474 def __init__( 

475 self, blocksize: int, fetcher: Fetcher, size: int, maxblocks: int = 32 

476 ) -> None: 

477 super().__init__(blocksize, fetcher, size) 

478 self.nblocks = math.ceil(size / blocksize) 

479 self.maxblocks = maxblocks 

480 self._fetch_block_cached = functools.lru_cache(maxblocks)(self._fetch_block) 

481 

482 def cache_info(self): 

483 """ 

484 The statistics on the block cache. 

485 

486 Returns 

487 ------- 

488 NamedTuple 

489 Returned directly from the LRU Cache used internally. 

490 """ 

491 return self._fetch_block_cached.cache_info() 

492 

493 def __getstate__(self) -> dict[str, Any]: 

494 state = self.__dict__.copy() 

495 del state["_fetch_block_cached"] 

496 return state 

497 

498 def __setstate__(self, state: dict[str, Any]) -> None: 

499 self.__dict__.update(state) 

500 self._fetch_block_cached = functools.lru_cache(state["maxblocks"])( 

501 self._fetch_block 

502 ) 

503 

504 def _fetch(self, start: int | None, end: int | None) -> bytes: 

505 if start is None: 

506 start = 0 

507 if end is None or end > self.size: 

508 end = self.size 

509 if start >= self.size or start >= end: 

510 return b"" 

511 

512 return self._read_cache( 

513 start, end, start // self.blocksize, (end - 1) // self.blocksize 

514 ) 

515 

516 def _fetch_block(self, block_number: int) -> bytes: 

517 """ 

518 Fetch the block of data for `block_number`. 

519 """ 

520 if block_number > self.nblocks: 

521 raise ValueError( 

522 f"'block_number={block_number}' is greater than " 

523 f"the number of blocks ({self.nblocks})" 

524 ) 

525 

526 start = block_number * self.blocksize 

527 end = start + self.blocksize 

528 self.total_requested_bytes += end - start 

529 self.miss_count += 1 

530 logger.info("BlockCache fetching block %d", block_number) 

531 block_contents = super()._fetch(start, end) 

532 return block_contents 

533 

534 def _read_cache( 

535 self, start: int, end: int, start_block_number: int, end_block_number: int 

536 ) -> bytes: 

537 """ 

538 Read from our block cache. 

539 

540 Parameters 

541 ---------- 

542 start, end : int 

543 The start and end byte positions. 

544 start_block_number, end_block_number : int 

545 The start and end block numbers. 

546 """ 

547 start_pos = start % self.blocksize 

548 end_pos = end % self.blocksize 

549 if end_pos == 0: 

550 end_pos = self.blocksize 

551 

552 self.hit_count += 1 

553 if start_block_number == end_block_number: 

554 block: bytes = self._fetch_block_cached(start_block_number) 

555 return block[start_pos:end_pos] 

556 

557 else: 

558 # read from the initial 

559 out = [self._fetch_block_cached(start_block_number)[start_pos:]] 

560 

561 # intermediate blocks 

562 # Note: it'd be nice to combine these into one big request. However 

563 # that doesn't play nicely with our LRU cache. 

564 out.extend( 

565 map( 

566 self._fetch_block_cached, 

567 range(start_block_number + 1, end_block_number), 

568 ) 

569 ) 

570 

571 # final block 

572 out.append(self._fetch_block_cached(end_block_number)[:end_pos]) 

573 

574 return b"".join(out) 

575 

576 

577class BytesCache(BaseCache): 

578 """Cache which holds data in a in-memory bytes object 

579 

580 Implements read-ahead by the block size, for semi-random reads progressing 

581 through the file. 

582 

583 Parameters 

584 ---------- 

585 trim: bool 

586 As we read more data, whether to discard the start of the buffer when 

587 we are more than a blocksize ahead of it. 

588 """ 

589 

590 name: ClassVar[str] = "bytes" 

591 

592 def __init__( 

593 self, blocksize: int, fetcher: Fetcher, size: int, trim: bool = True 

594 ) -> None: 

595 super().__init__(blocksize, fetcher, size) 

596 self.cache = b"" 

597 self.start: int | None = None 

598 self.end: int | None = None 

599 self.trim = trim 

600 

601 def _fetch(self, start: int | None, end: int | None) -> bytes: 

602 # TODO: only set start/end after fetch, in case it fails? 

603 # is this where retry logic might go? 

604 if start is None: 

605 start = 0 

606 if end is None: 

607 end = self.size 

608 if start >= self.size or start >= end: 

609 return b"" 

610 if ( 

611 self.start is not None 

612 and start >= self.start 

613 and self.end is not None 

614 and end <= self.end 

615 ): 

616 # cache hit: we have all the required data 

617 offset = start - self.start 

618 self.hit_count += 1 

619 return self.cache[offset : offset + end - start] 

620 

621 if self.blocksize: 

622 bend = min(self.size, end + self.blocksize) 

623 else: 

624 bend = end 

625 

626 if bend == start or start > self.size: 

627 return b"" 

628 

629 if (self.start is None or start < self.start) and ( 

630 self.end is None or end > self.end 

631 ): 

632 # First read, or extending both before and after 

633 self.total_requested_bytes += bend - start 

634 self.miss_count += 1 

635 self.cache = self.fetcher(start, bend) 

636 self.start = start 

637 else: 

638 assert self.start is not None 

639 assert self.end is not None 

640 self.miss_count += 1 

641 

642 if start < self.start: 

643 if self.end is None or self.end - end > self.blocksize: 

644 self.total_requested_bytes += bend - start 

645 self.cache = self.fetcher(start, bend) 

646 self.start = start 

647 else: 

648 self.total_requested_bytes += self.start - start 

649 new = self.fetcher(start, self.start) 

650 self.start = start 

651 self.cache = new + self.cache 

652 elif self.end is not None and bend > self.end: 

653 if self.end > self.size: 

654 pass 

655 elif end - self.end > self.blocksize: 

656 self.total_requested_bytes += bend - start 

657 self.cache = self.fetcher(start, bend) 

658 self.start = start 

659 else: 

660 self.total_requested_bytes += bend - self.end 

661 new = self.fetcher(self.end, bend) 

662 self.cache = self.cache + new 

663 

664 self.end = self.start + len(self.cache) 

665 offset = start - self.start 

666 out = self.cache[offset : offset + end - start] 

667 if self.trim: 

668 num = (self.end - self.start) // (self.blocksize + 1) 

669 if num > 1: 

670 self.start += self.blocksize * num 

671 self.cache = self.cache[self.blocksize * num :] 

672 return out 

673 

674 def __len__(self) -> int: 

675 return len(self.cache) 

676 

677 

678class AllBytes(BaseCache): 

679 """Cache entire contents of the file""" 

680 

681 name: ClassVar[str] = "all" 

682 

683 def __init__( 

684 self, 

685 blocksize: int | None = None, 

686 fetcher: Fetcher | None = None, 

687 size: int | None = None, 

688 data: bytes | None = None, 

689 ) -> None: 

690 super().__init__(blocksize, fetcher, size) # type: ignore[arg-type] 

691 if data is None: 

692 self.miss_count += 1 

693 self.total_requested_bytes += self.size 

694 data = self.fetcher(0, self.size) 

695 self.data = data 

696 

697 def _fetch(self, start: int | None, stop: int | None) -> bytes: 

698 self.hit_count += 1 

699 return self.data[start:stop] 

700 

701 

702class KnownPartsOfAFile(BaseCache): 

703 """ 

704 Cache holding known file parts. 

705 

706 Parameters 

707 ---------- 

708 blocksize: int 

709 How far to read ahead in numbers of bytes 

710 fetcher: func 

711 Function of the form f(start, end) which gets bytes from remote as 

712 specified 

713 size: int 

714 How big this file is 

715 data: dict 

716 A dictionary mapping explicit `(start, stop)` file-offset tuples 

717 with known bytes. 

718 strict: bool, default True 

719 Whether to fetch reads that go beyond a known byte-range boundary. 

720 If `False`, any read that ends outside a known part will be zero 

721 padded. Note that zero padding will not be used for reads that 

722 begin outside a known byte-range. 

723 """ 

724 

725 name: ClassVar[str] = "parts" 

726 

727 def __init__( 

728 self, 

729 blocksize: int, 

730 fetcher: Fetcher, 

731 size: int, 

732 data: dict[tuple[int, int], bytes] | None = None, 

733 strict: bool = False, 

734 **_: Any, 

735 ): 

736 super().__init__(blocksize, fetcher, size) 

737 self.strict = strict 

738 

739 # simple consolidation of contiguous blocks 

740 if data: 

741 old_offsets = sorted(data.keys()) 

742 offsets = [old_offsets[0]] 

743 blocks = [data.pop(old_offsets[0])] 

744 for start, stop in old_offsets[1:]: 

745 start0, stop0 = offsets[-1] 

746 if start == stop0: 

747 offsets[-1] = (start0, stop) 

748 blocks[-1] += data.pop((start, stop)) 

749 else: 

750 offsets.append((start, stop)) 

751 blocks.append(data.pop((start, stop))) 

752 

753 self.data = dict(zip(offsets, blocks)) 

754 else: 

755 self.data = {} 

756 

757 @property 

758 def size(self): 

759 return sum(_[1] - _[0] for _ in self.data) 

760 

761 @size.setter 

762 def size(self, value): 

763 pass 

764 

765 @property 

766 def nblocks(self): 

767 return len(self.data) 

768 

769 @nblocks.setter 

770 def nblocks(self, value): 

771 pass 

772 

773 def _fetch(self, start: int | None, stop: int | None) -> bytes: 

774 logger.debug("Known parts request %s %s", start, stop) 

775 if start is None: 

776 start = 0 

777 if stop is None: 

778 stop = self.size 

779 self.total_requested_bytes += stop - start 

780 out = b"" 

781 started = False 

782 loc_old = 0 

783 for loc0, loc1 in sorted(self.data): 

784 if (loc0 <= start < loc1) and (loc0 <= stop <= loc1): 

785 # entirely within the block 

786 off = start - loc0 

787 self.hit_count += 1 

788 return self.data[(loc0, loc1)][off : off + stop - start] 

789 if stop <= loc0: 

790 break 

791 if started and loc0 > loc_old: 

792 # a gap where we need data 

793 self.miss_count += 1 

794 if self.strict: 

795 raise ValueError 

796 out += b"\x00" * (loc0 - loc_old) 

797 if loc0 <= start < loc1: 

798 # found the start 

799 self.hit_count += 1 

800 off = start - loc0 

801 out = self.data[(loc0, loc1)][off : off + stop - start] 

802 started = True 

803 elif start < loc0 and stop > loc1: 

804 # the whole block 

805 self.hit_count += 1 

806 out += self.data[(loc0, loc1)] 

807 elif loc0 <= stop <= loc1: 

808 # end block 

809 self.hit_count += 1 

810 out = out + self.data[(loc0, loc1)][: stop - loc0] 

811 return out 

812 loc_old = loc1 

813 self.miss_count += 1 

814 if started and not self.strict: 

815 out = out + b"\x00" * (stop - loc_old) 

816 return out 

817 raise ValueError 

818 

819 

820class UpdatableLRU(Generic[P, T]): 

821 """ 

822 Custom implementation of LRU cache that allows updating keys 

823 

824 Used by BackgroundBlockCache 

825 """ 

826 

827 class CacheInfo(NamedTuple): 

828 hits: int 

829 misses: int 

830 maxsize: int 

831 currsize: int 

832 

833 def __init__(self, func: Callable[P, T], max_size: int = 128) -> None: 

834 self._cache: OrderedDict[Any, T] = collections.OrderedDict() 

835 self._func = func 

836 self._max_size = max_size 

837 self._hits = 0 

838 self._misses = 0 

839 self._lock = threading.Lock() 

840 

841 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: 

842 if kwargs: 

843 raise TypeError(f"Got unexpected keyword argument {kwargs.keys()}") 

844 with self._lock: 

845 if args in self._cache: 

846 self._cache.move_to_end(args) 

847 self._hits += 1 

848 return self._cache[args] 

849 

850 result = self._func(*args, **kwargs) 

851 

852 with self._lock: 

853 self._cache[args] = result 

854 self._misses += 1 

855 if len(self._cache) > self._max_size: 

856 self._cache.popitem(last=False) 

857 

858 return result 

859 

860 def is_key_cached(self, *args: Any) -> bool: 

861 with self._lock: 

862 return args in self._cache 

863 

864 def add_key(self, result: T, *args: Any) -> None: 

865 with self._lock: 

866 self._cache[args] = result 

867 if len(self._cache) > self._max_size: 

868 self._cache.popitem(last=False) 

869 

870 def cache_info(self) -> UpdatableLRU.CacheInfo: 

871 with self._lock: 

872 return self.CacheInfo( 

873 maxsize=self._max_size, 

874 currsize=len(self._cache), 

875 hits=self._hits, 

876 misses=self._misses, 

877 ) 

878 

879 

880class BackgroundBlockCache(BaseCache): 

881 """ 

882 Cache holding memory as a set of blocks with pre-loading of 

883 the next block in the background. 

884 

885 Requests are only ever made ``blocksize`` at a time, and are 

886 stored in an LRU cache. The least recently accessed block is 

887 discarded when more than ``maxblocks`` are stored. If the 

888 next block is not in cache, it is loaded in a separate thread 

889 in non-blocking way. 

890 

891 Parameters 

892 ---------- 

893 blocksize : int 

894 The number of bytes to store in each block. 

895 Requests are only ever made for ``blocksize``, so this 

896 should balance the overhead of making a request against 

897 the granularity of the blocks. 

898 fetcher : Callable 

899 size : int 

900 The total size of the file being cached. 

901 maxblocks : int 

902 The maximum number of blocks to cache for. The maximum memory 

903 use for this cache is then ``blocksize * maxblocks``. 

904 """ 

905 

906 name: ClassVar[str] = "background" 

907 

908 def __init__( 

909 self, blocksize: int, fetcher: Fetcher, size: int, maxblocks: int = 32 

910 ) -> None: 

911 super().__init__(blocksize, fetcher, size) 

912 self.nblocks = math.ceil(size / blocksize) 

913 self.maxblocks = maxblocks 

914 self._fetch_block_cached = UpdatableLRU(self._fetch_block, maxblocks) 

915 

916 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

917 self._fetch_future_block_number: int | None = None 

918 self._fetch_future: Future[bytes] | None = None 

919 self._fetch_future_lock = threading.Lock() 

920 self._closed = False 

921 

922 def cache_info(self) -> UpdatableLRU.CacheInfo: 

923 """ 

924 The statistics on the block cache. 

925 

926 Returns 

927 ------- 

928 NamedTuple 

929 Returned directly from the LRU Cache used internally. 

930 """ 

931 return self._fetch_block_cached.cache_info() 

932 

933 def close(self) -> None: 

934 """Cancel pending work and shut down the background worker.""" 

935 with self._fetch_future_lock: 

936 if self._closed: 

937 return 

938 self._closed = True 

939 future = self._fetch_future 

940 self._fetch_future = None 

941 self._fetch_future_block_number = None 

942 

943 if future is not None: 

944 future.cancel() 

945 self._thread_executor.shutdown(wait=True, cancel_futures=True) 

946 

947 # UpdatableLRU stores a bound method and otherwise forms a reference cycle. 

948 del self._fetch_block_cached 

949 

950 def __getstate__(self) -> dict[str, Any]: 

951 state = self.__dict__.copy() 

952 del state["_fetch_block_cached"] 

953 del state["_thread_executor"] 

954 del state["_fetch_future_block_number"] 

955 del state["_fetch_future"] 

956 del state["_fetch_future_lock"] 

957 return state 

958 

959 def __setstate__(self, state) -> None: 

960 self.__dict__.update(state) 

961 self._fetch_block_cached = UpdatableLRU(self._fetch_block, state["maxblocks"]) 

962 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

963 self._fetch_future_block_number = None 

964 self._fetch_future = None 

965 self._fetch_future_lock = threading.Lock() 

966 self._closed = False 

967 

968 def _fetch(self, start: int | None, end: int | None) -> bytes: 

969 if start is None: 

970 start = 0 

971 if end is None or end > self.size: 

972 end = self.size 

973 if start >= self.size or start >= end: 

974 return b"" 

975 

976 # byte position -> block numbers; ``end`` is exclusive 

977 start_block_number = start // self.blocksize 

978 end_block_number = (end - 1) // self.blocksize 

979 

980 fetch_future_block_number = None 

981 fetch_future = None 

982 with self._fetch_future_lock: 

983 # Background thread is running. Check we we can or must join it. 

984 if self._fetch_future is not None: 

985 assert self._fetch_future_block_number is not None 

986 if self._fetch_future.done(): 

987 logger.info("BlockCache joined background fetch without waiting.") 

988 self._fetch_block_cached.add_key( 

989 self._fetch_future.result(), self._fetch_future_block_number 

990 ) 

991 # Cleanup the fetch variables. Done with fetching the block. 

992 self._fetch_future_block_number = None 

993 self._fetch_future = None 

994 else: 

995 # Must join if we need the block for the current fetch 

996 must_join = bool( 

997 start_block_number 

998 <= self._fetch_future_block_number 

999 <= end_block_number 

1000 ) 

1001 if must_join: 

1002 # Copy to the local variables to release lock 

1003 # before waiting for result 

1004 fetch_future_block_number = self._fetch_future_block_number 

1005 fetch_future = self._fetch_future 

1006 

1007 # Cleanup the fetch variables. Have a local copy. 

1008 self._fetch_future_block_number = None 

1009 self._fetch_future = None 

1010 

1011 # Need to wait for the future for the current read 

1012 if fetch_future is not None: 

1013 logger.info("BlockCache waiting for background fetch.") 

1014 # Wait until result and put it in cache 

1015 self._fetch_block_cached.add_key( 

1016 fetch_future.result(), fetch_future_block_number 

1017 ) 

1018 

1019 # fetch next block in the background if nothing is running in the background, 

1020 # the block is within file and it is not already cached 

1021 end_block_plus_1 = end_block_number + 1 

1022 with self._fetch_future_lock: 

1023 if ( 

1024 self._fetch_future is None 

1025 and end_block_plus_1 <= self.nblocks 

1026 and not self._fetch_block_cached.is_key_cached(end_block_plus_1) 

1027 ): 

1028 self._fetch_future_block_number = end_block_plus_1 

1029 self._fetch_future = self._thread_executor.submit( 

1030 self._fetch_block, end_block_plus_1, "async" 

1031 ) 

1032 

1033 return self._read_cache( 

1034 start, 

1035 end, 

1036 start_block_number=start_block_number, 

1037 end_block_number=end_block_number, 

1038 ) 

1039 

1040 def _fetch_block(self, block_number: int, log_info: str = "sync") -> bytes: 

1041 """ 

1042 Fetch the block of data for `block_number`. 

1043 """ 

1044 if block_number > self.nblocks: 

1045 raise ValueError( 

1046 f"'block_number={block_number}' is greater than " 

1047 f"the number of blocks ({self.nblocks})" 

1048 ) 

1049 

1050 start = block_number * self.blocksize 

1051 end = start + self.blocksize 

1052 logger.info("BlockCache fetching block (%s) %d", log_info, block_number) 

1053 self.total_requested_bytes += end - start 

1054 self.miss_count += 1 

1055 block_contents = super()._fetch(start, end) 

1056 return block_contents 

1057 

1058 def _read_cache( 

1059 self, start: int, end: int, start_block_number: int, end_block_number: int 

1060 ) -> bytes: 

1061 """ 

1062 Read from our block cache. 

1063 

1064 Parameters 

1065 ---------- 

1066 start, end : int 

1067 The start and end byte positions. 

1068 start_block_number, end_block_number : int 

1069 The start and end block numbers. 

1070 """ 

1071 start_pos = start % self.blocksize 

1072 end_pos = end % self.blocksize 

1073 if end_pos == 0: 

1074 end_pos = self.blocksize 

1075 

1076 # kind of pointless to count this as a hit, but it is 

1077 self.hit_count += 1 

1078 

1079 if start_block_number == end_block_number: 

1080 block = self._fetch_block_cached(start_block_number) 

1081 return block[start_pos:end_pos] 

1082 

1083 else: 

1084 # read from the initial 

1085 out = [self._fetch_block_cached(start_block_number)[start_pos:]] 

1086 

1087 # intermediate blocks 

1088 # Note: it'd be nice to combine these into one big request. However 

1089 # that doesn't play nicely with our LRU cache. 

1090 out.extend( 

1091 map( 

1092 self._fetch_block_cached, 

1093 range(start_block_number + 1, end_block_number), 

1094 ) 

1095 ) 

1096 

1097 # final block 

1098 out.append(self._fetch_block_cached(end_block_number)[:end_pos]) 

1099 

1100 return b"".join(out) 

1101 

1102 

1103caches: dict[str | None, type[BaseCache]] = { 

1104 # one custom case 

1105 None: BaseCache, 

1106} 

1107 

1108 

1109def register_cache(cls: type[BaseCache], clobber: bool = False) -> None: 

1110 """'Register' cache implementation. 

1111 

1112 Parameters 

1113 ---------- 

1114 clobber: bool, optional 

1115 If set to True (default is False) - allow to overwrite existing 

1116 entry. 

1117 

1118 Raises 

1119 ------ 

1120 ValueError 

1121 """ 

1122 name = cls.name 

1123 if not clobber and name in caches: 

1124 raise ValueError(f"Cache with name {name!r} is already known: {caches[name]}") 

1125 caches[name] = cls 

1126 

1127 

1128for c in ( 

1129 BaseCache, 

1130 MMapCache, 

1131 BytesCache, 

1132 ReadAheadCache, 

1133 AdaptiveReadaheadCache, 

1134 BlockCache, 

1135 FirstChunkCache, 

1136 AllBytes, 

1137 KnownPartsOfAFile, 

1138 BackgroundBlockCache, 

1139): 

1140 register_cache(c)