client-query-cache 0.1.0__py3-none-any.whl
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/__init__.py +15 -0
- client_query_cache/_core/__init__.py +44 -0
- client_query_cache/_core/canonical.py +63 -0
- client_query_cache/_core/codec.py +55 -0
- client_query_cache/_core/collation.py +22 -0
- client_query_cache/_core/collection_metadata.py +65 -0
- client_query_cache/_core/entries.py +32 -0
- client_query_cache/_core/errors.py +25 -0
- client_query_cache/_core/identity_reads.py +64 -0
- client_query_cache/_core/keys.py +43 -0
- client_query_cache/_core/lifecycle.py +8 -0
- client_query_cache/_core/locking.py +40 -0
- client_query_cache/_core/lru.py +109 -0
- client_query_cache/_core/manager.py +892 -0
- client_query_cache/_core/namespace.py +35 -0
- client_query_cache/_core/order_sensitive_keys.py +69 -0
- client_query_cache/_core/projection.py +59 -0
- client_query_cache/_core/read_validation.py +89 -0
- client_query_cache/_core/snapshots.py +67 -0
- client_query_cache/_core/stream_cost.py +242 -0
- client_query_cache/_core/stream_events.py +157 -0
- client_query_cache/_core/stream_health.py +47 -0
- client_query_cache/_core/stream_options.py +14 -0
- client_query_cache/_core/unique_keys.py +119 -0
- client_query_cache/asynchronous/__init__.py +16 -0
- client_query_cache/asynchronous/collection.py +682 -0
- client_query_cache/asynchronous/database.py +59 -0
- client_query_cache/asynchronous/manager.py +118 -0
- client_query_cache/asynchronous/streams.py +300 -0
- client_query_cache/otel.py +206 -0
- client_query_cache/py.typed +0 -0
- client_query_cache/synchronous/__init__.py +16 -0
- client_query_cache/synchronous/collection.py +678 -0
- client_query_cache/synchronous/database.py +53 -0
- client_query_cache/synchronous/manager.py +118 -0
- client_query_cache/synchronous/streams.py +297 -0
- client_query_cache-0.1.0.dist-info/METADATA +114 -0
- client_query_cache-0.1.0.dist-info/RECORD +39 -0
- client_query_cache-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from pymongo.asynchronous.collection import AsyncCollection
|
|
8
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
9
|
+
|
|
10
|
+
from client_query_cache.asynchronous.collection import CachedCollection
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from client_query_cache.asynchronous.manager import CacheManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CachedDatabase[DocumentType: Mapping[str, Any]]:
|
|
17
|
+
__slots__ = ("_database", "_manager")
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, manager: CacheManager[DocumentType], database: AsyncDatabase[DocumentType]
|
|
21
|
+
) -> None:
|
|
22
|
+
self._manager = manager
|
|
23
|
+
self._database = database
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def manager(self) -> CacheManager[DocumentType]:
|
|
27
|
+
return self._manager
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return self._database.name
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def raw(self) -> AsyncDatabase[DocumentType]:
|
|
35
|
+
return self._database
|
|
36
|
+
|
|
37
|
+
def __getitem__(self, name: str) -> CachedCollection[DocumentType]:
|
|
38
|
+
return CachedCollection(self, self._database[name])
|
|
39
|
+
|
|
40
|
+
def __getattr__(self, name: str) -> Any: # noqa: ANN401
|
|
41
|
+
return self._wrap_delegated(getattr(self._database, name))
|
|
42
|
+
|
|
43
|
+
def _wrap_delegated(self, value: Any) -> Any: # noqa: ANN401
|
|
44
|
+
if isinstance(value, AsyncCollection):
|
|
45
|
+
return CachedCollection(self, value)
|
|
46
|
+
if isinstance(value, AsyncDatabase):
|
|
47
|
+
return CachedDatabase(self._manager, value)
|
|
48
|
+
if inspect.iscoroutine(value):
|
|
49
|
+
return self._await_and_wrap(value)
|
|
50
|
+
if not callable(value):
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
def _delegate(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
54
|
+
return self._wrap_delegated(value(*args, **kwargs))
|
|
55
|
+
|
|
56
|
+
return _delegate
|
|
57
|
+
|
|
58
|
+
async def _await_and_wrap(self, coroutine: Any) -> Any: # noqa: ANN401
|
|
59
|
+
return self._wrap_delegated(await coroutine)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Mapping
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
5
|
+
|
|
6
|
+
from client_query_cache._core.collection_metadata import (
|
|
7
|
+
CollectionMetadata,
|
|
8
|
+
CollectionMetadataCache,
|
|
9
|
+
)
|
|
10
|
+
from client_query_cache._core.manager import CacheCore
|
|
11
|
+
from client_query_cache._core.stream_options import (
|
|
12
|
+
DEFAULT_MAX_AWAIT_TIME_MS,
|
|
13
|
+
validate_max_await_time_ms,
|
|
14
|
+
)
|
|
15
|
+
from client_query_cache._core.unique_keys import (
|
|
16
|
+
UniqueKeyMetadata,
|
|
17
|
+
UniqueKeyMetadataCache,
|
|
18
|
+
discover_unique_keys,
|
|
19
|
+
)
|
|
20
|
+
from client_query_cache.asynchronous.database import CachedDatabase
|
|
21
|
+
from client_query_cache.asynchronous.streams import ChangeStreamCoordinator
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Sequence
|
|
25
|
+
|
|
26
|
+
from pymongo import AsyncMongoClient
|
|
27
|
+
|
|
28
|
+
from client_query_cache._core.collection_metadata import CollectionProbeResult
|
|
29
|
+
from client_query_cache._core.keys import NamespaceId
|
|
30
|
+
from client_query_cache._core.manager import CacheCoreConfig
|
|
31
|
+
from client_query_cache._core.unique_keys import UniqueKeyDefinition
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CacheManager[DocumentType: Mapping[str, Any]]:
|
|
35
|
+
__slots__ = ("_cache", "_client", "_coordinator", "_metadata", "_unique_keys")
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
client: AsyncMongoClient[DocumentType],
|
|
40
|
+
*,
|
|
41
|
+
cache_config: CacheCoreConfig | None = None,
|
|
42
|
+
max_await_time_ms: int = DEFAULT_MAX_AWAIT_TIME_MS,
|
|
43
|
+
) -> None:
|
|
44
|
+
validate_max_await_time_ms(max_await_time_ms)
|
|
45
|
+
self._client = client
|
|
46
|
+
self._cache = CacheCore(cache_config)
|
|
47
|
+
self._coordinator = ChangeStreamCoordinator(
|
|
48
|
+
client, self._cache, max_await_time_ms=max_await_time_ms
|
|
49
|
+
)
|
|
50
|
+
self._metadata = CollectionMetadataCache()
|
|
51
|
+
self._unique_keys = UniqueKeyMetadataCache()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def client(self) -> AsyncMongoClient[DocumentType]:
|
|
55
|
+
return self._client
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def cache_core(self) -> CacheCore:
|
|
59
|
+
return self._cache
|
|
60
|
+
|
|
61
|
+
async def ensure_cache_eligible(
|
|
62
|
+
self,
|
|
63
|
+
namespace: NamespaceId,
|
|
64
|
+
collection_probe: Callable[[], Awaitable[CollectionProbeResult | None]],
|
|
65
|
+
) -> bool:
|
|
66
|
+
await self._coordinator.activate_database(namespace.database)
|
|
67
|
+
if not self._cache.is_database_available(namespace.database):
|
|
68
|
+
return False
|
|
69
|
+
current_epoch = self._cache.current_epoch(namespace)
|
|
70
|
+
cached = self._metadata.get(namespace)
|
|
71
|
+
if cached is None or cached.checked_epoch != current_epoch:
|
|
72
|
+
probe_result = await collection_probe()
|
|
73
|
+
if probe_result is None:
|
|
74
|
+
return False
|
|
75
|
+
cached = CollectionMetadata(
|
|
76
|
+
checked_epoch=current_epoch,
|
|
77
|
+
is_cacheable=probe_result.is_cacheable,
|
|
78
|
+
default_collation=probe_result.default_collation,
|
|
79
|
+
)
|
|
80
|
+
self._metadata.put(namespace, cached)
|
|
81
|
+
return cached.is_cacheable
|
|
82
|
+
|
|
83
|
+
def default_collation_for(self, namespace: NamespaceId) -> Mapping[str, Any] | None:
|
|
84
|
+
cached = self._metadata.get(namespace)
|
|
85
|
+
return cached.default_collation if cached is not None else None
|
|
86
|
+
|
|
87
|
+
async def unique_keys_for(
|
|
88
|
+
self,
|
|
89
|
+
namespace: NamespaceId,
|
|
90
|
+
list_indexes: Callable[[], Awaitable[Sequence[Mapping[str, Any]] | None]],
|
|
91
|
+
) -> tuple[UniqueKeyDefinition, ...]:
|
|
92
|
+
current_generation = self._cache.current_index_generation(namespace)
|
|
93
|
+
cached = self._unique_keys.get(namespace)
|
|
94
|
+
if cached is None or cached.checked_index_generation != current_generation:
|
|
95
|
+
index_specs = await list_indexes()
|
|
96
|
+
if index_specs is None:
|
|
97
|
+
return ()
|
|
98
|
+
if self._cache.current_index_generation(namespace) != current_generation:
|
|
99
|
+
return ()
|
|
100
|
+
cached = UniqueKeyMetadata(
|
|
101
|
+
checked_index_generation=current_generation,
|
|
102
|
+
keys=discover_unique_keys(index_specs),
|
|
103
|
+
)
|
|
104
|
+
self._unique_keys.put(namespace, cached)
|
|
105
|
+
return cached.keys
|
|
106
|
+
|
|
107
|
+
def __getitem__(self, name: str) -> CachedDatabase[DocumentType]:
|
|
108
|
+
return CachedDatabase(self, self._client[name])
|
|
109
|
+
|
|
110
|
+
async def close(self) -> None:
|
|
111
|
+
await self._coordinator.close()
|
|
112
|
+
self._cache.close()
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> Self:
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
118
|
+
await self.close()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import bson
|
|
9
|
+
from bson.errors import BSONError
|
|
10
|
+
from pymongo.errors import OperationFailure, PyMongoError
|
|
11
|
+
|
|
12
|
+
from client_query_cache._core.errors import StreamLifecycleError, StreamStartupError
|
|
13
|
+
from client_query_cache._core.stream_events import (
|
|
14
|
+
build_change_stream_pipeline,
|
|
15
|
+
is_unresumable_change_stream_error,
|
|
16
|
+
route_change_event,
|
|
17
|
+
)
|
|
18
|
+
from client_query_cache._core.stream_health import RetryBackoff, StreamHealth
|
|
19
|
+
from client_query_cache._core.stream_options import DEFAULT_MAX_AWAIT_TIME_MS
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from pymongo import AsyncMongoClient
|
|
25
|
+
from pymongo.asynchronous.change_stream import AsyncDatabaseChangeStream
|
|
26
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
27
|
+
|
|
28
|
+
from client_query_cache._core.manager import CacheCore
|
|
29
|
+
|
|
30
|
+
MINIMUM_SERVER_VERSION = (8, 0)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DatabaseStreamSupervisor:
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"_backoff",
|
|
38
|
+
"_cache",
|
|
39
|
+
"_database",
|
|
40
|
+
"_health",
|
|
41
|
+
"_max_await_time_ms",
|
|
42
|
+
"_resume_token",
|
|
43
|
+
"_stop_event",
|
|
44
|
+
"_stream",
|
|
45
|
+
"_task",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
database: AsyncDatabase[Any],
|
|
51
|
+
cache: CacheCore,
|
|
52
|
+
*,
|
|
53
|
+
backoff: RetryBackoff | None = None,
|
|
54
|
+
max_await_time_ms: int = DEFAULT_MAX_AWAIT_TIME_MS,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._database = database
|
|
57
|
+
self._cache = cache
|
|
58
|
+
self._backoff = backoff if backoff is not None else RetryBackoff()
|
|
59
|
+
self._max_await_time_ms = max_await_time_ms
|
|
60
|
+
self._health = StreamHealth.STARTING
|
|
61
|
+
self._stream: AsyncDatabaseChangeStream[Any] | None = None
|
|
62
|
+
self._resume_token: Mapping[str, Any] | None = None
|
|
63
|
+
self._stop_event = asyncio.Event()
|
|
64
|
+
self._task: asyncio.Task[None] | None = None
|
|
65
|
+
self._cache.set_database_available(self._database.name, available=False)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def healthy(self) -> bool:
|
|
69
|
+
return self._health is StreamHealth.HEALTHY
|
|
70
|
+
|
|
71
|
+
async def start(self) -> None:
|
|
72
|
+
if self._health is not StreamHealth.STARTING:
|
|
73
|
+
message = "start() may only be called once per supervisor instance"
|
|
74
|
+
raise StreamLifecycleError(message)
|
|
75
|
+
self._set_health(StreamHealth.CONNECTING)
|
|
76
|
+
self._clear_namespaces_for_database()
|
|
77
|
+
try:
|
|
78
|
+
await self._ensure_server_supports_expanded_events()
|
|
79
|
+
await self._open_stream(resume_token=None, use_start_after=False)
|
|
80
|
+
except StreamStartupError:
|
|
81
|
+
self._set_health(StreamHealth.CLOSED)
|
|
82
|
+
raise
|
|
83
|
+
except PyMongoError as exc:
|
|
84
|
+
self._set_health(StreamHealth.CLOSED)
|
|
85
|
+
message = (
|
|
86
|
+
f"failed to open change stream for database {self._database.name!r}"
|
|
87
|
+
)
|
|
88
|
+
raise StreamStartupError(message) from exc
|
|
89
|
+
except asyncio.CancelledError:
|
|
90
|
+
self._set_health(StreamHealth.CLOSED)
|
|
91
|
+
stream = self._stream
|
|
92
|
+
if stream is not None:
|
|
93
|
+
with contextlib.suppress(PyMongoError):
|
|
94
|
+
await stream.close()
|
|
95
|
+
raise
|
|
96
|
+
if self._stop_event.is_set():
|
|
97
|
+
assert self._stream is not None
|
|
98
|
+
with contextlib.suppress(PyMongoError):
|
|
99
|
+
await self._stream.close()
|
|
100
|
+
self._set_health(StreamHealth.CLOSED)
|
|
101
|
+
message = "stop() was called while start() was still connecting"
|
|
102
|
+
raise StreamLifecycleError(message)
|
|
103
|
+
self._set_health(StreamHealth.HEALTHY)
|
|
104
|
+
self._task = asyncio.ensure_future(self._run())
|
|
105
|
+
|
|
106
|
+
async def stop(self) -> None:
|
|
107
|
+
self._stop_event.set()
|
|
108
|
+
self._set_health(StreamHealth.CLOSED)
|
|
109
|
+
task = self._task
|
|
110
|
+
if task is not None:
|
|
111
|
+
task.cancel()
|
|
112
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
113
|
+
await task
|
|
114
|
+
stream = self._stream
|
|
115
|
+
if stream is not None:
|
|
116
|
+
try:
|
|
117
|
+
await stream.close()
|
|
118
|
+
except PyMongoError:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"change stream close failed during shutdown",
|
|
121
|
+
extra={"database": self._database.name},
|
|
122
|
+
exc_info=True,
|
|
123
|
+
)
|
|
124
|
+
self._set_health(StreamHealth.CLOSED)
|
|
125
|
+
|
|
126
|
+
def _set_health(self, health: StreamHealth) -> None:
|
|
127
|
+
if self._stop_event.is_set() and health is StreamHealth.HEALTHY:
|
|
128
|
+
health = StreamHealth.CLOSED
|
|
129
|
+
self._cache.set_database_available(
|
|
130
|
+
self._database.name, available=health is StreamHealth.HEALTHY
|
|
131
|
+
)
|
|
132
|
+
self._health = health
|
|
133
|
+
|
|
134
|
+
async def _ensure_server_supports_expanded_events(self) -> None:
|
|
135
|
+
server_info = await self._database.client.server_info()
|
|
136
|
+
version = tuple(server_info["versionArray"][:2])
|
|
137
|
+
if version < MINIMUM_SERVER_VERSION:
|
|
138
|
+
message = (
|
|
139
|
+
f"MongoDB server version {server_info['version']} is not supported; "
|
|
140
|
+
"client_query_cache requires MongoDB 8.0 or newer"
|
|
141
|
+
)
|
|
142
|
+
raise StreamStartupError(message)
|
|
143
|
+
|
|
144
|
+
async def _open_stream(
|
|
145
|
+
self, *, resume_token: Mapping[str, Any] | None, use_start_after: bool
|
|
146
|
+
) -> None:
|
|
147
|
+
kwargs: dict[str, Any] = {
|
|
148
|
+
"show_expanded_events": True,
|
|
149
|
+
"max_await_time_ms": self._max_await_time_ms,
|
|
150
|
+
}
|
|
151
|
+
if resume_token is not None:
|
|
152
|
+
if use_start_after:
|
|
153
|
+
kwargs["start_after"] = resume_token
|
|
154
|
+
else:
|
|
155
|
+
kwargs["resume_after"] = resume_token
|
|
156
|
+
previous_stream = self._stream
|
|
157
|
+
self._stream = await self._database.watch(
|
|
158
|
+
build_change_stream_pipeline(), **kwargs
|
|
159
|
+
)
|
|
160
|
+
if previous_stream is not None:
|
|
161
|
+
with contextlib.suppress(PyMongoError):
|
|
162
|
+
await previous_stream.close()
|
|
163
|
+
if self._stop_event.is_set():
|
|
164
|
+
with contextlib.suppress(PyMongoError):
|
|
165
|
+
await self._stream.close()
|
|
166
|
+
|
|
167
|
+
async def _interruptible_sleep(self, delay: float) -> bool:
|
|
168
|
+
try:
|
|
169
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
|
|
170
|
+
except TimeoutError:
|
|
171
|
+
return False
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
async def _run(self) -> None:
|
|
175
|
+
while not self._stop_event.is_set():
|
|
176
|
+
assert self._stream is not None
|
|
177
|
+
self._cache.record_stream_poll(self._database.name)
|
|
178
|
+
try:
|
|
179
|
+
event = await self._stream.next()
|
|
180
|
+
except StopAsyncIteration, PyMongoError:
|
|
181
|
+
await self._handle_stream_failure()
|
|
182
|
+
continue
|
|
183
|
+
self._resume_token = self._stream.resume_token
|
|
184
|
+
must_reopen = route_change_event(self._cache, self._database.name, event)
|
|
185
|
+
self._record_logical_event_bytes(event)
|
|
186
|
+
if must_reopen:
|
|
187
|
+
await self._reopen_after_invalidate()
|
|
188
|
+
|
|
189
|
+
def _record_logical_event_bytes(self, event: Mapping[str, Any]) -> None:
|
|
190
|
+
try:
|
|
191
|
+
encoded_length = len(
|
|
192
|
+
bson.encode(dict(event), codec_options=self._database.codec_options)
|
|
193
|
+
)
|
|
194
|
+
except BSONError, TypeError, ValueError:
|
|
195
|
+
return
|
|
196
|
+
self._cache.record_logical_event_bytes(self._database.name, encoded_length)
|
|
197
|
+
|
|
198
|
+
async def _handle_stream_failure(self) -> None:
|
|
199
|
+
if self._stop_event.is_set():
|
|
200
|
+
return
|
|
201
|
+
self._set_health(StreamHealth.RECONNECTING)
|
|
202
|
+
if self._resume_token is None:
|
|
203
|
+
self._clear_namespaces_for_database()
|
|
204
|
+
await self._reopen_with_backoff(use_start_after=False)
|
|
205
|
+
|
|
206
|
+
async def _reopen_after_invalidate(self) -> None:
|
|
207
|
+
self._set_health(StreamHealth.RECONNECTING)
|
|
208
|
+
await self._reopen_with_backoff(use_start_after=True)
|
|
209
|
+
|
|
210
|
+
async def _reopen_with_backoff(self, *, use_start_after: bool) -> None:
|
|
211
|
+
while not self._stop_event.is_set():
|
|
212
|
+
try:
|
|
213
|
+
await self._open_stream(
|
|
214
|
+
resume_token=self._resume_token, use_start_after=use_start_after
|
|
215
|
+
)
|
|
216
|
+
except PyMongoError as exc:
|
|
217
|
+
if isinstance(
|
|
218
|
+
exc, OperationFailure
|
|
219
|
+
) and is_unresumable_change_stream_error(exc):
|
|
220
|
+
self._clear_namespaces_for_database()
|
|
221
|
+
self._resume_token = None
|
|
222
|
+
use_start_after = False
|
|
223
|
+
continue
|
|
224
|
+
delay = self._backoff.next_delay()
|
|
225
|
+
logger.warning(
|
|
226
|
+
"change stream reconnect failed, retrying with backoff",
|
|
227
|
+
extra={"database": self._database.name, "delay_seconds": delay},
|
|
228
|
+
exc_info=exc,
|
|
229
|
+
)
|
|
230
|
+
if await self._interruptible_sleep(delay):
|
|
231
|
+
return
|
|
232
|
+
continue
|
|
233
|
+
else:
|
|
234
|
+
self._backoff.reset()
|
|
235
|
+
if not self._stop_event.is_set():
|
|
236
|
+
self._set_health(StreamHealth.HEALTHY)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
def _clear_namespaces_for_database(self) -> None:
|
|
240
|
+
for namespace in self._cache.namespaces_for_database(self._database.name):
|
|
241
|
+
self._cache.clear_namespace(namespace)
|
|
242
|
+
self._cache.reset_stream_cost_statistics(self._database.name)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ChangeStreamCoordinator:
|
|
246
|
+
__slots__ = (
|
|
247
|
+
"_cache",
|
|
248
|
+
"_client",
|
|
249
|
+
"_closed",
|
|
250
|
+
"_lock",
|
|
251
|
+
"_max_await_time_ms",
|
|
252
|
+
"_supervisors",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
client: AsyncMongoClient[Any],
|
|
258
|
+
cache: CacheCore,
|
|
259
|
+
*,
|
|
260
|
+
max_await_time_ms: int = DEFAULT_MAX_AWAIT_TIME_MS,
|
|
261
|
+
) -> None:
|
|
262
|
+
self._client = client
|
|
263
|
+
self._max_await_time_ms = max_await_time_ms
|
|
264
|
+
self._cache = cache
|
|
265
|
+
self._supervisors: dict[str, DatabaseStreamSupervisor] = {}
|
|
266
|
+
self._closed = False
|
|
267
|
+
self._lock = asyncio.Lock()
|
|
268
|
+
|
|
269
|
+
async def activate_database(self, name: str) -> DatabaseStreamSupervisor | None:
|
|
270
|
+
async with self._lock:
|
|
271
|
+
if self._closed:
|
|
272
|
+
raise StreamLifecycleError("coordinator is closed")
|
|
273
|
+
try:
|
|
274
|
+
supervisor = self._supervisors[name]
|
|
275
|
+
except KeyError:
|
|
276
|
+
supervisor = DatabaseStreamSupervisor(
|
|
277
|
+
self._client[name],
|
|
278
|
+
self._cache,
|
|
279
|
+
max_await_time_ms=self._max_await_time_ms,
|
|
280
|
+
)
|
|
281
|
+
try:
|
|
282
|
+
await supervisor.start()
|
|
283
|
+
except StreamStartupError:
|
|
284
|
+
logger.warning(
|
|
285
|
+
"change stream startup failed for database %r; reads for "
|
|
286
|
+
"this database will bypass the cache",
|
|
287
|
+
name,
|
|
288
|
+
exc_info=True,
|
|
289
|
+
)
|
|
290
|
+
return None
|
|
291
|
+
self._supervisors[name] = supervisor
|
|
292
|
+
return supervisor
|
|
293
|
+
|
|
294
|
+
async def close(self) -> None:
|
|
295
|
+
async with self._lock:
|
|
296
|
+
self._closed = True
|
|
297
|
+
supervisors = list(self._supervisors.values())
|
|
298
|
+
self._supervisors.clear()
|
|
299
|
+
for supervisor in supervisors:
|
|
300
|
+
await supervisor.stop()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from opentelemetry.metrics import Observation
|
|
8
|
+
except ImportError as error:
|
|
9
|
+
_message = (
|
|
10
|
+
"OpenTelemetry metrics support requires the 'opentelemetry-api' package; "
|
|
11
|
+
"install it with the 'client-query-cache[otel]' extra"
|
|
12
|
+
)
|
|
13
|
+
raise ImportError(_message) from error
|
|
14
|
+
|
|
15
|
+
from client_query_cache._core.errors import CacheConfigurationError
|
|
16
|
+
from client_query_cache._core.stream_cost import (
|
|
17
|
+
INVALIDATION_LAG_CLOCK_SKEW_LIMITATION,
|
|
18
|
+
RESIDENT_BYTES_SCOPE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable, Iterable
|
|
23
|
+
|
|
24
|
+
from opentelemetry.metrics import CallbackOptions, Meter
|
|
25
|
+
|
|
26
|
+
from client_query_cache._core.manager import CacheCore
|
|
27
|
+
|
|
28
|
+
__all__ = ["register_cache_metrics"]
|
|
29
|
+
|
|
30
|
+
_DB_NAMESPACE_ATTRIBUTE = "db.namespace"
|
|
31
|
+
|
|
32
|
+
_CACHE_COUNTER_FIELDS: tuple[tuple[str, str, str], ...] = (
|
|
33
|
+
("client_query_cache.cache.hits", "hits", "Cumulative cache hits."),
|
|
34
|
+
("client_query_cache.cache.misses", "misses", "Cumulative cache misses."),
|
|
35
|
+
(
|
|
36
|
+
"client_query_cache.cache.evictions",
|
|
37
|
+
"evictions",
|
|
38
|
+
"Cumulative least-recently-used cache evictions.",
|
|
39
|
+
),
|
|
40
|
+
(
|
|
41
|
+
"client_query_cache.cache.bypasses",
|
|
42
|
+
"bypasses",
|
|
43
|
+
"Cumulative reads that bypassed the cache.",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"client_query_cache.cache.bypasses.oversized",
|
|
47
|
+
"oversized_bypasses",
|
|
48
|
+
"Cumulative cache bypasses caused by a result exceeding the max entry size.",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_STREAM_COUNTER_FIELDS: tuple[tuple[str, str, str, str], ...] = (
|
|
53
|
+
(
|
|
54
|
+
"client_query_cache.stream.polls",
|
|
55
|
+
"stream_polls",
|
|
56
|
+
"1",
|
|
57
|
+
"Cumulative manager calls to change-stream iteration, per database.",
|
|
58
|
+
),
|
|
59
|
+
(
|
|
60
|
+
"client_query_cache.stream.invalidations",
|
|
61
|
+
"invalidations",
|
|
62
|
+
"1",
|
|
63
|
+
"Cumulative invalidations applied, per database.",
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"client_query_cache.stream.logical_event_bytes",
|
|
67
|
+
"logical_event_bytes",
|
|
68
|
+
"By",
|
|
69
|
+
"Cumulative logical projected-event bytes observed, per database.",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_LAG_GAUGE_DESCRIPTION = (
|
|
74
|
+
"Simple order-statistic quantile, tagged by the 'percentile' attribute, of "
|
|
75
|
+
"currently retained invalidation-delivery-lag samples; not a confidence-interval "
|
|
76
|
+
f"estimate. {INVALIDATION_LAG_CLOCK_SKEW_LIMITATION}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _percentile(sorted_samples: tuple[float, ...], percentile: float) -> float:
|
|
81
|
+
index = round(percentile * (len(sorted_samples) - 1))
|
|
82
|
+
return sorted_samples[index]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _make_cache_counter_callback(
|
|
86
|
+
cache_core: CacheCore, field: str
|
|
87
|
+
) -> Callable[[CallbackOptions], Iterable[Observation]]:
|
|
88
|
+
def callback(_options: CallbackOptions) -> Iterable[Observation]:
|
|
89
|
+
yield Observation(getattr(cache_core.snapshot(), field))
|
|
90
|
+
|
|
91
|
+
return callback
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _register_cache_counters(meter: Meter, cache_core: CacheCore) -> None:
|
|
95
|
+
for name, field, description in _CACHE_COUNTER_FIELDS:
|
|
96
|
+
meter.create_observable_counter(
|
|
97
|
+
name,
|
|
98
|
+
callbacks=[_make_cache_counter_callback(cache_core, field)],
|
|
99
|
+
unit="1",
|
|
100
|
+
description=description,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _register_cache_gauges(meter: Meter, cache_core: CacheCore) -> None:
|
|
105
|
+
def entries_callback(_options: CallbackOptions) -> Iterable[Observation]:
|
|
106
|
+
yield Observation(cache_core.snapshot().entry_count)
|
|
107
|
+
|
|
108
|
+
def resident_bytes_callback(_options: CallbackOptions) -> Iterable[Observation]:
|
|
109
|
+
yield Observation(cache_core.snapshot().used_bytes)
|
|
110
|
+
|
|
111
|
+
meter.create_observable_gauge(
|
|
112
|
+
"client_query_cache.cache.entries",
|
|
113
|
+
callbacks=[entries_callback],
|
|
114
|
+
unit="1",
|
|
115
|
+
description="Current resident cache entry count.",
|
|
116
|
+
)
|
|
117
|
+
meter.create_observable_gauge(
|
|
118
|
+
"client_query_cache.cache.resident_bytes",
|
|
119
|
+
callbacks=[resident_bytes_callback],
|
|
120
|
+
unit="By",
|
|
121
|
+
description=RESIDENT_BYTES_SCOPE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _make_stream_counter_callback(
|
|
126
|
+
cache_core: CacheCore, field: str
|
|
127
|
+
) -> Callable[[CallbackOptions], Iterable[Observation]]:
|
|
128
|
+
def callback(_options: CallbackOptions) -> Iterable[Observation]:
|
|
129
|
+
for database in cache_core.active_stream_cost_databases():
|
|
130
|
+
snapshot = cache_core.stream_cost_snapshot(database)
|
|
131
|
+
yield Observation(
|
|
132
|
+
getattr(snapshot, field), {_DB_NAMESPACE_ATTRIBUTE: database}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return callback
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _register_stream_counters(meter: Meter, cache_core: CacheCore) -> None:
|
|
139
|
+
for name, field, unit, description in _STREAM_COUNTER_FIELDS:
|
|
140
|
+
meter.create_observable_counter(
|
|
141
|
+
name,
|
|
142
|
+
callbacks=[_make_stream_counter_callback(cache_core, field)],
|
|
143
|
+
unit=unit,
|
|
144
|
+
description=description,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _make_lag_percentile_callback(
|
|
149
|
+
cache_core: CacheCore, lag_percentiles: tuple[float, ...]
|
|
150
|
+
) -> Callable[[CallbackOptions], Iterable[Observation]]:
|
|
151
|
+
def callback(_options: CallbackOptions) -> Iterable[Observation]:
|
|
152
|
+
for database in cache_core.active_stream_cost_databases():
|
|
153
|
+
snapshot = cache_core.stream_cost_snapshot(database)
|
|
154
|
+
samples = tuple(
|
|
155
|
+
sorted(
|
|
156
|
+
value
|
|
157
|
+
for window in snapshot.invalidation_lag_windows
|
|
158
|
+
for value in window
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
if not samples:
|
|
162
|
+
continue
|
|
163
|
+
for percentile in lag_percentiles:
|
|
164
|
+
yield Observation(
|
|
165
|
+
_percentile(samples, percentile),
|
|
166
|
+
{
|
|
167
|
+
_DB_NAMESPACE_ATTRIBUTE: database,
|
|
168
|
+
"percentile": percentile,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return callback
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _register_lag_gauges(
|
|
176
|
+
meter: Meter, cache_core: CacheCore, lag_percentiles: tuple[float, ...]
|
|
177
|
+
) -> None:
|
|
178
|
+
meter.create_observable_gauge(
|
|
179
|
+
"client_query_cache.stream.invalidation_lag",
|
|
180
|
+
callbacks=[_make_lag_percentile_callback(cache_core, lag_percentiles)],
|
|
181
|
+
unit="s",
|
|
182
|
+
description=_LAG_GAUGE_DESCRIPTION,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _validate_lag_percentiles(lag_percentiles: tuple[float, ...]) -> None:
|
|
187
|
+
for percentile in lag_percentiles:
|
|
188
|
+
if not math.isfinite(percentile) or not 0.0 <= percentile <= 1.0:
|
|
189
|
+
message = (
|
|
190
|
+
f"lag_percentiles values must be finite numbers in [0.0, 1.0]; "
|
|
191
|
+
f"got {percentile!r}"
|
|
192
|
+
)
|
|
193
|
+
raise CacheConfigurationError(message)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def register_cache_metrics(
|
|
197
|
+
meter: Meter,
|
|
198
|
+
cache_core: CacheCore,
|
|
199
|
+
*,
|
|
200
|
+
lag_percentiles: tuple[float, ...] = (0.5, 0.95, 1.0),
|
|
201
|
+
) -> None:
|
|
202
|
+
_validate_lag_percentiles(lag_percentiles)
|
|
203
|
+
_register_cache_counters(meter, cache_core)
|
|
204
|
+
_register_cache_gauges(meter, cache_core)
|
|
205
|
+
_register_stream_counters(meter, cache_core)
|
|
206
|
+
_register_lag_gauges(meter, cache_core, lag_percentiles)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from client_query_cache._core.manager import CacheCore, CacheCoreConfig
|
|
2
|
+
|
|
3
|
+
from .collection import CachedCollection
|
|
4
|
+
from .database import CachedDatabase
|
|
5
|
+
from .manager import CacheManager
|
|
6
|
+
from .streams import ChangeStreamCoordinator, DatabaseStreamSupervisor
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CacheCore",
|
|
10
|
+
"CacheCoreConfig",
|
|
11
|
+
"CacheManager",
|
|
12
|
+
"CachedCollection",
|
|
13
|
+
"CachedDatabase",
|
|
14
|
+
"ChangeStreamCoordinator",
|
|
15
|
+
"DatabaseStreamSupervisor",
|
|
16
|
+
]
|