|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | import collections |
4 | 5 | import functools |
5 | 6 | import logging |
@@ -282,6 +283,95 @@ def _fetch(self, start: int | None, end: int | None) -> bytes: |
282 | 283 | return part + self.cache[:l] |
283 | 284 |
|
284 | 285 |
|
| 286 | +class AdaptiveReadaheadCache(BaseCache): |
| 287 | + """Adaptive readahead cache using the generic prefetch engine. |
| 288 | +
|
| 289 | + This cache remains API-compatible with existing fsspec cache wiring and |
| 290 | + falls back to classic ``ReadAheadCache`` when async prefetching is not |
| 291 | + available. |
| 292 | + """ |
| 293 | + |
| 294 | + name = "adaptive_readahead" |
| 295 | + |
| 296 | + def __init__( |
| 297 | + self, |
| 298 | + blocksize: int, |
| 299 | + fetcher: Fetcher, |
| 300 | + size: int, |
| 301 | + concurrency: int = 4, |
| 302 | + max_prefetch_size: int | None = None, |
| 303 | + ) -> None: |
| 304 | + super().__init__(blocksize, fetcher, size) |
| 305 | + self._fallback = ReadAheadCache(blocksize, fetcher, size) |
| 306 | + self._prefetcher = None |
| 307 | + |
| 308 | + async def _default_fetcher_async( |
| 309 | + start_offset: int, |
| 310 | + total_size: int, |
| 311 | + split_factor: int = 1, |
| 312 | + ) -> bytes: |
| 313 | + del split_factor |
| 314 | + return await asyncio.to_thread( |
| 315 | + self.fetcher, start_offset, start_offset + total_size |
| 316 | + ) |
| 317 | + |
| 318 | + try: |
| 319 | + import fsspec.asyn |
| 320 | + |
| 321 | + from .prefetch import BackgroundPrefetcher |
| 322 | + |
| 323 | + self._prefetcher = BackgroundPrefetcher( |
| 324 | + fetcher=_default_fetcher_async, |
| 325 | + size=size, |
| 326 | + concurrency=concurrency, |
| 327 | + max_prefetch_size=max_prefetch_size, |
| 328 | + loop=fsspec.asyn.get_loop(), |
| 329 | + ) |
| 330 | + except Exception as e: |
| 331 | + logger.debug( |
| 332 | + "AdaptiveReadaheadCache falling back to ReadAheadCache: %s", |
| 333 | + e, |
| 334 | + exc_info=True, |
| 335 | + ) |
| 336 | + self._prefetcher = None |
| 337 | + |
| 338 | + def _fetch(self, start: int | None, end: int | None) -> bytes: |
| 339 | + if self._prefetcher is None: |
| 340 | + out = self._fallback._fetch(start, end) |
| 341 | + self.hit_count = self._fallback.hit_count |
| 342 | + self.miss_count = self._fallback.miss_count |
| 343 | + self.total_requested_bytes = self._fallback.total_requested_bytes |
| 344 | + return out |
| 345 | + |
| 346 | + out = self._prefetcher.fetch(start, end) |
| 347 | + self.miss_count += 1 |
| 348 | + self.total_requested_bytes += len(out) |
| 349 | + return out |
| 350 | + |
| 351 | + def close(self) -> None: |
| 352 | + if self._prefetcher is not None: |
| 353 | + self._prefetcher.close() |
| 354 | + self._prefetcher = None |
| 355 | + |
| 356 | + def __getstate__(self) -> dict[str, Any]: |
| 357 | + # The prefetcher owns asyncio primitives that are not picklable. |
| 358 | + self.close() |
| 359 | + state = self.__dict__.copy() |
| 360 | + state["_prefetcher"] = None |
| 361 | + return state |
| 362 | + |
| 363 | + def __setstate__(self, state: dict[str, Any]) -> None: |
| 364 | + self.__dict__.update(state) |
| 365 | + self._prefetcher = None |
| 366 | + |
| 367 | + def __del__(self): |
| 368 | + try: |
| 369 | + self.close() |
| 370 | + except Exception: |
| 371 | + # Best-effort cleanup during GC. |
| 372 | + pass |
| 373 | + |
| 374 | + |
285 | 375 | class FirstChunkCache(BaseCache): |
286 | 376 | """Caches the first block of a file only |
287 | 377 |
|
@@ -997,6 +1087,7 @@ def register_cache(cls: type[BaseCache], clobber: bool = False) -> None: |
997 | 1087 | MMapCache, |
998 | 1088 | BytesCache, |
999 | 1089 | ReadAheadCache, |
| 1090 | + AdaptiveReadaheadCache, |
1000 | 1091 | BlockCache, |
1001 | 1092 | FirstChunkCache, |
1002 | 1093 | AllBytes, |
|
0 commit comments