Spaces:
Running
Running
File size: 18,189 Bytes
e16348e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | """Remote (HTTP) ChessTB tablebase support for the standard
``chess.chesstb`` module (noobpwnftw's python-chess fork, see
requirements.txt), selected through tablebase_router when
``config.REMOTE_MODE`` is ``"download"``.
Why this exists
----------------
``chess.chesstb.Tablebase`` only ever reads from a local filesystem path:
``_TableFile._open_source()`` resolves a real filesystem path with
``os.open()`` and memory-maps it (``mmap.mmap(..., access=mmap.ACCESS_READ)``),
and ``Tablebase._find()`` always checks candidates on the local filesystem.
``_open_source`` is itself a documented seam another transport can
replace -- ``remote_direct.py`` does exactly that, reading HTTP byte
ranges on demand. This module takes the simpler of the two
approaches: it leaves ``_open_source`` untouched and overrides only
``_find``, described below.
What this module does instead is subclass ``chess.chesstb.Tablebase`` and
override only the file-resolution step (``_find``): the first time a
probe touches a given material, the corresponding remote table file is
fetched in full and written to a local per-process cache directory, and
the resulting local path is handed unchanged to the standard, untouched
``WDLFile``/``DTZFile``/``DTCFile``/``DTMFile``/``DTM50File`` constructors --
which run completely unaware they're not reading from a hand-picked local
directory. Every
other behaviour (probing, index maths, block decoding, ``MissingTableError``,
the ``WIN``/``DRAW``/... constants, ``ProbeResult``) is the standard
module's own, unmodified implementation -- this module only changes *how a
table's bytes reach disk*, never what they mean once there.
A whole table file is downloaded the first time any position touches
that material, rather than only the specific byte ranges (header/index
plus whichever compressed blocks the position's index falls into) a probe
actually needs -- there's no way to get bytes in front of code that only
ever calls ``open()`` on a path other than putting real bytes at that
path first (see ``remote_direct.py`` for the byte-range alternative,
which reads through ``remote_source.RemoteFileView`` instead). Once
cached, later probes against that material are served straight
from local disk with no further network round trip. Concretely this
means: slower first touch of a given material (a full download instead
of a few small ranged fetches), fast disk-speed reads for everything
after, and real, if bounded and temporary, local disk usage. See
``_RemoteDiskCache`` below for the bound (``REMOTE_PAGE_CACHE_BYTES``
doing double duty as an on-disk budget here rather than an in-memory one)
and ``open_tablebase``/``Tablebase.close`` for the "temporary": the cache
lives in a fresh directory created per process and is removed when the
``Tablebase`` is closed (app.py does this at interpreter exit via
``atexit``, same as ``TB.close()``).
This module does not add any locking of its own around opening a
not-yet-seen remote material. ``SourcedTablebase`` holds upstream's
per-kind lock around ``_find`` whenever a download-mode source is
configured, so two threads racing to probe the same never-before-seen
material collapse onto one download. Materials of the same kind serialize
behind each other's download; that is a performance-only characteristic,
not a correctness gap.
"""
from __future__ import annotations
import collections
import os
import shutil
import tempfile
import threading
from typing import Optional
import chess.chesstb as chesstb
from tablebase_opening import block_cache_usage
from . import remote_source
__all__ = ["open_tablebase"]
class _RemoteDiskCache:
"""Soft-budgeted LRU of whole downloaded table files on local disk,
keyed by the remote-relative path each was fetched from.
Structurally mirrors one shard of :class:`remote_source._PageCache`
one layer up the stack: same OrderedDict-as-LRU-ledger, same soft
byte budget, same per-key lock so concurrent probe threads landing on
the same not-yet-cached material download it once rather than once
each -- whole files here instead of fixed-size pages, since
that's the unit the standard backend's file constructors can consume.
A freshly recorded (or freshly touched, via ``has()``) entry always
ends up at the end of the LRU order, and eviction always pops from
the front -- so the file ``_find()`` resolved a path for can
never be the one an eviction happening on another thread picks next;
only entries stale relative to it can be. That closes the same race
:meth:`remote_source._PageCacheShard.put` avoids for in-memory pages: a
long-untouched file could still be unlinked out from under a reader
holding an OS-level file descriptor already open on it, but on POSIX
an unlinked-but-open file stays readable to that descriptor until
it is closed, so an in-flight ``open().read()`` (the only operation the
standard ``WDLFile``/``DTZFile``/``DTCFile``/``DTMFile``/``DTM50File``
ever do) is unaffected either way.
"""
def __init__(self, root: str, max_bytes: int) -> None:
self.root = root
self.max_bytes = max_bytes
self._sizes: "collections.OrderedDict[str, int]" = collections.OrderedDict()
self._cur_bytes = 0
self._lock = threading.Lock()
self._entry_locks: dict[str, threading.Lock] = {}
self._meta_lock = threading.Lock()
# Guards the install-and-record tail of _ensure_cached() below
# (the os.replace() + record() that actually mutates `root`'s
# contents and this cache's bookkeeping together) against
# clear()'s rmtree/recreate running at the same moment. A
# per-entry lock (see lock_for()) already serializes concurrent
# downloads of the same rel_path; this instead protects the
# publish step against a whole-cache clear(). Reentrant because
# clear() below nests other locks (_lock, _meta_lock) inside it.
self.lifecycle_lock = threading.RLock()
# Counts downloads that currently have an open, not-yet-published
# temp file on disk. clear() waits for this to hit zero before
# rmtree-ing `root` -- see begin_download()/end_download() and
# clear() below for why: unlike lifecycle_lock, which only needs
# to cover the brief publish step, a download's temp file is
# vulnerable for its entire (potentially slow) transfer, not
# that tail. On POSIX, rmtree-ing a directory containing a
# file that's still open only unlinks its name -- the data stays
# readable/writable through the already-open descriptor -- but
# once that descriptor closes (as it does at the end of
# _ensure_cached()'s ``with open(tmp_path, "wb") as f:`` block,
# before publish), an unlinked file's data is actually freed, so
# the publish step's os.replace(tmp_path, ...) would fail with
# FileNotFoundError for a missing source path
# anywhere on disk. Deferring clear() instead of racing it avoids
# that outright, and -- unlike holding lifecycle_lock for the
# whole transfer -- doesn't serialize unrelated concurrent
# downloads against each other, only against clear() itself.
self._active_downloads = 0
self._no_active_downloads = threading.Condition(self.lifecycle_lock)
def begin_download(self) -> None:
"""Mark one download as having an in-flight temp file. Must be
paired with a later :meth:`end_download`, typically via
``try/finally``."""
with self.lifecycle_lock:
self._active_downloads += 1
def end_download(self) -> None:
with self.lifecycle_lock:
self._active_downloads -= 1
if self._active_downloads <= 0:
self._no_active_downloads.notify_all()
def lock_for(self, rel_path: str) -> threading.Lock:
lk = self._entry_locks.get(rel_path)
if lk is not None:
return lk
with self._meta_lock:
lk = self._entry_locks.get(rel_path)
if lk is None:
lk = threading.Lock()
self._entry_locks[rel_path] = lk
return lk
def local_path(self, rel_path: str) -> str:
return os.path.join(self.root, rel_path)
def has(self, rel_path: str) -> bool:
"""True if `rel_path` is already cached on disk -- and, if so,
mark it most-recently-used so a same-moment eviction on another
thread cannot acquire it (see class docstring)."""
with self._lock:
if rel_path in self._sizes:
self._sizes.move_to_end(rel_path)
return True
return False
def record(self, rel_path: str, size: int) -> None:
with self._lock:
old = self._sizes.pop(rel_path, None)
if old is not None:
self._cur_bytes -= old
self._sizes[rel_path] = size
self._cur_bytes += size
# Keep the newly added file (it is at the end); never evict fully
# empty, mirroring remote_source._PageCacheShard.put / chesstb.py's
# own _BlockCache.record.
while self._cur_bytes > self.max_bytes and len(self._sizes) > 1:
ev_path, ev_size = self._sizes.popitem(last=False)
self._cur_bytes -= ev_size
try:
os.remove(self.local_path(ev_path))
except OSError:
pass
def clear(self) -> None:
with self.lifecycle_lock:
while self._active_downloads > 0:
self._no_active_downloads.wait()
with self._lock:
self._sizes.clear()
self._cur_bytes = 0
with self._meta_lock:
self._entry_locks.clear()
shutil.rmtree(self.root, ignore_errors=True)
os.makedirs(self.root, exist_ok=True)
def stats(self) -> dict:
with self._lock:
return {
"cached_files": len(self._sizes),
"cur_bytes": self._cur_bytes,
"max_bytes": self.max_bytes,
}
class _RemoteCachingTablebase(chesstb.Tablebase):
"""A standard ``chess.chesstb.Tablebase`` whose table files are
fetched from a remote ``http(s)://`` base URL and disk-cached on
first touch, rather than read from a local directory. See this
module's docstring for the full design and trade-offs.
"""
def __init__(self, base_url: str, *, block_cache_bytes: int,
remote_page_cache_bytes: int, remote_page_size: int,
remote_timeout: float, remote_max_retries: int,
remote_pool_maxsize: int) -> None:
self._client = remote_source.RemoteHTTPClient(
base_url, timeout=remote_timeout, max_retries=remote_max_retries,
pool_maxsize=remote_pool_maxsize,
)
self._cache_root = tempfile.mkdtemp(prefix="chesstb_remote_cache_")
self._disk_cache = _RemoteDiskCache(self._cache_root, remote_page_cache_bytes)
self._download_chunk = remote_page_size
self._size_cache: dict[str, Optional[int]] = {}
self._size_lock = threading.Lock()
# Base Tablebase.__init__ calls self.add_directory(base_url) --
# overridden to a no-op below, since _find here resolves directly
# against "<kind>/" and never falls back to a bare-root candidate
# the way local _find does -- the published remote layout always
# has the kind subdirectories, per README.md's "Getting the
# tablebase files".
super().__init__(base_url, block_cache_bytes=block_cache_bytes)
def add_directory(self, base_dir: str) -> None:
"""No-op -- see the comment in __init__ above."""
# --- table file resolution: fetch-and-cache-to-disk, then delegate ---
def _find(self, kind: str, name: str, ext: str) -> Optional[str]:
rel_path = f"{kind}/{name}{ext}"
return self._ensure_cached(rel_path)
def _remote_size(self, rel_path: str) -> Optional[int]:
with self._size_lock:
if rel_path in self._size_cache:
return self._size_cache[rel_path]
size = self._client.head_size(rel_path) # may raise RemoteSourceError
with self._size_lock:
self._size_cache[rel_path] = size
return size
def _ensure_cached(self, rel_path: str) -> Optional[str]:
if self._disk_cache.has(rel_path):
return self._disk_cache.local_path(rel_path)
with self._disk_cache.lock_for(rel_path):
if self._disk_cache.has(rel_path):
return self._disk_cache.local_path(rel_path)
size = self._remote_size(rel_path)
if size is None:
return None
local_path = self._disk_cache.local_path(rel_path)
tmp_path = f"{local_path}.part"
# begin_download()/end_download() make a concurrent
# /admin/cache/clear() wait for this download rather than
# racing it -- see _RemoteDiskCache's docstring on
# _active_downloads for why racing it (rather than
# deferring) isn't safe to do here. This does not serialize
# concurrent downloads of different tables against each
# other -- only clear() waits, not other downloads -- so
# probe concurrency (see config.py's PROBE_THREADS) is
# unaffected; the per-path lock_for(rel_path) above already
# prevents two threads from downloading the same rel_path at
# once, which is the actual duplicate-work hazard here.
self._disk_cache.begin_download()
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
try:
with open(tmp_path, "wb") as f:
offset = 0
while offset < size:
remaining = size - offset
chunk = self._client.get_range(
rel_path, offset, min(self._download_chunk, remaining)
)
if not chunk:
raise remote_source.RemoteSourceError(
f"Incomplete download for {rel_path}: received "
f"{offset} of {size} bytes"
)
if len(chunk) > remaining:
raise remote_source.RemoteSourceError(
f"Oversized range response for {rel_path}: "
f"{len(chunk)} bytes requested max {remaining}"
)
f.write(chunk)
offset += len(chunk)
# Publication -- the only part that actually needs
# `root` to still exist and mean what it did when this
# method started -- is what lifecycle_lock protects.
# Only reached once the loop above has written exactly
# `size` bytes -- an atomic rename protects
# visibility, not content correctness, so a short
# download must never reach this line.
with self._disk_cache.lifecycle_lock:
os.replace(tmp_path, local_path)
self._disk_cache.record(rel_path, size)
except BaseException:
try:
os.remove(tmp_path)
except OSError:
pass
raise
finally:
self._disk_cache.end_download()
return local_path
# --- lifecycle / admin surfaces (reached through SourcedTablebase's
# clear_caches()/cache_stats(), both getattr-guarded so this stays
# optional) ---
def close(self) -> None:
try:
super().close()
finally:
try:
self._client.close()
finally:
shutil.rmtree(self._cache_root, ignore_errors=True)
def clear_caches(self) -> None:
"""Drop the decoded-block cache and the on-disk downloaded-file
cache, without forgetting which tables are open -- re-opening
tables here would defeat the point of a cache-clear (a fresh
Tablebase would re-download every table on first touch)."""
self._block_cache.clear()
self._disk_cache.clear()
def cache_stats(self) -> dict:
blocks, block_bytes = block_cache_usage(self)
return {
"block_cache_blocks": blocks,
"block_cache_bytes": block_bytes,
"remote_disk_cache": self._disk_cache.stats(),
"remote_http": self._client.stats(),
}
def open_tablebase(directory: str, *,
block_cache_bytes: int = chesstb.DEFAULT_BLOCK_CACHE_BYTES,
remote_page_cache_bytes: int = remote_source.DEFAULT_PAGE_CACHE_BYTES,
remote_page_size: int = remote_source.DEFAULT_PAGE_SIZE,
remote_timeout: float = remote_source.DEFAULT_TIMEOUT,
remote_max_retries: int = remote_source.DEFAULT_MAX_RETRIES,
remote_pool_maxsize: int = remote_source.DEFAULT_POOL_MAXSIZE,
) -> _RemoteCachingTablebase:
"""Open a remote ChessTB base URL, downloading and disk-caching tables
on first touch. Signature-compatible with ``remote_direct.open_tablebase``,
so tablebase_router.open_one selects either without special-casing.
"""
return _RemoteCachingTablebase(
directory,
block_cache_bytes=block_cache_bytes,
remote_page_cache_bytes=remote_page_cache_bytes,
remote_page_size=remote_page_size,
remote_timeout=remote_timeout,
remote_max_retries=remote_max_retries,
remote_pool_maxsize=remote_pool_maxsize,
)
|