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

580 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 self.blocks.clear() 

154 if self.location is None: 

155 fd = tempfile.TemporaryFile() 

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 block_groups = [] 

184 

185 # Consolidate needed blocks. 

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

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

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

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

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

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

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

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

194 # Extract the blocks from the enumerated sequence 

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

196 # Compute start of first block 

197 sstart = _blocks[0] * self.blocksize 

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

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

200 

201 # Fetch bytes (could be multiple consecutive blocks) 

202 self.total_requested_bytes += send - sstart 

203 logger.debug( 

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

205 ) 

206 ranges.append((sstart, send)) 

207 block_groups.append(_blocks) 

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

209 self.miss_count += len(_blocks) 

210 

211 if not ranges: 

212 return self.cache[start:end] 

213 

214 if self.multi_fetcher: 

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

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

217 sstart, send = ranges[idx] 

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

219 self.cache[sstart:send] = r 

220 self.blocks.update(block_groups[idx]) 

221 else: 

222 for idx, (sstart, send) in enumerate(ranges): 

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

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

225 self.blocks.update(block_groups[idx]) 

226 

227 return self.cache[start:end] 

228 

229 def close(self) -> None: 

230 if hasattr(self.cache, "close"): 

231 self.cache.close() 

232 

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

234 state = self.__dict__.copy() 

235 # Remove the unpicklable entries. 

236 del state["cache"] 

237 return state 

238 

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

240 # Restore instance attributes 

241 self.__dict__.update(state) 

242 self.cache = self._makefile() 

243 

244 

245class ReadAheadCache(BaseCache): 

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

247 

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

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

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

251 """ 

252 

253 name = "readahead" 

254 

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

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

257 self.cache = b"" 

258 self.start = 0 

259 self.end = 0 

260 

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

262 if start is None: 

263 start = 0 

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

265 end = self.size 

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

267 return b"" 

268 l = end - start 

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

270 # cache hit 

271 self.hit_count += 1 

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

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

274 # partial hit 

275 self.miss_count += 1 

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

277 l -= len(part) 

278 start = self.end 

279 else: 

280 # miss 

281 self.miss_count += 1 

282 part = b"" 

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

284 self.total_requested_bytes += end - start 

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

286 self.start = start 

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

288 return part + self.cache[:l] 

289 

290 

291class AdaptiveReadaheadCache(BaseCache): 

292 """Cache with adaptive asynchronous prefetching. 

293 

294 Optimized for sequential and near-sequential reads by dynamically 

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

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

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

298 

299 Parameters 

300 ---------- 

301 blocksize: int 

302 Nominal read size used by callers. 

303 fetcher: Fetcher 

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

305 size: int 

306 Total size of the file. 

307 concurrency: int 

308 Maximum number of concurrent background fetch tasks. 

309 max_prefetch_size: int | None 

310 Optional upper bound for adaptive prefetch size in bytes. 

311 """ 

312 

313 name = "adaptive" 

314 

315 def __init__( 

316 self, 

317 blocksize: int, 

318 fetcher: Fetcher, 

319 size: int, 

320 concurrency: int = 4, 

321 max_prefetch_size: int | None = None, 

322 ) -> None: 

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

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

325 self._prefetcher = None 

326 

327 async def _default_fetcher_async( 

328 start_offset: int, 

329 total_size: int, 

330 split_factor: int = 1, 

331 ) -> bytes: 

332 del split_factor 

333 return await asyncio.to_thread( 

334 self.fetcher, start_offset, start_offset + total_size 

335 ) 

336 

337 try: 

338 from . import asyn as fsspec_asyn 

339 except ImportError as e: 

340 raise ImportError( 

341 "AdaptiveReadaheadCache requires fsspec.asyn to be available" 

342 ) from e 

343 

344 try: 

345 from .prefetcher import BackgroundPrefetcher 

346 

347 try: 

348 fs_loop = fetcher.__self__.fs.loop 

349 except AttributeError: 

350 fs_loop = None 

351 self._prefetcher = BackgroundPrefetcher( 

352 fetcher=_default_fetcher_async, 

353 size=size, 

354 concurrency=concurrency, 

355 max_prefetch_size=max_prefetch_size, 

356 loop=( 

357 fs_loop 

358 if isinstance(fs_loop, asyncio.AbstractEventLoop) 

359 else fsspec_asyn.get_loop() 

360 ), 

361 ) 

362 logger.info( 

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

364 blocksize, 

365 size, 

366 concurrency, 

367 max_prefetch_size, 

368 ) 

369 except Exception as e: 

370 logger.info( 

371 "AdaptiveReadaheadCache fallback to ReadAheadCache: %s", 

372 e, 

373 exc_info=True, 

374 ) 

375 self._prefetcher = None 

376 

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

378 if self._prefetcher is None: 

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

380 self.hit_count = self._fallback.hit_count 

381 self.miss_count = self._fallback.miss_count 

382 self.total_requested_bytes = self._fallback.total_requested_bytes 

383 return out 

384 

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

386 self.miss_count += 1 

387 self.total_requested_bytes += len(out) 

388 return out 

389 

390 def close(self) -> None: 

391 if self._prefetcher is not None: 

392 self._prefetcher.close() 

393 self._prefetcher = None 

394 

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

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

397 self.close() 

398 state = self.__dict__.copy() 

399 state["_prefetcher"] = None 

400 return state 

401 

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

403 self.__dict__.update(state) 

404 self._prefetcher = None 

405 

406 def __del__(self): 

407 try: 

408 self.close() 

409 except Exception: 

410 # Best-effort cleanup during GC. 

411 pass 

412 

413 

414class FirstChunkCache(BaseCache): 

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

416 

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

418 but is randomly accessed. 

419 """ 

420 

421 name = "first" 

422 

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

424 if blocksize > size: 

425 # this will buffer the whole thing 

426 blocksize = size 

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

428 self.cache: bytes | None = None 

429 

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

431 start = start or 0 

432 if start > self.size: 

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

434 return b"" 

435 

436 if end is None: 

437 end = self.size 

438 end = min(end, self.size) 

439 

440 if start < self.blocksize: 

441 if self.cache is None: 

442 self.miss_count += 1 

443 if end > self.blocksize: 

444 self.total_requested_bytes += end 

445 data = self.fetcher(0, end) 

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

447 return data[start:] 

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

449 self.total_requested_bytes += self.blocksize 

450 part = self.cache[start:end] 

451 if end > self.blocksize: 

452 self.total_requested_bytes += end - self.blocksize 

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

454 self.hit_count += 1 

455 return part 

456 else: 

457 self.miss_count += 1 

458 self.total_requested_bytes += end - start 

459 return self.fetcher(start, end) 

460 

461 

462class BlockCache(BaseCache): 

463 """ 

464 Cache holding memory as a set of blocks. 

465 

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

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

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

469 

470 Parameters 

471 ---------- 

472 blocksize : int 

473 The number of bytes to store in each block. 

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

475 should balance the overhead of making a request against 

476 the granularity of the blocks. 

477 fetcher : Callable 

478 size : int 

479 The total size of the file being cached. 

480 maxblocks : int 

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

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

483 """ 

484 

485 name = "blockcache" 

486 

487 def __init__( 

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

489 ) -> None: 

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

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

492 self.maxblocks = maxblocks 

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

494 

495 def cache_info(self): 

496 """ 

497 The statistics on the block cache. 

498 

499 Returns 

500 ------- 

501 NamedTuple 

502 Returned directly from the LRU Cache used internally. 

503 """ 

504 return self._fetch_block_cached.cache_info() 

505 

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

507 state = self.__dict__.copy() 

508 del state["_fetch_block_cached"] 

509 return state 

510 

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

512 self.__dict__.update(state) 

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

514 self._fetch_block 

515 ) 

516 

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

518 if start is None: 

519 start = 0 

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

521 end = self.size 

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

523 return b"" 

524 

525 return self._read_cache( 

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

527 ) 

528 

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

530 """ 

531 Fetch the block of data for `block_number`. 

532 """ 

533 if block_number > self.nblocks: 

534 raise ValueError( 

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

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

537 ) 

538 

539 start = block_number * self.blocksize 

540 if start >= self.size: 

541 return b"" 

542 end = min(start + self.blocksize, self.size) 

543 self.total_requested_bytes += end - start 

544 self.miss_count += 1 

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

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

547 return block_contents 

548 

549 def _read_cache( 

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

551 ) -> bytes: 

552 """ 

553 Read from our block cache. 

554 

555 Parameters 

556 ---------- 

557 start, end : int 

558 The start and end byte positions. 

559 start_block_number, end_block_number : int 

560 The start and end block numbers. 

561 """ 

562 start_pos = start % self.blocksize 

563 end_pos = end % self.blocksize 

564 if end_pos == 0: 

565 end_pos = self.blocksize 

566 

567 self.hit_count += 1 

568 if start_block_number == end_block_number: 

569 block: bytes = self._fetch_block_cached(start_block_number) 

570 return block[start_pos:end_pos] 

571 

572 else: 

573 # read from the initial 

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

575 

576 # intermediate blocks 

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

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

579 out.extend( 

580 map( 

581 self._fetch_block_cached, 

582 range(start_block_number + 1, end_block_number), 

583 ) 

584 ) 

585 

586 # final block 

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

588 

589 return b"".join(out) 

590 

591 

592class BytesCache(BaseCache): 

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

594 

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

596 through the file. 

597 

598 Parameters 

599 ---------- 

600 trim: bool 

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

602 we are more than a blocksize ahead of it. 

603 """ 

604 

605 name: ClassVar[str] = "bytes" 

606 

607 def __init__( 

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

609 ) -> None: 

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

611 self.cache = b"" 

612 self.start: int | None = None 

613 self.end: int | None = None 

614 self.trim = trim 

615 

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

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

618 # is this where retry logic might go? 

619 if start is None: 

620 start = 0 

621 if end is None: 

622 end = self.size 

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

624 return b"" 

625 if ( 

626 self.start is not None 

627 and start >= self.start 

628 and self.end is not None 

629 and end <= self.end 

630 ): 

631 # cache hit: we have all the required data 

632 offset = start - self.start 

633 self.hit_count += 1 

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

635 

636 if self.blocksize: 

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

638 else: 

639 bend = end 

640 

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

642 return b"" 

643 

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

645 self.end is None or end > self.end 

646 ): 

647 # First read, or extending both before and after 

648 self.total_requested_bytes += bend - start 

649 self.miss_count += 1 

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

651 self.start = start 

652 else: 

653 assert self.start is not None 

654 assert self.end is not None 

655 self.miss_count += 1 

656 

657 if start < self.start: 

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

659 self.total_requested_bytes += bend - start 

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

661 self.start = start 

662 else: 

663 self.total_requested_bytes += self.start - start 

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

665 self.start = start 

666 self.cache = new + self.cache 

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

668 if self.end > self.size: 

669 pass 

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

671 self.total_requested_bytes += bend - start 

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

673 self.start = start 

674 else: 

675 self.total_requested_bytes += bend - self.end 

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

677 self.cache = self.cache + new 

678 

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

680 offset = start - self.start 

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

682 if self.trim: 

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

684 if num > 1: 

685 self.start += self.blocksize * num 

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

687 return out 

688 

689 def __len__(self) -> int: 

690 return len(self.cache) 

691 

692 

693class AllBytes(BaseCache): 

694 """Cache entire contents of the file""" 

695 

696 name: ClassVar[str] = "all" 

697 

698 def __init__( 

699 self, 

700 blocksize: int | None = None, 

701 fetcher: Fetcher | None = None, 

702 size: int | None = None, 

703 data: bytes | None = None, 

704 ) -> None: 

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

706 if data is None: 

707 self.miss_count += 1 

708 self.total_requested_bytes += self.size 

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

710 self.data = data 

711 

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

713 self.hit_count += 1 

714 return self.data[start:stop] 

715 

716 

717class KnownPartsOfAFile(BaseCache): 

718 """ 

719 Cache holding known file parts. 

720 

721 Parameters 

722 ---------- 

723 blocksize: int 

724 How far to read ahead in numbers of bytes 

725 fetcher: func 

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

727 specified 

728 size: int 

729 How big this file is 

730 data: dict 

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

732 with known bytes. 

733 strict: bool, default True 

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

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

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

737 begin outside a known byte-range. 

738 """ 

739 

740 name: ClassVar[str] = "parts" 

741 

742 def __init__( 

743 self, 

744 blocksize: int, 

745 fetcher: Fetcher, 

746 size: int, 

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

748 strict: bool = False, 

749 **_: Any, 

750 ): 

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

752 self.strict = strict 

753 

754 # simple consolidation of contiguous blocks 

755 if data: 

756 old_offsets = sorted(data.keys()) 

757 offsets = [old_offsets[0]] 

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

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

760 start0, stop0 = offsets[-1] 

761 if start == stop0: 

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

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

764 else: 

765 offsets.append((start, stop)) 

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

767 

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

769 else: 

770 self.data = {} 

771 

772 @property 

773 def size(self): 

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

775 

776 @size.setter 

777 def size(self, value): 

778 pass 

779 

780 @property 

781 def nblocks(self): 

782 return len(self.data) 

783 

784 @nblocks.setter 

785 def nblocks(self, value): 

786 pass 

787 

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

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

790 if start is None: 

791 start = 0 

792 if stop is None: 

793 stop = self.size 

794 self.total_requested_bytes += stop - start 

795 out = b"" 

796 started = False 

797 loc_old = 0 

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

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

800 # entirely within the block 

801 off = start - loc0 

802 self.hit_count += 1 

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

804 if stop <= loc0: 

805 break 

806 if started and loc0 > loc_old: 

807 # a gap where we need data 

808 self.miss_count += 1 

809 if self.strict: 

810 raise ValueError 

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

812 if loc0 <= start < loc1: 

813 # found the start 

814 self.hit_count += 1 

815 off = start - loc0 

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

817 started = True 

818 elif start < loc0 and stop > loc1: 

819 # the whole block 

820 self.hit_count += 1 

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

822 elif started and loc0 <= stop <= loc1: 

823 # end block. The start of the request must have been found in 

824 # an earlier part, else these bytes come from the wrong offsets 

825 self.hit_count += 1 

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

827 return out 

828 loc_old = loc1 

829 self.miss_count += 1 

830 if started and not self.strict: 

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

832 return out 

833 raise ValueError 

834 

835 

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

837 """ 

838 Custom implementation of LRU cache that allows updating keys 

839 

840 Used by BackgroundBlockCache 

841 """ 

842 

843 class CacheInfo(NamedTuple): 

844 hits: int 

845 misses: int 

846 maxsize: int 

847 currsize: int 

848 

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

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

851 self._func = func 

852 self._max_size = max_size 

853 self._hits = 0 

854 self._misses = 0 

855 self._lock = threading.Lock() 

856 

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

858 if kwargs: 

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

860 with self._lock: 

861 if args in self._cache: 

862 self._cache.move_to_end(args) 

863 self._hits += 1 

864 return self._cache[args] 

865 

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

867 

868 with self._lock: 

869 self._cache[args] = result 

870 self._misses += 1 

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

872 self._cache.popitem(last=False) 

873 

874 return result 

875 

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

877 with self._lock: 

878 return args in self._cache 

879 

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

881 with self._lock: 

882 self._cache[args] = result 

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

884 self._cache.popitem(last=False) 

885 

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

887 with self._lock: 

888 return self.CacheInfo( 

889 maxsize=self._max_size, 

890 currsize=len(self._cache), 

891 hits=self._hits, 

892 misses=self._misses, 

893 ) 

894 

895 

896class BackgroundBlockCache(BaseCache): 

897 """ 

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

899 the next block in the background. 

900 

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

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

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

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

905 in non-blocking way. 

906 

907 Parameters 

908 ---------- 

909 blocksize : int 

910 The number of bytes to store in each block. 

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

912 should balance the overhead of making a request against 

913 the granularity of the blocks. 

914 fetcher : Callable 

915 size : int 

916 The total size of the file being cached. 

917 maxblocks : int 

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

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

920 """ 

921 

922 name: ClassVar[str] = "background" 

923 

924 def __init__( 

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

926 ) -> None: 

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

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

929 self.maxblocks = maxblocks 

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

931 

932 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

933 self._fetch_future_block_number: int | None = None 

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

935 self._fetch_future_lock = threading.Lock() 

936 self._closed = False 

937 

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

939 """ 

940 The statistics on the block cache. 

941 

942 Returns 

943 ------- 

944 NamedTuple 

945 Returned directly from the LRU Cache used internally. 

946 """ 

947 return self._fetch_block_cached.cache_info() 

948 

949 def close(self) -> None: 

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

951 with self._fetch_future_lock: 

952 if self._closed: 

953 return 

954 self._closed = True 

955 future = self._fetch_future 

956 self._fetch_future = None 

957 self._fetch_future_block_number = None 

958 

959 if future is not None: 

960 future.cancel() 

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

962 

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

964 del self._fetch_block_cached 

965 

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

967 state = self.__dict__.copy() 

968 del state["_fetch_block_cached"] 

969 del state["_thread_executor"] 

970 del state["_fetch_future_block_number"] 

971 del state["_fetch_future"] 

972 del state["_fetch_future_lock"] 

973 return state 

974 

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

976 self.__dict__.update(state) 

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

978 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

979 self._fetch_future_block_number = None 

980 self._fetch_future = None 

981 self._fetch_future_lock = threading.Lock() 

982 self._closed = False 

983 

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

985 if start is None: 

986 start = 0 

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

988 end = self.size 

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

990 return b"" 

991 

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

993 start_block_number = start // self.blocksize 

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

995 

996 fetch_future_block_number = None 

997 fetch_future = None 

998 with self._fetch_future_lock: 

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

1000 if self._fetch_future is not None: 

1001 assert self._fetch_future_block_number is not None 

1002 if self._fetch_future.done(): 

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

1004 self._fetch_block_cached.add_key( 

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

1006 ) 

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

1008 self._fetch_future_block_number = None 

1009 self._fetch_future = None 

1010 else: 

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

1012 must_join = bool( 

1013 start_block_number 

1014 <= self._fetch_future_block_number 

1015 <= end_block_number 

1016 ) 

1017 if must_join: 

1018 # Copy to the local variables to release lock 

1019 # before waiting for result 

1020 fetch_future_block_number = self._fetch_future_block_number 

1021 fetch_future = self._fetch_future 

1022 

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

1024 self._fetch_future_block_number = None 

1025 self._fetch_future = None 

1026 

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

1028 if fetch_future is not None: 

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

1030 # Wait until result and put it in cache 

1031 self._fetch_block_cached.add_key( 

1032 fetch_future.result(), fetch_future_block_number 

1033 ) 

1034 

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

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

1037 end_block_plus_1 = end_block_number + 1 

1038 with self._fetch_future_lock: 

1039 if ( 

1040 self._fetch_future is None 

1041 and end_block_plus_1 <= self.nblocks 

1042 and not self._fetch_block_cached.is_key_cached(end_block_plus_1) 

1043 ): 

1044 self._fetch_future_block_number = end_block_plus_1 

1045 self._fetch_future = self._thread_executor.submit( 

1046 self._fetch_block, end_block_plus_1, "async" 

1047 ) 

1048 

1049 return self._read_cache( 

1050 start, 

1051 end, 

1052 start_block_number=start_block_number, 

1053 end_block_number=end_block_number, 

1054 ) 

1055 

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

1057 """ 

1058 Fetch the block of data for `block_number`. 

1059 """ 

1060 if block_number > self.nblocks: 

1061 raise ValueError( 

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

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

1064 ) 

1065 

1066 start = block_number * self.blocksize 

1067 if start >= self.size: 

1068 return b"" 

1069 end = min(start + self.blocksize, self.size) 

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

1071 self.total_requested_bytes += end - start 

1072 self.miss_count += 1 

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

1074 return block_contents 

1075 

1076 def _read_cache( 

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

1078 ) -> bytes: 

1079 """ 

1080 Read from our block cache. 

1081 

1082 Parameters 

1083 ---------- 

1084 start, end : int 

1085 The start and end byte positions. 

1086 start_block_number, end_block_number : int 

1087 The start and end block numbers. 

1088 """ 

1089 start_pos = start % self.blocksize 

1090 end_pos = end % self.blocksize 

1091 if end_pos == 0: 

1092 end_pos = self.blocksize 

1093 

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

1095 self.hit_count += 1 

1096 

1097 if start_block_number == end_block_number: 

1098 block = self._fetch_block_cached(start_block_number) 

1099 return block[start_pos:end_pos] 

1100 

1101 else: 

1102 # read from the initial 

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

1104 

1105 # intermediate blocks 

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

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

1108 out.extend( 

1109 map( 

1110 self._fetch_block_cached, 

1111 range(start_block_number + 1, end_block_number), 

1112 ) 

1113 ) 

1114 

1115 # final block 

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

1117 

1118 return b"".join(out) 

1119 

1120 

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

1122 # one custom case 

1123 None: BaseCache, 

1124} 

1125 

1126 

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

1128 """'Register' cache implementation. 

1129 

1130 Parameters 

1131 ---------- 

1132 clobber: bool, optional 

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

1134 entry. 

1135 

1136 Raises 

1137 ------ 

1138 ValueError 

1139 """ 

1140 name = cls.name 

1141 if not clobber and name in caches: 

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

1143 caches[name] = cls 

1144 

1145 

1146for c in ( 

1147 BaseCache, 

1148 MMapCache, 

1149 BytesCache, 

1150 ReadAheadCache, 

1151 AdaptiveReadaheadCache, 

1152 BlockCache, 

1153 FirstChunkCache, 

1154 AllBytes, 

1155 KnownPartsOfAFile, 

1156 BackgroundBlockCache, 

1157): 

1158 register_cache(c)