Skip to content

Commit 0eb95f9

Browse files
committed
perf: cache proxy detection misses
1 parent 92504cc commit 0eb95f9

3 files changed

Lines changed: 139 additions & 11 deletions

File tree

src/ape/managers/_contractscache.py

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from collections.abc import Collection
33
from concurrent.futures import ThreadPoolExecutor
44
from contextlib import contextmanager
5+
from dataclasses import dataclass
56
from functools import cached_property
67
from pathlib import Path
78
from typing import TYPE_CHECKING, Generic, TypeVar
@@ -30,6 +31,12 @@
3031
_BASE_MODEL = TypeVar("_BASE_MODEL", bound=BaseModel)
3132

3233

34+
@dataclass(frozen=True)
35+
class ProxyInfoCacheEntry:
36+
exists: bool
37+
value: ProxyInfoAPI | None = None
38+
39+
3340
class ApeDataCache(CacheDirectory, Generic[_BASE_MODEL]):
3441
"""
3542
A wrapper around some cached models in the data directory,
@@ -47,7 +54,7 @@ def __init__(
4754
data_folder = base_data_folder / ecosystem_key
4855
base_path = data_folder / network_key
4956
self._model_type = model_type
50-
self.memory: dict[str, _BASE_MODEL] = {}
57+
self.memory: dict[str, _BASE_MODEL | None] = {}
5158

5259
# Only write if we are not testing!
5360
self._write_to_disk = not network_key.endswith("-fork") and network_key != "local"
@@ -92,6 +99,60 @@ def get_type(self, key: str, fetch_from_disk: bool = True) -> _BASE_MODEL | None
9299
return None
93100

94101

102+
class ProxyInfoCache(ApeDataCache[ProxyInfoAPI]):
103+
"""
104+
Cache of proxy detection results.
105+
106+
A missing file means unchecked, `null` means checked and not a proxy, and a
107+
JSON object means checked and proxy info found.
108+
"""
109+
110+
def __init__(
111+
self,
112+
base_data_folder: Path,
113+
ecosystem_key: str,
114+
network_key: str,
115+
key: str,
116+
model_type: type[ProxyInfoAPI],
117+
):
118+
super().__init__(base_data_folder, ecosystem_key, network_key, key, model_type)
119+
self.memory: dict[str, ProxyInfoAPI | None] = {}
120+
121+
def __setitem__(self, key: str, value: ProxyInfoAPI | None): # type: ignore
122+
self.memory[key] = value
123+
if self._write_to_disk:
124+
self.cache_data(key, value.model_dump(mode="json") if value is not None else None)
125+
126+
def __delitem__(self, key: str):
127+
super().__delitem__(key)
128+
129+
def get_type(self, key: str, fetch_from_disk: bool = True) -> ProxyInfoAPI | None:
130+
return self.get_entry(key, fetch_from_disk=fetch_from_disk).value
131+
132+
def get_entry(self, key: str, fetch_from_disk: bool = True) -> ProxyInfoCacheEntry:
133+
if key in self.memory:
134+
return ProxyInfoCacheEntry(exists=True, value=self.memory[key])
135+
136+
elif fetch_from_disk and self._read_from_disk:
137+
file = self.get_file(key)
138+
if file.is_file():
139+
data = self.get_data(key)
140+
if data is None:
141+
self.memory[key] = None
142+
return ProxyInfoCacheEntry(exists=True)
143+
144+
# Found proxy info on disk.
145+
model = self._model_type.model_validate(data)
146+
# Cache locally for next time.
147+
self.memory[key] = model
148+
return ProxyInfoCacheEntry(exists=True, value=model)
149+
150+
return ProxyInfoCacheEntry(exists=False)
151+
152+
def clear_memory(self):
153+
self.memory = {}
154+
155+
95156
class ContractCache(BaseManager):
96157
"""
97158
A collection of cached contracts. Contracts can be cached in two ways:
@@ -115,8 +176,8 @@ def contract_types(self) -> ApeDataCache[ContractType]:
115176
return self._get_data_cache("contract_types", ContractType)
116177

117178
@property
118-
def proxy_infos(self) -> ApeDataCache[ProxyInfoAPI]:
119-
return self._get_data_cache("proxy_info", ProxyInfoAPI)
179+
def proxy_infos(self) -> ProxyInfoCache:
180+
return self._get_data_cache("proxy_info", ProxyInfoAPI, cache_type=ProxyInfoCache)
120181

121182
@property
122183
def blueprints(self) -> ApeDataCache[ContractType]:
@@ -132,6 +193,7 @@ def _get_data_cache(
132193
model_type: type,
133194
ecosystem_key: str | None = None,
134195
network_key: str | None = None,
196+
cache_type: type[ApeDataCache] = ApeDataCache,
135197
):
136198
ecosystem_name = ecosystem_key or self.provider.network.ecosystem.name
137199
network_name = network_key or self.provider.network.name.replace("-fork", "")
@@ -141,7 +203,7 @@ def _get_data_cache(
141203
if cache := self._caches[ecosystem_name][network_name].get(key):
142204
return cache
143205

144-
self._caches[ecosystem_name][network_name][key] = ApeDataCache(
206+
self._caches[ecosystem_name][network_name][key] = cache_type(
145207
self.config_manager.DATA_FOLDER, ecosystem_name, network_name, key, model_type
146208
)
147209
return self._caches[ecosystem_name][network_name][key]
@@ -178,6 +240,15 @@ def __setitem__(
178240
else:
179241
raise TypeError(item)
180242

243+
def cache_proxy_info_no_hit(self, address: AddressType):
244+
"""
245+
Cache that proxy detection found no proxy information for this address.
246+
247+
Args:
248+
address (AddressType): The address that is not a proxy.
249+
"""
250+
self.proxy_infos[address] = None
251+
181252
def cache_contract_type(
182253
self,
183254
address: AddressType,
@@ -267,6 +338,8 @@ def _delete_proxy(self, address: AddressType):
267338
target = info.target
268339
del self.proxy_infos[target]
269340
del self.contract_types[target]
341+
else:
342+
del self.proxy_infos[address]
270343

271344
def __contains__(self, address: AddressType) -> bool:
272345
return self.get(address) is not None
@@ -307,6 +380,7 @@ def cache_deployment(
307380

308381
else:
309382
# Cache as normal.
383+
self.cache_proxy_info_no_hit(address)
310384
self.contract_types[address] = contract_type
311385

312386
else:
@@ -584,9 +658,16 @@ def get(
584658
# Check broader sources, such as an explorer.
585659
if not proxy_info and detect_proxy:
586660
# Proxy info not provided. Attempt to detect.
587-
if not (proxy_info := self.proxy_infos[address_key]):
661+
cached_proxy_info = self.proxy_infos.get_entry(
662+
address_key, fetch_from_disk=fetch_from_disk
663+
)
664+
if cached_proxy_info.exists:
665+
proxy_info = cached_proxy_info.value
666+
else:
588667
if proxy_info := self.provider.network.ecosystem.get_proxy_info(address_key):
589-
self.proxy_infos[address_key] = proxy_info
668+
self.cache_proxy_info(address_key, proxy_info)
669+
else:
670+
self.cache_proxy_info_no_hit(address_key)
590671

591672
if proxy_info:
592673
if proxy_contract_type := self._get_proxy_contract_type(
@@ -917,7 +998,10 @@ def clear_local_caches(self):
917998
self.contract_creations,
918999
self.blueprints,
9191000
):
920-
cache.memory = {}
1001+
if isinstance(cache, ProxyInfoCache):
1002+
cache.clear_memory()
1003+
else:
1004+
cache.memory = {}
9211005

9221006
self.deployments.clear_local()
9231007

src/ape/utils/os.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ def __init__(self, path: Path):
396396

397397
self._path = path
398398

399-
def __getitem__(self, key: str) -> dict:
399+
def __getitem__(self, key: str) -> dict | None:
400400
"""
401401
Get the data from ``base_path / <key>.json``.
402402
@@ -405,7 +405,7 @@ def __getitem__(self, key: str) -> dict:
405405
"""
406406
return self.get_data(key)
407407

408-
def __setitem__(self, key: str, value: dict):
408+
def __setitem__(self, key: str, value: dict | None):
409409
"""
410410
Cache the given data to ``base_path / <key>.json``.
411411
@@ -427,14 +427,14 @@ def __delitem__(self, key: str):
427427
def get_file(self, key: str) -> Path:
428428
return self._path / f"{key}.json"
429429

430-
def cache_data(self, key: str, data: dict):
430+
def cache_data(self, key: str, data: dict | None):
431431
json_str = json.dumps(data)
432432
file = self.get_file(key)
433433
file.unlink(missing_ok=True)
434434
file.parent.mkdir(parents=True, exist_ok=True)
435435
file.write_text(json_str)
436436

437-
def get_data(self, key: str) -> dict:
437+
def get_data(self, key: str) -> dict | None:
438438
file = self.get_file(key)
439439
if not file.is_file():
440440
return {}

tests/functional/test_contracts_cache.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,47 @@ def test_instance_at_skip_proxy(mocker, chain, vyper_contract_instance, owner):
212212
assert address != arg
213213

214214

215+
def test_get_caches_proxy_info_no_hit(mocker, chain, vyper_contract_instance, ethereum):
216+
address = vyper_contract_instance.address
217+
with chain.contracts.use_temporary_caches():
218+
ecosystem_type = type(ethereum)
219+
get_proxy_info = ecosystem_type.get_proxy_info
220+
proxy_detection_spy = mocker.patch.object(
221+
ecosystem_type,
222+
"get_proxy_info",
223+
autospec=True,
224+
side_effect=lambda ecosystem, address: get_proxy_info(ecosystem, address),
225+
)
226+
227+
assert chain.contracts.get(address, fetch_from_explorer=False) is None
228+
assert proxy_detection_spy.call_count == 1
229+
cached_proxy_info = chain.contracts.proxy_infos.get_entry(address)
230+
assert cached_proxy_info.exists is True
231+
assert cached_proxy_info.value is None
232+
233+
assert chain.contracts.get(address, fetch_from_explorer=False) is None
234+
assert proxy_detection_spy.call_count == 1
235+
236+
237+
def test_cache_proxy_info_no_hit_live_network(chain, clean_contract_caches, dummy_live_network):
238+
address = "0x4a986a6dca6dbF99Bc3D17F8d71aFB0D60E740F9"
239+
cache = chain.contracts.proxy_infos
240+
241+
try:
242+
chain.contracts.cache_proxy_info_no_hit(address)
243+
assert cache.get_file(address).is_file()
244+
assert cache.get_data(address) is None
245+
246+
cache.clear_memory()
247+
cached_proxy_info = cache.get_entry(address)
248+
assert cached_proxy_info.exists is True
249+
assert cached_proxy_info.value is None
250+
assert cache[address] is None
251+
252+
finally:
253+
del cache[address]
254+
255+
215256
def test_cache_deployment_live_network(
216257
chain,
217258
project,
@@ -661,12 +702,15 @@ def test_clear_local_caches(chain, vyper_contract_instance, project, owner):
661702
chain.contracts.blueprints[address] = vyper_contract_instance.contract_type
662703
# Ensure proxy exists.
663704
proxy = project.SimpleProxy.deploy(address, sender=owner)
705+
# Ensure proxy no-hit exists.
706+
chain.contracts.cache_proxy_info_no_hit(address)
664707
# Ensure creation exists.
665708
_ = chain.contracts.get_creation_metadata(address)
666709

667710
# Test setup verification.
668711
assert address in chain.contracts.contract_types, "Setup failed - no contract type(s) cached"
669712
assert proxy.address in chain.contracts.proxy_infos, "Setup failed - no proxy cached"
713+
assert chain.contracts.proxy_infos.get_entry(address).exists, "Setup failed - no proxy no-hit"
670714
assert address in chain.contracts.contract_creations, "Setup failed - no creation(s) cached"
671715

672716
# This is the method we are testing.

0 commit comments

Comments
 (0)