yellowstone-fumarole-client 0.1.0rc2__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,297 @@
1
+ import asyncio
2
+ import logging
3
+ from yellowstone_fumarole_client.grpc_connectivity import (
4
+ FumaroleGrpcConnector,
5
+ )
6
+ from typing import Dict, Optional
7
+ from dataclasses import dataclass
8
+ from . import config
9
+ from yellowstone_fumarole_client.runtime.aio import (
10
+ AsyncioFumeDragonsmouthRuntime,
11
+ FumaroleSM,
12
+ DEFAULT_GC_INTERVAL,
13
+ DEFAULT_SLOT_MEMORY_RETENTION,
14
+ GrpcSlotDownloader,
15
+ )
16
+ from yellowstone_fumarole_proto.geyser_pb2 import SubscribeRequest, SubscribeUpdate
17
+ from yellowstone_fumarole_proto.fumarole_v2_pb2 import (
18
+ ControlResponse,
19
+ VersionRequest,
20
+ VersionResponse,
21
+ JoinControlPlane,
22
+ ControlCommand,
23
+ ListConsumerGroupsRequest,
24
+ ListConsumerGroupsResponse,
25
+ GetConsumerGroupInfoRequest,
26
+ ConsumerGroupInfo,
27
+ DeleteConsumerGroupRequest,
28
+ DeleteConsumerGroupResponse,
29
+ CreateConsumerGroupRequest,
30
+ CreateConsumerGroupResponse,
31
+ )
32
+ from yellowstone_fumarole_proto.fumarole_v2_pb2_grpc import FumaroleStub
33
+ import grpc
34
+
35
+ __all__ = [
36
+ "FumaroleClient",
37
+ "FumaroleConfig",
38
+ "FumaroleSubscribeConfig",
39
+ "DragonsmouthAdapterSession",
40
+ "DEFAULT_DRAGONSMOUTH_CAPACITY",
41
+ "DEFAULT_COMMIT_INTERVAL",
42
+ "DEFAULT_MAX_SLOT_DOWNLOAD_ATTEMPT",
43
+ "DEFAULT_CONCURRENT_DOWNLOAD_LIMIT_PER_TCP",
44
+ ]
45
+
46
+ # Constants
47
+ DEFAULT_DRAGONSMOUTH_CAPACITY = 10000
48
+ DEFAULT_COMMIT_INTERVAL = 5.0 # seconds
49
+ DEFAULT_MAX_SLOT_DOWNLOAD_ATTEMPT = 3
50
+ DEFAULT_CONCURRENT_DOWNLOAD_LIMIT_PER_TCP = 10
51
+
52
+ # Error classes
53
+
54
+
55
+ # FumaroleSubscribeConfig
56
+ @dataclass
57
+ class FumaroleSubscribeConfig:
58
+ """Configuration for subscribing to a dragonsmouth stream."""
59
+
60
+ # The maximum number of concurrent download tasks per TCP connection.
61
+ concurrent_download_limit: int = DEFAULT_CONCURRENT_DOWNLOAD_LIMIT_PER_TCP
62
+
63
+ # The interval at which to commit the slot memory.
64
+ commit_interval: float = DEFAULT_COMMIT_INTERVAL
65
+
66
+ # The maximum number of failed slot download attempts before giving up.
67
+ max_failed_slot_download_attempt: int = DEFAULT_MAX_SLOT_DOWNLOAD_ATTEMPT
68
+
69
+ # The maximum number of slots to download concurrently.
70
+ data_channel_capacity: int = DEFAULT_DRAGONSMOUTH_CAPACITY
71
+
72
+ # The interval at which to perform garbage collection on the slot memory.
73
+ gc_interval: int = DEFAULT_GC_INTERVAL
74
+
75
+ # The retention period for slot memory in seconds.
76
+ slot_memory_retention: int = DEFAULT_SLOT_MEMORY_RETENTION
77
+
78
+
79
+ # DragonsmouthAdapterSession
80
+ @dataclass
81
+ class DragonsmouthAdapterSession:
82
+ """Session for interacting with the dragonsmouth-like stream."""
83
+
84
+ # The queue for sending SubscribeRequest update to the dragonsmouth stream.
85
+ sink: asyncio.Queue
86
+
87
+ # The queue for receiving SubscribeUpdate from the dragonsmouth stream.
88
+ source: asyncio.Queue
89
+
90
+ # The task handle for the fumarole runtime.
91
+ fumarole_handle: asyncio.Task
92
+
93
+
94
+ # FumaroleClient
95
+ class FumaroleClient:
96
+ """Fumarole client for interacting with the Fumarole server."""
97
+
98
+ logger = logging.getLogger(__name__)
99
+
100
+ def __init__(self, connector: FumaroleGrpcConnector, stub: FumaroleStub):
101
+ self.connector = connector
102
+ self.stub = stub
103
+
104
+ @staticmethod
105
+ async def connect(config: config.FumaroleConfig) -> "FumaroleClient":
106
+ """Connect to the Fumarole server using the provided configuration.
107
+ Args:
108
+ config (FumaroleConfig): Configuration for the Fumarole client.
109
+ """
110
+ endpoint = config.endpoint
111
+ connector = FumaroleGrpcConnector(config=config, endpoint=endpoint)
112
+ FumaroleClient.logger.debug(f"Connecting to {endpoint}")
113
+ client = await connector.connect()
114
+ FumaroleClient.logger.debug(f"Connected to {endpoint}")
115
+ return FumaroleClient(connector=connector, stub=client)
116
+
117
+ async def version(self) -> VersionResponse:
118
+ """Get the version of the Fumarole server."""
119
+ request = VersionRequest()
120
+ response = await self.stub.version(request)
121
+ return response
122
+
123
+ async def dragonsmouth_subscribe(
124
+ self, consumer_group_name: str, request: SubscribeRequest
125
+ ) -> DragonsmouthAdapterSession:
126
+ """Subscribe to a dragonsmouth stream with default configuration.
127
+
128
+ Args:
129
+ consumer_group_name (str): The name of the consumer group.
130
+ request (SubscribeRequest): The request to subscribe to the dragonsmouth stream.
131
+ """
132
+ return await self.dragonsmouth_subscribe_with_config(
133
+ consumer_group_name, request, FumaroleSubscribeConfig()
134
+ )
135
+
136
+ async def dragonsmouth_subscribe_with_config(
137
+ self,
138
+ consumer_group_name: str,
139
+ request: SubscribeRequest,
140
+ config: FumaroleSubscribeConfig,
141
+ ) -> DragonsmouthAdapterSession:
142
+ """Subscribe to a dragonsmouth stream with custom configuration.
143
+
144
+ Args:
145
+ consumer_group_name (str): The name of the consumer group.
146
+ request (SubscribeRequest): The request to subscribe to the dragonsmouth stream.
147
+ config (FumaroleSubscribeConfig): The configuration for the dragonsmouth subscription.
148
+ """
149
+ dragonsmouth_outlet = asyncio.Queue(maxsize=config.data_channel_capacity)
150
+ fume_control_plane_q = asyncio.Queue(maxsize=100)
151
+
152
+ initial_join = JoinControlPlane(consumer_group_name=consumer_group_name)
153
+ initial_join_command = ControlCommand(initial_join=initial_join)
154
+ await fume_control_plane_q.put(initial_join_command)
155
+
156
+ FumaroleClient.logger.debug(
157
+ f"Sent initial join command: {initial_join_command}"
158
+ )
159
+
160
+ async def control_plane_sink():
161
+ while True:
162
+ try:
163
+ update = await fume_control_plane_q.get()
164
+ yield update
165
+ except asyncio.QueueShutDown:
166
+ break
167
+
168
+ fume_control_plane_stream_rx: grpc.aio.StreamStreamMultiCallable = (
169
+ self.stub.Subscribe(control_plane_sink())
170
+ )
171
+
172
+ control_response: ControlResponse = await fume_control_plane_stream_rx.read()
173
+ init = control_response.init
174
+ if init is None:
175
+ raise ValueError(f"Unexpected initial response: {control_response}")
176
+
177
+ # Once we have the initial response, we can spin a task to read from the stream
178
+ # and put the updates into the queue.
179
+ # This is a bit of a hack, but we need a Queue not a StreamStreamMultiCallable
180
+ # because Queue are cancel-safe, while Stream are not, or at least didn't find any docs about it.
181
+ fume_control_plane_rx_q = asyncio.Queue(maxsize=100)
182
+
183
+ async def control_plane_source():
184
+ while True:
185
+ try:
186
+ async for update in fume_control_plane_stream_rx:
187
+ await fume_control_plane_rx_q.put(update)
188
+ except asyncio.QueueShutDown:
189
+ break
190
+
191
+ _cp_src_task = asyncio.create_task(control_plane_source())
192
+
193
+ FumaroleClient.logger.debug(f"Control response: {control_response}")
194
+
195
+ last_committed_offset = init.last_committed_offsets.get(0)
196
+ if last_committed_offset is None:
197
+ raise ValueError("No last committed offset")
198
+
199
+ sm = FumaroleSM(last_committed_offset, config.slot_memory_retention)
200
+ subscribe_request_queue = asyncio.Queue(maxsize=100)
201
+
202
+ data_plane_client = await self.connector.connect()
203
+
204
+ grpc_slot_downloader = GrpcSlotDownloader(
205
+ client=data_plane_client,
206
+ )
207
+
208
+ rt = AsyncioFumeDragonsmouthRuntime(
209
+ sm=sm,
210
+ slot_downloader=grpc_slot_downloader,
211
+ subscribe_request_update_q=subscribe_request_queue,
212
+ subscribe_request=request,
213
+ consumer_group_name=consumer_group_name,
214
+ control_plane_tx_q=fume_control_plane_q,
215
+ control_plane_rx_q=fume_control_plane_rx_q,
216
+ dragonsmouth_outlet=dragonsmouth_outlet,
217
+ commit_interval=config.commit_interval,
218
+ gc_interval=config.gc_interval,
219
+ max_concurrent_download=config.concurrent_download_limit,
220
+ )
221
+
222
+ fumarole_handle = asyncio.create_task(rt.run())
223
+ FumaroleClient.logger.debug(f"Fumarole handle created: {fumarole_handle}")
224
+ return DragonsmouthAdapterSession(
225
+ sink=subscribe_request_queue,
226
+ source=dragonsmouth_outlet,
227
+ fumarole_handle=fumarole_handle,
228
+ )
229
+
230
+ async def list_consumer_groups(
231
+ self,
232
+ ) -> ListConsumerGroupsResponse:
233
+ """Lists all consumer groups."""
234
+ return await self.stub.ListConsumerGroups(ListConsumerGroupsRequest())
235
+
236
+ async def get_consumer_group_info(
237
+ self, consumer_group_name: str
238
+ ) -> Optional[ConsumerGroupInfo]:
239
+ """Gets information about a consumer group by name.
240
+ Returns None if the consumer group does not exist.
241
+
242
+ Args:
243
+ consumer_group_name (str): The name of the consumer group to retrieve information for.
244
+ """
245
+ try:
246
+ return await self.stub.GetConsumerGroupInfo(
247
+ GetConsumerGroupInfoRequest(consumer_group_name=consumer_group_name)
248
+ )
249
+ except grpc.aio.AioRpcError as e:
250
+ if e.code() == grpc.StatusCode.NOT_FOUND:
251
+ return None
252
+ else:
253
+ raise
254
+
255
+ async def delete_consumer_group(
256
+ self, consumer_group_name: str
257
+ ) -> DeleteConsumerGroupResponse:
258
+ """Delete a consumer group by name.
259
+
260
+ NOTE: this operation is idempotent, meaning that if the consumer group does not exist, it will not raise an error.
261
+ Args:
262
+ consumer_group_name (str): The name of the consumer group to delete.
263
+ """
264
+ return await self.stub.DeleteConsumerGroup(
265
+ DeleteConsumerGroupRequest(consumer_group_name=consumer_group_name)
266
+ )
267
+
268
+ async def delete_all_consumer_groups(
269
+ self,
270
+ ) -> DeleteConsumerGroupResponse:
271
+ """Deletes all consumer groups."""
272
+ consumer_group_list = await self.list_consumer_groups()
273
+
274
+ tasks = []
275
+
276
+ async with asyncio.TaskGroup() as tg:
277
+ for group in consumer_group_list.consumer_groups:
278
+ cg_name = group.consumer_group_name
279
+ task = tg.create_task(self.delete_consumer_group(cg_name))
280
+ tasks.append((cg_name, task))
281
+
282
+ # Raise an error if any task fails
283
+ for cg_name, task in tasks:
284
+ result = task.result()
285
+ if not result.success:
286
+ raise RuntimeError(
287
+ f"Failed to delete consumer group {cg_name}: {result.error}"
288
+ )
289
+
290
+ async def create_consumer_group(
291
+ self, request: CreateConsumerGroupRequest
292
+ ) -> CreateConsumerGroupResponse:
293
+ """Creates a new consumer group.
294
+ Args:
295
+ request (CreateConsumerGroupRequest): The request to create a consumer group.
296
+ """
297
+ return await self.stub.CreateConsumerGroup(request)
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ from typing import Dict, Optional
3
+ import yaml
4
+
5
+
6
+ @dataclass
7
+ class FumaroleConfig:
8
+ endpoint: str
9
+ x_token: Optional[str] = None
10
+ max_decoding_message_size_bytes: int = 512_000_000
11
+ x_metadata: Dict[str, str] = None
12
+
13
+ def __post_init__(self):
14
+ self.x_metadata = self.x_metadata or {}
15
+
16
+ @classmethod
17
+ def from_yaml(cls, fileobj) -> "FumaroleConfig":
18
+ data = yaml.safe_load(fileobj)
19
+ return cls(
20
+ endpoint=data["endpoint"],
21
+ x_token=data.get("x-token") or data.get("x_token"),
22
+ max_decoding_message_size_bytes=data.get(
23
+ "max_decoding_message_size_bytes", cls.max_decoding_message_size_bytes
24
+ ),
25
+ x_metadata=data.get("x-metadata", {}),
26
+ )
@@ -0,0 +1,197 @@
1
+ import logging
2
+ from typing import Optional
3
+ import grpc
4
+ from yellowstone_fumarole_client.config import FumaroleConfig
5
+ from yellowstone_fumarole_proto.fumarole_v2_pb2_grpc import FumaroleStub
6
+
7
+ X_TOKEN_HEADER = "x-token"
8
+
9
+
10
+ def _triton_sign_request(
11
+ callback: grpc.AuthMetadataPluginCallback,
12
+ x_token: Optional[str],
13
+ error: Optional[Exception],
14
+ ):
15
+ # WARNING: metadata is a 1d-tuple (<value>,), the last comma is necessary
16
+ metadata = ((X_TOKEN_HEADER, x_token),)
17
+ return callback(metadata, error)
18
+
19
+
20
+ class TritonAuthMetadataPlugin(grpc.AuthMetadataPlugin):
21
+ """Metadata wrapper for raw access token credentials."""
22
+
23
+ def __init__(self, x_token: str):
24
+ self.x_token = x_token
25
+
26
+ def __call__(
27
+ self,
28
+ context: grpc.AuthMetadataContext,
29
+ callback: grpc.AuthMetadataPluginCallback,
30
+ ):
31
+ return _triton_sign_request(callback, self.x_token, None)
32
+
33
+
34
+ def grpc_channel(endpoint: str, x_token=None, compression=None, *grpc_options):
35
+ options = [("grpc.max_receive_message_length", 111111110), *grpc_options]
36
+ if x_token is not None:
37
+ auth = TritonAuthMetadataPlugin(x_token)
38
+ # ssl_creds allow you to use our https endpoint
39
+ # grpc.ssl_channel_credentials with no arguments will look through your CA trust store.
40
+ ssl_creds = grpc.ssl_channel_credentials()
41
+
42
+ # call credentials will be sent on each request if setup with composite_channel_credentials.
43
+ call_creds: grpc.CallCredentials = grpc.metadata_call_credentials(auth)
44
+
45
+ # Combined creds will store the channel creds aswell as the call credentials
46
+ combined_creds = grpc.composite_channel_credentials(ssl_creds, call_creds)
47
+
48
+ return grpc.secure_channel(
49
+ endpoint,
50
+ credentials=combined_creds,
51
+ compression=compression,
52
+ options=options,
53
+ )
54
+ else:
55
+ return grpc.insecure_channel(endpoint, compression=compression, options=options)
56
+
57
+
58
+ # Because of a bug in grpcio library, multiple inheritance of ClientInterceptor subclasses does not work.
59
+ # You have to create a new class for each type of interceptor you want to use.
60
+
61
+
62
+ class MetadataInterceptor(
63
+ grpc.aio.UnaryStreamClientInterceptor,
64
+ grpc.aio.StreamUnaryClientInterceptor,
65
+ grpc.aio.StreamStreamClientInterceptor,
66
+ grpc.aio.UnaryUnaryClientInterceptor,
67
+ ):
68
+
69
+ def __init__(self, metadata):
70
+ if isinstance(metadata, dict):
71
+ metadata = metadata.items()
72
+ self.metadata = list(metadata)
73
+
74
+ async def intercept_unary_unary(
75
+ self, continuation, client_call_details: grpc.aio.ClientCallDetails, request
76
+ ):
77
+ # logging.debug("intercept_unary_unary")
78
+ new_details = client_call_details._replace(
79
+ metadata=self._merge_metadata(client_call_details.metadata)
80
+ )
81
+ return await continuation(new_details, request)
82
+
83
+ async def intercept_unary_stream(
84
+ self, continuation, client_call_details: grpc.aio.ClientCallDetails, request
85
+ ):
86
+ # logging.debug("intercept_unary_stream")
87
+ new_details = client_call_details._replace(
88
+ metadata=self._merge_metadata(client_call_details.metadata)
89
+ )
90
+ return await continuation(new_details, request)
91
+
92
+ async def intercept_stream_unary(
93
+ self, continuation, client_call_details: grpc.aio.ClientCallDetails, request
94
+ ):
95
+ # logging.debug("intercept_stream_unary")
96
+ new_details = client_call_details._replace(
97
+ metadata=self._merge_metadata(client_call_details.metadata)
98
+ )
99
+ return await continuation(new_details, request)
100
+
101
+ async def intercept_stream_stream(
102
+ self, continuation, client_call_details: grpc.aio.ClientCallDetails, request
103
+ ):
104
+ # logging.debug("intercept_stream_stream")
105
+ new_details = client_call_details._replace(
106
+ metadata=self._merge_metadata(client_call_details.metadata)
107
+ )
108
+ return await continuation(new_details, request)
109
+
110
+ def unary_stream_interceptor(self) -> grpc.aio.UnaryStreamClientInterceptor:
111
+ this = self
112
+
113
+ class Interceptor(grpc.aio.UnaryStreamClientInterceptor):
114
+ async def intercept_unary_stream(self, *args):
115
+ return await this.intercept_unary_stream(*args)
116
+
117
+ return Interceptor()
118
+
119
+ def stream_unary_interceptor(self) -> grpc.aio.StreamUnaryClientInterceptor:
120
+ this = self
121
+
122
+ class Interceptor(grpc.aio.StreamUnaryClientInterceptor):
123
+ async def intercept_stream_unary(self, *args):
124
+ return await this.intercept_stream_unary(*args)
125
+
126
+ return Interceptor()
127
+
128
+ def stream_stream_interceptor(self) -> grpc.aio.StreamStreamClientInterceptor:
129
+ this = self
130
+
131
+ class Interceptor(grpc.aio.StreamStreamClientInterceptor):
132
+ async def intercept_stream_stream(self, *args):
133
+ return await this.intercept_stream_stream(*args)
134
+
135
+ return Interceptor()
136
+
137
+ def unary_unary_interceptor(self) -> grpc.aio.UnaryUnaryClientInterceptor:
138
+ this = self
139
+
140
+ class Interceptor(grpc.aio.UnaryUnaryClientInterceptor):
141
+ async def intercept_unary_unary(self, *args):
142
+ return await this.intercept_unary_unary(*args)
143
+
144
+ return Interceptor()
145
+
146
+ def interceptors(self) -> list[grpc.aio.ClientInterceptor]:
147
+ return [
148
+ self.unary_unary_interceptor(),
149
+ self.unary_stream_interceptor(),
150
+ self.stream_unary_interceptor(),
151
+ self.stream_stream_interceptor(),
152
+ ]
153
+
154
+ def _merge_metadata(self, existing):
155
+ result = list(existing or []) + self.metadata
156
+ return result
157
+
158
+
159
+ class FumaroleGrpcConnector:
160
+ logger = logging.getLogger(__name__)
161
+
162
+ def __init__(self, config: FumaroleConfig, endpoint: str):
163
+ self.config = config
164
+ self.endpoint = endpoint
165
+
166
+ async def connect(self, *grpc_options) -> FumaroleStub:
167
+ options = [("grpc.max_receive_message_length", 111111110), *grpc_options]
168
+ interceptors = MetadataInterceptor(self.config.x_metadata).interceptors()
169
+ if self.config.x_token is not None:
170
+ auth = TritonAuthMetadataPlugin(self.config.x_token)
171
+ # ssl_creds allow you to use our https endpoint
172
+ # grpc.ssl_channel_credentials with no arguments will look through your CA trust store.
173
+ ssl_creds = grpc.ssl_channel_credentials()
174
+
175
+ # call credentials will be sent on each request if setup with composite_channel_credentials.
176
+ call_creds: grpc.CallCredentials = grpc.metadata_call_credentials(auth)
177
+
178
+ # Combined creds will store the channel creds aswell as the call credentials
179
+ combined_creds = grpc.composite_channel_credentials(ssl_creds, call_creds)
180
+ FumaroleGrpcConnector.logger.debug(
181
+ "Using secure channel with x-token authentication"
182
+ )
183
+ channel = grpc.aio.secure_channel(
184
+ self.endpoint,
185
+ credentials=combined_creds,
186
+ options=options,
187
+ interceptors=interceptors,
188
+ )
189
+ else:
190
+ FumaroleGrpcConnector.logger.debug(
191
+ "Using insecure channel without authentication"
192
+ )
193
+ channel = grpc.aio.insecure_channel(
194
+ self.endpoint, options=options, interceptors=interceptors
195
+ )
196
+
197
+ return FumaroleStub(channel)
File without changes