Skip to content

Commit 44abe31

Browse files
committed
fix python aio put stream transport
1 parent 9193301 commit 44abe31

3 files changed

Lines changed: 208 additions & 6 deletions

File tree

sdks/python/hatchet_sdk/clients/events.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import timezone
66
from typing import cast
77

8+
import grpc.aio
89
from google.protobuf import timestamp_pb2
910
from pydantic import BaseModel, ConfigDict
1011

@@ -26,6 +27,7 @@
2627
PushEventRequest,
2728
PutLogRequest,
2829
PutStreamEventRequest,
30+
PutStreamEventResponse,
2931
)
3032
from hatchet_sdk.contracts.events_pb2 import Event as EventProto
3133
from hatchet_sdk.contracts.events_pb2 import Events as EventsProto
@@ -117,6 +119,8 @@ def __init__(self, config: ClientConfig):
117119

118120
conn = new_conn(config, False)
119121
self.events_service_client = EventsServiceStub(conn)
122+
self.aio_events_service_client: EventsServiceStub | None = None
123+
self.aio_events_service_channel: grpc.aio.Channel | None = None
120124

121125
self.token = config.token
122126
self.namespace = config.namespace
@@ -127,6 +131,15 @@ def _wra(self, client: ApiClient) -> WorkflowRunsApi:
127131
def _ea(self, client: ApiClient) -> EventApi:
128132
return EventApi(client)
129133

134+
def _get_or_create_aio_events_service_client(self) -> EventsServiceStub:
135+
if self.aio_events_service_client is None:
136+
self.aio_events_service_channel = new_conn(self.client_config, True)
137+
self.aio_events_service_client = EventsServiceStub(
138+
self.aio_events_service_channel
139+
)
140+
141+
return self.aio_events_service_client
142+
130143
async def aio_push(
131144
self,
132145
event_key: str,
@@ -289,29 +302,57 @@ def log(
289302

290303
put_log(request, metadata=create_authorization_header(self.token))
291304

292-
def stream(self, data: str | bytes, step_run_id: str, index: int) -> None:
293-
put_stream_event = tenacity_retry(
294-
self.events_service_client.PutStreamEvent, self.client_config.tenacity
295-
)
305+
def _create_put_stream_event_request(
306+
self, data: str | bytes, step_run_id: str, index: int
307+
) -> PutStreamEventRequest:
296308
if isinstance(data, str):
297309
data_bytes = data.encode("utf-8")
298310
elif isinstance(data, bytes):
299311
data_bytes = data
300312
else:
301313
raise ValueError("Invalid data type. Expected str, bytes, or file.")
302314

303-
request = PutStreamEventRequest(
315+
return PutStreamEventRequest(
304316
task_run_external_id=step_run_id,
305317
created_at=proto_timestamp_now(),
306318
message=data_bytes,
307319
event_index=index,
308320
)
309321

322+
def stream(self, data: str | bytes, step_run_id: str, index: int) -> None:
323+
put_stream_event = tenacity_retry(
324+
self.events_service_client.PutStreamEvent, self.client_config.tenacity
325+
)
326+
request = self._create_put_stream_event_request(data, step_run_id, index)
327+
310328
try:
311329
put_stream_event(request, metadata=create_authorization_header(self.token))
312330
except Exception:
313331
raise
314332

333+
async def aio_stream(self, data: str | bytes, step_run_id: str, index: int) -> None:
334+
aio_events_service_client = self._get_or_create_aio_events_service_client()
335+
request = self._create_put_stream_event_request(data, step_run_id, index)
336+
337+
async def put_stream_event(
338+
request: PutStreamEventRequest,
339+
metadata: tuple[tuple[str, str]],
340+
) -> PutStreamEventResponse:
341+
return cast(
342+
PutStreamEventResponse,
343+
await aio_events_service_client.PutStreamEvent( # type: ignore[misc]
344+
request, metadata=metadata
345+
),
346+
)
347+
348+
retrying_put_stream_event = tenacity_retry(
349+
put_stream_event, self.client_config.tenacity
350+
)
351+
352+
await retrying_put_stream_event(
353+
request, metadata=create_authorization_header(self.token)
354+
)
355+
315356
async def aio_list(
316357
self,
317358
offset: int | None = None,

sdks/python/hatchet_sdk/context/context.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,16 @@ async def aio_put_stream(self, data: str | bytes) -> None:
544544
:param data: The data to send to the Hatchet API. Can be a string or bytes.
545545
:return: None
546546
"""
547-
await asyncio.to_thread(self.put_stream, data)
547+
try:
548+
ix = self._increment_stream_index()
549+
550+
await self._event_client.aio_stream(
551+
data=data,
552+
step_run_id=self._step_run_id,
553+
index=ix,
554+
)
555+
except Exception:
556+
logger.exception("error putting stream event")
548557

549558
def refresh_timeout(self, increment_by: Duration) -> None:
550559
"""
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from collections.abc import Awaitable
5+
from typing import cast
6+
7+
import grpc
8+
import grpc.aio
9+
import pytest
10+
import tenacity
11+
12+
from hatchet_sdk.clients.events import EventClient
13+
from hatchet_sdk.config import ClientConfig, TenacityConfig
14+
from hatchet_sdk.context.context import Context
15+
from hatchet_sdk.contracts.events_pb2 import (
16+
PutStreamEventRequest,
17+
PutStreamEventResponse,
18+
)
19+
from hatchet_sdk.contracts.events_pb2_grpc import EventsServiceStub
20+
21+
22+
def _make_grpc_error(code: grpc.StatusCode, details: str = "") -> grpc.aio.AioRpcError:
23+
empty: grpc.aio.Metadata = grpc.aio.Metadata()
24+
return grpc.aio.AioRpcError(code, empty, empty, details)
25+
26+
27+
class _GeneratedAioUnaryCall:
28+
"""Matches grpc.aio generated unary calls: sync call, awaitable result."""
29+
30+
def __init__(self, failures_before_success: int) -> None:
31+
self.failures_before_success = failures_before_success
32+
self.calls = 0
33+
self.requests: list[PutStreamEventRequest] = []
34+
self.metadata: list[tuple[tuple[str, str]]] = []
35+
36+
def __call__(
37+
self,
38+
request: PutStreamEventRequest,
39+
*,
40+
metadata: tuple[tuple[str, str]],
41+
) -> Awaitable[PutStreamEventResponse]:
42+
self.calls += 1
43+
self.requests.append(request)
44+
self.metadata.append(metadata)
45+
46+
async def response() -> PutStreamEventResponse:
47+
if self.calls <= self.failures_before_success:
48+
raise _make_grpc_error(grpc.StatusCode.UNAVAILABLE, "transient")
49+
50+
return PutStreamEventResponse()
51+
52+
return response()
53+
54+
55+
class _FakeAioEventsServiceStub:
56+
def __init__(self, put_stream_event: _GeneratedAioUnaryCall) -> None:
57+
self.PutStreamEvent = put_stream_event
58+
59+
60+
def _event_client(aio_stub: _FakeAioEventsServiceStub) -> EventClient:
61+
client = EventClient.__new__(EventClient)
62+
client.client_config = ClientConfig.model_construct(
63+
tenant_id="tenant",
64+
token="token",
65+
namespace="",
66+
server_url="http://localhost",
67+
host_port="localhost:7070",
68+
tenacity=TenacityConfig(max_attempts=3, wait=tenacity.wait_none),
69+
)
70+
client.token = "token"
71+
client.namespace = ""
72+
client.aio_events_service_client = cast(EventsServiceStub, aio_stub)
73+
client.aio_events_service_channel = None
74+
75+
return client
76+
77+
78+
@pytest.mark.parametrize(
79+
("data", "expected_message"),
80+
[
81+
("hello", b"hello"),
82+
(b"hello", b"hello"),
83+
],
84+
)
85+
async def test_aio_stream_retries_generated_aio_callable(
86+
data: str | bytes, expected_message: bytes
87+
) -> None:
88+
put_stream_event = _GeneratedAioUnaryCall(failures_before_success=2)
89+
client = _event_client(_FakeAioEventsServiceStub(put_stream_event))
90+
91+
await client.aio_stream(data, step_run_id="step-run-id", index=7)
92+
93+
assert put_stream_event.calls == 3
94+
assert [request.task_run_external_id for request in put_stream_event.requests] == [
95+
"step-run-id",
96+
"step-run-id",
97+
"step-run-id",
98+
]
99+
assert [request.message for request in put_stream_event.requests] == [
100+
expected_message,
101+
expected_message,
102+
expected_message,
103+
]
104+
assert [request.event_index for request in put_stream_event.requests] == [7, 7, 7]
105+
assert put_stream_event.metadata == [
106+
(("authorization", "bearer token"),),
107+
(("authorization", "bearer token"),),
108+
(("authorization", "bearer token"),),
109+
]
110+
111+
112+
class _RecordingEventClient:
113+
def __init__(self) -> None:
114+
self.calls: list[tuple[str | bytes, str, int]] = []
115+
self.both_calls_started = asyncio.Event()
116+
self.release_sends = asyncio.Event()
117+
118+
async def aio_stream(self, data: str | bytes, step_run_id: str, index: int) -> None:
119+
self.calls.append((data, step_run_id, index))
120+
if len(self.calls) == 2:
121+
self.both_calls_started.set()
122+
await self.release_sends.wait()
123+
124+
125+
def _context(event_client: _RecordingEventClient) -> Context:
126+
ctx = Context.__new__(Context)
127+
ctx._stream_index = 0
128+
ctx._step_run_id = "step-run-id"
129+
ctx._event_client = cast(EventClient, event_client)
130+
131+
return ctx
132+
133+
134+
async def test_aio_put_stream_assigns_index_before_async_send() -> None:
135+
event_client = _RecordingEventClient()
136+
ctx = _context(event_client)
137+
138+
tasks = [
139+
asyncio.create_task(ctx.aio_put_stream("first")),
140+
asyncio.create_task(ctx.aio_put_stream(b"second")),
141+
]
142+
143+
try:
144+
await asyncio.wait_for(event_client.both_calls_started.wait(), timeout=1)
145+
146+
assert event_client.calls == [
147+
("first", "step-run-id", 0),
148+
(b"second", "step-run-id", 1),
149+
]
150+
finally:
151+
event_client.release_sends.set()
152+
await asyncio.gather(*tasks)

0 commit comments

Comments
 (0)