Skip to content

Commit e4b1db0

Browse files
committed
Adjusted AsyncPersistentSubscription to support task cancellation, and adjusted examples.
1 parent f24cd11 commit e4b1db0

6 files changed

Lines changed: 338 additions & 67 deletions

File tree

README.md

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2514,7 +2514,10 @@ giving `RecordedEvent` objects. It also has `ack()`, `nack()` and `stop()`
25142514
methods.
25152515

25162516
```python
2517-
subscription = client.read_subscription_to_all(group_name=group_name1)
2517+
with client.read_subscription_to_all(
2518+
group_name=group_name1
2519+
) as subscription:
2520+
...
25182521
```
25192522

25202523
The `ack()` method should be used by a consumer to "acknowledge" to the server that
@@ -2536,15 +2539,18 @@ examples below.
25362539
```python
25372540
received_events = []
25382541

2539-
for event in subscription:
2540-
received_events.append(event)
2542+
with client.read_subscription_to_all(
2543+
group_name=group_name1
2544+
) as subscription:
2545+
for event in subscription:
2546+
received_events.append(event)
25412547

2542-
# Acknowledge the received event.
2543-
subscription.ack(event)
2548+
# Acknowledge the received event.
2549+
subscription.ack(event)
25442550

2545-
# Stop when 'event9' has been received.
2546-
if event == event9:
2547-
subscription.stop()
2551+
# Stop when 'event9' has been received.
2552+
if event == event9:
2553+
subscription.stop()
25482554
```
25492555

25502556
The `nack()` should be used by a consumer to "negatively acknowledge" to the server that
@@ -2802,28 +2808,25 @@ This method returns a `PersistentSubscription` object, which is an iterator
28022808
giving `RecordedEvent` objects, that also has `ack()`, `nack()` and `stop()`
28032809
methods.
28042810

2805-
```python
2806-
subscription = client.read_subscription_to_stream(
2807-
group_name=group_name2,
2808-
stream_name=stream_name2,
2809-
)
2810-
```
2811-
28122811
The example below iterates over the subscription object, and calls `ack()`.
28132812
The subscription's `stop()` method is called when we have received `event6`,
28142813
stopping the iteration, so that we can continue with the examples below.
28152814

28162815
```python
28172816
events = []
2818-
for event in subscription:
2819-
events.append(event)
2817+
with client.read_subscription_to_stream(
2818+
group_name=group_name2,
2819+
stream_name=stream_name2,
2820+
) as subscription:
2821+
for event in subscription:
2822+
events.append(event)
28202823

2821-
# Acknowledge the received event.
2822-
subscription.ack(event)
2824+
# Acknowledge the received event.
2825+
subscription.ack(event)
28232826

2824-
# Stop when 'event6' has been received.
2825-
if event == event6:
2826-
subscription.stop()
2827+
# Stop when 'event6' has been received.
2828+
if event == event6:
2829+
break
28272830
```
28282831

28292832
We can check we received all the events that were appended to `stream_name2`

kurrentdbclient/persistent.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -501,15 +501,51 @@ def __init__(
501501
AbstractAsyncPersistentSubscription.__init__(self)
502502
self._read_reqs = read_reqs
503503
self._stream_stream_call = stream_stream_call
504-
self._stream_stream_call_iter = stream_stream_call.__aiter__()
505504
self._expected_group_name = expected_group_name
506505
self._stream_name = stream_name
507506
self._is_stopping = False
507+
self._read_resp_iter = stream_stream_call.__aiter__()
508+
self._read_resp_queue = asyncio.Queue[persistent_pb2.ReadResp | None](
509+
maxsize=10
510+
)
511+
self._read_resp_stream_worker_co = self._read_resp_stream_worker()
512+
self._read_resp_stream_worker_task = asyncio.create_task(
513+
self._read_resp_stream_worker_co
514+
)
515+
516+
async def _read_resp_stream_worker(self) -> None:
517+
"""Coroutine for isolated task that only handles the gRPC stream."""
518+
try:
519+
while True:
520+
read_resp = await self._read_resp_iter.__anext__()
521+
await self._read_resp_queue.put(read_resp)
522+
if self._has_iter_error_for_testing():
523+
raise AioRpcError(
524+
grpc.StatusCode.INTERNAL,
525+
grpc.aio.Metadata(),
526+
grpc.aio.Metadata(),
527+
"",
528+
"",
529+
)
530+
except BaseException:
531+
# Drain the queue (to avoid blocking on put).
532+
try:
533+
while True:
534+
self._read_resp_queue.get_nowait()
535+
except asyncio.queues.QueueEmpty:
536+
pass
537+
# Unblock waiting on get().
538+
await self._read_resp_queue.put(None)
539+
# Reraise the error (appears at 'await task').
540+
raise
508541

509542
async def init(self) -> None:
510543
try:
511-
first_read_resp = await self._get_next_read_resp()
512-
if first_read_resp.WhichOneof("content") == "subscription_confirmation":
544+
first_read_resp = await self._read_resp_queue.get()
545+
if (
546+
first_read_resp is not None
547+
and first_read_resp.WhichOneof("content") == "subscription_confirmation"
548+
):
513549
expected_stream_name = (
514550
self._stream_name if self._stream_name is not None else "$all"
515551
)
@@ -522,14 +558,20 @@ async def init(self) -> None:
522558
if (
523559
confirmed_group_name != self._expected_group_name
524560
or confirmed_stream_name != expected_stream_name
525-
): # pragma: no cover
526-
raise SubscriptionConfirmationError
561+
):
562+
msg = (
563+
f"Expected group name: {self._expected_group_name}, "
564+
f"confirmed group name: {confirmed_group_name}, "
565+
f"expected stream name: {expected_stream_name}, "
566+
f"confirmed stream name: {expected_stream_name}"
567+
)
568+
raise SubscriptionConfirmationError(msg)
527569
self._subscription_id = subscription_id
528570
self._read_reqs.subscription_id = subscription_id.encode()
529571

530-
else: # pragma: no cover
572+
else:
531573
msg = f"Expected subscription confirmation, got: {first_read_resp}"
532-
raise KurrentDBClientError(msg)
574+
raise SubscriptionConfirmationError(msg)
533575
except BaseException:
534576
await self.stop(wait_until_stopped=False)
535577
raise
@@ -539,11 +581,13 @@ def subscription_id(self) -> str:
539581
return self._subscription_id
540582

541583
async def __anext__(self) -> RecordedEvent:
542-
if self._is_context_manager_active and self._is_stopping:
543-
raise StopAsyncIteration
544584
try:
545585
while True:
546-
read_resp = await self._get_next_read_resp()
586+
if self._is_context_manager_active and self._is_stopping:
587+
raise StopAsyncIteration
588+
read_resp = await self._read_resp_queue.get()
589+
if read_resp is None:
590+
raise StopAsyncIteration
547591
content_oneof = read_resp.WhichOneof("content")
548592
if content_oneof == "event":
549593
recorded_event = construct_recorded_event(read_resp.event)
@@ -560,37 +604,23 @@ async def __anext__(self) -> RecordedEvent:
560604
await self.stop(wait_until_stopped=False)
561605
raise
562606

563-
async def _get_next_read_resp(self) -> persistent_pb2.ReadResp:
564-
try:
565-
response = await self._stream_stream_call_iter.__anext__()
566-
if self._has_iter_error_for_testing():
567-
raise AioRpcError(
568-
grpc.StatusCode.INTERNAL,
569-
grpc.aio.Metadata(),
570-
grpc.aio.Metadata(),
571-
"",
572-
"",
573-
)
574-
575-
except asyncio.CancelledError as e:
576-
await self.stop(wait_until_stopped=False)
577-
if self._read_reqs.errored:
578-
raise ExceptionIteratingRequestsError from self._read_reqs.errored
579-
raise StopAsyncIteration from e
580-
except grpc.aio.AioRpcError as e:
581-
await self.stop(wait_until_stopped=False)
582-
raise handle_rpc_error(e) from None
583-
else:
584-
return response
585-
586607
async def stop(self, *, wait_until_stopped: bool = True) -> None:
587608
if self._is_context_manager_active:
588609
self._is_stopping = True
589610
elif not await self._set_is_stopped():
590611
await self._read_reqs.stop(wait_until_stopped=wait_until_stopped)
612+
self._read_resp_stream_worker_task.cancel()
591613
self._stream_stream_call.cancel()
592614
await asyncio.sleep(0.05)
593615
self._grpc_streamers.remove(self)
616+
try:
617+
await self._read_resp_stream_worker_task
618+
except asyncio.CancelledError:
619+
pass
620+
except grpc.RpcError as e:
621+
raise handle_rpc_error(e) from e
622+
if self._read_reqs.errored:
623+
raise ExceptionIteratingRequestsError from self._read_reqs.errored
594624

595625
async def ack(self, item: UUID | RecordedEvent) -> None:
596626
await self._read_reqs.ack(event_id=self._get_event_id(item))

kurrentdbclient/streams.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,12 @@ def __init__(
174174
self._unary_stream_call = unary_stream_call
175175
self._read_resp_iter = unary_stream_call.__aiter__()
176176
self._read_resp_queue = asyncio.Queue[streams_pb2.ReadResp | None](maxsize=10)
177-
self._stream_worker_co = self._stream_worker()
178-
self._stream_worker_task = asyncio.create_task(self._stream_worker_co)
177+
self._read_resp_stream_worker_co = self._read_resp_stream_worker()
178+
self._read_resp_stream_worker_task = asyncio.create_task(
179+
self._read_resp_stream_worker_co
180+
)
179181

180-
async def _stream_worker(self) -> None:
182+
async def _read_resp_stream_worker(self) -> None:
181183
"""Coroutine for isolated task that only handles the gRPC stream."""
182184
try:
183185
while True:
@@ -192,21 +194,25 @@ async def _stream_worker(self) -> None:
192194
"",
193195
)
194196
except StopAsyncIteration:
197+
# End of the stream, signal to end the iteration.
195198
await self._read_resp_queue.put(None)
196199
except BaseException:
200+
# Drain the queue (to avoid blocking on put).
197201
try:
198202
while True:
199203
self._read_resp_queue.get_nowait()
200204
except asyncio.queues.QueueEmpty:
201205
pass
206+
# Unblock waiting on get().
202207
await self._read_resp_queue.put(None)
208+
# Reraise the error (appears at 'await task').
203209
raise
204210

205211
async def __anext__(self) -> RecordedEvent:
206212
try:
207213
while True:
208-
# if self._is_context_manager_active and self._is_stopping:
209-
# raise StopAsyncIteration
214+
if self._is_context_manager_active and self._is_stopping:
215+
raise StopAsyncIteration
210216
read_resp = await self._read_resp_queue.get()
211217
if read_resp is None:
212218
raise StopAsyncIteration
@@ -223,12 +229,11 @@ async def stop(self, *, wait_until_stopped: bool = True) -> None:
223229
if self._is_context_manager_active:
224230
self._is_stopping = True
225231
elif not await self._set_is_stopped():
226-
self._stream_worker_task.cancel()
232+
self._read_resp_stream_worker_task.cancel()
227233
self._unary_stream_call.cancel()
228234
self._grpc_streamers.remove(self)
229-
230235
try:
231-
await self._stream_worker_task
236+
await self._read_resp_stream_worker_task
232237
except asyncio.CancelledError:
233238
pass
234239
except grpc.RpcError as e:

tests/test_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from kurrentdbclient import (
2222
DEFAULT_EXCLUDE_FILTER,
2323
KDB_SYSTEM_EVENTS_REGEX,
24+
CatchupSubscription,
2425
RecordedEvent,
2526
StreamState,
2627
)
@@ -3057,6 +3058,19 @@ def test_subscribe_to_all_can_be_stopped(self) -> None:
30573058
# Iterating should stop.
30583059
list(subscription)
30593060

3061+
# Exiting the context manager should stop the subscription.
3062+
subscription = self.client.subscribe_to_all()
3063+
with subscription:
3064+
pass
3065+
self.assertTrue(cast(CatchupSubscription, subscription)._is_stopped)
3066+
3067+
# Calling stop inside the context manager should terminate the iteration.
3068+
subscription = self.client.subscribe_to_all()
3069+
with subscription:
3070+
subscription.stop()
3071+
list(subscription)
3072+
self.assertTrue(cast(CatchupSubscription, subscription)._is_stopped)
3073+
30603074
def _test_subscribe_to_all_raises_consumer_too_slow(self) -> None:
30613075
# Todo: The server behaviour is too unreliable to run this test.
30623076
self.construct_client()
@@ -3720,7 +3734,7 @@ def test_subscription_to_all_read_with_nack_stop(self) -> None:
37203734
assert events[-2].data == event2.data
37213735
assert events[-1].data == event3.data
37223736

3723-
def test_persistent_subscription_ack_after_stop(self) -> None:
3737+
def test_subscription_to_all_ack_after_stop(self) -> None:
37243738
self.construct_client()
37253739

37263740
group_name = str(uuid4())

0 commit comments

Comments
 (0)