client-query-cache 0.3.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.3.0 → client_query_cache-0.5.0}/PKG-INFO +6 -4
  2. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/README.md +2 -2
  3. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/pyproject.toml +8 -7
  4. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/pyproject.toml.orig +17 -7
  5. client_query_cache-0.5.0/src/client_query_cache/_core/stream_activation.py +31 -0
  6. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_health.py +4 -0
  7. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/collection.py +4 -4
  8. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/manager.py +14 -18
  9. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/streams.py +118 -42
  10. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/collection.py +4 -4
  11. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/manager.py +10 -14
  12. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/streams.py +118 -50
  13. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/__init__.py +0 -0
  14. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/__init__.py +0 -0
  15. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/canonical.py +0 -0
  16. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/codec.py +0 -0
  17. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collation.py +0 -0
  18. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collection_metadata.py +0 -0
  19. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/count_reads.py +0 -0
  20. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/cursor_capture.py +0 -0
  21. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/entries.py +0 -0
  22. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/errors.py +0 -0
  23. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_one_reads.py +0 -0
  24. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_reads.py +0 -0
  25. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/identity_reads.py +0 -0
  26. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/keys.py +0 -0
  27. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lifecycle.py +0 -0
  28. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/locking.py +0 -0
  29. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lru.py +0 -0
  30. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/manager.py +0 -0
  31. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/namespace.py +0 -0
  32. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
  33. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/projection.py +0 -0
  34. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/query_filters.py +0 -0
  35. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_classification.py +0 -0
  36. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_validation.py +0 -0
  37. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/snapshots.py +0 -0
  38. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_cost.py +0 -0
  39. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_events.py +0 -0
  40. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_options.py +0 -0
  41. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/traversal.py +0 -0
  42. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/unique_keys.py +0 -0
  43. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/__init__.py +0 -0
  44. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/cursors.py +0 -0
  45. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/database.py +0 -0
  46. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/otel.py +0 -0
  47. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/py.typed +0 -0
  48. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/__init__.py +0 -0
  49. {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/cursors.py +0 -0
  50. {client_query_cache-0.3.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.3.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
 
@@ -45,7 +47,7 @@ with (
45
47
  MongoClient("mongodb://localhost:27017") as client,
46
48
  CacheManager(client) as cache,
47
49
  ):
48
- users = cache.cached(client["my_database"]["users"])
50
+ users = cache.get_cached_collection(client["my_database"]["users"])
49
51
  users.find_one({"_id": "alice"}) # Reads from MongoDB.
50
52
  users.find_one({"_id": "alice"}) # Repeated reads can use the cache.
51
53
  ```
@@ -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
 
@@ -31,7 +31,7 @@ with (
31
31
  MongoClient("mongodb://localhost:27017") as client,
32
32
  CacheManager(client) as cache,
33
33
  ):
34
- users = cache.cached(client["my_database"]["users"])
34
+ users = cache.get_cached_collection(client["my_database"]["users"])
35
35
  users.find_one({"_id": "alice"}) # Reads from MongoDB.
36
36
  users.find_one({"_id": "alice"}) # Repeated reads can use the cache.
37
37
  ```
@@ -1,9 +1,13 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.3.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]]
@@ -46,6 +50,8 @@ dev = [
46
50
  "python-snappy>=0.7.3",
47
51
  "pytest-xdist[psutil]>=3.8.0",
48
52
  "pytest-cov>=7.1.0",
53
+ "pyyaml>=6.0.3",
54
+ "types-pyyaml>=6.0.12.20260906",
49
55
  ]
50
56
  docs = [
51
57
  "mike",
@@ -61,16 +67,11 @@ build-backend = "uv_build"
61
67
  [tool.uv]
62
68
  environments = ["sys_platform == 'linux'"]
63
69
 
64
- [tool.uv.build-backend]
65
- module-root = "src"
66
-
67
70
  [tool.uv.sources.mike]
68
71
  git = "https://github.com/squidfunk/mike.git"
69
72
  rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac"
70
73
 
71
74
  [tool.slotscheck]
72
- strict-imports = true
73
- require-superclass = true
74
75
  require-subclass = true
75
76
  exclude-modules = """
76
77
  (
@@ -1,12 +1,16 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.3.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]
@@ -45,6 +49,8 @@ dev = [
45
49
  "python-snappy>=0.7.3",
46
50
  "pytest-xdist[psutil]>=3.8.0",
47
51
  "pytest-cov>=7.1.0",
52
+ "pyyaml>=6.0.3",
53
+ "types-pyyaml>=6.0.12.20260906",
48
54
  ]
49
55
  docs = ["mike", "tomli-w>=1.2.0", "zensical>=0.0.68"]
50
56
  memory = ["pytest-memray>=1.11.0"]
@@ -53,18 +59,17 @@ memory = ["pytest-memray>=1.11.0"]
53
59
  requires = ["uv_build>=0.12.18,<0.13.0"]
54
60
  build-backend = "uv_build"
55
61
 
56
- [tool.uv.build-backend]
57
- module-root = "src"
58
-
59
62
  [tool.uv]
63
+ # uv 0.12.9 / lock context: https://docs.astral.sh/uv/reference/settings/#environments
64
+ # The default is all environments. We override it because development dependencies and their lock resolution target Linux.
60
65
  environments = ["sys_platform == 'linux'"]
61
66
 
62
67
  [tool.uv.sources]
63
68
  mike = { git = "https://github.com/squidfunk/mike.git", rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac" } # pragma: allowlist secret
64
69
 
65
70
  [tool.slotscheck]
66
- strict-imports = true
67
- require-superclass = true
71
+ # slotscheck 0.21.0: https://github.com/ariebovenberg/slotscheck/blob/v0.21.0/src/slotscheck/config.py
72
+ # require-subclass: The default is false. We override it because subclasses must preserve their slotted parent's memory contract.
68
73
  require-subclass = true
69
74
  exclude-modules = '''
70
75
  (
@@ -77,10 +82,15 @@ exclude-modules = '''
77
82
  paths = ["src", "tests", "benchmarks", ".vulture_whitelist"]
78
83
 
79
84
  [tool.ruff-extra-rules]
85
+ # ruff-extra-rules v0.5.0: https://github.com/alessio-locatelli/ruff-extra-rules/blob/v0.5.0/README.md
86
+ # The default is false. We override it because hook runs should apply available repairs.
80
87
  fix = true
81
88
  exclude = ["specifications"]
82
89
  extend-select = ["unused-pytriage"]
83
90
  [tool.ruff-extra-rules.meaningless-vars]
91
+ # ruff-extra-rules v0.5.0: https://github.com/alessio-locatelli/ruff-extra-rules/tree/v0.5.0/src/pre_commit_hooks/ast_checks
92
+ # For meaningless-vars, redundant-assignment, redundant-type-conversion, redundant-dict-get, and defined-far-from-use level:
93
+ # The default is conservative. We override it because repository quality checks must include the broader supported simplifications and naming diagnostics.
84
94
  level = "aggressive"
85
95
  [tool.ruff-extra-rules.redundant-assignment]
86
96
  level = "aggressive"
@@ -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:
@@ -178,7 +178,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
178
178
  )
179
179
  effective_collation = effective_find_one_collation(
180
180
  collation,
181
- self._database.manager.default_collation_for(self._namespace()),
181
+ self._database.manager._default_collation_for(self._namespace()), # noqa: SLF001
182
182
  )
183
183
  if collation is None and effective_collation is not None:
184
184
  read_shape = canonicalize(
@@ -195,7 +195,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
195
195
  )
196
196
  namespace = self._namespace()
197
197
  cache = self._database.manager.cache_core
198
- known_keys = await self._database.manager.unique_keys_for(namespace)
198
+ known_keys = await self._database.manager._unique_keys_for(namespace) # noqa: SLF001
199
199
  unique_key_match = (
200
200
  match_unique_key(filter_query, known_keys, effective_collation)
201
201
  if identity is NO_IDENTITY and known_keys is not None
@@ -495,7 +495,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
495
495
  async def _match_unique_key(
496
496
  self, filter_query: object, effective_collation: Mapping[str, Any] | None
497
497
  ) -> tuple[UniqueKeyDefinition, tuple[Any, ...]] | None:
498
- keys = await self._database.manager.unique_keys_for(
498
+ keys = await self._database.manager._unique_keys_for( # noqa: SLF001
499
499
  self._namespace(), self._list_indexes_probe
500
500
  )
501
501
  if not keys:
@@ -655,7 +655,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
655
655
  return self._forced_database
656
656
 
657
657
  async def _cache_ineligibility_reason(self) -> BypassReason | None:
658
- return await self._database.manager.cache_ineligibility_reason(
658
+ return await self._database.manager._cache_ineligibility_reason( # noqa: SLF001
659
659
  self._namespace(), self._probe_collection
660
660
  )
661
661
 
@@ -76,21 +76,13 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
76
76
  def active_stream_cost_databases(self) -> list[str]:
77
77
  return self._cache.active_stream_cost_databases()
78
78
 
79
- async def ensure_cache_eligible(
80
- self,
81
- namespace: NamespaceId,
82
- collection_probe: Callable[[], Awaitable[CollectionProbeResult | None]],
83
- ) -> bool:
84
- return (
85
- await self.cache_ineligibility_reason(namespace, collection_probe) is None
86
- )
87
-
88
- async def cache_ineligibility_reason(
79
+ async def _cache_ineligibility_reason(
89
80
  self,
90
81
  namespace: NamespaceId,
91
82
  collection_probe: Callable[[], Awaitable[CollectionProbeResult | None]],
92
83
  ) -> BypassReason | None:
93
- await self._coordinator.activate_database(namespace.database)
84
+ if await self._coordinator.activate_database(namespace.database) is None:
85
+ return BypassReason.STREAM_UNAVAILABLE
94
86
  if not self._cache.is_database_available(namespace.database):
95
87
  return BypassReason.STREAM_UNAVAILABLE
96
88
  current_epoch = self._cache.current_epoch(namespace)
@@ -112,23 +104,25 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
112
104
  self._metadata.put(namespace, cached)
113
105
  return cached.bypass_reason
114
106
 
115
- def default_collation_for(self, namespace: NamespaceId) -> Mapping[str, Any] | None:
107
+ def _default_collation_for(
108
+ self, namespace: NamespaceId
109
+ ) -> Mapping[str, Any] | None:
116
110
  cached = self._metadata.get(namespace)
117
111
  return cached.default_collation if cached is not None else None
118
112
 
119
113
  @overload
120
- async def unique_keys_for(
114
+ async def _unique_keys_for(
121
115
  self,
122
116
  namespace: NamespaceId,
123
117
  list_indexes: Callable[[], Awaitable[Sequence[Mapping[str, Any]] | None]],
124
118
  ) -> tuple[UniqueKeyDefinition, ...]: ...
125
119
 
126
120
  @overload
127
- async def unique_keys_for(
121
+ async def _unique_keys_for(
128
122
  self, namespace: NamespaceId, list_indexes: None = None
129
123
  ) -> tuple[UniqueKeyDefinition, ...] | None: ...
130
124
 
131
- async def unique_keys_for(
125
+ async def _unique_keys_for(
132
126
  self,
133
127
  namespace: NamespaceId,
134
128
  list_indexes: Callable[[], Awaitable[Sequence[Mapping[str, Any]] | None]]
@@ -154,7 +148,7 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
154
148
  def __getitem__(self, name: str) -> CachedDatabase[DocumentType]:
155
149
  return CachedDatabase(self, self._client[name])
156
150
 
157
- def cached(
151
+ def get_cached_collection(
158
152
  self, collection: AsyncCollection[DocumentType]
159
153
  ) -> CachedCollection[DocumentType]:
160
154
  if collection.database.client is not self._client:
@@ -166,8 +160,10 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
166
160
  return CachedCollection(CachedDatabase(self, collection.database), collection)
167
161
 
168
162
  async def close(self) -> None:
169
- await self._coordinator.close()
170
- self._cache.close()
163
+ try:
164
+ await self._coordinator.close()
165
+ finally:
166
+ self._cache.close()
171
167
 
172
168
  async def __aenter__(self) -> Self:
173
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()
@@ -178,7 +178,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
178
178
  )
179
179
  effective_collation = effective_find_one_collation(
180
180
  collation,
181
- self._database.manager.default_collation_for(self._namespace()),
181
+ self._database.manager._default_collation_for(self._namespace()), # noqa: SLF001
182
182
  )
183
183
  if collation is None and effective_collation is not None:
184
184
  read_shape = canonicalize(
@@ -195,7 +195,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
195
195
  )
196
196
  namespace = self._namespace()
197
197
  cache = self._database.manager.cache_core
198
- known_keys = self._database.manager.unique_keys_for(namespace)
198
+ known_keys = self._database.manager._unique_keys_for(namespace) # noqa: SLF001
199
199
  unique_key_match = (
200
200
  match_unique_key(filter_query, known_keys, effective_collation)
201
201
  if identity is NO_IDENTITY and known_keys is not None
@@ -491,7 +491,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
491
491
  def _match_unique_key(
492
492
  self, filter_query: object, effective_collation: Mapping[str, Any] | None
493
493
  ) -> tuple[UniqueKeyDefinition, tuple[Any, ...]] | None:
494
- keys = self._database.manager.unique_keys_for(
494
+ keys = self._database.manager._unique_keys_for( # noqa: SLF001
495
495
  self._namespace(), self._list_indexes_probe
496
496
  )
497
497
  if not keys:
@@ -651,7 +651,7 @@ class CachedCollection[DocumentType: Mapping[str, Any]]:
651
651
  return self._forced_database
652
652
 
653
653
  def _cache_ineligibility_reason(self) -> BypassReason | None:
654
- return self._database.manager.cache_ineligibility_reason(
654
+ return self._database.manager._cache_ineligibility_reason( # noqa: SLF001
655
655
  self._namespace(), self._probe_collection
656
656
  )
657
657
 
@@ -76,19 +76,13 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
76
76
  def active_stream_cost_databases(self) -> list[str]:
77
77
  return self._cache.active_stream_cost_databases()
78
78
 
79
- def ensure_cache_eligible(
80
- self,
81
- namespace: NamespaceId,
82
- collection_probe: Callable[[], CollectionProbeResult | None],
83
- ) -> bool:
84
- return self.cache_ineligibility_reason(namespace, collection_probe) is None
85
-
86
- def cache_ineligibility_reason(
79
+ def _cache_ineligibility_reason(
87
80
  self,
88
81
  namespace: NamespaceId,
89
82
  collection_probe: Callable[[], CollectionProbeResult | None],
90
83
  ) -> BypassReason | None:
91
- self._coordinator.activate_database(namespace.database)
84
+ if self._coordinator.activate_database(namespace.database) is None:
85
+ return BypassReason.STREAM_UNAVAILABLE
92
86
  if not self._cache.is_database_available(namespace.database):
93
87
  return BypassReason.STREAM_UNAVAILABLE
94
88
  current_epoch = self._cache.current_epoch(namespace)
@@ -110,23 +104,25 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
110
104
  self._metadata.put(namespace, cached)
111
105
  return cached.bypass_reason
112
106
 
113
- def default_collation_for(self, namespace: NamespaceId) -> Mapping[str, Any] | None:
107
+ def _default_collation_for(
108
+ self, namespace: NamespaceId
109
+ ) -> Mapping[str, Any] | None:
114
110
  cached = self._metadata.get(namespace)
115
111
  return cached.default_collation if cached is not None else None
116
112
 
117
113
  @overload
118
- def unique_keys_for(
114
+ def _unique_keys_for(
119
115
  self,
120
116
  namespace: NamespaceId,
121
117
  list_indexes: Callable[[], Sequence[Mapping[str, Any]] | None],
122
118
  ) -> tuple[UniqueKeyDefinition, ...]: ...
123
119
 
124
120
  @overload
125
- def unique_keys_for(
121
+ def _unique_keys_for(
126
122
  self, namespace: NamespaceId, list_indexes: None = None
127
123
  ) -> tuple[UniqueKeyDefinition, ...] | None: ...
128
124
 
129
- def unique_keys_for(
125
+ def _unique_keys_for(
130
126
  self,
131
127
  namespace: NamespaceId,
132
128
  list_indexes: Callable[[], Sequence[Mapping[str, Any]] | None] | None = None,
@@ -151,7 +147,7 @@ class CacheManager[DocumentType: Mapping[str, Any]]:
151
147
  def __getitem__(self, name: str) -> CachedDatabase[DocumentType]:
152
148
  return CachedDatabase(self, self._client[name])
153
149
 
154
- def cached(
150
+ def get_cached_collection(
155
151
  self, collection: Collection[DocumentType]
156
152
  ) -> CachedCollection[DocumentType]:
157
153
  if collection.database.client is not self._client:
@@ -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()