client-query-cache 0.4.0__tar.gz → 0.6.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.6.0}/PKG-INFO +15 -4
  2. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/README.md +4 -1
  3. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/pyproject.toml +13 -2
  4. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/pyproject.toml.orig +13 -2
  5. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/entries.py +1 -0
  6. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/manager.py +14 -4
  7. client_query_cache-0.6.0/src/client_query_cache/_core/stream_activation.py +31 -0
  8. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/stream_health.py +4 -0
  9. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/collection.py +8 -2
  10. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/manager.py +6 -3
  11. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/streams.py +118 -42
  12. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/collection.py +8 -2
  13. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/manager.py +2 -1
  14. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/streams.py +118 -50
  15. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/__init__.py +0 -0
  16. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/__init__.py +0 -0
  17. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/canonical.py +0 -0
  18. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/codec.py +0 -0
  19. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/collation.py +0 -0
  20. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/collection_metadata.py +0 -0
  21. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/count_reads.py +0 -0
  22. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/cursor_capture.py +0 -0
  23. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/errors.py +0 -0
  24. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/find_one_reads.py +0 -0
  25. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/find_reads.py +0 -0
  26. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/identity_reads.py +0 -0
  27. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/keys.py +0 -0
  28. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/lifecycle.py +0 -0
  29. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/locking.py +0 -0
  30. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/lru.py +0 -0
  31. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/namespace.py +0 -0
  32. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
  33. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/projection.py +0 -0
  34. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/query_filters.py +0 -0
  35. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/read_classification.py +0 -0
  36. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/read_validation.py +0 -0
  37. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/snapshots.py +0 -0
  38. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/stream_cost.py +0 -0
  39. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/stream_events.py +0 -0
  40. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/stream_options.py +0 -0
  41. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/traversal.py +0 -0
  42. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/_core/unique_keys.py +0 -0
  43. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/__init__.py +0 -0
  44. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/cursors.py +0 -0
  45. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/asynchronous/database.py +0 -0
  46. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/otel.py +0 -0
  47. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/py.typed +0 -0
  48. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/__init__.py +0 -0
  49. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/cursors.py +0 -0
  50. {client_query_cache-0.4.0 → client_query_cache-0.6.0}/src/client_query_cache/synchronous/database.py +0 -0
@@ -1,12 +1,20 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: client-query-cache
3
- Version: 0.4.0
3
+ Version: 0.6.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
+ License-Expression: Apache-2.0
8
+ Classifier: Framework :: AsyncIO
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Programming Language :: Python :: 3.15
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
7
15
  Requires-Dist: pymongo>=4.18.1
8
16
  Requires-Dist: opentelemetry-api>=1.45.0 ; extra == 'otel'
9
- Requires-Python: >=3.14.6
17
+ Requires-Python: >=3.14
10
18
  Project-URL: Repository, https://github.com/alessio-locatelli/client-query-cache
11
19
  Project-URL: Documentation, https://alessio-locatelli.github.io/client-query-cache/
12
20
  Provides-Extra: otel
@@ -14,7 +22,10 @@ Description-Content-Type: text/markdown
14
22
 
15
23
  # Coherent client-side caching for PyMongo
16
24
 
17
- [![Python 3.14.6+](https://img.shields.io/badge/python-3.14.6%2B-blue.svg)](https://www.python.org/downloads/)
25
+ [![PyPI](https://img.shields.io/pypi/v/client-query-cache?color=lightgrey)](https://pypi.org/project/client-query-cache/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/client-query-cache)](https://alessio-locatelli.github.io/client-query-cache/getting-started/installation/)
27
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/alessio-locatelli/client-query-cache/actions/workflows/test.yml)
28
+ [![Typing](https://img.shields.io/pypi/types/client-query-cache)](https://pypi.org/project/client-query-cache/)
18
29
 
19
30
  `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
31
 
@@ -1,6 +1,9 @@
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
+ [![PyPI](https://img.shields.io/pypi/v/client-query-cache?color=lightgrey)](https://pypi.org/project/client-query-cache/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/client-query-cache)](https://alessio-locatelli.github.io/client-query-cache/getting-started/installation/)
5
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/alessio-locatelli/client-query-cache/actions/workflows/test.yml)
6
+ [![Typing](https://img.shields.io/pypi/types/client-query-cache)](https://pypi.org/project/client-query-cache/)
4
7
 
5
8
  `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
9
 
@@ -1,9 +1,19 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.4.0"
3
+ version = "0.6.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
+ license = "Apache-2.0"
8
+ classifiers = [
9
+ "Framework :: AsyncIO",
10
+ "Intended Audience :: Developers",
11
+ "Programming Language :: Python :: 3.14",
12
+ "Programming Language :: Python :: 3.15",
13
+ "Topic :: Database",
14
+ "Topic :: Software Development :: Libraries :: Python Modules",
15
+ "Typing :: Typed",
16
+ ]
7
17
  dependencies = ["pymongo>=4.18.1"]
8
18
 
9
19
  [[project.authors]]
@@ -42,6 +52,7 @@ dev = [
42
52
  "opentelemetry-api>=1.45.0",
43
53
  "opentelemetry-sdk>=1.45.0",
44
54
  "types-psutil>=7.2.2.20260906",
55
+ "psutil>=7.2.2",
45
56
  "dnspython>=2.8.0",
46
57
  "python-snappy>=0.7.3",
47
58
  "pytest-xdist[psutil]>=3.8.0",
@@ -1,12 +1,22 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.4.0"
3
+ version = "0.6.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
+ license = "Apache-2.0"
11
+ classifiers = [
12
+ "Framework :: AsyncIO",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3.14",
15
+ "Programming Language :: Python :: 3.15",
16
+ "Topic :: Database",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ "Typing :: Typed",
19
+ ]
10
20
  dependencies = ["pymongo>=4.18.1"]
11
21
 
12
22
  [project.urls]
@@ -41,6 +51,7 @@ dev = [
41
51
  "opentelemetry-api>=1.45.0",
42
52
  "opentelemetry-sdk>=1.45.0",
43
53
  "types-psutil>=7.2.2.20260906",
54
+ "psutil>=7.2.2",
44
55
  "dnspython>=2.8.0",
45
56
  "python-snappy>=0.7.3",
46
57
  "pytest-xdist[psutil]>=3.8.0",
@@ -32,3 +32,4 @@ class CacheEntry:
32
32
  class LookupResult:
33
33
  hit: bool
34
34
  value: Any = None
35
+ deferred_miss: bool = False
@@ -54,6 +54,8 @@ DEFAULT_MAX_ENTRY_BYTES = 1 * 1024 * 1024
54
54
 
55
55
  _NOT_INDEXED = object()
56
56
  _DEFAULT_DATABASE_AVAILABILITY = (True, 0)
57
+ _MISS = LookupResult(hit=False)
58
+ _DEFERRED_MISS = LookupResult(hit=False, deferred_miss=True)
57
59
 
58
60
  logger = logging.getLogger(__name__)
59
61
 
@@ -313,6 +315,9 @@ class _CacheCoreLifecycle(_CacheCoreBase):
313
315
  self._database_namespaces.clear()
314
316
  logger.info("cache manager closed")
315
317
 
318
+ def record_miss(self) -> None:
319
+ self._statistics.record_miss()
320
+
316
321
  def record_bypass(self, reason: BypassReason = BypassReason.UNSPECIFIED) -> None:
317
322
  self._statistics.record_bypass(reason)
318
323
 
@@ -908,6 +913,7 @@ class _CacheCoreLookup(_CacheCoreBase):
908
913
  discriminator: object,
909
914
  *,
910
915
  codec_options: CodecOptions[Any] | None = None,
916
+ defer_miss: bool = False,
911
917
  ) -> LookupResult:
912
918
  self._ensure_active()
913
919
  if not self._is_database_available(namespace.database):
@@ -917,18 +923,22 @@ class _CacheCoreLookup(_CacheCoreBase):
917
923
  key = NamespaceCacheKey(namespace, canonical_discriminator)
918
924
  entry = self._lru.peek(key)
919
925
  if entry is None:
920
- self._statistics.record_miss()
921
- return LookupResult(hit=False)
926
+ return self._namespace_miss(defer_miss=defer_miss)
922
927
  state = self._namespace(namespace)
923
928
  with self._namespace_section(state):
924
929
  valid = (state.generation,) == entry.generation_key
925
930
  if not valid:
926
- self._statistics.record_miss()
927
- return LookupResult(hit=False)
931
+ return self._namespace_miss(defer_miss=defer_miss)
928
932
  self._lru.touch(key)
929
933
  self._statistics.record_hit()
930
934
  return LookupResult(hit=True, value=decode_value(entry.value, codec_options))
931
935
 
936
+ def _namespace_miss(self, *, defer_miss: bool) -> LookupResult:
937
+ if defer_miss:
938
+ return _DEFERRED_MISS
939
+ self._statistics.record_miss()
940
+ return _MISS
941
+
932
942
  def resolve_alias(
933
943
  self,
934
944
  namespace: NamespaceId,
@@ -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:
@@ -209,15 +209,21 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
209
209
  cache.current_index_generation(namespace),
210
210
  )
211
211
  )
212
+ discovers_unique_keys = identity is NO_IDENTITY and known_keys is None
212
213
  lookup_result = cache.lookup_namespace(
213
- namespace, discriminator, codec_options=codec_options
214
+ namespace,
215
+ discriminator,
216
+ codec_options=codec_options,
217
+ defer_miss=discovers_unique_keys,
214
218
  )
215
219
  if lookup_result.hit:
216
220
  return cast("DocumentType | None", lookup_result.value)
217
- if identity is NO_IDENTITY and known_keys is None:
221
+ if discovers_unique_keys:
218
222
  unique_key_match = await self._match_unique_key(
219
223
  filter_query, effective_collation
220
224
  )
225
+ if unique_key_match is None and lookup_result.deferred_miss:
226
+ cache.record_miss()
221
227
  if unique_key_match is not None:
222
228
  key_definition, key_values = unique_key_match
223
229
  return await self._find_one_by_unique_key(
@@ -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()
@@ -209,15 +209,21 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
209
209
  cache.current_index_generation(namespace),
210
210
  )
211
211
  )
212
+ discovers_unique_keys = identity is NO_IDENTITY and known_keys is None
212
213
  lookup_result = cache.lookup_namespace(
213
- namespace, discriminator, codec_options=codec_options
214
+ namespace,
215
+ discriminator,
216
+ codec_options=codec_options,
217
+ defer_miss=discovers_unique_keys,
214
218
  )
215
219
  if lookup_result.hit:
216
220
  return cast("DocumentType | None", lookup_result.value)
217
- if identity is NO_IDENTITY and known_keys is None:
221
+ if discovers_unique_keys:
218
222
  unique_key_match = self._match_unique_key(
219
223
  filter_query, effective_collation
220
224
  )
225
+ if unique_key_match is None and lookup_result.deferred_miss:
226
+ cache.record_miss()
221
227
  if unique_key_match is not None:
222
228
  key_definition, key_values = unique_key_match
223
229
  return self._find_one_by_unique_key(
@@ -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()