client-query-cache 0.4.0__tar.gz → 0.5.0__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 (50) hide show
  1. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/PKG-INFO +5 -3
  2. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/README.md +1 -1
  3. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/pyproject.toml +6 -2
  4. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/pyproject.toml.orig +6 -2
  5. client_query_cache-0.5.0/src/client_query_cache/_core/stream_activation.py +31 -0
  6. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_health.py +4 -0
  7. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/manager.py +6 -3
  8. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/streams.py +118 -42
  9. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/manager.py +2 -1
  10. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/streams.py +118 -50
  11. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/__init__.py +0 -0
  12. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/__init__.py +0 -0
  13. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/canonical.py +0 -0
  14. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/codec.py +0 -0
  15. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collation.py +0 -0
  16. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collection_metadata.py +0 -0
  17. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/count_reads.py +0 -0
  18. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/cursor_capture.py +0 -0
  19. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/entries.py +0 -0
  20. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/errors.py +0 -0
  21. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_one_reads.py +0 -0
  22. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_reads.py +0 -0
  23. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/identity_reads.py +0 -0
  24. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/keys.py +0 -0
  25. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lifecycle.py +0 -0
  26. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/locking.py +0 -0
  27. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lru.py +0 -0
  28. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/manager.py +0 -0
  29. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/namespace.py +0 -0
  30. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
  31. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/projection.py +0 -0
  32. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/query_filters.py +0 -0
  33. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_classification.py +0 -0
  34. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_validation.py +0 -0
  35. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/snapshots.py +0 -0
  36. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_cost.py +0 -0
  37. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_events.py +0 -0
  38. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_options.py +0 -0
  39. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/traversal.py +0 -0
  40. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/unique_keys.py +0 -0
  41. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/__init__.py +0 -0
  42. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/collection.py +0 -0
  43. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/cursors.py +0 -0
  44. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/database.py +0 -0
  45. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/otel.py +0 -0
  46. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/py.typed +0 -0
  47. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/__init__.py +0 -0
  48. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/collection.py +0 -0
  49. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/cursors.py +0 -0
  50. {client_query_cache-0.4.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/database.py +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: client-query-cache
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Client-side caching for PyMongo, kept coherent using MongoDB change streams.
5
5
  Author: Alessio Locatelli
6
6
  Author-email: Alessio Locatelli <<software.development@secure.mailbox.org>>
7
+ Classifier: Programming Language :: Python :: 3.14
8
+ Classifier: Programming Language :: Python :: 3.15
7
9
  Requires-Dist: pymongo>=4.18.1
8
10
  Requires-Dist: opentelemetry-api>=1.45.0 ; extra == 'otel'
9
- Requires-Python: >=3.14.6
11
+ Requires-Python: >=3.14
10
12
  Project-URL: Repository, https://github.com/alessio-locatelli/client-query-cache
11
13
  Project-URL: Documentation, https://alessio-locatelli.github.io/client-query-cache/
12
14
  Provides-Extra: otel
@@ -14,7 +16,7 @@ Description-Content-Type: text/markdown
14
16
 
15
17
  # Coherent client-side caching for PyMongo
16
18
 
17
- [![Python 3.14.6+](https://img.shields.io/badge/python-3.14.6%2B-blue.svg)](https://www.python.org/downloads/)
19
+ [![Supported Python versions on PyPI](https://img.shields.io/pypi/pyversions/client-query-cache.svg)](https://pypi.org/project/client-query-cache/)
18
20
 
19
21
  `client-query-cache` uses MongoDB change streams to keep cached reads coherent. It supports synchronous and asyncio applications without requiring a separate cache server.
20
22
 
@@ -1,6 +1,6 @@
1
1
  # Coherent client-side caching for PyMongo
2
2
 
3
- [![Python 3.14.6+](https://img.shields.io/badge/python-3.14.6%2B-blue.svg)](https://www.python.org/downloads/)
3
+ [![Supported Python versions on PyPI](https://img.shields.io/pypi/pyversions/client-query-cache.svg)](https://pypi.org/project/client-query-cache/)
4
4
 
5
5
  `client-query-cache` uses MongoDB change streams to keep cached reads coherent. It supports synchronous and asyncio applications without requiring a separate cache server.
6
6
 
@@ -1,9 +1,13 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Client-side caching for PyMongo, kept coherent using MongoDB change streams."
5
5
  readme = "README.md"
6
- requires-python = ">=3.14.6"
6
+ requires-python = ">=3.14"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3.14",
9
+ "Programming Language :: Python :: 3.15",
10
+ ]
7
11
  dependencies = ["pymongo>=4.18.1"]
8
12
 
9
13
  [[project.authors]]
@@ -1,12 +1,16 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Client-side caching for PyMongo, kept coherent using MongoDB change streams."
5
5
  authors = [
6
6
  { name = "Alessio Locatelli", email = "<software.development@secure.mailbox.org>" },
7
7
  ]
8
8
  readme = "README.md"
9
- requires-python = ">=3.14.6"
9
+ requires-python = ">=3.14"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3.14",
12
+ "Programming Language :: Python :: 3.15",
13
+ ]
10
14
  dependencies = ["pymongo>=4.18.1"]
11
15
 
12
16
  [project.urls]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass, field
5
+ from time import monotonic
6
+
7
+ from client_query_cache._core.stream_health import RetryBackoff
8
+
9
+
10
+ def _startup_jitter(_low: float, cap: float) -> float:
11
+ return random.uniform(cap / 2, cap)
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class StartupRetry:
16
+ deadline: float = 0.0 # Zero permits the first attempt.
17
+ backoff: RetryBackoff = field(default_factory=RetryBackoff)
18
+
19
+ def ready(self) -> bool:
20
+ return monotonic() >= self.deadline
21
+
22
+ def failed(self) -> None:
23
+ self.deadline = monotonic() + self.backoff.next_delay(_startup_jitter)
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class StreamActivation[Supervisor, Completion]:
28
+ supervisor: Supervisor
29
+ completion: Completion
30
+ pending: bool = True
31
+ retry: StartupRetry = field(default_factory=StartupRetry)
@@ -63,6 +63,10 @@ class StreamHealthRegistry:
63
63
  str, StreamHealthStatus | Callable[[], StreamHealthStatus]
64
64
  ] = {} # Can be empty.
65
65
 
66
+ def record_connecting(self, database_name: str) -> None:
67
+ with self._lock:
68
+ self._states[database_name] = StreamHealthStatus.CONNECTING
69
+
66
70
  def record_starting(
67
71
  self, database_name: str, health: Callable[[], StreamHealthStatus]
68
72
  ) -> None:
@@ -81,7 +81,8 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
81
81
  namespace: NamespaceId,
82
82
  collection_probe: Callable[[], Awaitable[CollectionProbeResult | None]],
83
83
  ) -> BypassReason | None:
84
- await self._coordinator.activate_database(namespace.database)
84
+ if await self._coordinator.activate_database(namespace.database) is None:
85
+ return BypassReason.STREAM_UNAVAILABLE
85
86
  if not self._cache.is_database_available(namespace.database):
86
87
  return BypassReason.STREAM_UNAVAILABLE
87
88
  current_epoch = self._cache.current_epoch(namespace)
@@ -159,8 +160,10 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
159
160
  return CachedCollection(CachedDatabase(self, collection.database), collection)
160
161
 
161
162
  async def close(self) -> None:
162
- await self._coordinator.close()
163
- self._cache.close()
163
+ try:
164
+ await self._coordinator.close()
165
+ finally:
166
+ self._cache.close()
164
167
 
165
168
  async def __aenter__(self) -> Self:
166
169
  return self
@@ -9,6 +9,7 @@ from bson.errors import BSONError
9
9
  from pymongo.errors import OperationFailure, PyMongoError
10
10
 
11
11
  from client_query_cache._core.errors import StreamLifecycleError, StreamStartupError
12
+ from client_query_cache._core.stream_activation import StreamActivation
12
13
  from client_query_cache._core.stream_events import (
13
14
  build_change_stream_pipeline,
14
15
  is_unresumable_change_stream_error,
@@ -38,14 +39,28 @@ MINIMUM_SERVER_VERSION = (8, 0)
38
39
  logger = logging.getLogger(__name__)
39
40
 
40
41
 
42
+ async def _join_cleanup(task: asyncio.Task[None]) -> None:
43
+ cancelled = False
44
+ while not task.done():
45
+ try:
46
+ await asyncio.shield(task)
47
+ except asyncio.CancelledError:
48
+ cancelled = True
49
+ task.result()
50
+ if cancelled:
51
+ raise asyncio.CancelledError
52
+
53
+
41
54
  class DatabaseStreamSupervisor:
42
55
  __slots__ = (
43
56
  "_backoff",
44
57
  "_cache",
58
+ "_closed_stream",
45
59
  "_database",
46
60
  "_health",
47
61
  "_max_await_time_ms",
48
62
  "_resume_token",
63
+ "_shutdown_task",
49
64
  "_stop_event",
50
65
  "_stream",
51
66
  "_task",
@@ -65,9 +80,11 @@ class DatabaseStreamSupervisor:
65
80
  self._max_await_time_ms = max_await_time_ms
66
81
  self._health = StreamHealth.STARTING
67
82
  self._stream: AsyncDatabaseChangeStream[Any] | None = None
83
+ self._closed_stream: AsyncDatabaseChangeStream[Any] | None = None
68
84
  self._resume_token: Mapping[str, Any] | None = None
69
85
  self._stop_event = asyncio.Event()
70
86
  self._task: asyncio.Task[None] | None = None
87
+ self._shutdown_task: asyncio.Task[None] | None = None
71
88
  self._cache.set_database_available(self._database.name, available=False)
72
89
 
73
90
  @property
@@ -96,29 +113,30 @@ class DatabaseStreamSupervisor:
96
113
  )
97
114
  raise StreamStartupError(message) from exc
98
115
  except asyncio.CancelledError:
99
- self._set_health(StreamHealth.CLOSED)
100
- stream = self._stream
101
- if stream is not None:
102
- try:
103
- await stream.close()
104
- except PyMongoError:
105
- pass
116
+ await self.stop()
106
117
  raise
107
118
  if self._stop_event.is_set():
108
- assert self._stream is not None
109
- try:
110
- await self._stream.close()
111
- except PyMongoError:
112
- pass
113
- self._set_health(StreamHealth.CLOSED)
119
+ await self.stop()
114
120
  message = "stop() was called while start() was still connecting"
115
121
  raise StreamLifecycleError(message)
116
122
  self._set_health(StreamHealth.HEALTHY)
117
123
  self._task = asyncio.ensure_future(self._run())
118
124
 
119
- async def stop(self) -> None:
125
+ def request_stop(self) -> None:
120
126
  self._stop_event.set()
121
127
  self._set_health(StreamHealth.CLOSED)
128
+ if self._task is not None:
129
+ self._task.cancel()
130
+
131
+ async def stop(self) -> None:
132
+ self.request_stop()
133
+ if self._shutdown_task is None or (
134
+ self._shutdown_task.done() and self._stream is not self._closed_stream
135
+ ):
136
+ self._shutdown_task = asyncio.create_task(self._finish_stop())
137
+ await _join_cleanup(self._shutdown_task)
138
+
139
+ async def _finish_stop(self) -> None:
122
140
  task = self._task
123
141
  if task is not None:
124
142
  task.cancel()
@@ -136,6 +154,7 @@ class DatabaseStreamSupervisor:
136
154
  extra={"database": self._database.name},
137
155
  exc_info=True,
138
156
  )
157
+ self._closed_stream = stream
139
158
  self._set_health(StreamHealth.CLOSED)
140
159
 
141
160
  def _set_health(self, health: StreamHealth) -> None:
@@ -177,11 +196,6 @@ class DatabaseStreamSupervisor:
177
196
  await previous_stream.close()
178
197
  except PyMongoError:
179
198
  pass
180
- if self._stop_event.is_set():
181
- try:
182
- await self._stream.close()
183
- except PyMongoError:
184
- pass
185
199
 
186
200
  async def _interruptible_sleep(self, delay: float) -> bool:
187
201
  try:
@@ -268,12 +282,14 @@ class DatabaseStreamSupervisor:
268
282
 
269
283
  class ChangeStreamCoordinator:
270
284
  __slots__ = (
285
+ "_activations",
271
286
  "_cache",
272
287
  "_client",
273
288
  "_closed",
274
289
  "_health_registry",
275
290
  "_lock",
276
291
  "_max_await_time_ms",
292
+ "_shutdown_task",
277
293
  "_supervisors",
278
294
  )
279
295
 
@@ -287,49 +303,109 @@ class ChangeStreamCoordinator:
287
303
  self._client = client
288
304
  self._max_await_time_ms = max_await_time_ms
289
305
  self._cache = cache
306
+ self._activations: dict[
307
+ str, StreamActivation[DatabaseStreamSupervisor, asyncio.Event]
308
+ ] = {} # Can be empty.
290
309
  self._supervisors: dict[str, DatabaseStreamSupervisor] = {}
291
310
  self._closed = False
292
311
  self._health_registry = StreamHealthRegistry()
293
312
  self._lock = asyncio.Lock()
313
+ self._shutdown_task: asyncio.Task[None] | None = None
294
314
 
295
315
  async def activate_database(self, name: str) -> DatabaseStreamSupervisor | None:
296
316
  async with self._lock:
297
317
  if self._closed:
298
318
  raise StreamLifecycleError("coordinator is closed")
299
319
  try:
300
- supervisor = self._supervisors[name]
320
+ return self._supervisors[name]
301
321
  except KeyError:
302
- supervisor = DatabaseStreamSupervisor(
303
- self._client[name],
304
- self._cache,
305
- max_await_time_ms=self._max_await_time_ms,
322
+ pass
323
+ try:
324
+ activation = self._activations[name]
325
+ except KeyError:
326
+ activation = None
327
+ if activation is not None and (
328
+ activation.pending or not activation.retry.ready()
329
+ ):
330
+ return None
331
+ supervisor = DatabaseStreamSupervisor(
332
+ self._client[name],
333
+ self._cache,
334
+ max_await_time_ms=self._max_await_time_ms,
335
+ )
336
+ if activation is None:
337
+ activation = StreamActivation(supervisor, asyncio.Event())
338
+ self._activations[name] = activation
339
+ else:
340
+ activation.supervisor = supervisor
341
+ activation.pending = True
342
+ activation.completion.clear()
343
+ self._health_registry.record_connecting(name)
344
+ published = False
345
+ try:
346
+ try:
347
+ await supervisor.start()
348
+ except StreamStartupError:
349
+ async with self._lock:
350
+ if not self._closed:
351
+ activation.retry.failed()
352
+ self._health_registry.record_startup_failure(name)
353
+ logger.warning(
354
+ "change stream startup failed for database %r; reads for "
355
+ "this database will bypass the cache",
356
+ name,
357
+ exc_info=True,
306
358
  )
307
- self._health_registry.record_starting(name, supervisor.health_status)
308
- try:
309
- await supervisor.start()
310
- except StreamStartupError:
311
- self._health_registry.record_startup_failure(name)
312
- logger.warning(
313
- "change stream startup failed for database %r; reads for "
314
- "this database will bypass the cache",
315
- name,
316
- exc_info=True,
317
- )
318
- return None
319
- except asyncio.CancelledError:
320
- self._health_registry.record_startup_failure(name)
321
- raise
359
+ return None
360
+ async with self._lock:
361
+ if self._closed:
362
+ raise StreamLifecycleError("coordinator is closed")
322
363
  self._supervisors[name] = supervisor
364
+ del self._activations[name]
365
+ self._health_registry.record_starting(name, supervisor.health_status)
366
+ published = True
323
367
  return supervisor
368
+ finally:
369
+ if not published:
370
+ try:
371
+ await supervisor.stop()
372
+ finally:
373
+ async with self._lock:
374
+ if not self._closed:
375
+ self._health_registry.record_startup_failure(name)
376
+ activation.pending = False
377
+ activation.completion.set()
378
+ else:
379
+ activation.completion.set()
324
380
 
325
381
  def stream_health_snapshot(self, database_name: str) -> StreamHealthSnapshot:
326
382
  return self._health_registry.snapshot(database_name)
327
383
 
328
384
  async def close(self) -> None:
329
- async with self._lock:
385
+ if self._shutdown_task is None:
330
386
  self._closed = True
331
387
  self._health_registry.close()
332
- supervisors = list(self._supervisors.values())
333
- self._supervisors.clear()
388
+ supervisors = tuple(self._supervisors.values())
389
+ activations = tuple(self._activations.values())
390
+ for supervisor in supervisors:
391
+ supervisor.request_stop()
392
+ for activation in activations:
393
+ activation.supervisor.request_stop()
394
+ self._shutdown_task = asyncio.create_task(
395
+ self._finish_close(supervisors, activations)
396
+ )
397
+ await _join_cleanup(self._shutdown_task)
398
+
399
+ async def _finish_close(
400
+ self,
401
+ supervisors: tuple[DatabaseStreamSupervisor, ...],
402
+ activations: tuple[
403
+ StreamActivation[DatabaseStreamSupervisor, asyncio.Event], ...
404
+ ],
405
+ ) -> None:
406
+ for activation in activations:
407
+ await activation.completion.wait()
334
408
  for supervisor in supervisors:
335
409
  await supervisor.stop()
410
+ self._activations.clear()
411
+ self._supervisors.clear()
@@ -81,7 +81,8 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
81
81
  namespace: NamespaceId,
82
82
  collection_probe: Callable[[], CollectionProbeResult | None],
83
83
  ) -> BypassReason | None:
84
- self._coordinator.activate_database(namespace.database)
84
+ if self._coordinator.activate_database(namespace.database) is None:
85
+ return BypassReason.STREAM_UNAVAILABLE
85
86
  if not self._cache.is_database_available(namespace.database):
86
87
  return BypassReason.STREAM_UNAVAILABLE
87
88
  current_epoch = self._cache.current_epoch(namespace)
@@ -9,6 +9,7 @@ from bson.errors import BSONError
9
9
  from pymongo.errors import OperationFailure, PyMongoError
10
10
 
11
11
  from client_query_cache._core.errors import StreamLifecycleError, StreamStartupError
12
+ from client_query_cache._core.stream_activation import StreamActivation
12
13
  from client_query_cache._core.stream_events import (
13
14
  build_change_stream_pipeline,
14
15
  is_unresumable_change_stream_error,
@@ -103,41 +104,45 @@ class DatabaseStreamSupervisor:
103
104
  )
104
105
  raise StreamStartupError(message) from exc
105
106
  with self._lifecycle_lock:
106
- if self._stop_event.is_set():
107
- assert self._stream is not None
108
- try:
109
- self._stream.close()
110
- except PyMongoError:
111
- pass
112
- self._set_health(StreamHealth.CLOSED)
113
- message = "stop() was called while start() was still connecting"
114
- raise StreamLifecycleError(message)
115
- self._set_health(StreamHealth.HEALTHY)
116
- self._thread = threading.Thread(
117
- target=self._run,
118
- name=f"client-query-cache-stream-{self._database.name}",
119
- daemon=True,
120
- )
121
- self._thread.start()
107
+ stopped = self._stop_event.is_set()
108
+ if not stopped:
109
+ self._set_health(StreamHealth.HEALTHY)
110
+ self._thread = threading.Thread(
111
+ target=self._run,
112
+ name=f"client-query-cache-stream-{self._database.name}",
113
+ daemon=True,
114
+ )
115
+ self._thread.start()
116
+ if stopped:
117
+ self.stop()
118
+ message = "stop() was called while start() was still connecting"
119
+ raise StreamLifecycleError(message)
122
120
 
123
- def stop(self) -> None:
121
+ def request_stop(self) -> None:
124
122
  with self._lifecycle_lock:
125
123
  self._stop_event.set()
126
124
  self._set_health(StreamHealth.CLOSED)
125
+
126
+ def stop(self) -> None:
127
+ self.request_stop()
128
+ with self._lifecycle_lock:
127
129
  stream = self._stream
128
- if stream is not None:
129
- try:
130
- stream.close()
131
- except PyMongoError:
132
- logger.warning(
133
- "change stream close failed during shutdown",
134
- extra={"database": self._database.name},
135
- exc_info=True,
136
- )
137
130
  thread = self._thread
131
+ if stream is not None:
132
+ try:
133
+ stream.close()
134
+ except PyMongoError:
135
+ logger.warning(
136
+ "change stream close failed during shutdown",
137
+ extra={"database": self._database.name},
138
+ exc_info=True,
139
+ )
138
140
  if thread is not None:
139
141
  thread.join()
140
- self._set_health(StreamHealth.CLOSED)
142
+ with self._lifecycle_lock:
143
+ if self._stream is stream:
144
+ self._stream = None
145
+ self._set_health(StreamHealth.CLOSED)
141
146
 
142
147
  def _ensure_server_supports_expanded_events(self) -> None:
143
148
  server_info = self._database.client.server_info()
@@ -168,11 +173,13 @@ class DatabaseStreamSupervisor:
168
173
  previous_stream.close()
169
174
  except PyMongoError:
170
175
  pass
176
+
171
177
  if self._stop_event.is_set():
172
178
  try:
173
179
  self._stream.close()
174
180
  except PyMongoError:
175
181
  pass
182
+ self._stream = None
176
183
 
177
184
  def _set_health(self, health: StreamHealth) -> None:
178
185
  with self._lifecycle_lock:
@@ -262,12 +269,14 @@ class DatabaseStreamSupervisor:
262
269
 
263
270
  class ChangeStreamCoordinator:
264
271
  __slots__ = (
272
+ "_activations",
265
273
  "_cache",
266
274
  "_client",
267
275
  "_closed",
268
276
  "_health_registry",
269
277
  "_lock",
270
278
  "_max_await_time_ms",
279
+ "_shutdown_complete",
271
280
  "_supervisors",
272
281
  )
273
282
 
@@ -281,46 +290,105 @@ class ChangeStreamCoordinator:
281
290
  self._client = client
282
291
  self._max_await_time_ms = max_await_time_ms
283
292
  self._cache = cache
293
+ self._activations: dict[
294
+ str, StreamActivation[DatabaseStreamSupervisor, threading.Event]
295
+ ] = {} # Can be empty.
284
296
  self._supervisors: dict[str, DatabaseStreamSupervisor] = {}
285
297
  self._closed = False
286
298
  self._health_registry = StreamHealthRegistry()
287
299
  self._lock = threading.Lock()
300
+ self._shutdown_complete = threading.Event()
288
301
 
289
302
  def activate_database(self, name: str) -> DatabaseStreamSupervisor | None:
290
303
  with self._lock:
291
304
  if self._closed:
292
305
  raise StreamLifecycleError("coordinator is closed")
293
306
  try:
294
- supervisor = self._supervisors[name]
307
+ return self._supervisors[name]
295
308
  except KeyError:
296
- supervisor = DatabaseStreamSupervisor(
297
- self._client[name],
298
- self._cache,
299
- max_await_time_ms=self._max_await_time_ms,
309
+ pass
310
+ try:
311
+ activation = self._activations[name]
312
+ except KeyError:
313
+ activation = None
314
+ if activation is not None and (
315
+ activation.pending or not activation.retry.ready()
316
+ ):
317
+ return None
318
+ supervisor = DatabaseStreamSupervisor(
319
+ self._client[name],
320
+ self._cache,
321
+ max_await_time_ms=self._max_await_time_ms,
322
+ )
323
+ if activation is None:
324
+ activation = StreamActivation(supervisor, threading.Event())
325
+ self._activations[name] = activation
326
+ else:
327
+ activation.supervisor = supervisor
328
+ activation.pending = True
329
+ activation.completion.clear()
330
+ self._health_registry.record_connecting(name)
331
+ published = False
332
+ try:
333
+ try:
334
+ supervisor.start()
335
+ except StreamStartupError:
336
+ with self._lock:
337
+ if not self._closed:
338
+ activation.retry.failed()
339
+ self._health_registry.record_startup_failure(name)
340
+ logger.warning(
341
+ "change stream startup failed for database %r; reads for "
342
+ "this database will bypass the cache",
343
+ name,
344
+ exc_info=True,
300
345
  )
301
- self._health_registry.record_starting(name, supervisor.health_status)
302
- try:
303
- supervisor.start()
304
- except StreamStartupError:
305
- self._health_registry.record_startup_failure(name)
306
- logger.warning(
307
- "change stream startup failed for database %r; reads for "
308
- "this database will bypass the cache",
309
- name,
310
- exc_info=True,
311
- )
312
- return None
346
+ return None
347
+ with self._lock:
348
+ if self._closed:
349
+ raise StreamLifecycleError("coordinator is closed")
313
350
  self._supervisors[name] = supervisor
351
+ del self._activations[name]
352
+ self._health_registry.record_starting(name, supervisor.health_status)
353
+ published = True
314
354
  return supervisor
355
+ finally:
356
+ if not published:
357
+ try:
358
+ supervisor.stop()
359
+ finally:
360
+ with self._lock:
361
+ if not self._closed:
362
+ self._health_registry.record_startup_failure(name)
363
+ activation.pending = False
364
+ activation.completion.set()
365
+ else:
366
+ activation.completion.set()
315
367
 
316
368
  def stream_health_snapshot(self, database_name: str) -> StreamHealthSnapshot:
317
369
  return self._health_registry.snapshot(database_name)
318
370
 
319
371
  def close(self) -> None:
320
372
  with self._lock:
321
- self._closed = True
322
- self._health_registry.close()
323
- supervisors = list(self._supervisors.values())
324
- self._supervisors.clear()
325
- for supervisor in supervisors:
326
- supervisor.stop()
373
+ owns_cleanup = not self._closed
374
+ if owns_cleanup:
375
+ self._closed = True
376
+ self._health_registry.close()
377
+ supervisors = tuple(self._supervisors.values())
378
+ activations = tuple(self._activations.values())
379
+ for supervisor in supervisors:
380
+ supervisor.request_stop()
381
+ for activation in activations:
382
+ activation.supervisor.request_stop()
383
+ self._supervisors.clear()
384
+ self._activations.clear()
385
+ if not owns_cleanup:
386
+ self._shutdown_complete.wait()
387
+ return
388
+ try:
389
+ for activation in activations:
390
+ activation.completion.wait()
391
+ for supervisor in supervisors:
392
+ supervisor.stop()
393
+ finally:
394
+ self._shutdown_complete.set()