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,53 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from pymongo.synchronous.collection import Collection
7
+ from pymongo.synchronous.database import Database
8
+
9
+ from client_query_cache.synchronous.collection import CachedCollection
10
+
11
+ if TYPE_CHECKING:
12
+ from client_query_cache.synchronous.manager import CacheManager
13
+
14
+
15
+ class CachedDatabase[DocumentType: Mapping[str, Any]]:
16
+ __slots__ = ("_database", "_manager")
17
+
18
+ def __init__(
19
+ self, manager: CacheManager[DocumentType], database: Database[DocumentType]
20
+ ) -> None:
21
+ self._manager = manager
22
+ self._database = database
23
+
24
+ @property
25
+ def manager(self) -> CacheManager[DocumentType]:
26
+ return self._manager
27
+
28
+ @property
29
+ def name(self) -> str:
30
+ return self._database.name
31
+
32
+ @property
33
+ def raw(self) -> Database[DocumentType]:
34
+ return self._database
35
+
36
+ def __getitem__(self, name: str) -> CachedCollection[DocumentType]:
37
+ return CachedCollection(self, self._database[name])
38
+
39
+ def __getattr__(self, name: str) -> Any: # noqa: ANN401
40
+ return self._wrap_delegated(getattr(self._database, name))
41
+
42
+ def _wrap_delegated(self, value: Any) -> Any: # noqa: ANN401
43
+ if isinstance(value, Collection):
44
+ return CachedCollection(self, value)
45
+ if isinstance(value, Database):
46
+ return CachedDatabase(self._manager, value)
47
+ if not callable(value):
48
+ return value
49
+
50
+ def _delegate(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
51
+ return self._wrap_delegated(value(*args, **kwargs))
52
+
53
+ return _delegate
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import 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.synchronous.database import CachedDatabase
21
+ from client_query_cache.synchronous.streams import ChangeStreamCoordinator
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable, Sequence
25
+
26
+ from pymongo import MongoClient
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: MongoClient[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) -> MongoClient[DocumentType]:
55
+ return self._client
56
+
57
+ @property
58
+ def cache_core(self) -> CacheCore:
59
+ return self._cache
60
+
61
+ def ensure_cache_eligible(
62
+ self,
63
+ namespace: NamespaceId,
64
+ collection_probe: Callable[[], CollectionProbeResult | None],
65
+ ) -> bool:
66
+ 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 = 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
+ def unique_keys_for(
88
+ self,
89
+ namespace: NamespaceId,
90
+ list_indexes: Callable[[], 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 = 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
+ def close(self) -> None:
111
+ self._coordinator.close()
112
+ self._cache.close()
113
+
114
+ def __enter__(self) -> Self:
115
+ return self
116
+
117
+ def __exit__(self, *_exc_info: object) -> None:
118
+ self.close()
@@ -0,0 +1,297 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import threading
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 MongoClient
25
+ from pymongo.synchronous.change_stream import DatabaseChangeStream
26
+ from pymongo.synchronous.database import Database
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
+ "_health_lock",
42
+ "_lifecycle_lock",
43
+ "_max_await_time_ms",
44
+ "_resume_token",
45
+ "_stop_event",
46
+ "_stream",
47
+ "_thread",
48
+ )
49
+
50
+ def __init__(
51
+ self,
52
+ database: Database[Any],
53
+ cache: CacheCore,
54
+ *,
55
+ backoff: RetryBackoff | None = None,
56
+ max_await_time_ms: int = DEFAULT_MAX_AWAIT_TIME_MS,
57
+ ) -> None:
58
+ self._database = database
59
+ self._cache = cache
60
+ self._backoff = backoff if backoff is not None else RetryBackoff()
61
+ self._max_await_time_ms = max_await_time_ms
62
+ self._health = StreamHealth.STARTING
63
+ self._health_lock = threading.Lock()
64
+ self._lifecycle_lock = threading.RLock()
65
+ self._stream: DatabaseChangeStream[Any] | None = None
66
+ self._resume_token: Mapping[str, Any] | None = None
67
+ self._stop_event = threading.Event()
68
+ self._thread: threading.Thread | None = None
69
+ self._cache.set_database_available(self._database.name, available=False)
70
+
71
+ @property
72
+ def healthy(self) -> bool:
73
+ with self._health_lock:
74
+ return self._health is StreamHealth.HEALTHY
75
+
76
+ def start(self) -> None:
77
+ with self._lifecycle_lock:
78
+ if self._health is not StreamHealth.STARTING:
79
+ message = "start() may only be called once per supervisor instance"
80
+ raise StreamLifecycleError(message)
81
+ self._set_health(StreamHealth.CONNECTING)
82
+ self._clear_namespaces_for_database()
83
+ try:
84
+ self._ensure_server_supports_expanded_events()
85
+ self._open_stream(resume_token=None, use_start_after=False)
86
+ except StreamStartupError:
87
+ self._set_health(StreamHealth.CLOSED)
88
+ raise
89
+ except PyMongoError as exc:
90
+ self._set_health(StreamHealth.CLOSED)
91
+ message = (
92
+ f"failed to open change stream for database {self._database.name!r}"
93
+ )
94
+ raise StreamStartupError(message) from exc
95
+ with self._lifecycle_lock:
96
+ if self._stop_event.is_set():
97
+ assert self._stream is not None
98
+ with contextlib.suppress(PyMongoError):
99
+ 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._thread = threading.Thread(
105
+ target=self._run,
106
+ name=f"client-query-cache-stream-{self._database.name}",
107
+ daemon=True,
108
+ )
109
+ self._thread.start()
110
+
111
+ def stop(self) -> None:
112
+ with self._lifecycle_lock:
113
+ self._stop_event.set()
114
+ self._set_health(StreamHealth.CLOSED)
115
+ stream = self._stream
116
+ if stream is not None:
117
+ try:
118
+ stream.close()
119
+ except PyMongoError:
120
+ logger.warning(
121
+ "change stream close failed during shutdown",
122
+ extra={"database": self._database.name},
123
+ exc_info=True,
124
+ )
125
+ thread = self._thread
126
+ if thread is not None:
127
+ thread.join()
128
+ self._set_health(StreamHealth.CLOSED)
129
+
130
+ def _ensure_server_supports_expanded_events(self) -> None:
131
+ server_info = self._database.client.server_info()
132
+ version = tuple(server_info["versionArray"][:2])
133
+ if version < MINIMUM_SERVER_VERSION:
134
+ message = (
135
+ f"MongoDB server version {server_info['version']} is not supported; "
136
+ "client_query_cache requires MongoDB 8.0 or newer"
137
+ )
138
+ raise StreamStartupError(message)
139
+
140
+ def _open_stream(
141
+ self, *, resume_token: Mapping[str, Any] | None, use_start_after: bool
142
+ ) -> None:
143
+ kwargs: dict[str, Any] = {
144
+ "show_expanded_events": True,
145
+ "max_await_time_ms": self._max_await_time_ms,
146
+ }
147
+ if resume_token is not None:
148
+ if use_start_after:
149
+ kwargs["start_after"] = resume_token
150
+ else:
151
+ kwargs["resume_after"] = resume_token
152
+ previous_stream = self._stream
153
+ self._stream = self._database.watch(build_change_stream_pipeline(), **kwargs)
154
+ if previous_stream is not None:
155
+ with contextlib.suppress(PyMongoError):
156
+ previous_stream.close()
157
+ if self._stop_event.is_set():
158
+ with contextlib.suppress(PyMongoError):
159
+ self._stream.close()
160
+
161
+ def _set_health(self, health: StreamHealth) -> None:
162
+ with self._lifecycle_lock:
163
+ if self._stop_event.is_set() and health is StreamHealth.HEALTHY:
164
+ health = StreamHealth.CLOSED
165
+ with self._health_lock:
166
+ self._cache.set_database_available(
167
+ self._database.name, available=health is StreamHealth.HEALTHY
168
+ )
169
+ self._health = health
170
+
171
+ def _run(self) -> None:
172
+ while not self._stop_event.is_set():
173
+ assert self._stream is not None
174
+ self._cache.record_stream_poll(self._database.name)
175
+ try:
176
+ event = self._stream.next()
177
+ except StopIteration, PyMongoError:
178
+ self._handle_stream_failure()
179
+ continue
180
+ self._resume_token = self._stream.resume_token
181
+ must_reopen = route_change_event(self._cache, self._database.name, event)
182
+ self._record_logical_event_bytes(event)
183
+ if must_reopen:
184
+ self._reopen_after_invalidate()
185
+
186
+ def _record_logical_event_bytes(self, event: Mapping[str, Any]) -> None:
187
+ try:
188
+ encoded_length = len(
189
+ bson.encode(dict(event), codec_options=self._database.codec_options)
190
+ )
191
+ except BSONError, TypeError, ValueError:
192
+ return
193
+ self._cache.record_logical_event_bytes(self._database.name, encoded_length)
194
+
195
+ def _handle_stream_failure(self) -> None:
196
+ if self._stop_event.is_set():
197
+ return
198
+ self._set_health(StreamHealth.RECONNECTING)
199
+ if self._resume_token is None:
200
+ self._clear_namespaces_for_database()
201
+ self._reopen_with_backoff(use_start_after=False)
202
+
203
+ def _reopen_after_invalidate(self) -> None:
204
+ self._set_health(StreamHealth.RECONNECTING)
205
+ self._reopen_with_backoff(use_start_after=True)
206
+
207
+ def _reopen_with_backoff(self, *, use_start_after: bool) -> None:
208
+ while not self._stop_event.is_set():
209
+ try:
210
+ self._open_stream(
211
+ resume_token=self._resume_token, use_start_after=use_start_after
212
+ )
213
+ except PyMongoError as exc:
214
+ if isinstance(
215
+ exc, OperationFailure
216
+ ) and is_unresumable_change_stream_error(exc):
217
+ self._clear_namespaces_for_database()
218
+ self._resume_token = None
219
+ use_start_after = False
220
+ continue
221
+ delay = self._backoff.next_delay()
222
+ logger.warning(
223
+ "change stream reconnect failed, retrying with backoff",
224
+ extra={"database": self._database.name, "delay_seconds": delay},
225
+ exc_info=exc,
226
+ )
227
+ if self._stop_event.wait(delay):
228
+ return
229
+ continue
230
+ else:
231
+ self._backoff.reset()
232
+ if not self._stop_event.is_set():
233
+ self._set_health(StreamHealth.HEALTHY)
234
+ return
235
+
236
+ def _clear_namespaces_for_database(self) -> None:
237
+ for namespace in self._cache.namespaces_for_database(self._database.name):
238
+ self._cache.clear_namespace(namespace)
239
+ self._cache.reset_stream_cost_statistics(self._database.name)
240
+
241
+
242
+ class ChangeStreamCoordinator:
243
+ __slots__ = (
244
+ "_cache",
245
+ "_client",
246
+ "_closed",
247
+ "_lock",
248
+ "_max_await_time_ms",
249
+ "_supervisors",
250
+ )
251
+
252
+ def __init__(
253
+ self,
254
+ client: MongoClient[Any],
255
+ cache: CacheCore,
256
+ *,
257
+ max_await_time_ms: int = DEFAULT_MAX_AWAIT_TIME_MS,
258
+ ) -> None:
259
+ self._client = client
260
+ self._max_await_time_ms = max_await_time_ms
261
+ self._cache = cache
262
+ self._supervisors: dict[str, DatabaseStreamSupervisor] = {}
263
+ self._closed = False
264
+ self._lock = threading.Lock()
265
+
266
+ def activate_database(self, name: str) -> DatabaseStreamSupervisor | None:
267
+ with self._lock:
268
+ if self._closed:
269
+ raise StreamLifecycleError("coordinator is closed")
270
+ try:
271
+ supervisor = self._supervisors[name]
272
+ except KeyError:
273
+ supervisor = DatabaseStreamSupervisor(
274
+ self._client[name],
275
+ self._cache,
276
+ max_await_time_ms=self._max_await_time_ms,
277
+ )
278
+ try:
279
+ supervisor.start()
280
+ except StreamStartupError:
281
+ logger.warning(
282
+ "change stream startup failed for database %r; reads for "
283
+ "this database will bypass the cache",
284
+ name,
285
+ exc_info=True,
286
+ )
287
+ return None
288
+ self._supervisors[name] = supervisor
289
+ return supervisor
290
+
291
+ def close(self) -> None:
292
+ with self._lock:
293
+ self._closed = True
294
+ supervisors = list(self._supervisors.values())
295
+ self._supervisors.clear()
296
+ for supervisor in supervisors:
297
+ supervisor.stop()
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.3
2
+ Name: client-query-cache
3
+ Version: 0.1.0
4
+ Summary: Client-side caching for PyMongo, kept coherent using MongoDB change streams.
5
+ Author: Alessio Locatelli
6
+ Author-email: Alessio Locatelli <<software.development@secure.mailbox.org>>
7
+ Requires-Dist: pymongo>=4.18.1
8
+ Requires-Dist: opentelemetry-api>=1.45.0 ; extra == 'otel'
9
+ Requires-Python: >=3.14.6
10
+ Project-URL: Repository, https://github.com/alessio-locatelli/client-query-cache
11
+ Provides-Extra: otel
12
+ Description-Content-Type: text/markdown
13
+
14
+ # client-query-cache
15
+
16
+ Client-side caching for PyMongo, kept coherent using MongoDB change streams. For Python applications that already talk to MongoDB through PyMongo directly, it adds a coherent read cache without introducing a separate cache server or changing how you connect. The library supports synchronous and asyncio clients.
17
+
18
+ It caches reads whose results the manager can invalidate correctly when the underlying data changes, and leaves everything else — including all writes — to go straight to MongoDB. Invalidation is asynchronous: a read running concurrently with a write can still return the previous cached value until the manager processes that write's change-stream event.
19
+
20
+ **Built for production:** 100% covered, extensively tested from cache-core invariants through real MongoDB deployments, continuously benchmarked, and protected by an automated pull-request performance regression guard.
21
+
22
+ ![Cached reads are up to about 1,200 times faster than a direct read, and roughly the same speed whether the server is local or a real remote deployment. Direct local server read 120 microseconds, direct real deployment (Atlas M0 free tier) read 79.4 milliseconds, cached read about 61 microseconds either way. Bars use a logarithmic scale.](docs/assets/benchmark-latency-light.svg)
23
+
24
+ Read latency across two different deployments, so you can see the range: the local-server row is the median from one of the [retained local benchmark reports](docs/stream-cost-benchmarks.md); the M0-deployment row is the mean of one batch from the [real-server benchmark](CONTRIBUTING.md#real-server-benchmark) against a free-tier Atlas (M0) cluster — plotted on a logarithmic axis given the size of the gap. Cached-read latency barely moves between the two, since a cache hit never touches the network. Neither number is a universal performance guarantee for your own workload or deployment — see [Stream cost benchmarks](docs/stream-cost-benchmarks.md) for the full local workload matrix and how to reproduce it.
25
+
26
+ ---
27
+
28
+ ## Requirements
29
+
30
+ `client-query-cache` requires Python 3.14.6 or newer.
31
+
32
+ Reads and writes work against any MongoDB deployment PyMongo supports. **Effective caching** needs two separate things: a replica set or sharded cluster, since MongoDB only provides change streams on one of those topologies, not a standalone server; and MongoDB 8.0 or newer, a floor this library enforces itself at startup rather than a limit of change streams themselves. Against a deployment that doesn't meet both, the manager doesn't raise: it logs a warning and bypasses the cache for that database, executing every read as a normal, uncached PyMongo call.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ uv add client-query-cache
38
+ ```
39
+
40
+ Or with pip: `pip install client-query-cache`.
41
+
42
+ ## Usage
43
+
44
+ `CacheManager` wraps a `pymongo.MongoClient` (or `pymongo.AsyncMongoClient`) that you construct and own. Its database and collection facades cache a narrow set of PyMongo's own read methods — `find_one`, `find`, `aggregate`, `count_documents`, `estimated_document_count`, and `distinct` — and keep cached results coherent as the underlying data changes. Every other operation, including all writes, is called directly on the facade the same way you'd call it on the wrapped PyMongo object:
45
+
46
+ ```python
47
+ from pymongo import MongoClient
48
+
49
+ from client_query_cache import CacheManager
50
+
51
+ with (
52
+ MongoClient("mongodb://localhost:27017") as client,
53
+ CacheManager(client) as manager,
54
+ ):
55
+ collection = manager["my_database"]["my_collection"]
56
+ collection.insert_one({"_id": "example", "value": 42})
57
+
58
+ collection.find_one({"_id": "example"}) # cache miss: reads from MongoDB
59
+ collection.find_one({"_id": "example"}) # cache hit: served from the cache
60
+
61
+ # Bridge stats like this into OpenTelemetry:
62
+ # docs/architecture.md#opentelemetry-metrics
63
+ print(manager.cache_core.snapshot().hits) # 1
64
+
65
+ collection.create_index("email", unique=True)
66
+ collection.insert_one({"_id": "user-1", "email": "a@example.com"})
67
+ collection.find_one({"email": "a@example.com"}) # also cached, like an `_id` lookup
68
+ ```
69
+
70
+ `find_one` caches a lookup by `_id` and by any other field the database enforces as unique, discovered automatically from the collection's own indexes — there's nothing to declare. Only a plain unique index qualifies: a partial, sparse, or hashed unique index, or a read whose collation doesn't match the index's collation, falls back to an uncached read instead.
71
+
72
+ `CacheManager` starts a background change-stream task the first time a read touches a database, so close it (or use it as a context manager, as above) alongside the client — closing only the client leaves that background task running against a closed connection.
73
+
74
+ The same facades are available for `pymongo.AsyncMongoClient` under `client_query_cache.asynchronous`, with the same methods as coroutines:
75
+
76
+ ```python
77
+ import asyncio
78
+
79
+ from pymongo import AsyncMongoClient
80
+
81
+ from client_query_cache.asynchronous import CacheManager
82
+
83
+
84
+ async def main() -> None:
85
+ async with (
86
+ AsyncMongoClient("mongodb://localhost:27017") as client,
87
+ CacheManager(client) as manager,
88
+ ):
89
+ collection = manager["my_database"]["my_collection"]
90
+ await collection.insert_one({"_id": "example", "value": 42})
91
+
92
+ await collection.find_one({"_id": "example"}) # cache miss
93
+ await collection.find_one({"_id": "example"}) # cache hit
94
+
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ A read bypasses the cache instead of using it whenever caching it safely isn't possible — for example, a caller-selected session, read preference, or read concern; a view; a nondeterministic or cross-collection aggregation pipeline; a time-series collection; or a database whose change stream isn't healthy. See [Bypass conditions](docs/api-reference.md#bypass-conditions) in the API reference for the complete list.
100
+
101
+ ## Scope and constraints
102
+
103
+ - **Writes aren't cached** — only the six read methods listed above are. See [why only reads are cached](docs/architecture.md#why-only-reads-are-cached) for the rationale.
104
+ - **Each `CacheManager` is independent** — it owns its own in-process cache and its own change-stream cursors; nothing is shared between managers or processes. See [capacity planning](docs/architecture.md#capacity-estimation) before creating one per request or one per worker process.
105
+
106
+ ## Documentation
107
+
108
+ - [API reference](docs/api-reference.md) — the complete public surface: construction, configuration, limits, ownership, and raw fallback.
109
+ - [Architecture and operations](docs/architecture.md) — system requirements, capacity planning, retry/error handling, observability, security, and recovery behavior.
110
+ - [Stream cost benchmarks](docs/stream-cost-benchmarks.md) — whether caching fits your workload, and the controlled benchmark reports backing that guidance.
111
+
112
+ ---
113
+
114
+ > MongoDB is a registered trademark of MongoDB, Inc. This project is independent and is not affiliated with, sponsored by, or endorsed by MongoDB, Inc.
@@ -0,0 +1,39 @@
1
+ client_query_cache/__init__.py,sha256=M7VxCKrdtxXbi9F3V9uxDMkI0GR9f4wKWco_gV-mzFs,246
2
+ client_query_cache/_core/__init__.py,sha256=u6RnJIkw78X9bRUOo-euhpWQhleCO5_lbpXVstvjULQ,1206
3
+ client_query_cache/_core/canonical.py,sha256=x7jlufMjneAx4bbCQ3rOI70qeuoME-APjyvLYD7H4Gs,1997
4
+ client_query_cache/_core/codec.py,sha256=gsaLTzPTqGr5cxuEa4tNjevz_GmNPvJOI6n6wuBenoo,1471
5
+ client_query_cache/_core/collation.py,sha256=zDiMlgqj5aQACmyg_qJXp3ibMfI2V2PjKcE05NZdeV8,468
6
+ client_query_cache/_core/collection_metadata.py,sha256=iC9T08Q-I5_IoI9PP8JwDzmbHLGyP7KoyBBf9mY7bUA,1756
7
+ client_query_cache/_core/entries.py,sha256=4sPA7wM1NeymoLbwQbz2Jb9-v2_xm7awVZOZf7SA7KA,777
8
+ client_query_cache/_core/errors.py,sha256=W3GpmO1FQRN-WqhdXqolEYlnTul2o1p3mBaEWmKx8k0,358
9
+ client_query_cache/_core/identity_reads.py,sha256=VqcPzWlsa-l2gO66iDwRDXp9Wag2HCxb31olnvqC2Lk,1615
10
+ client_query_cache/_core/keys.py,sha256=_cAaesjVYq8rZct29HgNpb0aWlQ76QNDrUMO2bFXp-E,1009
11
+ client_query_cache/_core/lifecycle.py,sha256=AMCMnzJKNkHtakWhPwfxXnBtwT5nFT6O9DJ2H90E8VU,132
12
+ client_query_cache/_core/locking.py,sha256=WmulnmWEqYwuOsj_O2cWLYsPah3Hq5AhHcfeNe0GLLM,1114
13
+ client_query_cache/_core/lru.py,sha256=t6yzayAfGFg2Au3TOOu8si7ve3tyCAuzmh_RHnvVk8I,3583
14
+ client_query_cache/_core/manager.py,sha256=hPht6bqBmeOpw7LOjngxi411vJuUx6UjOBrXJDI1ebQ,32984
15
+ client_query_cache/_core/namespace.py,sha256=sXJmhcQ5jEN81A9JlVD3nRSwQk5BBZVGukvUo4XOMs8,1135
16
+ client_query_cache/_core/order_sensitive_keys.py,sha256=qZWN5-pMyqiSBnnd9642yZUQqw0TznF-15LebzNj4I8,2048
17
+ client_query_cache/_core/projection.py,sha256=kCBavYPibKQEFRdBepMMcTW9b8K5JIJh3Op_OPFkyXo,1831
18
+ client_query_cache/_core/read_validation.py,sha256=QgFMMnSNcv8PRYA53Lr1a9Q5IATjUbnyHZ9BiMEhfm4,2556
19
+ client_query_cache/_core/snapshots.py,sha256=ynkdq6si-cR2BKvKYbxozCQw3WacuOm5CoSqtIxRF48,1530
20
+ client_query_cache/_core/stream_cost.py,sha256=X9BDDQErSMAoIg7ZZVmeFOIUy6OvfrHLcePVRXd6z5I,7917
21
+ client_query_cache/_core/stream_events.py,sha256=Jr6hJAs8yaB8ttQ5ifcPAo-yMDdmBGSYxPMLceH0nYM,4990
22
+ client_query_cache/_core/stream_health.py,sha256=myIWEejUlgKgvK_xgWnDh4UnOH1V0DAAOz99F0IcXaw,1301
23
+ client_query_cache/_core/stream_options.py,sha256=1Ds_ziji8q0b7lYAlqeAiN-LeWjdBInXW2NESOYjo9g,543
24
+ client_query_cache/_core/unique_keys.py,sha256=ST6o7gTeYIbcjWOZnlxQghWNYH1-P3h7BLxN59cs0nM,3541
25
+ client_query_cache/asynchronous/__init__.py,sha256=Th1VJmN6Vo2o975yDxobyjt0_JNMGnYgs0Fv1zqyPEU,440
26
+ client_query_cache/asynchronous/collection.py,sha256=06mqdHrrzYALnJI-1CnFT3DFMFAQDXoBr82rO3I4L8o,26043
27
+ client_query_cache/asynchronous/database.py,sha256=n1y4cqawEPxOfQMilI09DeIKbR-y800MhDztlEJQWFg,1923
28
+ client_query_cache/asynchronous/manager.py,sha256=hl8uk5N0dm9DaXvNVrO8akyczO9peyG0--J5xs7eaVE,4406
29
+ client_query_cache/asynchronous/streams.py,sha256=kw2RIRHJNeMaxmWXL2Kd_KODNsNxZg9ZMotNsCHXZaM,11189
30
+ client_query_cache/otel.py,sha256=OY263r51rNSdcFP5zFG9njJNk-3PzvXIYVLFqYwiJmI,6852
31
+ client_query_cache/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
+ client_query_cache/synchronous/__init__.py,sha256=Th1VJmN6Vo2o975yDxobyjt0_JNMGnYgs0Fv1zqyPEU,440
33
+ client_query_cache/synchronous/collection.py,sha256=1VbFBocneS6O01iBHiQOgVyW7T9lhd5WywP-5YTeRgg,25382
34
+ client_query_cache/synchronous/database.py,sha256=ZZSMLUEhMn8g-lNsi2KWYCJkVxmiyEiSxKvjxxpIwhk,1658
35
+ client_query_cache/synchronous/manager.py,sha256=D-oysms4D6TJ3hZkfcjpd3iJ0ucmx0TjS-qjg-W9Vkk,4294
36
+ client_query_cache/synchronous/streams.py,sha256=lZJU95_pwPuTJxLw5-un-Y97pW3m3KLWjwfcxekeqsg,11046
37
+ client_query_cache-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
38
+ client_query_cache-0.1.0.dist-info/METADATA,sha256=vaFIE_dQZqnCEri8hj7lBQlDvN45M1Hnwtp-tOfkzJE,7504
39
+ client_query_cache-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any