Skip to content

Commit 8d94629

Browse files
Add event stream design
1 parent 68252bd commit 8d94629

File tree

1 file changed

+275
-0
lines changed

1 file changed

+275
-0
lines changed

designs/event-streams.md

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
# Event Streams
2+
3+
Event streams represent a behavioral difference in Smithy operations. Most
4+
operations work philosophically like functions in python - you provide some
5+
parameters once, and get results once. Event streams, on the other hand,
6+
represent a continual exchange of data which may be flow in one direction
7+
or in both directions (a.k.a. a "bidirectional" or "duplex" stream).
8+
9+
To facilitate these different usage scenarios, the return type event stream
10+
operations are altered to provide customers with persistent stream objects
11+
that they can write or read to.
12+
13+
## Event Publishers
14+
15+
An `AsyncEventPublisher` is used to send events to a service.
16+
17+
```python
18+
class AsyncEventPublisher[E: SerializableShape](Protocol):
19+
async def send(self, event: E) -> None:
20+
...
21+
22+
async def close(self) -> None:
23+
pass
24+
25+
async def __aenter__(self) -> Self:
26+
return self
27+
28+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any):
29+
await self.close()
30+
```
31+
32+
Publishers expose a `send` method that takes an event class which implements
33+
`SerializableShape`. It then passes that shape to an internal `ShapeSerializer`
34+
and sends it over the connection. (Note that these `ShapeSerializer`s and
35+
connection types are internal, and so are not part of the interface shown
36+
above.)
37+
38+
The `ShapeSerializer`s work in exactly the same way as they do for other use
39+
cases. They are ultimately driven by each `SerializableShape`'s `serialize`
40+
method.
41+
42+
Publishers also expose a few Python standard methods. `close` can be used to
43+
clean up any long-running resources, such as an HTTP connection or open file
44+
handle. The async context manager magic methods are also supported, and by
45+
default they just serve to autoatically call `close` on exit. It is important
46+
however that implementations of `AsyncEventPublisher` MUST NOT require
47+
`__aenter__` or any other method to be called prior to `send`. These publishers
48+
are intended to be immediately useful and so any setup SHOULD take place while
49+
constructing them in the `ClientProtocol`.
50+
51+
```python
52+
async with publisher:
53+
publisher.send(FooEvent(foo="bar"))
54+
```
55+
56+
## Event Receivers
57+
58+
An `AsyncEventReceiver` is used to receive events from a service.
59+
60+
```python
61+
class AsyncEventReceiver[E: DeserializableShape](Protocol):
62+
63+
async def receive(self) -> E | None:
64+
...
65+
66+
async def close(self) -> None:
67+
pass
68+
69+
async def __anext__(self) -> E:
70+
result = await self.receive()
71+
if result is None:
72+
await self.close()
73+
raise StopAsyncIteration
74+
return result
75+
76+
def __aiter__(self) -> Self:
77+
return self
78+
79+
async def __enter__(self) -> Self:
80+
return self
81+
82+
async def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any):
83+
await self.close()
84+
```
85+
86+
Similar to publishers, these expose a single method that MUST be implemented.
87+
The `receive` method receives a single event from among the different declared
88+
event types. These events are read from the connection and then deserialized
89+
with `ShapeDeserializer`s.
90+
91+
The `ShapeDeserializer`s work in mostly the same way as they do for other use
92+
cases. They are ultimately driven by each `DeserializableShape`'s `deserialize`
93+
method. Since the shape on the wire might be one of several types, a
94+
`TypeRegistry` SHOULD be used to access the correct event shape. Protocols MUST
95+
have some sort of discriminator on the wire that can be used to match the wire
96+
event to the ID of the shape it represents.
97+
98+
Receivers also expose a few standard Python methods. `close` can be used to
99+
clean up any long-running resources, such as an HTTP connection or open file
100+
handle. The async context manager magic methods are also supported, and by
101+
default they just serve to autoatically call `close` on exit. It is important
102+
however that implementations of `AsyncEventReceiver` MUST NOT require
103+
`__aenter__` or any other method to be called prior to `receive`. These
104+
receivers are intended to be immediately useful and so any setup SHOULD take
105+
place while constructing them.
106+
107+
`AsyncEventReceiver` additionally implements the async iterable methods, which
108+
is the standard way of interacting with async streams in Python. These methods
109+
are fully implemented by the `AsyncEventReceiver` class, so any implementations
110+
that inherit from it do not need to do anything. `close` is automatically called
111+
when no more events are available.
112+
113+
```python
114+
def handle_event(event: ExampleEventStream):
115+
# Events are a union, so you must check which kind was received
116+
match event:
117+
case FooEvent:
118+
print(event.foo)
119+
case _:
120+
print(f"Unkown event: {event}")
121+
122+
123+
# Usage via directly calling `receive`
124+
async with receiver_a:
125+
if (event := await receiver_a.receive()) is not None:
126+
handle_event(event)
127+
128+
129+
# Usage via iterator
130+
async for event in reciever:
131+
handle_event(event)
132+
```
133+
134+
## Operation Return Types
135+
136+
An event stream operation may stream events to the service, from the service, or
137+
both. Each of these cases deserves to be handled separately, and so each has a
138+
different return type that encapsulates a publisher and/or receiver. These cases
139+
are handled by the following classes:
140+
141+
* `DuplexEventStream` is returned when the operation has both input and output
142+
streams.
143+
* `InputEventStream` is returned when the operation only has an input stream.
144+
* `OutputEventStream` is returned when the operation only has an output stream.
145+
146+
```python
147+
class DuplexEventStream[I: SerializableShape, O: DeserializableShape, R](Protocol):
148+
149+
input_stream: AsyncEventPublisher[I]
150+
151+
_output_stream: AsyncEventReceiver[O] | None = None
152+
_response: R | None = None
153+
154+
@property
155+
def output_stream(self) -> AsyncEventReceiver[O] | None:
156+
return self._output_stream
157+
158+
@output_stream.setter
159+
def output_stream(self, value: AsyncEventReceiver[O]) -> None:
160+
self._output_stream = value
161+
162+
@property
163+
def response(self) -> R | None:
164+
return self._response
165+
166+
@response.setter
167+
def response(self, value: R) -> None:
168+
self._response = value
169+
170+
async def await_output(self) -> tuple[R, AsyncEventReceiver[O]]:
171+
...
172+
173+
async def close(self) -> None:
174+
if self.output_stream is None:
175+
_, self.output_stream = await self.await_output()
176+
177+
await self.input_stream.close()
178+
await self.output_stream.close()
179+
180+
async def __aenter__(self) -> Self:
181+
return self
182+
183+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any):
184+
await self.close()
185+
186+
187+
class InputEventStream[I: SerializableShape, R](Protocol):
188+
189+
input_stream: AsyncEventPublisher[I]
190+
191+
_response: R | None = None
192+
193+
@property
194+
def response(self) -> R | None:
195+
return self._response
196+
197+
@response.setter
198+
def response(self, value: R) -> None:
199+
self._response = value
200+
201+
async def await_output(self) -> R:
202+
...
203+
204+
async def close(self) -> None:
205+
await self.input_stream.close()
206+
207+
async def __aenter__(self) -> Self:
208+
return self
209+
210+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any):
211+
await self.close()
212+
213+
214+
class OutputEventStream[O: DeserializableShape, R](Protocol):
215+
216+
output_stream: AsyncEventReceiver[O]
217+
218+
response: R
219+
220+
async def close(self) -> None:
221+
await self.output_stream.close()
222+
223+
async def __aenter__(self) -> Self:
224+
return self
225+
226+
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any):
227+
await self.close()
228+
```
229+
230+
All three classes share certain functionality. They all implement `close` and
231+
the async context manager magic methods. By default these just call close on
232+
the underlying publisher and/or receiver.
233+
234+
Both `InputEventStream` and `DuplexEventStream` have an `await_output` method
235+
that waits for the initial request to be received, returning that and the output
236+
stream. Their `response` and `output_stream` properties will not be set until
237+
then. This is important because clients MUST be able to start sending events to
238+
the service immediately, without waiting for the initial response. This is
239+
critical because there are existing services that require one or more events to
240+
be sent before they start sending responses.
241+
242+
```python
243+
with await client.duplex_operation(DuplexInput(spam="eggs")) as stream:
244+
stream.input_stream.send(FooEvent(foo="bar"))
245+
246+
initial, output_stream = await stream.await_output()
247+
248+
for event in output_stream:
249+
handle_event(event)
250+
251+
252+
with await client.input_operation() as stream:
253+
stream.input_stream.send(FooEvent(foo="bar"))
254+
```
255+
256+
The `OutputEventStream`'s initial `response` and `output_stream` will never be
257+
`None`, however. Instead, the `ClientProtocol` MUST set values for these when
258+
constructing the object. This differs from the other stream types because the
259+
lack of an input stream means that the service has nothing to wait on from the
260+
client before sending responses.
261+
262+
```python
263+
with await client.output_operation() as stream:
264+
for event in output_stream:
265+
handle_event(event)
266+
```
267+
268+
## FAQ
269+
270+
### Why aren't the event streams one class?
271+
272+
Forcing the three event stream variants into one class makes typing a mess. When
273+
they're separate, they can be paramaterized on their event union without having
274+
to lean on `Any`. It also doesn't expose properties that will always be `None`
275+
and doesn't force properties that will never be `None` to be declared optional.

0 commit comments

Comments
 (0)