yellowstone-fumarole-client 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yellowstone_fumarole_client/__init__.py +27 -11
- yellowstone_fumarole_client/error.py +19 -0
- yellowstone_fumarole_client/runtime/aio.py +63 -20
- yellowstone_fumarole_client/runtime/state_machine.py +3 -1
- {yellowstone_fumarole_client-0.2.1.dist-info → yellowstone_fumarole_client-0.3.0.dist-info}/METADATA +1 -1
- {yellowstone_fumarole_client-0.2.1.dist-info → yellowstone_fumarole_client-0.3.0.dist-info}/RECORD +7 -6
- {yellowstone_fumarole_client-0.2.1.dist-info → yellowstone_fumarole_client-0.3.0.dist-info}/WHEEL +0 -0
@@ -33,6 +33,7 @@ from yellowstone_fumarole_proto.fumarole_pb2 import (
|
|
33
33
|
CreateConsumerGroupResponse,
|
34
34
|
)
|
35
35
|
from yellowstone_fumarole_proto.fumarole_pb2_grpc import FumaroleStub
|
36
|
+
from yellowstone_fumarole_client.error import SubscribeError
|
36
37
|
import grpc
|
37
38
|
|
38
39
|
from yellowstone_fumarole_client import config
|
@@ -54,6 +55,7 @@ DEFAULT_COMMIT_INTERVAL = 5.0 # seconds
|
|
54
55
|
DEFAULT_MAX_SLOT_DOWNLOAD_ATTEMPT = 3
|
55
56
|
DEFAULT_CONCURRENT_DOWNLOAD_LIMIT_PER_TCP = 10
|
56
57
|
|
58
|
+
LOGGER = logging.getLogger(__name__)
|
57
59
|
# Error classes
|
58
60
|
|
59
61
|
|
@@ -262,18 +264,30 @@ class FumaroleClient:
|
|
262
264
|
max_concurrent_download=config.concurrent_download_limit,
|
263
265
|
)
|
264
266
|
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
rt_task = asyncio.create_task(rt_run(rt))
|
270
|
-
|
267
|
+
rt_task = asyncio.create_task(rt.run())
|
268
|
+
rt_task.set_name("rt_task")
|
269
|
+
control_plane_src_task.set_name("control_plane_src_task")
|
271
270
|
async def fumarole_overseer():
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
t
|
271
|
+
try:
|
272
|
+
done, pending = await asyncio.wait(
|
273
|
+
[rt_task, control_plane_src_task], return_when=asyncio.FIRST_COMPLETED
|
274
|
+
)
|
275
|
+
for t in done:
|
276
|
+
try:
|
277
|
+
exc = t.exception()
|
278
|
+
LOGGER.error(f"Fumarole task '{t.get_name()}' failed: {exc}")
|
279
|
+
except asyncio.CancelledError:
|
280
|
+
pass
|
281
|
+
if exc is not None:
|
282
|
+
try:
|
283
|
+
await dragonsmouth_outlet.put(exc)
|
284
|
+
except asyncio.QueueShutdown:
|
285
|
+
pass
|
286
|
+
for t in pending:
|
287
|
+
t.cancel()
|
288
|
+
finally:
|
289
|
+
await rt.aclose()
|
290
|
+
|
277
291
|
|
278
292
|
fumarole_handle = asyncio.create_task(fumarole_overseer())
|
279
293
|
|
@@ -281,6 +295,8 @@ class FumaroleClient:
|
|
281
295
|
try:
|
282
296
|
while True:
|
283
297
|
update = await dragonsmouth_outlet.get()
|
298
|
+
if issubclass(type(update), Exception):
|
299
|
+
raise update
|
284
300
|
yield update
|
285
301
|
except (asyncio.CancelledError, asyncio.QueueShutDown):
|
286
302
|
pass
|
@@ -0,0 +1,19 @@
|
|
1
|
+
from typing import Mapping
|
2
|
+
|
3
|
+
|
4
|
+
class FumaroleClientError(Exception):
|
5
|
+
"""Base class for all Fumarole-related exceptions."""
|
6
|
+
pass
|
7
|
+
|
8
|
+
|
9
|
+
class SubscribeError(FumaroleClientError):
|
10
|
+
"""Base class for all errors related to subscription."""
|
11
|
+
pass
|
12
|
+
|
13
|
+
|
14
|
+
class DownloadSlotError(SubscribeError):
|
15
|
+
"""Exception raised for errors in the download slot process."""
|
16
|
+
|
17
|
+
def __init__(self, message: str, ctx: Mapping[str, any]):
|
18
|
+
super().__init__(message)
|
19
|
+
self.ctx = ctx
|
@@ -2,7 +2,7 @@
|
|
2
2
|
from abc import abstractmethod, ABC
|
3
3
|
import asyncio
|
4
4
|
import grpc
|
5
|
-
from typing import Optional
|
5
|
+
from typing import Literal, Mapping, Optional
|
6
6
|
from collections import deque
|
7
7
|
from dataclasses import dataclass
|
8
8
|
import time
|
@@ -31,6 +31,7 @@ from yellowstone_fumarole_proto.fumarole_pb2_grpc import (
|
|
31
31
|
)
|
32
32
|
from yellowstone_fumarole_client.utils.aio import Interval
|
33
33
|
from yellowstone_fumarole_client.grpc_connectivity import FumaroleGrpcConnector
|
34
|
+
from yellowstone_fumarole_client.error import SubscribeError, DownloadSlotError
|
34
35
|
import logging
|
35
36
|
|
36
37
|
|
@@ -51,13 +52,28 @@ class CompletedDownloadBlockTask:
|
|
51
52
|
total_event_downloaded: int
|
52
53
|
|
53
54
|
|
55
|
+
DownloadBlockErrorKind = Literal[
|
56
|
+
"Disconnected",
|
57
|
+
"OutletDisconnected",
|
58
|
+
"BlockShardNotFound",
|
59
|
+
"FailedDownload",
|
60
|
+
"Fatal",
|
61
|
+
"SessionDeprecated"
|
62
|
+
]
|
63
|
+
|
54
64
|
@dataclass
|
55
65
|
class DownloadBlockError:
|
56
66
|
"""Represents an error that occurred during the download of a block."""
|
57
|
-
|
58
|
-
kind: str # 'Disconnected', 'OutletDisconnected', 'BlockShardNotFound', 'FailedDownload', 'Fatal'
|
67
|
+
kind: DownloadBlockErrorKind
|
59
68
|
message: str
|
69
|
+
ctx: Mapping[str, any]
|
60
70
|
|
71
|
+
def into_subscribe_error(self) -> SubscribeError:
|
72
|
+
"""Convert the error into a SubscribeError."""
|
73
|
+
return DownloadSlotError(
|
74
|
+
f"{self.kind}: {self.message}",
|
75
|
+
ctx=self.ctx
|
76
|
+
)
|
61
77
|
|
62
78
|
@dataclass
|
63
79
|
class DownloadTaskResult:
|
@@ -133,10 +149,14 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
133
149
|
self.commit_interval = commit_interval
|
134
150
|
self.gc_interval = gc_interval
|
135
151
|
self.max_concurrent_download = max_concurrent_download
|
152
|
+
self.poll_hist_inflight = False
|
153
|
+
self.commit_offset_inflight = False
|
136
154
|
|
137
155
|
# holds metadata about the download task
|
138
156
|
self.download_tasks = dict()
|
139
157
|
self.inflight_tasks = dict()
|
158
|
+
self.successful_download_cnt = 0
|
159
|
+
self.is_closed = False
|
140
160
|
|
141
161
|
async def __aenter__(self):
|
142
162
|
return self
|
@@ -167,10 +187,12 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
167
187
|
|
168
188
|
match response_field:
|
169
189
|
case "poll_hist":
|
190
|
+
self.poll_hist_inflight = False
|
170
191
|
poll_hist = control_response.poll_hist
|
171
192
|
LOGGER.debug(f"Received poll history {len(poll_hist.events)} events")
|
172
193
|
self.sm.queue_blockchain_event(poll_hist.events)
|
173
194
|
case "commit_offset":
|
195
|
+
self.commit_offset_inflight = False
|
174
196
|
commit_offset = control_response.commit_offset
|
175
197
|
LOGGER.debug(f"Received commit offset: {commit_offset}")
|
176
198
|
self.sm.update_committed_offset(commit_offset.offset)
|
@@ -181,9 +203,12 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
181
203
|
|
182
204
|
async def poll_history_if_needed(self):
|
183
205
|
"""Poll the history if the state machine needs new events."""
|
206
|
+
if self.poll_hist_inflight:
|
207
|
+
return
|
184
208
|
if self.sm.need_new_blockchain_events():
|
185
209
|
cmd = self._build_poll_history_cmd(self.sm.committable_offset)
|
186
210
|
await self.control_plane_tx.put(cmd)
|
211
|
+
self.poll_hist_inflight = True
|
187
212
|
|
188
213
|
def commitment_level(self):
|
189
214
|
"""Gets the commitment level from the subscribe request."""
|
@@ -220,18 +245,26 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
220
245
|
self.inflight_tasks[download_task] = DOWNLOAD_TASK_TYPE_MARKER
|
221
246
|
LOGGER.debug(f"Scheduling download task for slot {download_request.slot}")
|
222
247
|
|
223
|
-
def _handle_download_result(self, download_result: DownloadTaskResult):
|
248
|
+
async def _handle_download_result(self, download_result: DownloadTaskResult):
|
224
249
|
"""Handles the result of a download task."""
|
225
250
|
if download_result.kind == "Ok":
|
251
|
+
self.successful_download_cnt += 1
|
226
252
|
completed = download_result.completed
|
227
253
|
LOGGER.debug(
|
228
|
-
f"Download completed for slot {completed.slot}, shard {completed.shard_idx}, {completed.total_event_downloaded} total events"
|
254
|
+
f"Download({self.successful_download_cnt}) completed for slot {completed.slot}, shard {completed.shard_idx}, {completed.total_event_downloaded} total events"
|
229
255
|
)
|
230
256
|
self.sm.make_slot_download_progress(completed.slot, completed.shard_idx)
|
231
257
|
else:
|
232
258
|
slot = download_result.slot
|
233
|
-
|
234
|
-
|
259
|
+
err_kind = download_result.err.kind
|
260
|
+
LOGGER.error(f"Download error for slot {slot}: {download_result.err}")
|
261
|
+
# If the client queue is disconnected, we don't do anything, next run iteration will close anyway.
|
262
|
+
self.is_closed = True
|
263
|
+
if err_kind != "OutletDisconnected":
|
264
|
+
try:
|
265
|
+
await self.dragonsmouth_outlet.put(download_result.err.into_subscribe_error())
|
266
|
+
except asyncio.QueueShutDown:
|
267
|
+
pass
|
235
268
|
|
236
269
|
async def _force_commit_offset(self):
|
237
270
|
LOGGER.debug(f"Force committing offset {self.sm.committable_offset}")
|
@@ -241,9 +274,12 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
241
274
|
|
242
275
|
async def _commit_offset(self):
|
243
276
|
self.last_commit = time.time()
|
277
|
+
if self.commit_offset_inflight:
|
278
|
+
return
|
244
279
|
if self.sm.last_committed_offset < self.sm.committable_offset:
|
245
280
|
LOGGER.debug(f"Committing offset {self.sm.committable_offset}")
|
246
281
|
await self._force_commit_offset()
|
282
|
+
self.commit_offset_inflight = True
|
247
283
|
|
248
284
|
async def _drain_slot_status(self):
|
249
285
|
"""Drains the slot status from the state machine and sends updates to the Dragonsmouth outlet."""
|
@@ -318,7 +354,7 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
318
354
|
): COMMIT_TICK_TYPE_MARKER,
|
319
355
|
}
|
320
356
|
|
321
|
-
while self.inflight_tasks:
|
357
|
+
while self.inflight_tasks and not self.is_closed:
|
322
358
|
ticks += 1
|
323
359
|
LOGGER.debug(f"Runtime loop tick")
|
324
360
|
if ticks % self.gc_interval == 0:
|
@@ -360,7 +396,7 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
360
396
|
elif sigcode == DOWNLOAD_TASK_TYPE_MARKER:
|
361
397
|
LOGGER.debug("Download task result received")
|
362
398
|
assert self.download_tasks.pop(t)
|
363
|
-
self._handle_download_result(result)
|
399
|
+
await self._handle_download_result(result)
|
364
400
|
elif sigcode == COMMIT_TICK_TYPE_MARKER:
|
365
401
|
LOGGER.debug("Commit tick reached")
|
366
402
|
await self._commit_offset()
|
@@ -372,10 +408,7 @@ class AsyncioFumeDragonsmouthRuntime:
|
|
372
408
|
raise RuntimeError(f"Unexpected task name: {sigcode}")
|
373
409
|
|
374
410
|
await self._drain_slot_status()
|
375
|
-
|
376
411
|
await self.aclose()
|
377
|
-
LOGGER.debug("Fumarole runtime exiting")
|
378
|
-
|
379
412
|
|
380
413
|
# DownloadTaskRunnerChannels
|
381
414
|
@dataclass
|
@@ -446,15 +479,21 @@ class GrpcDownloadBlockTaskRun:
|
|
446
479
|
self.dragonsmouth_oulet = dragonsmouth_oulet
|
447
480
|
|
448
481
|
def map_tonic_error_code_to_download_block_error(
|
449
|
-
self,
|
482
|
+
self,
|
483
|
+
e: grpc.aio.AioRpcError
|
450
484
|
) -> DownloadBlockError:
|
451
485
|
code = e.code()
|
486
|
+
ctx = {
|
487
|
+
"grpc_status_code": code,
|
488
|
+
"slot": self.download_request.slot,
|
489
|
+
"block_uid": self.download_request.block_uid,
|
490
|
+
}
|
452
491
|
if code == grpc.StatusCode.NOT_FOUND:
|
453
492
|
return DownloadBlockError(
|
454
|
-
kind="BlockShardNotFound", message="Block shard not found"
|
493
|
+
kind="BlockShardNotFound", message="Block shard not found", ctx=ctx,
|
455
494
|
)
|
456
495
|
elif code == grpc.StatusCode.UNAVAILABLE:
|
457
|
-
return DownloadBlockError(kind="Disconnected", message="Disconnected")
|
496
|
+
return DownloadBlockError(kind="Disconnected", message="Disconnected", ctx=ctx)
|
458
497
|
elif code in (
|
459
498
|
grpc.StatusCode.INTERNAL,
|
460
499
|
grpc.StatusCode.ABORTED,
|
@@ -464,11 +503,13 @@ class GrpcDownloadBlockTaskRun:
|
|
464
503
|
grpc.StatusCode.CANCELLED,
|
465
504
|
grpc.StatusCode.DEADLINE_EXCEEDED,
|
466
505
|
):
|
467
|
-
return DownloadBlockError(kind="FailedDownload", message="Failed download")
|
506
|
+
return DownloadBlockError(kind="FailedDownload", message="Failed download", ctx=ctx)
|
468
507
|
elif code == grpc.StatusCode.INVALID_ARGUMENT:
|
469
508
|
raise ValueError("Invalid argument")
|
509
|
+
elif code == grpc.StatusCode.FAILED_PRECONDITION:
|
510
|
+
return DownloadBlockError(kind="SessionDeprecated", message="Session is deprecated", ctx=ctx)
|
470
511
|
else:
|
471
|
-
return DownloadBlockError(kind="Fatal", message=f"Unknown error: {code}")
|
512
|
+
return DownloadBlockError(kind="Fatal", message=f"Unknown error: {code}", ctx=ctx)
|
472
513
|
|
473
514
|
async def run(self) -> DownloadTaskResult:
|
474
515
|
request = DownloadBlockShard(
|
@@ -525,13 +566,15 @@ class GrpcDownloadBlockTaskRun:
|
|
525
566
|
),
|
526
567
|
)
|
527
568
|
case unknown:
|
528
|
-
raise RuntimeError("Unexpected response kind: {unknown}")
|
569
|
+
raise RuntimeError(f"Unexpected response kind: {unknown}")
|
570
|
+
|
529
571
|
except grpc.aio.AioRpcError as e:
|
530
|
-
|
572
|
+
e2 = self.map_tonic_error_code_to_download_block_error(e)
|
573
|
+
LOGGER.error(f"Download block error: {e}, {e2}")
|
531
574
|
return DownloadTaskResult(
|
532
575
|
kind="Err",
|
533
576
|
slot=self.download_request.slot,
|
534
|
-
err=
|
577
|
+
err=e2,
|
535
578
|
)
|
536
579
|
|
537
580
|
return DownloadTaskResult(
|
@@ -322,4 +322,6 @@ class FumaroleSM:
|
|
322
322
|
|
323
323
|
def need_new_blockchain_events(self) -> bool:
|
324
324
|
"""Check if new blockchain events are needed."""
|
325
|
-
|
325
|
+
MINIMUM_UNPROCESSED_BLOCKCHAIN_EVENT = 10
|
326
|
+
return len(self.unprocessed_blockchain_event) < MINIMUM_UNPROCESSED_BLOCKCHAIN_EVENT \
|
327
|
+
or (not self.slot_status_update_queue and not self.blocked_slot_status_update)
|
{yellowstone_fumarole_client-0.2.1.dist-info → yellowstone_fumarole_client-0.3.0.dist-info}/RECORD
RENAMED
@@ -1,9 +1,10 @@
|
|
1
|
-
yellowstone_fumarole_client/__init__.py,sha256=
|
1
|
+
yellowstone_fumarole_client/__init__.py,sha256=Ngm4D8PMKKifrGyjg82XRfdrqOXrjtxLH6sg0YoW6Eg,14128
|
2
2
|
yellowstone_fumarole_client/config.py,sha256=aclhCPUy6RO-xtXR9w8otmt1RzFZyFnbF28jk115C2g,1394
|
3
|
+
yellowstone_fumarole_client/error.py,sha256=TFoy72LyzgHRwx9J7uvurB0jsnnmxbJwJaioSlUpcds,483
|
3
4
|
yellowstone_fumarole_client/grpc_connectivity.py,sha256=Sex_x6_Bha0wGD7rRqr-V_slsohX1tDFeiHdqahLJ4Q,6639
|
4
5
|
yellowstone_fumarole_client/runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
|
-
yellowstone_fumarole_client/runtime/aio.py,sha256=
|
6
|
-
yellowstone_fumarole_client/runtime/state_machine.py,sha256=
|
6
|
+
yellowstone_fumarole_client/runtime/aio.py,sha256=DLdsTv9hwXov4irOTnESYENFwWX6_NpwT8xEj3yZMW4,23143
|
7
|
+
yellowstone_fumarole_client/runtime/state_machine.py,sha256=eH2ARUOBdBJqk1zs9WLXD1WixqtgZX4bIdDkg1cWttA,12781
|
7
8
|
yellowstone_fumarole_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
9
|
yellowstone_fumarole_client/utils/aio.py,sha256=lm_BNkPiw5CJ6FjDlQUPoCKAqY3eYFAedAJB8mhNbzE,639
|
9
10
|
yellowstone_fumarole_client/utils/collections.py,sha256=NgXpepuRZKsUy0x1XW9pSjztIrlG7LUyMsETagfPgL0,989
|
@@ -17,6 +18,6 @@ yellowstone_fumarole_proto/geyser_pb2_grpc.py,sha256=JCEz0KM_jg_610HyQI_F1K4kJlR
|
|
17
18
|
yellowstone_fumarole_proto/solana_storage_pb2.py,sha256=LS-P5EPyS0n1pO9_U73rA6SPlbGSTEC2qYhuS3skzA8,8443
|
18
19
|
yellowstone_fumarole_proto/solana_storage_pb2.pyi,sha256=HivhoN4VEe_W7kB4lc2Un5AeTAv3xiKR_HLI096qmyA,13040
|
19
20
|
yellowstone_fumarole_proto/solana_storage_pb2_grpc.py,sha256=-rb9Dr0HXohIrHrnxukYrmUYl6OCVbkQYh2-pCO6740,895
|
20
|
-
yellowstone_fumarole_client-0.
|
21
|
-
yellowstone_fumarole_client-0.
|
22
|
-
yellowstone_fumarole_client-0.
|
21
|
+
yellowstone_fumarole_client-0.3.0.dist-info/METADATA,sha256=lzwP6_lZMW8HXwBgVmNBmXJq3S48CsoOkTcfQ2xsMQ4,4156
|
22
|
+
yellowstone_fumarole_client-0.3.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
23
|
+
yellowstone_fumarole_client-0.3.0.dist-info/RECORD,,
|
{yellowstone_fumarole_client-0.2.1.dist-info → yellowstone_fumarole_client-0.3.0.dist-info}/WHEEL
RENAMED
File without changes
|