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

517 statements  

1from __future__ import annotations 

2 

3import collections 

4import functools 

5import logging 

6import math 

7import os 

8import threading 

9from collections import OrderedDict 

10from collections.abc import Callable 

11from concurrent.futures import Future, ThreadPoolExecutor 

12from itertools import groupby 

13from operator import itemgetter 

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

15 

16if TYPE_CHECKING: 

17 import mmap 

18 

19 from typing_extensions import ParamSpec 

20 

21 P = ParamSpec("P") 

22else: 

23 P = TypeVar("P") 

24 

25T = TypeVar("T") 

26 

27 

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

29 

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

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

32 

33 

34class BaseCache: 

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

36 

37 Acts as base class for other cachers 

38 

39 Parameters 

40 ---------- 

41 blocksize: int 

42 How far to read ahead in numbers of bytes 

43 fetcher: func 

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

45 specified 

46 size: int 

47 How big this file is 

48 """ 

49 

50 name: ClassVar[str] = "none" 

51 

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

53 self.blocksize = blocksize 

54 self.nblocks = 0 

55 self.fetcher = fetcher 

56 self.size = size 

57 self.hit_count = 0 

58 self.miss_count = 0 

59 # the bytes that we actually requested 

60 self.total_requested_bytes = 0 

61 

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

63 if start is None: 

64 start = 0 

65 if stop is None: 

66 stop = self.size 

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

68 return b"" 

69 return self.fetcher(start, stop) 

70 

71 def _reset_stats(self) -> None: 

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

73 self.hit_count = 0 

74 self.miss_count = 0 

75 self.total_requested_bytes = 0 

76 

77 def _log_stats(self) -> str: 

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

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

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

81 return "" 

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

83 

84 def __repr__(self) -> str: 

85 # TODO: use rich for better formatting 

86 return f""" 

87 <{self.__class__.__name__}: 

88 block size : {self.blocksize} 

89 block count : {self.nblocks} 

90 file size : {self.size} 

91 cache hits : {self.hit_count} 

92 cache misses: {self.miss_count} 

93 total requested bytes: {self.total_requested_bytes}> 

94 """ 

95 

96 

97class MMapCache(BaseCache): 

98 """memory-mapped sparse file cache 

99 

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

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

102 

103 This cache method might only work on posix 

104 

105 Parameters 

106 ---------- 

107 blocksize: int 

108 How far to read ahead in numbers of bytes 

109 fetcher: Fetcher 

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

111 specified 

112 size: int 

113 How big this file is 

114 location: str 

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

116 created using tempfile.TemporaryFile(). 

117 blocks: set[int] 

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

119 set is created. 

120 multi_fetcher: MultiFetcher 

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

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

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

124 """ 

125 

126 name = "mmap" 

127 

128 def __init__( 

129 self, 

130 blocksize: int, 

131 fetcher: Fetcher, 

132 size: int, 

133 location: str | None = None, 

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

135 multi_fetcher: MultiFetcher | None = None, 

136 ) -> None: 

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

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

139 self.location = location 

140 self.multi_fetcher = multi_fetcher 

141 self.cache = self._makefile() 

142 

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

144 import mmap 

145 import tempfile 

146 

147 if self.size == 0: 

148 return bytearray() 

149 

150 # posix version 

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

152 if self.location is None: 

153 fd = tempfile.TemporaryFile() 

154 self.blocks = set() 

155 else: 

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

157 fd.seek(self.size - 1) 

158 fd.write(b"1") 

159 fd.flush() 

160 else: 

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

162 

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

164 

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

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

167 if start is None: 

168 start = 0 

169 if end is None: 

170 end = self.size 

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

172 return b"" 

173 start_block = start // self.blocksize 

174 end_block = end // self.blocksize 

175 block_range = range(start_block, end_block + 1) 

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

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

178 # Count the number of blocks already cached 

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

180 

181 ranges = [] 

182 

183 # Consolidate needed blocks. 

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

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

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

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

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

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

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

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

192 # Extract the blocks from the enumerated sequence 

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

194 # Compute start of first block 

195 sstart = _blocks[0] * self.blocksize 

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

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

198 

199 # Fetch bytes (could be multiple consecutive blocks) 

200 self.total_requested_bytes += send - sstart 

201 logger.debug( 

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

203 ) 

204 ranges.append((sstart, send)) 

205 

206 # Update set of cached blocks 

207 self.blocks.update(_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 else: 

221 for sstart, send in ranges: 

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

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

224 

225 return self.cache[start:end] 

226 

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

228 state = self.__dict__.copy() 

229 # Remove the unpicklable entries. 

230 del state["cache"] 

231 return state 

232 

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

234 # Restore instance attributes 

235 self.__dict__.update(state) 

236 self.cache = self._makefile() 

237 

238 

239class ReadAheadCache(BaseCache): 

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

241 

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

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

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

245 """ 

246 

247 name = "readahead" 

248 

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

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

251 self.cache = b"" 

252 self.start = 0 

253 self.end = 0 

254 

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

256 if start is None: 

257 start = 0 

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

259 end = self.size 

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

261 return b"" 

262 l = end - start 

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

264 # cache hit 

265 self.hit_count += 1 

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

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

268 # partial hit 

269 self.miss_count += 1 

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

271 l -= len(part) 

272 start = self.end 

273 else: 

274 # miss 

275 self.miss_count += 1 

276 part = b"" 

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

278 self.total_requested_bytes += end - start 

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

280 self.start = start 

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

282 return part + self.cache[:l] 

283 

284 

285class FirstChunkCache(BaseCache): 

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

287 

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

289 but is randomly accessed. 

290 """ 

291 

292 name = "first" 

293 

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

295 if blocksize > size: 

296 # this will buffer the whole thing 

297 blocksize = size 

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

299 self.cache: bytes | None = None 

300 

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

302 start = start or 0 

303 if start > self.size: 

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

305 return b"" 

306 

307 if end is None: 

308 end = self.size 

309 end = min(end, self.size) 

310 

311 if start < self.blocksize: 

312 if self.cache is None: 

313 self.miss_count += 1 

314 if end > self.blocksize: 

315 self.total_requested_bytes += end 

316 data = self.fetcher(0, end) 

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

318 return data[start:] 

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

320 self.total_requested_bytes += self.blocksize 

321 part = self.cache[start:end] 

322 if end > self.blocksize: 

323 self.total_requested_bytes += end - self.blocksize 

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

325 self.hit_count += 1 

326 return part 

327 else: 

328 self.miss_count += 1 

329 self.total_requested_bytes += end - start 

330 return self.fetcher(start, end) 

331 

332 

333class BlockCache(BaseCache): 

334 """ 

335 Cache holding memory as a set of blocks. 

336 

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

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

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

340 

341 Parameters 

342 ---------- 

343 blocksize : int 

344 The number of bytes to store in each block. 

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

346 should balance the overhead of making a request against 

347 the granularity of the blocks. 

348 fetcher : Callable 

349 size : int 

350 The total size of the file being cached. 

351 maxblocks : int 

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

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

354 """ 

355 

356 name = "blockcache" 

357 

358 def __init__( 

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

360 ) -> None: 

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

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

363 self.maxblocks = maxblocks 

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

365 

366 def cache_info(self): 

367 """ 

368 The statistics on the block cache. 

369 

370 Returns 

371 ------- 

372 NamedTuple 

373 Returned directly from the LRU Cache used internally. 

374 """ 

375 return self._fetch_block_cached.cache_info() 

376 

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

378 state = self.__dict__ 

379 del state["_fetch_block_cached"] 

380 return state 

381 

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

383 self.__dict__.update(state) 

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

385 self._fetch_block 

386 ) 

387 

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

389 if start is None: 

390 start = 0 

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

392 end = self.size 

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

394 return b"" 

395 

396 return self._read_cache( 

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

398 ) 

399 

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

401 """ 

402 Fetch the block of data for `block_number`. 

403 """ 

404 if block_number > self.nblocks: 

405 raise ValueError( 

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

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

408 ) 

409 

410 start = block_number * self.blocksize 

411 end = start + self.blocksize 

412 self.total_requested_bytes += end - start 

413 self.miss_count += 1 

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

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

416 return block_contents 

417 

418 def _read_cache( 

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

420 ) -> bytes: 

421 """ 

422 Read from our block cache. 

423 

424 Parameters 

425 ---------- 

426 start, end : int 

427 The start and end byte positions. 

428 start_block_number, end_block_number : int 

429 The start and end block numbers. 

430 """ 

431 start_pos = start % self.blocksize 

432 end_pos = end % self.blocksize 

433 if end_pos == 0: 

434 end_pos = self.blocksize 

435 

436 self.hit_count += 1 

437 if start_block_number == end_block_number: 

438 block: bytes = self._fetch_block_cached(start_block_number) 

439 return block[start_pos:end_pos] 

440 

441 else: 

442 # read from the initial 

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

444 

445 # intermediate blocks 

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

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

448 out.extend( 

449 map( 

450 self._fetch_block_cached, 

451 range(start_block_number + 1, end_block_number), 

452 ) 

453 ) 

454 

455 # final block 

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

457 

458 return b"".join(out) 

459 

460 

461class BytesCache(BaseCache): 

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

463 

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

465 through the file. 

466 

467 Parameters 

468 ---------- 

469 trim: bool 

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

471 we are more than a blocksize ahead of it. 

472 """ 

473 

474 name: ClassVar[str] = "bytes" 

475 

476 def __init__( 

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

478 ) -> None: 

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

480 self.cache = b"" 

481 self.start: int | None = None 

482 self.end: int | None = None 

483 self.trim = trim 

484 

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

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

487 # is this where retry logic might go? 

488 if start is None: 

489 start = 0 

490 if end is None: 

491 end = self.size 

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

493 return b"" 

494 if ( 

495 self.start is not None 

496 and start >= self.start 

497 and self.end is not None 

498 and end < self.end 

499 ): 

500 # cache hit: we have all the required data 

501 offset = start - self.start 

502 self.hit_count += 1 

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

504 

505 if self.blocksize: 

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

507 else: 

508 bend = end 

509 

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

511 return b"" 

512 

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

514 self.end is None or end > self.end 

515 ): 

516 # First read, or extending both before and after 

517 self.total_requested_bytes += bend - start 

518 self.miss_count += 1 

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

520 self.start = start 

521 else: 

522 assert self.start is not None 

523 assert self.end is not None 

524 self.miss_count += 1 

525 

526 if start < self.start: 

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

528 self.total_requested_bytes += bend - start 

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

530 self.start = start 

531 else: 

532 self.total_requested_bytes += self.start - start 

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

534 self.start = start 

535 self.cache = new + self.cache 

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

537 if self.end > self.size: 

538 pass 

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

540 self.total_requested_bytes += bend - start 

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

542 self.start = start 

543 else: 

544 self.total_requested_bytes += bend - self.end 

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

546 self.cache = self.cache + new 

547 

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

549 offset = start - self.start 

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

551 if self.trim: 

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

553 if num > 1: 

554 self.start += self.blocksize * num 

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

556 return out 

557 

558 def __len__(self) -> int: 

559 return len(self.cache) 

560 

561 

562class AllBytes(BaseCache): 

563 """Cache entire contents of the file""" 

564 

565 name: ClassVar[str] = "all" 

566 

567 def __init__( 

568 self, 

569 blocksize: int | None = None, 

570 fetcher: Fetcher | None = None, 

571 size: int | None = None, 

572 data: bytes | None = None, 

573 ) -> None: 

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

575 if data is None: 

576 self.miss_count += 1 

577 self.total_requested_bytes += self.size 

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

579 self.data = data 

580 

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

582 self.hit_count += 1 

583 return self.data[start:stop] 

584 

585 

586class KnownPartsOfAFile(BaseCache): 

587 """ 

588 Cache holding known file parts. 

589 

590 Parameters 

591 ---------- 

592 blocksize: int 

593 How far to read ahead in numbers of bytes 

594 fetcher: func 

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

596 specified 

597 size: int 

598 How big this file is 

599 data: dict 

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

601 with known bytes. 

602 strict: bool, default True 

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

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

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

606 begin outside a known byte-range. 

607 """ 

608 

609 name: ClassVar[str] = "parts" 

610 

611 def __init__( 

612 self, 

613 blocksize: int, 

614 fetcher: Fetcher, 

615 size: int, 

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

617 strict: bool = False, 

618 **_: Any, 

619 ): 

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

621 self.strict = strict 

622 

623 # simple consolidation of contiguous blocks 

624 if data: 

625 old_offsets = sorted(data.keys()) 

626 offsets = [old_offsets[0]] 

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

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

629 start0, stop0 = offsets[-1] 

630 if start == stop0: 

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

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

633 else: 

634 offsets.append((start, stop)) 

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

636 

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

638 else: 

639 self.data = {} 

640 

641 @property 

642 def size(self): 

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

644 

645 @size.setter 

646 def size(self, value): 

647 pass 

648 

649 @property 

650 def nblocks(self): 

651 return len(self.data) 

652 

653 @nblocks.setter 

654 def nblocks(self, value): 

655 pass 

656 

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

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

659 if start is None: 

660 start = 0 

661 if stop is None: 

662 stop = self.size 

663 self.total_requested_bytes += stop - start 

664 out = b"" 

665 started = False 

666 loc_old = 0 

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

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

669 # entirely within the block 

670 off = start - loc0 

671 self.hit_count += 1 

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

673 if stop <= loc0: 

674 break 

675 if started and loc0 > loc_old: 

676 # a gap where we need data 

677 self.miss_count += 1 

678 if self.strict: 

679 raise ValueError 

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

681 if loc0 <= start < loc1: 

682 # found the start 

683 self.hit_count += 1 

684 off = start - loc0 

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

686 started = True 

687 elif start < loc0 and stop > loc1: 

688 # the whole block 

689 self.hit_count += 1 

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

691 elif loc0 <= stop <= loc1: 

692 # end block 

693 self.hit_count += 1 

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

695 return out 

696 loc_old = loc1 

697 self.miss_count += 1 

698 if started and not self.strict: 

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

700 return out 

701 raise ValueError 

702 

703 

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

705 """ 

706 Custom implementation of LRU cache that allows updating keys 

707 

708 Used by BackgroundBlockCache 

709 """ 

710 

711 class CacheInfo(NamedTuple): 

712 hits: int 

713 misses: int 

714 maxsize: int 

715 currsize: int 

716 

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

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

719 self._func = func 

720 self._max_size = max_size 

721 self._hits = 0 

722 self._misses = 0 

723 self._lock = threading.Lock() 

724 

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

726 if kwargs: 

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

728 with self._lock: 

729 if args in self._cache: 

730 self._cache.move_to_end(args) 

731 self._hits += 1 

732 return self._cache[args] 

733 

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

735 

736 with self._lock: 

737 self._cache[args] = result 

738 self._misses += 1 

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

740 self._cache.popitem(last=False) 

741 

742 return result 

743 

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

745 with self._lock: 

746 return args in self._cache 

747 

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

749 with self._lock: 

750 self._cache[args] = result 

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

752 self._cache.popitem(last=False) 

753 

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

755 with self._lock: 

756 return self.CacheInfo( 

757 maxsize=self._max_size, 

758 currsize=len(self._cache), 

759 hits=self._hits, 

760 misses=self._misses, 

761 ) 

762 

763 

764class BackgroundBlockCache(BaseCache): 

765 """ 

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

767 the next block in the background. 

768 

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

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

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

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

773 in non-blocking way. 

774 

775 Parameters 

776 ---------- 

777 blocksize : int 

778 The number of bytes to store in each block. 

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

780 should balance the overhead of making a request against 

781 the granularity of the blocks. 

782 fetcher : Callable 

783 size : int 

784 The total size of the file being cached. 

785 maxblocks : int 

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

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

788 """ 

789 

790 name: ClassVar[str] = "background" 

791 

792 def __init__( 

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

794 ) -> None: 

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

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

797 self.maxblocks = maxblocks 

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

799 

800 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

801 self._fetch_future_block_number: int | None = None 

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

803 self._fetch_future_lock = threading.Lock() 

804 self._closed = False 

805 

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

807 """ 

808 The statistics on the block cache. 

809 

810 Returns 

811 ------- 

812 NamedTuple 

813 Returned directly from the LRU Cache used internally. 

814 """ 

815 return self._fetch_block_cached.cache_info() 

816 

817 def close(self) -> None: 

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

819 with self._fetch_future_lock: 

820 if self._closed: 

821 return 

822 self._closed = True 

823 future = self._fetch_future 

824 self._fetch_future = None 

825 self._fetch_future_block_number = None 

826 

827 if future is not None: 

828 future.cancel() 

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

830 

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

832 del self._fetch_block_cached 

833 

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

835 state = self.__dict__ 

836 del state["_fetch_block_cached"] 

837 del state["_thread_executor"] 

838 del state["_fetch_future_block_number"] 

839 del state["_fetch_future"] 

840 del state["_fetch_future_lock"] 

841 return state 

842 

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

844 self.__dict__.update(state) 

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

846 self._thread_executor = ThreadPoolExecutor(max_workers=1) 

847 self._fetch_future_block_number = None 

848 self._fetch_future = None 

849 self._fetch_future_lock = threading.Lock() 

850 self._closed = False 

851 

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

853 if start is None: 

854 start = 0 

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

856 end = self.size 

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

858 return b"" 

859 

860 # byte position -> block numbers 

861 start_block_number = start // self.blocksize 

862 end_block_number = end // self.blocksize 

863 

864 fetch_future_block_number = None 

865 fetch_future = None 

866 with self._fetch_future_lock: 

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

868 if self._fetch_future is not None: 

869 assert self._fetch_future_block_number is not None 

870 if self._fetch_future.done(): 

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

872 self._fetch_block_cached.add_key( 

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

874 ) 

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

876 self._fetch_future_block_number = None 

877 self._fetch_future = None 

878 else: 

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

880 must_join = bool( 

881 start_block_number 

882 <= self._fetch_future_block_number 

883 <= end_block_number 

884 ) 

885 if must_join: 

886 # Copy to the local variables to release lock 

887 # before waiting for result 

888 fetch_future_block_number = self._fetch_future_block_number 

889 fetch_future = self._fetch_future 

890 

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

892 self._fetch_future_block_number = None 

893 self._fetch_future = None 

894 

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

896 if fetch_future is not None: 

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

898 # Wait until result and put it in cache 

899 self._fetch_block_cached.add_key( 

900 fetch_future.result(), fetch_future_block_number 

901 ) 

902 

903 # these are cached, so safe to do multiple calls for the same start and end. 

904 for block_number in range(start_block_number, end_block_number + 1): 

905 self._fetch_block_cached(block_number) 

906 

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

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

909 end_block_plus_1 = end_block_number + 1 

910 with self._fetch_future_lock: 

911 if ( 

912 self._fetch_future is None 

913 and end_block_plus_1 <= self.nblocks 

914 and not self._fetch_block_cached.is_key_cached(end_block_plus_1) 

915 ): 

916 self._fetch_future_block_number = end_block_plus_1 

917 self._fetch_future = self._thread_executor.submit( 

918 self._fetch_block, end_block_plus_1, "async" 

919 ) 

920 

921 return self._read_cache( 

922 start, 

923 end, 

924 start_block_number=start_block_number, 

925 end_block_number=end_block_number, 

926 ) 

927 

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

929 """ 

930 Fetch the block of data for `block_number`. 

931 """ 

932 if block_number > self.nblocks: 

933 raise ValueError( 

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

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

936 ) 

937 

938 start = block_number * self.blocksize 

939 end = start + self.blocksize 

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

941 self.total_requested_bytes += end - start 

942 self.miss_count += 1 

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

944 return block_contents 

945 

946 def _read_cache( 

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

948 ) -> bytes: 

949 """ 

950 Read from our block cache. 

951 

952 Parameters 

953 ---------- 

954 start, end : int 

955 The start and end byte positions. 

956 start_block_number, end_block_number : int 

957 The start and end block numbers. 

958 """ 

959 start_pos = start % self.blocksize 

960 end_pos = end % self.blocksize 

961 

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

963 self.hit_count += 1 

964 

965 if start_block_number == end_block_number: 

966 block = self._fetch_block_cached(start_block_number) 

967 return block[start_pos:end_pos] 

968 

969 else: 

970 # read from the initial 

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

972 

973 # intermediate blocks 

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

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

976 out.extend( 

977 map( 

978 self._fetch_block_cached, 

979 range(start_block_number + 1, end_block_number), 

980 ) 

981 ) 

982 

983 # final block 

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

985 

986 return b"".join(out) 

987 

988 

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

990 # one custom case 

991 None: BaseCache, 

992} 

993 

994 

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

996 """'Register' cache implementation. 

997 

998 Parameters 

999 ---------- 

1000 clobber: bool, optional 

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

1002 entry. 

1003 

1004 Raises 

1005 ------ 

1006 ValueError 

1007 """ 

1008 name = cls.name 

1009 if not clobber and name in caches: 

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

1011 caches[name] = cls 

1012 

1013 

1014for c in ( 

1015 BaseCache, 

1016 MMapCache, 

1017 BytesCache, 

1018 ReadAheadCache, 

1019 BlockCache, 

1020 FirstChunkCache, 

1021 AllBytes, 

1022 KnownPartsOfAFile, 

1023 BackgroundBlockCache, 

1024): 

1025 register_cache(c)