Skip to content

Commit 59a9fa8

Browse files
committed
feat: updated topic message query to handle message
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
1 parent 15032e1 commit 59a9fa8

2 files changed

Lines changed: 124 additions & 45 deletions

File tree

src/hiero_sdk_python/query/topic_message_query.py

Lines changed: 107 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from __future__ import annotations
22

3+
import logging
4+
import re
35
import threading
46
import time
57
from collections.abc import Callable
8+
from dataclasses import dataclass, field
69
from datetime import datetime
710

11+
import grpc
12+
813
from hiero_sdk_python.client.client import Client
914
from hiero_sdk_python.consensus.topic_id import TopicId
1015
from hiero_sdk_python.consensus.topic_message import TopicMessage
@@ -14,6 +19,19 @@
1419
from hiero_sdk_python.utils.subscription_handle import SubscriptionHandle
1520

1621

22+
logger = logging.getLogger(__name__)
23+
24+
RST_STREAM = re.compile(r"\brst[^0-9a-zA-Z]stream\b", re.IGNORECASE | re.DOTALL)
25+
26+
27+
@dataclass
28+
class SubscriptionState:
29+
attempt: int = 0
30+
count: int = 0
31+
last_message: mirror_proto.ConsensusTopicResponse | None = None
32+
pending_messages: dict[str, list[mirror_proto.ConsensusTopicResponse]] = field(default_factory=dict)
33+
34+
1735
class TopicMessageQuery:
1836
"""
1937
A query to subscribe to messages from a specific HCS topic, via a mirror node.
@@ -31,23 +49,31 @@ def __init__(
3149
chunking_enabled: bool = False,
3250
) -> None:
3351
"""Initializes a TopicMessageQuery."""
34-
self._topic_id: TopicId | None = self._parse_topic_id(topic_id) if topic_id else None
52+
self._topic_id: basic_types_pb2.TopicID | None = self._parse_topic_id(topic_id) if topic_id else None
3553
self._start_time: timestamp_pb2.Timestamp | None = self._parse_timestamp(start_time) if start_time else None
3654
self._end_time: timestamp_pb2.Timestamp | None = self._parse_timestamp(end_time) if end_time else None
3755
self._limit: int | None = limit
3856
self._chunking_enabled: bool = chunking_enabled
39-
self._completion_handler: Callable[[], None] | None = None
4057

4158
self._max_attempts: int = 10
4259
self._max_backoff: float = 8.0
4360

61+
self._completion_handler: Callable[[], None] | None = self._on_complete
62+
self._error_handler: Callable[[], None] | None = self._on_error
63+
4464
def set_max_attempts(self, attempts: int) -> TopicMessageQuery:
4565
"""Sets the maximum number of attempts to reconnect on failure."""
66+
if attempts <= 0:
67+
raise ValueError("max_attempts must be greater than 0")
68+
4669
self._max_attempts = attempts
4770
return self
4871

4972
def set_max_backoff(self, backoff: float) -> TopicMessageQuery:
5073
"""Sets the maximum backoff time in seconds for reconnection attempts."""
74+
if backoff < 0.5:
75+
raise ValueError("max_backoff must be at least 500 ms")
76+
5177
self._max_backoff = backoff
5278
return self
5379

@@ -56,23 +82,10 @@ def set_completion_handler(self, handler: Callable[[], None]) -> TopicMessageQue
5682
self._completion_handler = handler
5783
return self
5884

59-
def _parse_topic_id(self, topic_id: str | TopicId) -> basic_types_pb2.TopicID:
60-
"""Parses a topic ID from a string or TopicId object into a protobuf TopicID."""
61-
if isinstance(topic_id, str):
62-
parts = topic_id.strip().split(".")
63-
if len(parts) != 3:
64-
raise ValueError(f"Invalid topic ID string: {topic_id}")
65-
shard, realm, topic = map(int, parts)
66-
return basic_types_pb2.TopicID(shardNum=shard, realmNum=realm, topicNum=topic)
67-
if isinstance(topic_id, TopicId):
68-
return topic_id._to_proto()
69-
raise TypeError("Invalid topic_id format. Must be a string or TopicId.")
70-
71-
def _parse_timestamp(self, dt: datetime) -> timestamp_pb2.Timestamp:
72-
"""Converts a datetime object to a protobuf Timestamp."""
73-
seconds = int(dt.timestamp())
74-
nanos = int((dt.timestamp() - seconds) * 1e9)
75-
return timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos)
85+
def set_error_handler(self, handler: Callable[[], None]) -> TopicMessageQuery:
86+
"""Sets a completion handler that is called when the subscription completes."""
87+
self._error_handler = handler
88+
return self
7689

7790
def set_topic_id(self, topic_id: str | TopicId) -> TopicMessageQuery:
7891
"""Sets the topic ID for the query."""
@@ -99,6 +112,41 @@ def set_chunking_enabled(self, enabled: bool) -> TopicMessageQuery:
99112
self._chunking_enabled = enabled
100113
return self
101114

115+
def _on_complete(self) -> None:
116+
logger.info(f"Subscription to topic {self._topic_id} complete")
117+
118+
def _on_error(self, err: Exception) -> None:
119+
if isinstance(err, grpc.RpcError) and err.code() == grpc.StatusCode.CANCELLED:
120+
logger.warning(f"Call is cancelled for topic {self._topic_id}")
121+
else:
122+
logger.error(f"Error attempting to subscribe to topic {self._topic_id}: {err}")
123+
124+
def _should_retry(self, err: Exception) -> bool:
125+
if isinstance(err, grpc.RpcError):
126+
return err.code() in (
127+
grpc.StatusCode.NOT_FOUND,
128+
grpc.StatusCode.UNAVAILABLE,
129+
grpc.StatusCode.RESOURCE_EXHAUSTED,
130+
) or (err.code() == grpc.StatusCode.INTERNAL and bool(RST_STREAM.search(err.details())))
131+
132+
return True
133+
134+
def _parse_topic_id(self, topic_id: str | TopicId) -> basic_types_pb2.TopicID:
135+
"""Parses a topic ID from a string or TopicId object into a protobuf TopicID."""
136+
if isinstance(topic_id, str):
137+
topic_id = TopicId.from_string(topic_id)
138+
139+
if isinstance(topic_id, TopicId):
140+
return topic_id._to_proto()
141+
142+
raise TypeError("Invalid topic_id format. Must be a string or TopicId.")
143+
144+
def _parse_timestamp(self, dt: datetime) -> timestamp_pb2.Timestamp:
145+
"""Converts a datetime object to a protobuf Timestamp."""
146+
seconds = int(dt.timestamp())
147+
nanos = int((dt.timestamp() - seconds) * 1e9)
148+
return timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos)
149+
102150
def subscribe(
103151
self,
104152
client: Client,
@@ -111,49 +159,61 @@ def subscribe(
111159
if not client.mirror_stub:
112160
raise ValueError("Client has no mirror_stub. Did you configure a mirror node address?")
113161

114-
request = mirror_proto.ConsensusTopicQuery(topicID=self._topic_id)
115-
if self._start_time:
116-
request.consensusStartTime.CopyFrom(self._start_time)
117-
if self._end_time:
118-
request.consensusEndTime.CopyFrom(self._end_time)
119-
if self._limit is not None:
120-
request.limit = self._limit
121-
122162
subscription_handle = SubscriptionHandle()
123-
124-
pending_chunks: dict[str, list[mirror_proto.ConsensusTopicResponse]] = {}
163+
state = SubscriptionState()
125164

126165
def run_stream():
127-
attempt = 0
128-
while attempt < self._max_attempts and not subscription_handle.is_cancelled():
166+
while state.attempt < self._max_attempts and not subscription_handle.is_cancelled():
167+
request = mirror_proto.ConsensusTopicQuery(topicID=self._topic_id)
168+
169+
if self._end_time is not None:
170+
request.consensusEndTime.CopyFrom(self._end_time)
171+
172+
if state.last_message is not None:
173+
last_message_time = state.last_message.consensusTimestamp
174+
request.consensusStartTime.seconds = last_message_time.seconds
175+
request.consensusStartTime.nanos = last_message_time.nanos + 1
176+
177+
if self._limit > 0:
178+
request.limit = max(0, self._limit - state.count)
179+
else:
180+
if self._start_time is not None:
181+
request.consensusStartTime.CopyFrom(self._start_time)
182+
request.limit = self._limit
183+
129184
try:
130185
message_stream = client.mirror_stub.subscribeTopic(request)
186+
subscription_handle._set_call(message_stream)
131187

132188
for response in message_stream:
133189
if subscription_handle.is_cancelled():
134190
return
135191

192+
state.count += 1
193+
state.last_message = response
194+
136195
if (
137196
not self._chunking_enabled
138197
or not response.HasField("chunkInfo")
139198
or response.chunkInfo.total <= 1
140199
):
141-
msg_obj = TopicMessage.of_single(response)
142-
on_message(msg_obj)
200+
message = TopicMessage.of_single(response)
201+
on_message(message)
143202
continue
144203

145204
initial_tx_id = TransactionId._from_proto(response.chunkInfo.initialTransactionID)
146205

147-
if initial_tx_id not in pending_chunks:
148-
pending_chunks[initial_tx_id] = []
206+
if initial_tx_id not in state.pending_messages:
207+
state.pending_messages[initial_tx_id] = []
149208

150-
pending_chunks[initial_tx_id].append(response)
209+
chunks = state.pending_messages[initial_tx_id]
210+
chunks.append(response)
151211

152-
if len(pending_chunks[initial_tx_id]) == response.chunkInfo.total:
153-
chunk_list = pending_chunks.pop(initial_tx_id)
212+
if len(chunks) == response.chunkInfo.total:
213+
del state.pending_messages[initial_tx_id]
154214

155-
msg_obj = TopicMessage.of_many(chunk_list)
156-
on_message(msg_obj)
215+
message = TopicMessage.of_many(chunks)
216+
on_message(message)
157217

158218
if self._completion_handler:
159219
self._completion_handler()
@@ -163,13 +223,17 @@ def run_stream():
163223
if subscription_handle.is_cancelled():
164224
return
165225

166-
attempt += 1
167-
if attempt >= self._max_attempts:
226+
if state.attempt >= self._max_attempts or not self._should_retry(e):
227+
if self._error_handler:
228+
self._error_handler(e)
168229
if on_error:
169230
on_error(e)
170231
return
171232

172-
delay = min(0.5 * (2 ** (attempt - 1)), self._max_backoff)
233+
delay = min(0.5 * (2 ** (state.attempt)), self._max_backoff)
234+
logger.warning(f"Error subscribing to topic attempt {state.attempt}. Retrying in {int(delay)}s...")
235+
236+
state.attempt += 1
173237
time.sleep(delay)
174238

175239
thread = threading.Thread(target=run_stream, daemon=True)

src/hiero_sdk_python/utils/subscription_handle.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import threading
4+
from typing import Any
45

56

67
class SubscriptionHandle:
@@ -12,11 +13,25 @@ class SubscriptionHandle:
1213

1314
def __init__(self):
1415
self._cancelled = threading.Event()
15-
self._thread = None
16+
self._thread: threading.Thread | None = None
17+
self._call: Any | None = None
18+
self._lock = threading.Lock()
19+
20+
def _set_call(self, call: Any):
21+
"""Sets the active gRPC call so it can be cancelled."""
22+
with self._lock:
23+
self._call = call
24+
25+
if self._cancelled.is_set():
26+
self._call.cancel()
1627

1728
def cancel(self):
1829
"""Signals to cancel the subscription."""
19-
self._cancelled.set()
30+
with self._lock:
31+
self._cancelled.set()
32+
33+
if self._call:
34+
self._call.cancel()
2035

2136
def is_cancelled(self) -> bool:
2237
"""Returns True if this subscription is already cancelled."""

0 commit comments

Comments
 (0)