Skip to content

Commit 4641e93

Browse files
authored
Merge pull request #132 from opentensor/release/1.3.0
Release/1.3.0
2 parents 2816a38 + e0b7aa3 commit 4641e93

File tree

13 files changed

+525
-154
lines changed

13 files changed

+525
-154
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Run Tests
2+
3+
on:
4+
push:
5+
branches: [main, staging]
6+
pull_request:
7+
branches: [main, staging]
8+
workflow_dispatch:
9+
10+
jobs:
11+
find-tests:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Check-out repository
15+
uses: actions/checkout@v4
16+
17+
- name: Find test files
18+
id: get-tests
19+
run: |
20+
test_files=$(find tests -name "test*.py" | jq -R -s -c 'split("\n") | map(select(. != ""))')
21+
echo "::set-output name=test-files::$test_files"
22+
23+
pull-docker-image:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Log in to GitHub Container Registry
27+
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $GITHUB_ACTOR --password-stdin
28+
29+
- name: Pull Docker Image
30+
run: docker pull ghcr.io/opentensor/subtensor-localnet:devnet-ready
31+
32+
- name: Save Docker Image to Cache
33+
run: docker save -o subtensor-localnet.tar ghcr.io/opentensor/subtensor-localnet:devnet-ready
34+
35+
- name: Upload Docker Image as Artifact
36+
uses: actions/upload-artifact@v4
37+
with:
38+
name: subtensor-localnet
39+
path: subtensor-localnet.tar
40+
41+
run-unit-tests:
42+
name: ${{ matrix.test-file }} / Python ${{ matrix.python-version }}
43+
needs:
44+
- find-tests
45+
- pull-docker-image
46+
runs-on: ubuntu-latest
47+
timeout-minutes: 30
48+
strategy:
49+
fail-fast: false
50+
max-parallel: 32
51+
matrix:
52+
test-file: ${{ fromJson(needs.find-tests.outputs.test-files) }}
53+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
54+
55+
steps:
56+
- name: Check-out repository
57+
uses: actions/checkout@v4
58+
59+
- name: Install uv
60+
uses: astral-sh/setup-uv@v4
61+
with:
62+
python-version: ${{ matrix.python-version }}
63+
64+
- name: Install dependencies
65+
run: |
66+
uv venv .venv
67+
source .venv/bin/activate
68+
uv pip install .[dev]
69+
70+
- name: Download Docker Image
71+
uses: actions/download-artifact@v4
72+
with:
73+
name: subtensor-localnet
74+
75+
- name: Load Docker Image
76+
run: docker load -i subtensor-localnet.tar
77+
78+
- name: Run pytest
79+
run: |
80+
source .venv/bin/activate
81+
uv run pytest ${{ matrix.test-file }} -v -s

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# Changelog
22

3-
## 1.2.1 /2025-05-22
3+
## 1.3.0 /2025-06-10
4+
5+
* Add GH test runner by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/129
6+
* Edge Case Fixes by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/127
7+
* Add archive node to retry substrate by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/128
8+
9+
**Full Changelog**: https://github.com/opentensor/async-substrate-interface/compare/v1.2.2...v1.3.0
10+
11+
## 1.2.2 /2025-05-22
412

513
## What's Changed
614
* Add proper mock support by @thewhaleking in https://github.com/opentensor/async-substrate-interface/pull/123

async_substrate_interface/async_substrate.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
TYPE_CHECKING,
2323
)
2424

25-
import asyncstdlib as a
2625
from bt_decode import MetadataV15, PortableRegistry, decode as decode_by_type_string
2726
from scalecodec.base import ScaleBytes, ScaleType, RuntimeConfigurationObject
2827
from scalecodec.types import (
@@ -42,6 +41,7 @@
4241
BlockNotFound,
4342
MaxRetriesExceeded,
4443
MetadataAtVersionNotFound,
44+
StateDiscardedError,
4545
)
4646
from async_substrate_interface.protocols import Keypair
4747
from async_substrate_interface.types import (
@@ -58,7 +58,7 @@
5858
get_next_id,
5959
rng as random,
6060
)
61-
from async_substrate_interface.utils.cache import async_sql_lru_cache
61+
from async_substrate_interface.utils.cache import async_sql_lru_cache, CachedFetcher
6262
from async_substrate_interface.utils.decoding import (
6363
_determine_if_old_runtime_call,
6464
_bt_decode_to_dict_or_list,
@@ -539,14 +539,17 @@ def __init__(
539539
"You are instantiating the AsyncSubstrateInterface Websocket outside of an event loop. "
540540
"Verify this is intended."
541541
)
542-
now = asyncio.new_event_loop().time()
542+
# default value for in case there's no running asyncio loop
543+
# this really doesn't matter in most cases, as it's only used for comparison on the first call to
544+
# see how long it's been since the last call
545+
now = 0.0
543546
self.last_received = now
544547
self.last_sent = now
548+
self._in_use_ids = set()
545549

546550
async def __aenter__(self):
547-
async with self._lock:
548-
self._in_use += 1
549-
await self.connect()
551+
self._in_use += 1
552+
await self.connect()
550553
return self
551554

552555
@staticmethod
@@ -559,18 +562,19 @@ async def connect(self, force=False):
559562
self.last_sent = now
560563
if self._exit_task:
561564
self._exit_task.cancel()
562-
if not self._initialized or force:
563-
self._initialized = True
564-
try:
565-
self._receiving_task.cancel()
566-
await self._receiving_task
567-
await self.ws.close()
568-
except (AttributeError, asyncio.CancelledError):
569-
pass
570-
self.ws = await asyncio.wait_for(
571-
connect(self.ws_url, **self._options), timeout=10
572-
)
573-
self._receiving_task = asyncio.create_task(self._start_receiving())
565+
async with self._lock:
566+
if not self._initialized or force:
567+
try:
568+
self._receiving_task.cancel()
569+
await self._receiving_task
570+
await self.ws.close()
571+
except (AttributeError, asyncio.CancelledError):
572+
pass
573+
self.ws = await asyncio.wait_for(
574+
connect(self.ws_url, **self._options), timeout=10
575+
)
576+
self._receiving_task = asyncio.create_task(self._start_receiving())
577+
self._initialized = True
574578

575579
async def __aexit__(self, exc_type, exc_val, exc_tb):
576580
async with self._lock: # TODO is this actually what I want to happen?
@@ -619,6 +623,7 @@ async def _recv(self) -> None:
619623
self._open_subscriptions -= 1
620624
if "id" in response:
621625
self._received[response["id"]] = response
626+
self._in_use_ids.remove(response["id"])
622627
elif "params" in response:
623628
self._received[response["params"]["subscription"]] = response
624629
else:
@@ -649,6 +654,9 @@ async def send(self, payload: dict) -> int:
649654
id: the internal ID of the request (incremented int)
650655
"""
651656
original_id = get_next_id()
657+
while original_id in self._in_use_ids:
658+
original_id = get_next_id()
659+
self._in_use_ids.add(original_id)
652660
# self._open_subscriptions += 1
653661
await self.max_subscriptions.acquire()
654662
try:
@@ -674,7 +682,7 @@ async def retrieve(self, item_id: int) -> Optional[dict]:
674682
self.max_subscriptions.release()
675683
return item
676684
except KeyError:
677-
await asyncio.sleep(0.001)
685+
await asyncio.sleep(0.1)
678686
return None
679687

680688

@@ -725,6 +733,7 @@ def __init__(
725733
)
726734
else:
727735
self.ws = AsyncMock(spec=Websocket)
736+
728737
self._lock = asyncio.Lock()
729738
self.config = {
730739
"use_remote_preset": use_remote_preset,
@@ -748,6 +757,12 @@ def __init__(
748757
self.registry_type_map = {}
749758
self.type_id_to_name = {}
750759
self._mock = _mock
760+
self._block_hash_fetcher = CachedFetcher(512, self._get_block_hash)
761+
self._parent_hash_fetcher = CachedFetcher(512, self._get_parent_block_hash)
762+
self._runtime_info_fetcher = CachedFetcher(16, self._get_block_runtime_info)
763+
self._runtime_version_for_fetcher = CachedFetcher(
764+
512, self._get_block_runtime_version_for
765+
)
751766

752767
async def __aenter__(self):
753768
if not self._mock:
@@ -1869,9 +1884,8 @@ async def get_metadata(self, block_hash=None) -> MetadataV15:
18691884

18701885
return runtime.metadata_v15
18711886

1872-
@a.lru_cache(maxsize=512)
18731887
async def get_parent_block_hash(self, block_hash):
1874-
return await self._get_parent_block_hash(block_hash)
1888+
return await self._parent_hash_fetcher.execute(block_hash)
18751889

18761890
async def _get_parent_block_hash(self, block_hash):
18771891
block_header = await self.rpc_request("chain_getHeader", [block_hash])
@@ -1916,9 +1930,8 @@ async def get_storage_by_key(self, block_hash: str, storage_key: str) -> Any:
19161930
"Unknown error occurred during retrieval of events"
19171931
)
19181932

1919-
@a.lru_cache(maxsize=16)
19201933
async def get_block_runtime_info(self, block_hash: str) -> dict:
1921-
return await self._get_block_runtime_info(block_hash)
1934+
return await self._runtime_info_fetcher.execute(block_hash)
19221935

19231936
get_block_runtime_version = get_block_runtime_info
19241937

@@ -1929,9 +1942,8 @@ async def _get_block_runtime_info(self, block_hash: str) -> dict:
19291942
response = await self.rpc_request("state_getRuntimeVersion", [block_hash])
19301943
return response.get("result")
19311944

1932-
@a.lru_cache(maxsize=512)
19331945
async def get_block_runtime_version_for(self, block_hash: str):
1934-
return await self._get_block_runtime_version_for(block_hash)
1946+
return await self._runtime_version_for_fetcher.execute(block_hash)
19351947

19361948
async def _get_block_runtime_version_for(self, block_hash: str):
19371949
"""
@@ -2137,6 +2149,7 @@ async def _make_rpc_request(
21372149
storage_item,
21382150
result_handler,
21392151
)
2152+
21402153
request_manager.add_response(
21412154
item_id, decoded_response, complete
21422155
)
@@ -2149,14 +2162,14 @@ async def _make_rpc_request(
21492162
and current_time - self.ws.last_sent >= self.retry_timeout
21502163
):
21512164
if attempt >= self.max_retries:
2152-
logger.warning(
2165+
logger.error(
21532166
f"Timed out waiting for RPC requests {attempt} times. Exiting."
21542167
)
21552168
raise MaxRetriesExceeded("Max retries reached.")
21562169
else:
21572170
self.ws.last_received = time.time()
21582171
await self.ws.connect(force=True)
2159-
logger.error(
2172+
logger.warning(
21602173
f"Timed out waiting for RPC requests. "
21612174
f"Retrying attempt {attempt + 1} of {self.max_retries}"
21622175
)
@@ -2223,9 +2236,8 @@ async def rpc_request(
22232236
]
22242237
result = await self._make_rpc_request(payloads, result_handler=result_handler)
22252238
if "error" in result[payload_id][0]:
2226-
if (
2227-
"Failed to get runtime version"
2228-
in result[payload_id][0]["error"]["message"]
2239+
if "Failed to get runtime version" in (
2240+
err_msg := result[payload_id][0]["error"]["message"]
22292241
):
22302242
logger.warning(
22312243
"Failed to get runtime. Re-fetching from chain, and retrying."
@@ -2234,15 +2246,21 @@ async def rpc_request(
22342246
return await self.rpc_request(
22352247
method, params, result_handler, block_hash, reuse_block_hash
22362248
)
2237-
raise SubstrateRequestException(result[payload_id][0]["error"]["message"])
2249+
elif (
2250+
"Client error: Api called for an unknown Block: State already discarded"
2251+
in err_msg
2252+
):
2253+
bh = err_msg.split("State already discarded for ")[1].strip()
2254+
raise StateDiscardedError(bh)
2255+
else:
2256+
raise SubstrateRequestException(err_msg)
22382257
if "result" in result[payload_id][0]:
22392258
return result[payload_id][0]
22402259
else:
22412260
raise SubstrateRequestException(result[payload_id][0])
22422261

2243-
@a.lru_cache(maxsize=512)
22442262
async def get_block_hash(self, block_id: int) -> str:
2245-
return await self._get_block_hash(block_id)
2263+
return await self._block_hash_fetcher.execute(block_id)
22462264

22472265
async def _get_block_hash(self, block_id: int) -> str:
22482266
return (await self.rpc_request("chain_getBlockHash", [block_id]))["result"]

async_substrate_interface/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ def __init__(self):
2222
super().__init__(message)
2323

2424

25+
class StateDiscardedError(SubstrateRequestException):
26+
def __init__(self, block_hash: str):
27+
self.block_hash = block_hash
28+
message = (
29+
f"State discarded for {block_hash}. This indicates the block is too old, and you should instead "
30+
f"make this request using an archive node."
31+
)
32+
super().__init__(message)
33+
34+
2535
class StorageFunctionNotFound(ValueError):
2636
pass
2737

0 commit comments

Comments
 (0)