yellowstone-fumarole-client 0.1.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.
@@ -0,0 +1,525 @@
1
+ # DataPlaneConn
2
+ from abc import abstractmethod, ABC
3
+ import asyncio
4
+ import uuid
5
+ import grpc
6
+ from typing import Optional, List
7
+ from collections import abc, deque
8
+ from dataclasses import dataclass
9
+ import time
10
+ from yellowstone_fumarole_client.runtime.state_machine import (
11
+ FumaroleSM,
12
+ FumeDownloadRequest,
13
+ FumeOffset,
14
+ FumeShardIdx,
15
+ )
16
+ from yellowstone_fumarole_proto.geyser_pb2 import (
17
+ SubscribeRequest,
18
+ SubscribeUpdate,
19
+ SubscribeUpdateSlot,
20
+ CommitmentLevel as ProtoCommitmentLevel,
21
+ )
22
+ from yellowstone_fumarole_proto.fumarole_v2_pb2 import (
23
+ ControlCommand,
24
+ PollBlockchainHistory,
25
+ CommitOffset,
26
+ ControlResponse,
27
+ DownloadBlockShard,
28
+ BlockFilters,
29
+ )
30
+ from yellowstone_fumarole_proto.fumarole_v2_pb2_grpc import (
31
+ FumaroleStub,
32
+ )
33
+ from yellowstone_fumarole_client.utils.aio import Interval
34
+ from yellowstone_fumarole_client.grpc_connectivity import FumaroleGrpcConnector
35
+ import logging
36
+
37
+
38
+ # Constants
39
+ DEFAULT_GC_INTERVAL = 5
40
+
41
+ DEFAULT_SLOT_MEMORY_RETENTION = 10000
42
+
43
+
44
+ # DownloadTaskResult
45
+ @dataclass
46
+ class CompletedDownloadBlockTask:
47
+ """Represents a completed download block task."""
48
+
49
+ slot: int
50
+ block_uid: bytes
51
+ shard_idx: FumeShardIdx
52
+ total_event_downloaded: int
53
+
54
+
55
+ @dataclass
56
+ class DownloadBlockError:
57
+ """Represents an error that occurred during the download of a block."""
58
+
59
+ kind: str # 'Disconnected', 'OutletDisconnected', 'BlockShardNotFound', 'FailedDownload', 'Fatal'
60
+ message: str
61
+
62
+
63
+ @dataclass
64
+ class DownloadTaskResult:
65
+ """Represents the result of a download task."""
66
+
67
+ kind: str # 'Ok' or 'Err'
68
+ completed: Optional[CompletedDownloadBlockTask] = None
69
+ slot: Optional[int] = None
70
+ err: Optional[DownloadBlockError] = None
71
+
72
+
73
+ LOGGER = logging.getLogger(__name__)
74
+
75
+
76
+ class AsyncSlotDownloader(ABC):
77
+ """Abstract base class for slot downloaders."""
78
+
79
+ @abstractmethod
80
+ async def run_download(
81
+ self, subscribe_request: SubscribeRequest, spec: "DownloadTaskArgs"
82
+ ) -> DownloadTaskResult:
83
+ """Run the download task for a given slot."""
84
+ pass
85
+
86
+
87
+ # TokioFumeDragonsmouthRuntime
88
+ class AsyncioFumeDragonsmouthRuntime:
89
+ """Asynchronous runtime for Fumarole with Dragonsmouth-like stream support."""
90
+
91
+ def __init__(
92
+ self,
93
+ sm: FumaroleSM,
94
+ slot_downloader: AsyncSlotDownloader,
95
+ subscribe_request_update_q: asyncio.Queue,
96
+ subscribe_request: SubscribeRequest,
97
+ consumer_group_name: str,
98
+ control_plane_tx_q: asyncio.Queue,
99
+ control_plane_rx_q: asyncio.Queue,
100
+ dragonsmouth_outlet: asyncio.Queue,
101
+ commit_interval: float, # in seconds
102
+ gc_interval: int,
103
+ max_concurrent_download: int = 10,
104
+ ):
105
+ """Initialize the runtime with the given parameters.
106
+
107
+ Args:
108
+ sm (FumaroleSM): The state machine managing the Fumarole state.
109
+ slot_downloader (AsyncSlotDownloader): The downloader for slots.
110
+ subscribe_request_update_q (asyncio.Queue): The queue for subscribe request updates.
111
+ subscribe_request (SubscribeRequest): The initial subscribe request.
112
+ consumer_group_name (str): The name of the consumer group.
113
+ control_plane_tx_q (asyncio.Queue): The queue for sending control commands.
114
+ control_plane_rx_q (asyncio.Queue): The queue for receiving control responses.
115
+ dragonsmouth_outlet (asyncio.Queue): The outlet for Dragonsmouth updates.
116
+ commit_interval (float): The interval for committing offsets, in seconds.
117
+ gc_interval (int): The interval for garbage collection, in seconds.
118
+ max_concurrent_download (int): The maximum number of concurrent download tasks.
119
+ """
120
+ self.sm = sm
121
+ self.slot_downloader: AsyncSlotDownloader = slot_downloader
122
+ self.subscribe_request_update_q = subscribe_request_update_q
123
+ self.subscribe_request = subscribe_request
124
+ self.consumer_group_name = consumer_group_name
125
+ self.control_plane_tx = control_plane_tx_q
126
+ self.control_plane_rx = control_plane_rx_q
127
+ self.dragonsmouth_outlet = dragonsmouth_outlet
128
+ self.commit_interval = commit_interval
129
+ self.gc_interval = gc_interval
130
+ self.max_concurrent_download = max_concurrent_download
131
+ self.download_tasks = dict()
132
+ self.inner_runtime_channel: asyncio.Queue = asyncio.Queue()
133
+
134
+ def _build_poll_history_cmd(
135
+ self, from_offset: Optional[FumeOffset]
136
+ ) -> ControlCommand:
137
+ """Build a command to poll the blockchain history."""
138
+ return ControlCommand(poll_hist=PollBlockchainHistory(shard_id=0, limit=None))
139
+
140
+ def _build_commit_offset_cmd(self, offset: FumeOffset) -> ControlCommand:
141
+ return ControlCommand(commit_offset=CommitOffset(offset=offset, shard_id=0))
142
+
143
+ def _handle_control_response(self, control_response: ControlResponse):
144
+ """Handle the control response received from the control plane."""
145
+ response_field = control_response.WhichOneof("response")
146
+ assert response_field is not None, "Control response is empty"
147
+
148
+ match response_field:
149
+ case "poll_hist":
150
+ poll_hist = control_response.poll_hist
151
+ LOGGER.debug(f"Received poll history {len(poll_hist.events)} events")
152
+ self.sm.queue_blockchain_event(poll_hist.events)
153
+ case "commit_offset":
154
+ commit_offset = control_response.commit_offset
155
+ LOGGER.debug(f"Received commit offset: {commit_offset}")
156
+ self.sm.update_committed_offset(commit_offset.offset)
157
+ case "pong":
158
+ LOGGER.debug("Received pong")
159
+ case _:
160
+ raise ValueError("Unexpected control response")
161
+
162
+ async def poll_history_if_needed(self):
163
+ """Poll the history if the state machine needs new events."""
164
+ if self.sm.need_new_blockchain_events():
165
+ cmd = self._build_poll_history_cmd(self.sm.committable_offset)
166
+ await self.control_plane_tx.put(cmd)
167
+
168
+ def commitment_level(self):
169
+ """Gets the commitment level from the subscribe request."""
170
+ return self.subscribe_request.commitment
171
+
172
+ def _schedule_download_task_if_any(self):
173
+ """Schedules download tasks if there are any available slots."""
174
+ while True:
175
+ LOGGER.debug("Checking for download tasks to schedule")
176
+ if len(self.download_tasks) >= self.max_concurrent_download:
177
+ break
178
+
179
+ # Pop a slot to download from the state machine
180
+ LOGGER.debug("Popping slot to download")
181
+ download_request = self.sm.pop_slot_to_download(self.commitment_level())
182
+ if not download_request:
183
+ LOGGER.debug("No download request available")
184
+ break
185
+
186
+ LOGGER.debug(f"Download request for slot {download_request.slot} popped")
187
+ assert (
188
+ download_request.blockchain_id
189
+ ), "Download request must have a blockchain ID"
190
+ download_task_args = DownloadTaskArgs(
191
+ download_request=download_request,
192
+ dragonsmouth_outlet=self.dragonsmouth_outlet,
193
+ )
194
+
195
+ coro = self.slot_downloader.run_download(
196
+ self.subscribe_request, download_task_args
197
+ )
198
+ donwload_task = asyncio.create_task(coro)
199
+ self.download_tasks[donwload_task] = download_request
200
+ LOGGER.debug(f"Scheduling download task for slot {download_request.slot}")
201
+
202
+ def _handle_download_result(self, download_result: DownloadTaskResult):
203
+ """Handles the result of a download task."""
204
+ if download_result.kind == "Ok":
205
+ completed = download_result.completed
206
+ LOGGER.debug(
207
+ f"Download completed for slot {completed.slot}, shard {completed.shard_idx}, {completed.total_event_downloaded} total events"
208
+ )
209
+ self.sm.make_slot_download_progress(completed.slot, completed.shard_idx)
210
+ else:
211
+ slot = download_result.slot
212
+ err = download_result.err
213
+ raise RuntimeError(f"Failed to download slot {slot}: {err.message}")
214
+
215
+ async def _force_commit_offset(self):
216
+ LOGGER.debug(f"Force committing offset {self.sm.committable_offset}")
217
+ await self.control_plane_tx.put(
218
+ self._build_commit_offset_cmd(self.sm.committable_offset)
219
+ )
220
+
221
+ async def _commit_offset(self):
222
+ if self.sm.last_committed_offset < self.sm.committable_offset:
223
+ LOGGER.debug(f"Committing offset {self.sm.committable_offset}")
224
+ await self._force_commit_offset()
225
+ self.last_commit = time.time()
226
+
227
+ async def _drain_slot_status(self):
228
+ """Drains the slot status from the state machine and sends updates to the Dragonsmouth outlet."""
229
+ commitment = self.subscribe_request.commitment
230
+ slot_status_vec = deque()
231
+ while slot_status := self.sm.pop_next_slot_status():
232
+ slot_status_vec.append(slot_status)
233
+
234
+ if not slot_status_vec:
235
+ return
236
+
237
+ LOGGER.debug(f"Draining {len(slot_status_vec)} slot status")
238
+ for slot_status in slot_status_vec:
239
+ matched_filters = []
240
+ for filter_name, filter in self.subscribe_request.slots.items():
241
+ if (
242
+ filter.filter_by_commitment
243
+ and slot_status.commitment_level == commitment
244
+ ):
245
+ matched_filters.append(filter_name)
246
+ elif not filter.filter_by_commitment:
247
+ matched_filters.append(filter_name)
248
+
249
+ if matched_filters:
250
+ update = SubscribeUpdate(
251
+ filters=matched_filters,
252
+ created_at=None,
253
+ slot=SubscribeUpdateSlot(
254
+ slot=slot_status.slot,
255
+ parent=slot_status.parent_slot,
256
+ status=slot_status.commitment_level,
257
+ dead_error=slot_status.dead_error,
258
+ ),
259
+ )
260
+ try:
261
+ await self.dragonsmouth_outlet.put(update)
262
+ except asyncio.QueueFull:
263
+ return
264
+
265
+ self.sm.mark_event_as_processed(slot_status.session_sequence)
266
+
267
+ async def _handle_control_plane_resp(
268
+ self, result: ControlResponse | Exception
269
+ ) -> bool:
270
+ """Handles the control plane response."""
271
+ if isinstance(result, Exception):
272
+ await self.dragonsmouth_outlet.put(result)
273
+ return False
274
+ self._handle_control_response(result)
275
+ return True
276
+
277
+ def handle_new_subscribe_request(self, subscribe_request: SubscribeRequest):
278
+ self.subscribe_request = subscribe_request
279
+
280
+ async def run(self):
281
+ """Runs the Fumarole asyncio runtime."""
282
+ LOGGER.debug(f"Fumarole runtime starting...")
283
+ await self.control_plane_tx.put(self._build_poll_history_cmd(None))
284
+ LOGGER.debug("Initial poll history command sent")
285
+ await self._force_commit_offset()
286
+ LOGGER.debug("Initial commit offset command sent")
287
+ ticks = 0
288
+
289
+ task_map = {
290
+ asyncio.create_task(
291
+ self.subscribe_request_update_q.get()
292
+ ): "dragonsmouth_bidi",
293
+ asyncio.create_task(self.control_plane_rx.get()): "control_plane_rx",
294
+ asyncio.create_task(Interval(self.commit_interval).tick()): "commit_tick",
295
+ }
296
+
297
+ pending = set(task_map.keys())
298
+ while pending:
299
+ ticks += 1
300
+ LOGGER.debug(f"Runtime loop tick")
301
+ if ticks % self.gc_interval == 0:
302
+ LOGGER.debug("Running garbage collection")
303
+ self.sm.gc()
304
+ ticks = 0
305
+ LOGGER.debug(f"Polling history if needed")
306
+ await self.poll_history_if_needed()
307
+ LOGGER.debug("Scheduling download tasks if any")
308
+ self._schedule_download_task_if_any()
309
+ for t in self.download_tasks.keys():
310
+ pending.add(t)
311
+ task_map[t] = "download_task"
312
+
313
+ download_task_inflight = len(self.download_tasks)
314
+ LOGGER.debug(
315
+ f"Current download tasks in flight: {download_task_inflight} / {self.max_concurrent_download}"
316
+ )
317
+ done, pending = await asyncio.wait(
318
+ pending, return_when=asyncio.FIRST_COMPLETED
319
+ )
320
+ for t in done:
321
+ result = t.result()
322
+ name = task_map.pop(t)
323
+ match name:
324
+ case "dragonsmouth_bidi":
325
+ LOGGER.debug("Dragonsmouth subscribe request received")
326
+ assert isinstance(
327
+ result, SubscribeRequest
328
+ ), "Expected SubscribeRequest"
329
+ self.handle_new_subscribe_request(result)
330
+ new_task = asyncio.create_task(
331
+ self.subscribe_request_update_q.get()
332
+ )
333
+ task_map[new_task] = "dragonsmouth_bidi"
334
+ pending.add(new_task)
335
+ pass
336
+ case "control_plane_rx":
337
+ LOGGER.debug("Control plane response received")
338
+ if not await self._handle_control_plane_resp(result):
339
+ LOGGER.debug("Control plane error")
340
+ return
341
+ new_task = asyncio.create_task(self.control_plane_rx.get())
342
+ task_map[new_task] = "control_plane_rx"
343
+ pending.add(new_task)
344
+ case "download_task":
345
+ LOGGER.debug("Download task result received")
346
+ assert self.download_tasks.pop(t)
347
+ self._handle_download_result(result)
348
+ case "commit_tick":
349
+ LOGGER.debug("Commit tick reached")
350
+ await self._commit_offset()
351
+ new_task = asyncio.create_task(
352
+ Interval(self.commit_interval).tick()
353
+ )
354
+ task_map[new_task] = "commit_tick"
355
+ pending.add(new_task)
356
+ case unknown:
357
+ raise RuntimeError(f"Unexpected task name: {unknown}")
358
+
359
+ await self._drain_slot_status()
360
+
361
+ LOGGER.debug("Fumarole runtime exiting")
362
+
363
+
364
+ # DownloadTaskRunnerChannels
365
+ @dataclass
366
+ class DownloadTaskRunnerChannels:
367
+ download_task_queue_tx: asyncio.Queue
368
+ cnc_tx: asyncio.Queue
369
+ download_result_rx: asyncio.Queue
370
+
371
+
372
+ # DownloadTaskRunnerCommand
373
+ @dataclass
374
+ class DownloadTaskRunnerCommand:
375
+ kind: str
376
+ subscribe_request: Optional[SubscribeRequest] = None
377
+
378
+ @classmethod
379
+ def UpdateSubscribeRequest(cls, subscribe_request: SubscribeRequest):
380
+ return cls(kind="UpdateSubscribeRequest", subscribe_request=subscribe_request)
381
+
382
+
383
+ # DownloadTaskArgs
384
+ @dataclass
385
+ class DownloadTaskArgs:
386
+ download_request: FumeDownloadRequest
387
+ dragonsmouth_outlet: asyncio.Queue
388
+
389
+
390
+ class GrpcSlotDownloader(AsyncSlotDownloader):
391
+
392
+ def __init__(
393
+ self,
394
+ client: FumaroleStub,
395
+ ):
396
+ self.client = client
397
+
398
+ async def run_download(
399
+ self, subscribe_request: SubscribeRequest, spec: DownloadTaskArgs
400
+ ) -> DownloadTaskResult:
401
+
402
+ download_task = GrpcDownloadBlockTaskRun(
403
+ download_request=spec.download_request,
404
+ client=self.client,
405
+ filters=BlockFilters(
406
+ accounts=subscribe_request.accounts,
407
+ transactions=subscribe_request.transactions,
408
+ entries=subscribe_request.entry,
409
+ blocks_meta=subscribe_request.blocks_meta,
410
+ ),
411
+ dragonsmouth_oulet=spec.dragonsmouth_outlet,
412
+ )
413
+
414
+ LOGGER.debug(f"Running download task for slot {spec.download_request.slot}")
415
+ return await download_task.run()
416
+
417
+
418
+ # GrpcDownloadBlockTaskRun
419
+ class GrpcDownloadBlockTaskRun:
420
+ def __init__(
421
+ self,
422
+ download_request: FumeDownloadRequest,
423
+ client: FumaroleStub,
424
+ filters: Optional[BlockFilters],
425
+ dragonsmouth_oulet: asyncio.Queue,
426
+ ):
427
+ self.download_request = download_request
428
+ self.client = client
429
+ self.filters = filters
430
+ self.dragonsmouth_oulet = dragonsmouth_oulet
431
+
432
+ def map_tonic_error_code_to_download_block_error(
433
+ self, e: grpc.aio.AioRpcError
434
+ ) -> DownloadBlockError:
435
+ code = e.code()
436
+ if code == grpc.StatusCode.NOT_FOUND:
437
+ return DownloadBlockError(
438
+ kind="BlockShardNotFound", message="Block shard not found"
439
+ )
440
+ elif code == grpc.StatusCode.UNAVAILABLE:
441
+ return DownloadBlockError(kind="Disconnected", message="Disconnected")
442
+ elif code in (
443
+ grpc.StatusCode.INTERNAL,
444
+ grpc.StatusCode.ABORTED,
445
+ grpc.StatusCode.DATA_LOSS,
446
+ grpc.StatusCode.RESOURCE_EXHAUSTED,
447
+ grpc.StatusCode.UNKNOWN,
448
+ grpc.StatusCode.CANCELLED,
449
+ grpc.StatusCode.DEADLINE_EXCEEDED,
450
+ ):
451
+ return DownloadBlockError(kind="FailedDownload", message="Failed download")
452
+ elif code == grpc.StatusCode.INVALID_ARGUMENT:
453
+ raise ValueError("Invalid argument")
454
+ else:
455
+ return DownloadBlockError(kind="Fatal", message=f"Unknown error: {code}")
456
+
457
+ async def run(self) -> DownloadTaskResult:
458
+ request = DownloadBlockShard(
459
+ blockchain_id=self.download_request.blockchain_id,
460
+ block_uid=self.download_request.block_uid,
461
+ shard_idx=0,
462
+ blockFilters=self.filters,
463
+ )
464
+ try:
465
+ LOGGER.debug(
466
+ f"Requesting download for block {self.download_request.block_uid.hex()} at slot {self.download_request.slot}"
467
+ )
468
+ resp = self.client.DownloadBlock(request)
469
+ except grpc.aio.AioRpcError as e:
470
+ LOGGER.error(f"Download block error: {e}")
471
+ return DownloadTaskResult(
472
+ kind="Err",
473
+ slot=self.download_request.slot,
474
+ err=self.map_tonic_error_code_to_download_block_error(e),
475
+ )
476
+
477
+ total_event_downloaded = 0
478
+ try:
479
+ async for data in resp:
480
+ kind = data.WhichOneof("response")
481
+ match kind:
482
+ case "update":
483
+ update = data.update
484
+ assert update is not None, "Update is None"
485
+ total_event_downloaded += 1
486
+ try:
487
+ await self.dragonsmouth_oulet.put(update)
488
+ except asyncio.QueueShutDown:
489
+ LOGGER.error("Dragonsmouth outlet is disconnected")
490
+ return DownloadTaskResult(
491
+ kind="Err",
492
+ slot=self.download_request.slot,
493
+ err=DownloadBlockError(
494
+ kind="OutletDisconnected",
495
+ message="Outlet disconnected",
496
+ ),
497
+ )
498
+ case "block_shard_download_finish":
499
+ LOGGER.debug(
500
+ f"Download finished for block {self.download_request.block_uid.hex()} at slot {self.download_request.slot}"
501
+ )
502
+ return DownloadTaskResult(
503
+ kind="Ok",
504
+ completed=CompletedDownloadBlockTask(
505
+ slot=self.download_request.slot,
506
+ block_uid=self.download_request.block_uid,
507
+ shard_idx=0,
508
+ total_event_downloaded=total_event_downloaded,
509
+ ),
510
+ )
511
+ case unknown:
512
+ raise RuntimeError("Unexpected response kind: {unknown}")
513
+ except grpc.aio.AioRpcError as e:
514
+ LOGGER.error(f"Download block error: {e}")
515
+ return DownloadTaskResult(
516
+ kind="Err",
517
+ slot=self.download_request.slot,
518
+ err=self.map_tonic_error_code_to_download_block_error(e),
519
+ )
520
+
521
+ return DownloadTaskResult(
522
+ kind="Err",
523
+ slot=self.download_request.slot,
524
+ err=DownloadBlockError(kind="FailedDownload", message="Failed download"),
525
+ )