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.
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/PKG-INFO +6 -4
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/README.md +2 -2
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/pyproject.toml +8 -7
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/pyproject.toml.orig +17 -7
- client_query_cache-0.5.0/src/client_query_cache/_core/stream_activation.py +31 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_health.py +4 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/collection.py +4 -4
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/manager.py +14 -18
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/streams.py +118 -42
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/collection.py +4 -4
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/manager.py +10 -14
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/streams.py +118 -50
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/__init__.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/__init__.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/canonical.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/codec.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collation.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collection_metadata.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/count_reads.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/cursor_capture.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/entries.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/errors.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_one_reads.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_reads.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/identity_reads.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/keys.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lifecycle.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/locking.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lru.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/manager.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/namespace.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/projection.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/query_filters.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_classification.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/read_validation.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/snapshots.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_cost.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_events.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_options.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/traversal.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/unique_keys.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/__init__.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/cursors.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/database.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/otel.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/py.typed +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/__init__.py +0 -0
- {client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/cursors.py +0 -0
- {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
|
+
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
|
|
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
|
-
[](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.
|
|
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
|
-
[](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.
|
|
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
|
+
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
|
+
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
|
+
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
|
|
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
|
-
|
|
67
|
-
require-
|
|
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)
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_health.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
658
|
+
return await self._database.manager._cache_ineligibility_reason( # noqa: SLF001
|
|
659
659
|
self._namespace(), self._probe_collection
|
|
660
660
|
)
|
|
661
661
|
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/manager.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/streams.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
return self._supervisors[name]
|
|
301
321
|
except KeyError:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
385
|
+
if self._shutdown_task is None:
|
|
330
386
|
self._closed = True
|
|
331
387
|
self._health_registry.close()
|
|
332
|
-
supervisors =
|
|
333
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
654
|
+
return self._database.manager._cache_ineligibility_reason( # noqa: SLF001
|
|
655
655
|
self._namespace(), self._probe_collection
|
|
656
656
|
)
|
|
657
657
|
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/manager.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
121
|
+
def _unique_keys_for(
|
|
126
122
|
self, namespace: NamespaceId, list_indexes: None = None
|
|
127
123
|
) -> tuple[UniqueKeyDefinition, ...] | None: ...
|
|
128
124
|
|
|
129
|
-
def
|
|
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
|
|
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:
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/streams.py
RENAMED
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
self.
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
307
|
+
return self._supervisors[name]
|
|
295
308
|
except KeyError:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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()
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/__init__.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/canonical.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/collation.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/count_reads.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/cursor_capture.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/entries.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/errors.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_one_reads.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/find_reads.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/identity_reads.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/lifecycle.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/locking.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/manager.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/namespace.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/projection.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/query_filters.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/snapshots.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_cost.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_events.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/stream_options.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/traversal.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/_core/unique_keys.py
RENAMED
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/asynchronous/cursors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/__init__.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/cursors.py
RENAMED
|
File without changes
|
{client_query_cache-0.3.0 → client_query_cache-0.5.0}/src/client_query_cache/synchronous/database.py
RENAMED
|
File without changes
|