yellowstone-fumarole-client 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (23) hide show
  1. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/PKG-INFO +1 -1
  2. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/pyproject.toml +1 -1
  3. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/__init__.py +27 -11
  4. yellowstone_fumarole_client-0.3.0/yellowstone_fumarole_client/error.py +19 -0
  5. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/runtime/aio.py +63 -20
  6. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/runtime/state_machine.py +3 -1
  7. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/README.md +0 -0
  8. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/config.py +0 -0
  9. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/grpc_connectivity.py +0 -0
  10. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/runtime/__init__.py +0 -0
  11. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/utils/__init__.py +0 -0
  12. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/utils/aio.py +0 -0
  13. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_client/utils/collections.py +0 -0
  14. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/__init__.py +0 -0
  15. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/fumarole_pb2.py +0 -0
  16. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/fumarole_pb2.pyi +0 -0
  17. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/fumarole_pb2_grpc.py +0 -0
  18. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/geyser_pb2.py +0 -0
  19. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/geyser_pb2.pyi +0 -0
  20. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/geyser_pb2_grpc.py +0 -0
  21. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/solana_storage_pb2.py +0 -0
  22. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/solana_storage_pb2.pyi +0 -0
  23. {yellowstone_fumarole_client-0.2.1 → yellowstone_fumarole_client-0.3.0}/yellowstone_fumarole_proto/solana_storage_pb2_grpc.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: yellowstone-fumarole-client
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Yellowstone Fumarole Python Client
5
5
  Home-page: https://github.com/rpcpool/yellowstone-fumarole
6
6
  Author: Louis-Vincent
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "yellowstone-fumarole-client"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  homepage = "https://github.com/rpcpool/yellowstone-fumarole"
5
5
  repository = "https://github.com/rpcpool/yellowstone-fumarole"
6
6
  description = "Yellowstone Fumarole Python Client"
@@ -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
- async def rt_run(rt):
266
- async with rt as rt:
267
- await rt.run()
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
- done, pending = await asyncio.wait(
273
- [rt_task, control_plane_src_task], return_when=asyncio.FIRST_COMPLETED
274
- )
275
- for t in pending:
276
- t.cancel()
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
- err = download_result.err
234
- raise RuntimeError(f"Failed to download slot {slot}: {err.message}")
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, e: grpc.aio.AioRpcError
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
- LOGGER.error(f"Download block error: {e}")
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=self.map_tonic_error_code_to_download_block_error(e),
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
- return not self.slot_status_update_queue and not self.blocked_slot_status_update
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)