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.
Files changed (39) hide show
  1. client_query_cache/__init__.py +15 -0
  2. client_query_cache/_core/__init__.py +44 -0
  3. client_query_cache/_core/canonical.py +63 -0
  4. client_query_cache/_core/codec.py +55 -0
  5. client_query_cache/_core/collation.py +22 -0
  6. client_query_cache/_core/collection_metadata.py +65 -0
  7. client_query_cache/_core/entries.py +32 -0
  8. client_query_cache/_core/errors.py +25 -0
  9. client_query_cache/_core/identity_reads.py +64 -0
  10. client_query_cache/_core/keys.py +43 -0
  11. client_query_cache/_core/lifecycle.py +8 -0
  12. client_query_cache/_core/locking.py +40 -0
  13. client_query_cache/_core/lru.py +109 -0
  14. client_query_cache/_core/manager.py +892 -0
  15. client_query_cache/_core/namespace.py +35 -0
  16. client_query_cache/_core/order_sensitive_keys.py +69 -0
  17. client_query_cache/_core/projection.py +59 -0
  18. client_query_cache/_core/read_validation.py +89 -0
  19. client_query_cache/_core/snapshots.py +67 -0
  20. client_query_cache/_core/stream_cost.py +242 -0
  21. client_query_cache/_core/stream_events.py +157 -0
  22. client_query_cache/_core/stream_health.py +47 -0
  23. client_query_cache/_core/stream_options.py +14 -0
  24. client_query_cache/_core/unique_keys.py +119 -0
  25. client_query_cache/asynchronous/__init__.py +16 -0
  26. client_query_cache/asynchronous/collection.py +682 -0
  27. client_query_cache/asynchronous/database.py +59 -0
  28. client_query_cache/asynchronous/manager.py +118 -0
  29. client_query_cache/asynchronous/streams.py +300 -0
  30. client_query_cache/otel.py +206 -0
  31. client_query_cache/py.typed +0 -0
  32. client_query_cache/synchronous/__init__.py +16 -0
  33. client_query_cache/synchronous/collection.py +678 -0
  34. client_query_cache/synchronous/database.py +53 -0
  35. client_query_cache/synchronous/manager.py +118 -0
  36. client_query_cache/synchronous/streams.py +297 -0
  37. client_query_cache-0.1.0.dist-info/METADATA +114 -0
  38. client_query_cache-0.1.0.dist-info/RECORD +39 -0
  39. 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
+ ]