|
11 | 11 | from blockscout_mcp_server.models import ChainInfo |
12 | 12 |
|
13 | 13 |
|
14 | | -class ChainCache: |
15 | | - def __init__(self) -> None: |
16 | | - self._cache: dict[str, tuple[str | None, float]] = {} |
17 | | - self._locks_lock = anyio.Lock() |
18 | | - self._locks: dict[str, anyio.Lock] = {} |
19 | | - |
20 | | - async def _get_or_create_lock(self, chain_id: str) -> anyio.Lock: |
21 | | - """Get or create a lock for a specific chain.""" |
22 | | - if lock := self._locks.get(chain_id): |
23 | | - return lock |
24 | | - async with self._locks_lock: |
25 | | - if lock := self._locks.get(chain_id): |
26 | | - return lock |
27 | | - new_lock = anyio.Lock() |
28 | | - self._locks[chain_id] = new_lock |
29 | | - return new_lock |
30 | | - |
31 | | - @property |
32 | | - def _lock_keys(self) -> set[str]: |
33 | | - """Return a snapshot of chain IDs with initialized locks.""" |
34 | | - return set(self._locks.keys()) |
35 | | - |
36 | | - def get(self, chain_id: str) -> tuple[str | None, float] | None: |
37 | | - """Retrieve an entry without validating expiry (no locking). |
38 | | -
|
39 | | - Returns ``(url_or_none, expiry_monotonic)`` or ``None``. |
40 | | - """ |
41 | | - return self._cache.get(chain_id) |
42 | | - |
43 | | - async def set(self, chain_id: str, blockscout_url: str | None) -> None: |
44 | | - """Cache the URL (or lack thereof) for a single chain.""" |
45 | | - expiry = time.monotonic() + config.chain_cache_ttl_seconds |
46 | | - chain_lock = await self._get_or_create_lock(chain_id) |
47 | | - async with chain_lock: |
48 | | - self._cache[chain_id] = (blockscout_url, expiry) |
49 | | - |
50 | | - async def set_failure(self, chain_id: str) -> None: |
51 | | - """Cache a negative lookup with a shorter TTL for faster rediscovery.""" |
52 | | - expiry = time.monotonic() + config.pro_api_config_ttl_seconds |
53 | | - chain_lock = await self._get_or_create_lock(chain_id) |
54 | | - async with chain_lock: |
55 | | - self._cache[chain_id] = (None, expiry) |
56 | | - |
57 | | - async def bulk_set(self, chain_urls: dict[str, str | None]) -> None: |
58 | | - """Cache URLs from a bulk /api/chains response concurrently.""" |
59 | | - expiry = time.monotonic() + config.chain_cache_ttl_seconds |
60 | | - |
61 | | - async def _set_with_expiry(chain_id: str, url: str | None) -> None: |
62 | | - chain_lock = await self._get_or_create_lock(chain_id) |
63 | | - async with chain_lock: |
64 | | - self._cache[chain_id] = (url, expiry) |
65 | | - |
66 | | - async with anyio.create_task_group() as tg: |
67 | | - for chain_id, url in chain_urls.items(): |
68 | | - tg.start_soon(_set_with_expiry, chain_id, url) |
69 | | - |
70 | | - async def replace_success_entries(self, chain_urls: dict[str, str]) -> None: |
71 | | - """Authoritatively replace positive entries from latest PRO API snapshot. |
72 | | -
|
73 | | - The initial cache scan (list comprehension) is not guarded by a lock and |
74 | | - relies on the caller serialising access — currently ``pro_api_config_cache.lock`` |
75 | | - in ``ensure_pro_api_config()``. |
76 | | - """ |
77 | | - expiry = time.monotonic() + config.chain_cache_ttl_seconds |
78 | | - stale_success_ids = [cid for cid, (url, _) in self._cache.items() if url is not None and cid not in chain_urls] |
79 | | - |
80 | | - async def _remove(chain_id: str) -> None: |
81 | | - chain_lock = await self._get_or_create_lock(chain_id) |
82 | | - async with chain_lock: |
83 | | - entry = self._cache.get(chain_id) |
84 | | - if entry and entry[0] is not None: |
85 | | - self._cache.pop(chain_id, None) |
86 | | - |
87 | | - async def _upsert(chain_id: str, url: str) -> None: |
88 | | - chain_lock = await self._get_or_create_lock(chain_id) |
89 | | - async with chain_lock: |
90 | | - self._cache[chain_id] = (url, expiry) |
91 | | - |
92 | | - async with anyio.create_task_group() as tg: |
93 | | - for chain_id in stale_success_ids: |
94 | | - tg.start_soon(_remove, chain_id) |
95 | | - for chain_id, url in chain_urls.items(): |
96 | | - tg.start_soon(_upsert, chain_id, url) |
97 | | - |
98 | | - async def invalidate(self, chain_id: str) -> None: |
99 | | - """Remove an entry from the cache if present.""" |
100 | | - if chain_id not in self._cache: |
101 | | - return |
102 | | - chain_lock = await self._get_or_create_lock(chain_id) |
103 | | - async with chain_lock: |
104 | | - self._cache.pop(chain_id, None) |
105 | | - |
106 | | - |
107 | 14 | class ChainsListCache: |
108 | 15 | """In-process TTL cache for the chains list.""" |
109 | 16 |
|
|
0 commit comments