kurrentdbclient 1.3.4__tar.gz → 1.3.6__tar.gz

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.
Files changed (63) hide show
  1. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/PKG-INFO +3 -3
  2. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/README.md +2 -2
  3. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/__init__.py +1 -1
  4. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/asyncio_client.py +18 -6
  5. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/client.py +172 -13
  6. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/common.py +121 -27
  7. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/connection.py +2 -2
  8. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/exceptions.py +8 -2
  9. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/spanners.py +6 -10
  10. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/persistent.py +27 -10
  11. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/streams.py +46 -14
  12. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/pyproject.toml +2 -2
  13. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/LICENSE +0 -0
  14. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/connection_spec.py +0 -0
  15. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/events.py +0 -0
  16. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/gossip.py +0 -0
  17. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/__init__.py +0 -0
  18. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/__init__.py +0 -0
  19. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/attributes.py +0 -0
  20. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/grpc.py +0 -0
  21. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/package.py +0 -0
  22. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/utils.py +0 -0
  23. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/instrumentation/opentelemetry/version.py +0 -0
  24. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/projections.py +0 -0
  25. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/google/rpc/code_pb2.py +0 -0
  26. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/google/rpc/code_pb2.pyi +0 -0
  27. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/google/rpc/code_pb2_grpc.py +0 -0
  28. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/errors_pb2.py +0 -0
  29. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/errors_pb2.pyi +0 -0
  30. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/errors_pb2_grpc.py +0 -0
  31. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/rpc_pb2.py +0 -0
  32. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/rpc_pb2.pyi +0 -0
  33. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/kurrent/rpc/rpc_pb2_grpc.py +0 -0
  34. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/cluster_pb2.py +0 -0
  35. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/cluster_pb2.pyi +0 -0
  36. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/cluster_pb2_grpc.py +0 -0
  37. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/gossip_pb2.py +0 -0
  38. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/gossip_pb2.pyi +0 -0
  39. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/gossip_pb2_grpc.py +0 -0
  40. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/persistent_pb2.py +0 -0
  41. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/persistent_pb2.pyi +0 -0
  42. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/persistent_pb2_grpc.py +0 -0
  43. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/projections_pb2.py +0 -0
  44. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/projections_pb2.pyi +0 -0
  45. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/projections_pb2_grpc.py +0 -0
  46. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/shared_pb2.py +0 -0
  47. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/shared_pb2.pyi +0 -0
  48. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/shared_pb2_grpc.py +0 -0
  49. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/status_pb2.py +0 -0
  50. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/status_pb2.pyi +0 -0
  51. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/status_pb2_grpc.py +0 -0
  52. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/streams_pb2.py +0 -0
  53. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/streams_pb2.pyi +0 -0
  54. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v1/streams_pb2_grpc.py +0 -0
  55. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/errors_pb2.py +0 -0
  56. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/errors_pb2.pyi +0 -0
  57. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/errors_pb2_grpc.py +0 -0
  58. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/streams_pb2.py +0 -0
  59. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/streams_pb2.pyi +0 -0
  60. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/protos/v2/streams/streams_pb2_grpc.py +0 -0
  61. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/py.typed +0 -0
  62. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/unpack_error_status.py +0 -0
  63. {kurrentdbclient-1.3.4 → kurrentdbclient-1.3.6}/kurrentdbclient/v2streams.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kurrentdbclient
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Python gRPC Client for KurrentDB
5
5
  License: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -57,8 +57,8 @@ with the KurrentDB team, and are officially supported by Kurrent Inc.
57
57
  Although not all aspects of the KurrentDB gRPC API are implemented, most
58
58
  features are presented in an easy-to-use interface.
59
59
 
60
- These clients have been tested to work with KurrentDB versions 25.0, 25.1, 26.0,
61
- and 26.1, and EventStoreDB versions 23.10 and 24.10, with and without SSL/TLS,
60
+ These clients have been tested to work with KurrentDB versions 25.0, 25.1, 26.0, 26.1,
61
+ and 26.2, and EventStoreDB versions 23.10 and 24.10, with and without SSL/TLS,
62
62
  in both single-server and cluster modes, across Python versions 3.10, 3.11, 3.12,
63
63
  3.13, 3.14, and 3.15.
64
64
 
@@ -22,8 +22,8 @@ with the KurrentDB team, and are officially supported by Kurrent Inc.
22
22
  Although not all aspects of the KurrentDB gRPC API are implemented, most
23
23
  features are presented in an easy-to-use interface.
24
24
 
25
- These clients have been tested to work with KurrentDB versions 25.0, 25.1, 26.0,
26
- and 26.1, and EventStoreDB versions 23.10 and 24.10, with and without SSL/TLS,
25
+ These clients have been tested to work with KurrentDB versions 25.0, 25.1, 26.0, 26.1,
26
+ and 26.2, and EventStoreDB versions 23.10 and 24.10, with and without SSL/TLS,
27
27
  in both single-server and cluster modes, across Python versions 3.10, 3.11, 3.12,
28
28
  3.13, 3.14, and 3.15.
29
29
 
@@ -28,7 +28,7 @@ from kurrentdbclient.streams import (
28
28
  ReadResponse,
29
29
  )
30
30
 
31
- __version__ = "1.3.4"
31
+ __version__ = "1.3.6"
32
32
 
33
33
  __all__ = [
34
34
  "DEFAULT_EXCLUDE_FILTER",
@@ -65,6 +65,7 @@ from kurrentdbclient.exceptions import (
65
65
 
66
66
  if TYPE_CHECKING:
67
67
  from collections.abc import Iterable, Sequence
68
+ from uuid import UUID
68
69
 
69
70
  from kurrentdbclient.persistent import ConsumerStrategy, SubscriptionInfo
70
71
  from kurrentdbclient.projections import ProjectionState, ProjectionStatistics
@@ -80,6 +81,7 @@ def autoreconnect(f: _TCallable) -> _TCallable:
80
81
  client: AsyncKurrentDBClient, *args: Any, **kwargs: Any
81
82
  ) -> Any:
82
83
  await client.connect()
84
+ connection_id = client.connection_id
83
85
  try:
84
86
  return await f(client, *args, **kwargs)
85
87
 
@@ -92,7 +94,8 @@ def autoreconnect(f: _TCallable) -> _TCallable:
92
94
  )
93
95
  ):
94
96
  await client.reconnect(
95
- grpc_target(e.host, e.port) if e.host and e.port else None
97
+ grpc_target(e.host, e.port) if e.host and e.port else None,
98
+ connection_id=connection_id,
96
99
  )
97
100
  await asyncio.sleep(0.1)
98
101
  return await f(client, *args, **kwargs)
@@ -100,13 +103,13 @@ def autoreconnect(f: _TCallable) -> _TCallable:
100
103
 
101
104
  except grpc.aio.UsageError as e:
102
105
  if "Channel is closed" in str(e):
103
- await client.reconnect()
106
+ await client.reconnect(connection_id=connection_id)
104
107
  await asyncio.sleep(0.1)
105
108
  return await f(client, *args, **kwargs)
106
109
  raise # pragma: no cover
107
110
 
108
111
  except ServiceUnavailableError:
109
- await client.reconnect()
112
+ await client.reconnect(connection_id=connection_id)
110
113
  await asyncio.sleep(0.1)
111
114
  return await f(client, *args, **kwargs)
112
115
 
@@ -119,7 +122,7 @@ def retrygrpc(f: _TCallable) -> _TCallable:
119
122
  try:
120
123
  return await f(*args, **kwargs)
121
124
  except GrpcError:
122
- await asyncio.sleep(0.1)
125
+ await asyncio.sleep(0.5)
123
126
  return await f(*args, **kwargs)
124
127
 
125
128
  return cast(_TCallable, retrygrpc_decorator)
@@ -143,6 +146,11 @@ class AsyncKurrentDBClient(BaseKurrentDBClient):
143
146
  self._is_reconnection_required = Event()
144
147
  self._connection_lock = Lock()
145
148
 
149
+ @property
150
+ def connection_id(self) -> UUID:
151
+ assert self._connection is not None
152
+ return self._connection.connection_id
153
+
146
154
  async def connect(self) -> None:
147
155
  if self._connection is None:
148
156
  async with self._connection_lock:
@@ -163,8 +171,12 @@ class AsyncKurrentDBClient(BaseKurrentDBClient):
163
171
  def connection_target(self) -> str:
164
172
  return self.connection.grpc_target
165
173
 
166
- async def reconnect(self, grpc_target: str | None = None) -> None:
167
- self._is_reconnection_required.set()
174
+ async def reconnect(
175
+ self, grpc_target: str | None = None, connection_id: UUID | None = None
176
+ ) -> None:
177
+ # Avoid reconnecting if client already has a new connection.
178
+ if connection_id is None or connection_id == self.connection_id:
179
+ self._is_reconnection_required.set()
168
180
  async with self._connection_lock:
169
181
  if self._is_reconnection_required.is_set():
170
182
  new = await self._connect(grpc_target)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import json
4
5
  import random
5
6
  import sys
@@ -62,6 +63,7 @@ from kurrentdbclient.events import (
62
63
  StreamStateCheck,
63
64
  )
64
65
  from kurrentdbclient.exceptions import (
66
+ AccessDeniedError,
65
67
  DiscoveryFailedError,
66
68
  FollowerNotFoundError,
67
69
  GrpcError,
@@ -81,6 +83,7 @@ from kurrentdbclient.gossip import (
81
83
 
82
84
  if TYPE_CHECKING:
83
85
  from collections.abc import Iterable, Sequence
86
+ from uuid import UUID
84
87
 
85
88
  from kurrentdbclient.persistent import (
86
89
  ConsumerStrategy,
@@ -116,9 +119,14 @@ def autoreconnect(f: _TCallable) -> _TCallable:
116
119
  def autoreconnect_decorator(*args: Any, **kwargs: Any) -> Any:
117
120
  client = args[0]
118
121
  assert isinstance(client, KurrentDBClient)
122
+ connection_id = client.connection_id
119
123
  try:
120
124
  return f(*args, **kwargs)
121
125
 
126
+ # TODO: Maybe refine the retry so that we do reconnect and try again when
127
+ # there is connection failure after retrying without reconnecting because
128
+ # the connection has changed. Need to think about this a bit more...
129
+
122
130
  except NodeIsNotLeaderError as e:
123
131
  if (
124
132
  client.connection_spec.options.node_preference == NODE_PREFERENCE_LEADER
@@ -128,7 +136,8 @@ def autoreconnect(f: _TCallable) -> _TCallable:
128
136
  )
129
137
  ):
130
138
  client.reconnect(
131
- grpc_target(e.host, e.port) if e.host and e.port else None
139
+ grpc_target(e.host, e.port) if e.host and e.port else None,
140
+ connection_id=connection_id,
132
141
  )
133
142
  sleep(0.1)
134
143
  return f(*args, **kwargs)
@@ -137,13 +146,13 @@ def autoreconnect(f: _TCallable) -> _TCallable:
137
146
  except ValueError as e:
138
147
  s = str(e)
139
148
  if "Channel closed!" in s or "Cannot invoke RPC on closed channel!" in s:
140
- client.reconnect()
149
+ client.reconnect(connection_id=connection_id)
141
150
  sleep(0.1)
142
151
  return f(*args, **kwargs)
143
152
  raise # pragma: no cover
144
153
 
145
154
  except ServiceUnavailableError:
146
- client.reconnect()
155
+ client.reconnect(connection_id=connection_id)
147
156
  sleep(0.1)
148
157
  return f(*args, **kwargs)
149
158
 
@@ -322,6 +331,10 @@ class KurrentDBClient(BaseKurrentDBClient):
322
331
  def connection_target(self) -> str:
323
332
  return self._connection.grpc_target
324
333
 
334
+ @property
335
+ def connection_id(self) -> UUID:
336
+ return self._connection.connection_id
337
+
325
338
  @property
326
339
  def streams(self) -> StreamsService:
327
340
  return self._connection.streams
@@ -411,14 +424,22 @@ class KurrentDBClient(BaseKurrentDBClient):
411
424
 
412
425
  return connection
413
426
 
414
- def reconnect(self, grpc_target: str | None = None) -> None:
415
- self._is_reconnection_required.set()
427
+ def reconnect(
428
+ self, grpc_target: str | None = None, *, connection_id: UUID | None = None
429
+ ) -> None:
430
+ # Avoid reconnecting if client already has a new connection.
431
+ if connection_id is None or connection_id == self.connection_id:
432
+ self._is_reconnection_required.set()
433
+ # Single reconnection.
416
434
  with self._reconnection_lock:
417
435
  if self._is_reconnection_required.is_set():
418
436
  new_conn = self._connect(grpc_target)
419
437
  old_conn, self._connection = self._connection, new_conn
420
438
  old_conn.close()
421
439
  self._is_reconnection_required.clear()
440
+ # Exercise the connection (avoids delayed errors due to lazy channel)
441
+ with contextlib.suppress(AccessDeniedError):
442
+ self._get_commit_position()
422
443
  else: # pragma: no cover
423
444
  # Todo: Test with concurrent writes to wrong node state.
424
445
  pass
@@ -678,17 +699,19 @@ class KurrentDBClient(BaseKurrentDBClient):
678
699
  """
679
700
  Returns a sequence of recorded events from the named stream.
680
701
  """
681
- with self.read_stream(
702
+ with self._read_stream(
682
703
  stream_name=stream_name,
683
704
  stream_position=stream_position,
684
705
  backwards=backwards,
685
706
  resolve_links=resolve_links,
686
707
  limit=limit,
687
708
  timeout=timeout,
688
- credentials=credentials or self._call_credentials,
709
+ credentials=credentials,
689
710
  ) as events:
690
711
  return tuple(events)
691
712
 
713
+ @retrygrpc
714
+ @autoreconnect
692
715
  def read_stream(
693
716
  self,
694
717
  stream_name: str,
@@ -699,6 +722,30 @@ class KurrentDBClient(BaseKurrentDBClient):
699
722
  limit: int = sys.maxsize,
700
723
  timeout: float | None = None,
701
724
  credentials: grpc.CallCredentials | None = None,
725
+ ) -> AbstractReadResponse:
726
+ """
727
+ Reads recorded events from the named stream.
728
+ """
729
+ return self._read_stream(
730
+ stream_name=stream_name,
731
+ stream_position=stream_position,
732
+ backwards=backwards,
733
+ resolve_links=resolve_links,
734
+ limit=limit,
735
+ timeout=timeout,
736
+ credentials=credentials,
737
+ )
738
+
739
+ def _read_stream(
740
+ self,
741
+ stream_name: str,
742
+ *,
743
+ stream_position: int | None = None,
744
+ backwards: bool = False,
745
+ resolve_links: bool = False,
746
+ limit: int = sys.maxsize,
747
+ timeout: float | None = None,
748
+ credentials: grpc.CallCredentials | None = None,
702
749
  ) -> AbstractReadResponse:
703
750
  """
704
751
  Reads recorded events from the named stream.
@@ -714,6 +761,8 @@ class KurrentDBClient(BaseKurrentDBClient):
714
761
  credentials=credentials or self._call_credentials,
715
762
  )
716
763
 
764
+ @retrygrpc
765
+ @autoreconnect
717
766
  def read_all(
718
767
  self,
719
768
  *,
@@ -727,6 +776,36 @@ class KurrentDBClient(BaseKurrentDBClient):
727
776
  limit: int = sys.maxsize,
728
777
  timeout: float | None = None,
729
778
  credentials: grpc.CallCredentials | None = None,
779
+ ) -> AbstractReadResponse:
780
+ """
781
+ Reads recorded events in "all streams" in the database.
782
+ """
783
+ return self._read_all(
784
+ commit_position=commit_position,
785
+ backwards=backwards,
786
+ resolve_links=resolve_links,
787
+ filter_exclude=filter_exclude,
788
+ filter_include=filter_include,
789
+ filter_by_stream_name=filter_by_stream_name,
790
+ filter_by_prefix=filter_by_prefix,
791
+ limit=limit,
792
+ timeout=timeout,
793
+ credentials=credentials,
794
+ )
795
+
796
+ def _read_all(
797
+ self,
798
+ *,
799
+ commit_position: int | None = None,
800
+ backwards: bool = False,
801
+ resolve_links: bool = False,
802
+ filter_exclude: Sequence[str] = DEFAULT_EXCLUDE_FILTER,
803
+ filter_include: Sequence[str] = (),
804
+ filter_by_stream_name: bool = False,
805
+ filter_by_prefix: bool = False,
806
+ limit: int = sys.maxsize,
807
+ timeout: float | None = None,
808
+ credentials: grpc.CallCredentials | None = None,
730
809
  ) -> AbstractReadResponse:
731
810
  """
732
811
  Reads recorded events in "all streams" in the database.
@@ -745,6 +824,8 @@ class KurrentDBClient(BaseKurrentDBClient):
745
824
  credentials=credentials or self._call_credentials,
746
825
  )
747
826
 
827
+ @retrygrpc
828
+ @autoreconnect
748
829
  def read_index(
749
830
  self,
750
831
  index_name: str,
@@ -758,7 +839,7 @@ class KurrentDBClient(BaseKurrentDBClient):
758
839
  if not index_name.startswith("$idx-"):
759
840
  index_name = f"$idx-{index_name}"
760
841
 
761
- return self.read_all(
842
+ return self._read_all(
762
843
  commit_position=commit_position,
763
844
  filter_include=(index_name,),
764
845
  filter_by_stream_name=True,
@@ -817,15 +898,34 @@ class KurrentDBClient(BaseKurrentDBClient):
817
898
  """
818
899
  Returns the current commit position of the database.
819
900
  """
820
- read_response = self.streams.read(
901
+ return self._get_commit_position(
902
+ filter_exclude=filter_exclude,
903
+ filter_include=filter_include,
904
+ filter_by_stream_name=filter_by_stream_name,
905
+ timeout=timeout,
906
+ credentials=credentials,
907
+ )
908
+
909
+ def _get_commit_position(
910
+ self,
911
+ *,
912
+ filter_exclude: Sequence[str] = DEFAULT_EXCLUDE_FILTER,
913
+ filter_include: Sequence[str] = (),
914
+ filter_by_stream_name: bool = False,
915
+ timeout: float | None = None,
916
+ credentials: grpc.CallCredentials | None = None,
917
+ ) -> int:
918
+ """
919
+ Returns the current commit position of the database.
920
+ """
921
+ read_response = self._read_all(
821
922
  backwards=True,
822
923
  filter_exclude=filter_exclude,
823
924
  filter_include=filter_include,
824
925
  filter_by_stream_name=filter_by_stream_name,
825
926
  limit=1,
826
927
  timeout=timeout,
827
- metadata=self._call_metadata,
828
- credentials=credentials or self._call_credentials,
928
+ credentials=credentials,
829
929
  )
830
930
  recorded_events = tuple(read_response)
831
931
  if recorded_events:
@@ -905,6 +1005,45 @@ class KurrentDBClient(BaseKurrentDBClient):
905
1005
  include_fell_behind: bool = False,
906
1006
  timeout: float | None = None,
907
1007
  credentials: grpc.CallCredentials | None = None,
1008
+ ) -> AbstractCatchupSubscription:
1009
+ """
1010
+ Starts a catch-up subscription, from which all
1011
+ recorded events in the database can be received.
1012
+ """
1013
+ return self._subscribe_to_all(
1014
+ commit_position=commit_position,
1015
+ from_end=from_end,
1016
+ resolve_links=resolve_links,
1017
+ filter_exclude=filter_exclude,
1018
+ filter_include=filter_include,
1019
+ filter_by_stream_name=filter_by_stream_name,
1020
+ filter_by_prefix=filter_by_prefix,
1021
+ include_checkpoints=include_checkpoints,
1022
+ window_size=window_size,
1023
+ checkpoint_interval_multiplier=checkpoint_interval_multiplier,
1024
+ include_caught_up=include_caught_up,
1025
+ include_fell_behind=include_fell_behind,
1026
+ timeout=timeout,
1027
+ credentials=credentials,
1028
+ )
1029
+
1030
+ def _subscribe_to_all(
1031
+ self,
1032
+ *,
1033
+ commit_position: int | None = None,
1034
+ from_end: bool = False,
1035
+ resolve_links: bool = False,
1036
+ filter_exclude: Sequence[str] = DEFAULT_EXCLUDE_FILTER,
1037
+ filter_include: Sequence[str] = (),
1038
+ filter_by_stream_name: bool = False,
1039
+ filter_by_prefix: bool = False,
1040
+ include_checkpoints: bool = False,
1041
+ window_size: int = DEFAULT_WINDOW_SIZE,
1042
+ checkpoint_interval_multiplier: int = DEFAULT_CHECKPOINT_INTERVAL_MULTIPLIER,
1043
+ include_caught_up: bool = False,
1044
+ include_fell_behind: bool = False,
1045
+ timeout: float | None = None,
1046
+ credentials: grpc.CallCredentials | None = None,
908
1047
  ) -> AbstractCatchupSubscription:
909
1048
  """
910
1049
  Starts a catch-up subscription, from which all
@@ -929,6 +1068,8 @@ class KurrentDBClient(BaseKurrentDBClient):
929
1068
  credentials=credentials or self._call_credentials,
930
1069
  )
931
1070
 
1071
+ @retrygrpc
1072
+ @autoreconnect
932
1073
  def subscribe_to_index(
933
1074
  self,
934
1075
  index_name: str,
@@ -941,7 +1082,7 @@ class KurrentDBClient(BaseKurrentDBClient):
941
1082
  if not index_name.startswith("$idx-"):
942
1083
  index_name = f"$idx-{index_name}"
943
1084
 
944
- return self.subscribe_to_all(
1085
+ return self._subscribe_to_all(
945
1086
  commit_position=commit_position,
946
1087
  filter_include=(index_name,),
947
1088
  filter_by_stream_name=True,
@@ -1421,6 +1562,24 @@ class KurrentDBClient(BaseKurrentDBClient):
1421
1562
  *,
1422
1563
  timeout: float | None = None,
1423
1564
  credentials: grpc.CallCredentials | None = None,
1565
+ ) -> SubscriptionInfo:
1566
+ """
1567
+ Gets info for a persistent subscription.
1568
+ """
1569
+ return self._get_subscription_info(
1570
+ group_name=group_name,
1571
+ stream_name=stream_name,
1572
+ timeout=timeout,
1573
+ credentials=credentials,
1574
+ )
1575
+
1576
+ def _get_subscription_info(
1577
+ self,
1578
+ group_name: str,
1579
+ stream_name: str | None = None,
1580
+ *,
1581
+ timeout: float | None = None,
1582
+ credentials: grpc.CallCredentials | None = None,
1424
1583
  ) -> SubscriptionInfo:
1425
1584
  """
1426
1585
  Gets info for a persistent subscription.
@@ -1754,7 +1913,7 @@ class KurrentDBClient(BaseKurrentDBClient):
1754
1913
  Updates a persistent subscription on one stream.
1755
1914
  """
1756
1915
 
1757
- info = self.get_subscription_info(
1916
+ info = self._get_subscription_info(
1758
1917
  group_name=group_name,
1759
1918
  stream_name=stream_name,
1760
1919
  timeout=timeout,
@@ -30,8 +30,10 @@ import kurrentdbclient.protos.v2.streams.errors_pb2 as v2streams_errors_pb2
30
30
  from kurrentdbclient.events import RecordedEvent
31
31
  from kurrentdbclient.exceptions import (
32
32
  AbortedByServerError,
33
+ AccessDeniedError,
33
34
  AlreadyExistsError,
34
35
  CancelledByClientError,
36
+ CancelledError,
35
37
  ConsistencyCheckFailure,
36
38
  ConsistencyChecksFailedError,
37
39
  ConsumerTooSlowError,
@@ -109,13 +111,23 @@ class BaseGrpcStreamer:
109
111
  TGrpcStreamer = TypeVar("TGrpcStreamer", bound=BaseGrpcStreamer)
110
112
 
111
113
 
114
+ class GrpcStreamersCollectionClosedError(Exception):
115
+ pass
116
+
117
+
112
118
  class BaseGrpcStreamers(Generic[TGrpcStreamer]):
113
119
  def __init__(self) -> None:
114
120
  self.map: WeakValueDictionary[int, TGrpcStreamer] = WeakValueDictionary()
115
121
  self.lock = threading.Lock()
122
+ self._is_closed = False
116
123
 
117
124
  def add(self, streamer: TGrpcStreamer) -> None:
125
+ # print(f"Adding streamer: {id(streamer)}")
118
126
  with self.lock:
127
+ if self._is_closed:
128
+ # This is hit by the test_replicate_github_issue_40 tests when
129
+ # run separately but for some reason not with the full suite
130
+ raise GrpcStreamersCollectionClosedError
119
131
  self.map[id(streamer)] = streamer
120
132
 
121
133
  def __iter__(self) -> Iterator[TGrpcStreamer]:
@@ -132,24 +144,42 @@ TGrpcStreamers = TypeVar("TGrpcStreamers", bound=BaseGrpcStreamers[Any])
132
144
 
133
145
  class GrpcStreamer(BaseGrpcStreamer, ABC):
134
146
  def __init__(self, grpc_streamers: GrpcStreamers) -> None:
135
- self._grpc_streamers = grpc_streamers
136
- self._grpc_streamers.add(self)
137
147
  self._is_stopped = False
148
+ self._is_cancelled = False
138
149
  self._is_stopped_lock = threading.Lock()
150
+ self._grpc_streamers = grpc_streamers
151
+ try:
152
+ self._grpc_streamers.add(self)
153
+ except GrpcStreamersCollectionClosedError: # pragma: no cover
154
+ # This is hit by the test_replicate_github_issue_40 tests when
155
+ # run separately but for some reason not with the full suite
156
+ self.stop(cancel=True)
157
+
158
+ @property
159
+ def is_stopped(self) -> bool:
160
+ return self._is_stopped
161
+
162
+ @property
163
+ def is_cancelled(self) -> bool:
164
+ return self._is_cancelled
139
165
 
140
166
  @abstractmethod
141
- def stop(self) -> None:
167
+ def stop(self, *, cancel: bool = False) -> None:
142
168
  """
143
169
  Stops the iterator(s) of streaming call.
144
170
  """
145
171
 
146
- def _set_is_stopped(self) -> bool:
172
+ def _remove(self) -> None:
173
+ self._grpc_streamers.remove(self)
174
+
175
+ def _set_is_stopped(self, *, cancel: bool = False) -> bool:
147
176
  is_stopped = True
148
177
  if self._is_stopped is False:
149
178
  with self._is_stopped_lock:
150
179
  if self._is_stopped is False:
151
180
  is_stopped = False
152
181
  self._is_stopped = True
182
+ self._is_cancelled = cancel
153
183
  else: # pragma: no cover
154
184
  pass
155
185
  return is_stopped
@@ -157,32 +187,57 @@ class GrpcStreamer(BaseGrpcStreamer, ABC):
157
187
 
158
188
  class GrpcStreamers(BaseGrpcStreamers[GrpcStreamer]):
159
189
  def close(self) -> None:
190
+ with self.lock:
191
+ self._is_closed = True
160
192
  for grpc_streamer in self:
161
- # print("closing streamer")
162
- grpc_streamer.stop()
163
- # print("closed streamer")
193
+ grpc_streamer.stop(cancel=True)
164
194
 
165
195
 
166
196
  class AsyncGrpcStreamer(BaseGrpcStreamer, ABC):
167
197
  def __init__(self, grpc_streamers: AsyncGrpcStreamers) -> None:
168
198
  self._grpc_streamers = grpc_streamers
169
- self._grpc_streamers.add(self)
170
199
  self._is_stopped = False
200
+ self._is_cancelled = False
171
201
  self._is_stopped_lock = asyncio.Lock()
172
202
 
203
+ async def add_to_steamers(self) -> None:
204
+ try:
205
+ self._grpc_streamers.add(self)
206
+ except GrpcStreamersCollectionClosedError: # pragma: no cover
207
+ # This is hit by the test_replicate_github_issue_40 tests when
208
+ # run separately but for some reason not with the full suite
209
+ await self.stop(cancel=True)
210
+
211
+ @property
212
+ def is_stopped(self) -> bool:
213
+ return self._is_stopped
214
+
215
+ @property
216
+ def is_cancelled(self) -> bool:
217
+ return self._is_cancelled
218
+
173
219
  @abstractmethod
174
- async def stop(self, *, timeout: float | None = None) -> None:
220
+ async def stop(
221
+ self,
222
+ *,
223
+ timeout: float | None = None,
224
+ cancel: bool = False,
225
+ ) -> None:
175
226
  """
176
227
  Stops the iterator(s) of streaming call.
177
228
  """
178
229
 
179
- async def _set_is_stopped(self) -> bool:
230
+ def _remove(self) -> None:
231
+ self._grpc_streamers.remove(self)
232
+
233
+ async def _set_is_stopped(self, *, cancel: bool = False) -> bool:
180
234
  is_stopped = True
181
235
  if self._is_stopped is False:
182
236
  async with self._is_stopped_lock:
183
237
  if self._is_stopped is False:
184
238
  is_stopped = False
185
239
  self._is_stopped = True
240
+ self._is_cancelled = cancel
186
241
  else: # pragma: no cover
187
242
  pass
188
243
  return is_stopped
@@ -190,9 +245,11 @@ class AsyncGrpcStreamer(BaseGrpcStreamer, ABC):
190
245
 
191
246
  class AsyncGrpcStreamers(BaseGrpcStreamers[AsyncGrpcStreamer]):
192
247
  async def close(self) -> None:
248
+ with self.lock:
249
+ self._is_closed = True
193
250
  for async_grpc_streamer in self:
194
251
  # print("closing streamer")
195
- await async_grpc_streamer.stop(timeout=1)
252
+ await async_grpc_streamer.stop(timeout=1, cancel=True)
196
253
  # print("closed streamer")
197
254
 
198
255
 
@@ -295,65 +352,93 @@ def handle_rpc_error(e: grpc.RpcError) -> KurrentDBClientError: # noqa: PLR0911
295
352
  )
296
353
  return KurrentDBClientError(details_str)
297
354
 
355
+ # Handle error according the status code of the given error.
298
356
  if e.code() == grpc.StatusCode.UNKNOWN:
299
357
  if "Exception was thrown by handler" in details_str:
358
+ # This just happens from time to time in KurrentDB (various operations)
300
359
  return ExceptionThrownByHandlerError(details_str)
360
+
361
+ # no cover >=26.2: start
301
362
  if (
302
363
  "Envelope callback expected Updated, received Conflict instead"
303
364
  in details_str
304
365
  ):
305
- # Projections.Create does this....
366
+ # Projections.Create in when projection exists with same name.
367
+ # KurrentDB >=26.2 returns error with status code ALREADY_EXISTS.
306
368
  return AlreadyExistsError(details_str)
369
+ # no cover >=26.2: stop
370
+
371
+ # no cover >=24.10: start
307
372
  if (
308
373
  "Envelope callback expected Updated, received NotFound instead"
309
374
  in details_str
310
375
  ):
311
376
  # Projections.Update and Projections.Delete does this in < v24.6
312
- return NotFoundError(details_str) # pragma: no cover
377
+ return NotFoundError(details_str)
378
+ # no cover >=24.10: stop
379
+
380
+ # no cover >=24.10: start
313
381
  if (
314
382
  "Envelope callback expected Statistics, received NotFound instead"
315
383
  in details_str
316
384
  ):
317
385
  # Projections.Statistics does this in < v24.6
318
- return NotFoundError(details_str) # pragma: no cover
386
+ return NotFoundError(details_str)
387
+ # no cover >=24.10: stop
388
+
389
+ # no cover >=24.10: start
319
390
  if (
320
391
  "Envelope callback expected ProjectionState, received NotFound instead"
321
392
  in details_str
322
393
  ):
323
394
  # Projections.State does this in < v24.6
324
- return NotFoundError(details_str) # pragma: no cover
395
+ return NotFoundError(details_str)
396
+ # no cover >=24.10: stop
397
+
398
+ # no cover >=24.10: start
325
399
  if (
326
400
  "Envelope callback expected ProjectionResult, received NotFound instead"
327
401
  in details_str
328
402
  ):
329
403
  # Projections.Result does this in < v24.6
330
404
  return NotFoundError(details_str) # pragma: no cover
405
+ # no cover >=24.10: stop
406
+
407
+ # no cover >=26.2: start
331
408
  if (
332
409
  "Envelope callback expected Updated, received OperationFailed instead"
333
410
  in details_str
334
411
  ):
335
- # Projections.Delete does this....
412
+ # Projections.Delete does this when projection not stopped.
413
+ # KurrentDB >=26.2 returns error with status code FAILED_PRECONDITION.
336
414
  return OperationFailedError(details_str)
415
+ # no cover >=26.2: stop
416
+
337
417
  # no cover <25.1: start
338
418
  if "Unexpected FilteredReadAllResult: InvalidPosition" in details_str:
339
419
  # Commit position does not exist.
340
420
  return InvalidCommitPositionError(details_str)
341
421
  # no cover <25.1: stop
342
- return UnknownError(details_str) # pragma: no cover
422
+
423
+ # Catch all other unknown errors.
424
+ return UnknownError(details_str)
343
425
 
344
426
  if e.code() == grpc.StatusCode.ABORTED:
345
427
  if "Consumer too slow" in details_str:
346
428
  return ConsumerTooSlowError(details_str)
347
429
  return AbortedByServerError(details_str)
348
- if (
349
- e.code() == grpc.StatusCode.CANCELLED
350
- and details_str == "Locally cancelled by application!"
351
- ):
352
- return CancelledByClientError(details_str)
430
+
431
+ if e.code() == grpc.StatusCode.CANCELLED:
432
+ if details_str == "Locally cancelled by application!":
433
+ return CancelledByClientError(details_str)
434
+ return CancelledError(details_str)
435
+
353
436
  if e.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
354
437
  return GrpcDeadlineExceededError(details_str)
438
+
355
439
  if e.code() == grpc.StatusCode.UNAUTHENTICATED:
356
440
  return UnauthenticatedError(details_str)
441
+
357
442
  if e.code() == grpc.StatusCode.UNAVAILABLE:
358
443
  if "SSL_ERROR" in details_str:
359
444
  # root_certificates is None and CA cert not installed
@@ -365,8 +450,10 @@ def handle_rpc_error(e: grpc.RpcError) -> KurrentDBClientError: # noqa: PLR0911
365
450
  # given root_certificates is invalid
366
451
  return SSLError(details_str)
367
452
  return ServiceUnavailableError(details_str)
453
+
368
454
  if e.code() == grpc.StatusCode.ALREADY_EXISTS:
369
455
  return AlreadyExistsError(details_str)
456
+
370
457
  if e.code() == grpc.StatusCode.NOT_FOUND:
371
458
  if details_str == "Leader info available":
372
459
  trailing_metadata = {m[0]: m[1] for m in e.trailing_metadata()} # type: ignore[index]
@@ -382,17 +469,24 @@ def handle_rpc_error(e: grpc.RpcError) -> KurrentDBClientError: # noqa: PLR0911
382
469
  ),
383
470
  )
384
471
  return NotFoundError()
472
+
385
473
  if e.code() == grpc.StatusCode.FAILED_PRECONDITION:
386
474
  if details_str is not None and details_str.startswith(
387
475
  "Maximum subscriptions reached"
388
476
  ):
389
477
  return MaximumSubscriptionsReachedError(details_str)
390
- # no cover: start
478
+ # no cover <26.2: start
479
+ # Projections.Delete does this in >=26.2 when projection not stopped.
391
480
  return FailedPreconditionError(details_str)
392
- # no cover: stop
393
- if e.code() == grpc.StatusCode.INTERNAL: # pragma: no cover
481
+ # no cover <26.2: stop
482
+
483
+ if e.code() == grpc.StatusCode.PERMISSION_DENIED:
484
+ return AccessDeniedError(details_str)
485
+
486
+ if e.code() == grpc.StatusCode.INTERNAL:
394
487
  return InternalError(details_str)
395
- return GrpcError(str(e))
488
+
489
+ return GrpcError(str(e)) # pragma: no cover
396
490
 
397
491
 
398
492
  def _convert_append_consistency_violations(
@@ -572,7 +666,7 @@ class RecordedEventIterator(
572
666
  self.stop()
573
667
 
574
668
  @abstractmethod
575
- def stop(self) -> None:
669
+ def stop(self, *, cancel: bool = False) -> None:
576
670
  pass # pragma: no cover
577
671
 
578
672
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
+ from uuid import uuid4
4
5
 
5
6
  from kurrentdbclient.common import AsyncGrpcStreamers, GrpcStreamers
6
7
  from kurrentdbclient.gossip import AsyncGossipService, GossipService
@@ -21,6 +22,7 @@ if TYPE_CHECKING:
21
22
  class BaseKurrentDBConnection:
22
23
  def __init__(self, grpc_target: str):
23
24
  self._grpc_target = grpc_target
25
+ self.connection_id = uuid4()
24
26
 
25
27
  @property
26
28
  def grpc_target(self) -> str:
@@ -75,9 +77,7 @@ class KurrentDBConnection(BaseKurrentDBConnection):
75
77
  self._grpc_streamers.close()
76
78
  # self.grpc_channel.unsubscribe(self._receive_channel_connectivity_state)
77
79
  # sleep(0.1) # Allow connectivity polling to stop.
78
- # print("closing channel")
79
80
  self._grpc_channel.close()
80
- # print("closed channel")
81
81
 
82
82
 
83
83
  class AsyncKurrentDBConnection(BaseKurrentDBConnection):
@@ -61,9 +61,15 @@ class GrpcDeadlineExceededError(GrpcError, DeadlineExceededError):
61
61
  """
62
62
 
63
63
 
64
- class CancelledByClientError(KurrentDBClientError):
64
+ class CancelledError(GrpcError):
65
65
  """
66
- Raised when gRPC operation is cancelled.
66
+ Raised when gRPC operation is somehow cancelled.
67
+ """
68
+
69
+
70
+ class CancelledByClientError(CancelledError):
71
+ """
72
+ Raised when gRPC operation is known to have been cancelled by client.
67
73
  """
68
74
 
69
75
 
@@ -279,6 +279,7 @@ def span_read_stream(
279
279
  stream_name: str,
280
280
  *args: Any,
281
281
  **kwargs: Any,
282
+ # TODO: Can't this use AbstractAsyncReadResponse rather than AsyncReadResponse?
282
283
  ) -> OverloadedSpannerResponse[AbstractReadResponse, AsyncReadResponse]:
283
284
  span_name, span_kind = _get_span_name_and_kind(spanned_func)
284
285
  with _start_span(tracer, span_name, span_kind) as span:
@@ -294,6 +295,7 @@ def span_read_stream(
294
295
  if inspect.iscoroutine(response):
295
296
 
296
297
  async def wrap_response() -> AsyncReadResponse:
298
+ # TODO: Can this be AbstractAsyncReadResponse?
297
299
  return cast(
298
300
  AsyncReadResponse,
299
301
  TracedAsyncReadResponse(
@@ -317,7 +319,8 @@ def span_read_stream(
317
319
  span_name=span_name,
318
320
  span_kind=span_kind,
319
321
  )
320
- except Exception as e:
322
+ except Exception as e: # pragma: no cover
323
+ # Hard to cover this consistently due to timing of channel readiness.
321
324
  _set_span_error(span, e)
322
325
  raise
323
326
  else:
@@ -777,15 +780,8 @@ class TracedRecordedEventIterator(
777
780
  span.end()
778
781
  self._current_span = None
779
782
 
780
- def stop(self) -> None:
781
- self.response.stop()
782
-
783
- def __enter__(self) -> Self:
784
- self.response.__enter__()
785
- return self
786
-
787
- def __exit__(self, *args: object, **kwargs: Any) -> None:
788
- return self.response.__exit__(*args, **kwargs)
783
+ def stop(self, *, cancel: bool = False) -> None:
784
+ self.response.stop(cancel=cancel)
789
785
 
790
786
  def __del__(self) -> None:
791
787
  current_span = self._current_span
@@ -597,20 +597,28 @@ class AsyncPersistentSubscription(
597
597
  return recorded_event
598
598
  else: # pragma: no cover
599
599
  pass
600
- except BaseException:
601
- await self.stop(timeout=1)
600
+ except BaseException as e:
601
+ was_cancelled = self._is_cancelled
602
+ await self.stop(timeout=1, cancel=True)
603
+ if was_cancelled and isinstance(e, StopAsyncIteration):
604
+ raise CancelledByClientError from e
602
605
  raise
603
606
 
604
- async def stop(self, *, timeout: float | None = None) -> None:
607
+ async def stop(
608
+ self,
609
+ *,
610
+ timeout: float | None = None,
611
+ cancel: bool = False,
612
+ ) -> None:
605
613
  if self._is_context_manager_active:
606
614
  self._is_stopping = True
607
- elif not await self._set_is_stopped():
615
+ elif not await self._set_is_stopped(cancel=cancel):
608
616
  try:
609
617
  await self._read_reqs.stop(timeout=timeout)
610
618
  finally:
611
619
  self._read_resp_stream_worker_task.cancel()
612
620
  self._stream_stream_call.cancel()
613
- self._grpc_streamers.remove(self)
621
+ self._remove()
614
622
  try:
615
623
  await self._read_resp_stream_worker_task
616
624
  except asyncio.CancelledError:
@@ -660,6 +668,7 @@ class PersistentSubscription(
660
668
  self._read_reqs = read_reqs
661
669
  self._read_resps = read_resps
662
670
  self._is_stopping = False
671
+ self._is_cancelling = False
663
672
 
664
673
  try:
665
674
  first_read_resp = self._get_next_read_resp()
@@ -698,8 +707,14 @@ class PersistentSubscription(
698
707
  while True:
699
708
  try:
700
709
  read_resp = self._get_next_read_resp()
701
- except CancelledByClientError:
702
- raise StopIteration from None
710
+ except CancelledByClientError as e:
711
+ # Grpc stream is cancelled.
712
+ self.stop(cancel=True)
713
+ if self._is_cancelled:
714
+ # Response is stopped and cancelled.
715
+ raise
716
+ # Response is stopped but not cancelled.
717
+ raise StopIteration from e
703
718
  content_oneof = read_resp.WhichOneof("content")
704
719
  if content_oneof == "event":
705
720
  recorded_event = construct_recorded_event(read_resp.event)
@@ -733,13 +748,14 @@ class PersistentSubscription(
733
748
  self._grpc_streamers.remove(self)
734
749
  self._is_stopped = True
735
750
 
736
- def stop(self) -> None:
751
+ def stop(self, *, cancel: bool = False) -> None:
737
752
  if self._is_context_manager_active:
738
753
  self._is_stopping = True
739
- elif not self._set_is_stopped():
754
+ self._is_cancelling = cancel
755
+ elif not self._set_is_stopped(cancel=cancel):
740
756
  self._read_reqs.stop()
741
757
  self._read_resps.cancel()
742
- self._grpc_streamers.remove(self)
758
+ self._remove()
743
759
 
744
760
  def ack(self, item: UUID | RecordedEvent) -> None:
745
761
  self._read_reqs.ack(event_id=self._get_event_id(item))
@@ -1505,6 +1521,7 @@ class AsyncPersistentSubscriptionsService(
1505
1521
  expected_group_name=group_name,
1506
1522
  stream_name=stream_name,
1507
1523
  )
1524
+ await subscription.add_to_steamers()
1508
1525
  await subscription.init()
1509
1526
  return subscription
1510
1527
 
@@ -214,17 +214,25 @@ class AsyncReadResponse(BaseReadResponse, AsyncGrpcStreamer, AbstractAsyncReadRe
214
214
  )
215
215
  if recorded_event is not None:
216
216
  return recorded_event
217
- except BaseException:
218
- await self.stop()
217
+ except BaseException as e:
218
+ was_cancelled = self._is_cancelled
219
+ await self.stop(cancel=True)
220
+ if was_cancelled and isinstance(e, StopAsyncIteration):
221
+ raise CancelledByClientError from e
219
222
  raise
220
223
 
221
- async def stop(self, *, timeout: float | None = None) -> None:
224
+ async def stop(
225
+ self,
226
+ *,
227
+ timeout: float | None = None,
228
+ cancel: bool = False,
229
+ ) -> None:
222
230
  if self._is_context_manager_active:
223
231
  self._is_stopping = True
224
- elif not await self._set_is_stopped():
232
+ elif not await self._set_is_stopped(cancel=cancel):
225
233
  self._read_resp_stream_worker_task.cancel()
226
234
  self._unary_stream_call.cancel()
227
- self._grpc_streamers.remove(self)
235
+ self._remove()
228
236
  try:
229
237
  await self._read_resp_stream_worker_task
230
238
  except asyncio.CancelledError:
@@ -284,10 +292,10 @@ class ReadResponse(GrpcStreamer, BaseReadResponse, AbstractReadResponse):
284
292
  stream_name: str | None,
285
293
  grpc_streamers: GrpcStreamers,
286
294
  ):
287
- GrpcStreamer.__init__(self, grpc_streamers=grpc_streamers)
288
295
  BaseReadResponse.__init__(self, stream_name=stream_name)
289
296
  AbstractReadResponse.__init__(self)
290
297
  self._read_resps = read_resps
298
+ GrpcStreamer.__init__(self, grpc_streamers=grpc_streamers)
291
299
 
292
300
  def __next__(self) -> RecordedEvent:
293
301
  try:
@@ -298,12 +306,32 @@ class ReadResponse(GrpcStreamer, BaseReadResponse, AbstractReadResponse):
298
306
  )
299
307
  if recorded_event is not None:
300
308
  return recorded_event
301
- except CancelledByClientError:
302
- self.stop()
303
- raise StopIteration from None
304
- except:
305
- self.stop()
309
+ except StopIteration as e:
310
+ # Grpc stream is finished.
311
+ self.stop(cancel=False)
312
+ if self._is_cancelled:
313
+ # Grpc stream is finished but response is cancelled.
314
+ raise CancelledByClientError from e
315
+ # Grpc stream is finished but response is not cancelled.
306
316
  raise
317
+ except CancelledByClientError as e:
318
+ # Grpc stream is cancelled.
319
+ self.stop(cancel=True)
320
+ if self._is_cancelled:
321
+ # Response is stopped and cancelled.
322
+ raise
323
+ # Response is stopped but not cancelled.
324
+ raise StopIteration from e
325
+ except Exception as e:
326
+ # Some channel error.
327
+ if self._is_cancelled:
328
+ # Some channel error but response was cancelled.
329
+ # print("Was cancelled, got error:", e)
330
+ raise CancelledByClientError from e
331
+ # Some channel error but response was not cancelled.
332
+ # print(f"Not cancelled ({id(self)}), got error:", e)
333
+ self.stop(cancel=True)
334
+ raise e
307
335
 
308
336
  def _get_next_read_resp(self) -> streams_pb2.ReadResp:
309
337
  try:
@@ -314,10 +342,10 @@ class ReadResponse(GrpcStreamer, BaseReadResponse, AbstractReadResponse):
314
342
  assert isinstance(read_resp, streams_pb2.ReadResp)
315
343
  return read_resp
316
344
 
317
- def stop(self) -> None:
318
- if not self._set_is_stopped():
345
+ def stop(self, *, cancel: bool = False) -> None:
346
+ if not self._set_is_stopped(cancel=cancel):
319
347
  self._read_resps.cancel()
320
- self._grpc_streamers.remove(self)
348
+ self._remove()
321
349
 
322
350
 
323
351
  class CatchupSubscription(ReadResponse, AbstractCatchupSubscription):
@@ -988,6 +1016,7 @@ class AsyncStreamsService(BaseStreamsService[AsyncGrpcStreamers]):
988
1016
  stream_name=stream_name,
989
1017
  grpc_streamers=self._grpc_streamers,
990
1018
  )
1019
+ await response.add_to_steamers()
991
1020
  else:
992
1021
  response = AsyncCatchupSubscription(
993
1022
  unary_stream_call=unary_stream_call,
@@ -997,6 +1026,7 @@ class AsyncStreamsService(BaseStreamsService[AsyncGrpcStreamers]):
997
1026
  include_fell_behind=include_fell_behind,
998
1027
  grpc_streamers=self._grpc_streamers,
999
1028
  )
1029
+ await response.add_to_steamers()
1000
1030
  try:
1001
1031
  await response.check_confirmation()
1002
1032
  except BaseException:
@@ -1184,6 +1214,8 @@ class StreamsService(BaseStreamsService[GrpcStreamers]):
1184
1214
  )
1185
1215
 
1186
1216
  # Send the read request, and iterate over the response.
1217
+ # - if channel is closed, this raises ValueError('Channel closed!').
1218
+ # - although a fresh channel doesn't do this,
1187
1219
  read_resps = self._stub.Read(
1188
1220
  read_req,
1189
1221
  timeout=timeout,
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "kurrentdbclient"
7
- version = "1.3.4"
7
+ version = "1.3.6"
8
8
  description = "Python gRPC Client for KurrentDB"
9
9
  license = { text = "BSD-3-Clause" }
10
10
  readme = "README.md"
@@ -204,7 +204,7 @@ branch = true
204
204
  omit = [
205
205
  "kurrentdbclient/protos/*"
206
206
  ]
207
- relative_files = true
207
+ #relative_files = true
208
208
 
209
209
  [tool.coverage.report]
210
210
  fail_under = 100
File without changes