client-query-cache 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. client_query_cache-0.3.0/PKG-INFO +59 -0
  2. client_query_cache-0.3.0/README.md +45 -0
  3. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/pyproject.toml +21 -2
  4. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/pyproject.toml.orig +15 -2
  5. client_query_cache-0.3.0/src/client_query_cache/__init__.py +37 -0
  6. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/collation.py +12 -0
  7. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/collection_metadata.py +21 -5
  8. client_query_cache-0.3.0/src/client_query_cache/_core/count_reads.py +32 -0
  9. client_query_cache-0.3.0/src/client_query_cache/_core/cursor_capture.py +93 -0
  10. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/entries.py +2 -0
  11. client_query_cache-0.3.0/src/client_query_cache/_core/find_one_reads.py +108 -0
  12. client_query_cache-0.3.0/src/client_query_cache/_core/find_reads.py +69 -0
  13. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/locking.py +4 -4
  14. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/manager.py +125 -21
  15. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/namespace.py +9 -1
  16. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/projection.py +3 -1
  17. client_query_cache-0.3.0/src/client_query_cache/_core/query_filters.py +27 -0
  18. client_query_cache-0.3.0/src/client_query_cache/_core/read_classification.py +85 -0
  19. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/read_validation.py +4 -8
  20. client_query_cache-0.3.0/src/client_query_cache/_core/snapshots.py +112 -0
  21. client_query_cache-0.3.0/src/client_query_cache/_core/stream_health.py +116 -0
  22. client_query_cache-0.3.0/src/client_query_cache/_core/traversal.py +20 -0
  23. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/unique_keys.py +14 -1
  24. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/__init__.py +16 -0
  25. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/collection.py +269 -262
  26. client_query_cache-0.3.0/src/client_query_cache/asynchronous/cursors.py +315 -0
  27. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/database.py +10 -22
  28. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/manager.py +64 -6
  29. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/streams.py +44 -9
  30. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/otel.py +30 -10
  31. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/__init__.py +16 -0
  32. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/collection.py +273 -264
  33. client_query_cache-0.3.0/src/client_query_cache/synchronous/cursors.py +312 -0
  34. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/database.py +10 -16
  35. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/manager.py +61 -6
  36. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/streams.py +36 -7
  37. client_query_cache-0.1.0/PKG-INFO +0 -114
  38. client_query_cache-0.1.0/README.md +0 -101
  39. client_query_cache-0.1.0/src/client_query_cache/__init__.py +0 -15
  40. client_query_cache-0.1.0/src/client_query_cache/_core/snapshots.py +0 -67
  41. client_query_cache-0.1.0/src/client_query_cache/_core/stream_health.py +0 -47
  42. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/__init__.py +0 -0
  43. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/canonical.py +0 -0
  44. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/codec.py +0 -0
  45. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/errors.py +0 -0
  46. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/identity_reads.py +0 -0
  47. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/keys.py +0 -0
  48. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/lifecycle.py +0 -0
  49. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/lru.py +0 -0
  50. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
  51. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_cost.py +0 -0
  52. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_events.py +0 -0
  53. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_options.py +0 -0
  54. {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/py.typed +0 -0
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.3
2
+ Name: client-query-cache
3
+ Version: 0.3.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
+ Project-URL: Documentation, https://alessio-locatelli.github.io/client-query-cache/
12
+ Provides-Extra: otel
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Coherent client-side caching for PyMongo
16
+
17
+ [![Python 3.14.6+](https://img.shields.io/badge/python-3.14.6%2B-blue.svg)](https://www.python.org/downloads/)
18
+
19
+ `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
+
21
+ **Built for production:** Designed for high-traffic, read-heavy applications, with cache hits served from memory to reduce latency and database load. Quality is backed by a 100% test-coverage requirement, real MongoDB integration and concurrency stress tests, and automated performance and memory regression checks.
22
+
23
+ ![Illustrative read latency on a logarithmic scale: direct local MongoDB read, 120 microseconds; direct Atlas M0 read, 79.4 milliseconds; cached read in either deployment, about 61 microseconds.](docs/user/assets/benchmark-latency-light.svg)
24
+
25
+ Results vary by workload and deployment. [Measurements and methodology](https://alessio-locatelli.github.io/client-query-cache/benchmarks/).
26
+
27
+ Caching requires [MongoDB 8.0+ on a replica set or sharded cluster](https://alessio-locatelli.github.io/client-query-cache/getting-started/installation/#requirements). Reads on standalone servers and older MongoDB versions run through PyMongo without caching.
28
+
29
+ Invalidation is asynchronous: a cached read can return a preceding value until the write's change-stream event is processed. Use PyMongo directly when a read must immediately observe a preceding write.
30
+
31
+ ## Quick start
32
+
33
+ ```bash
34
+ uv add client-query-cache
35
+ ```
36
+
37
+ Or use `pip install client-query-cache`.
38
+
39
+ ```python
40
+ from pymongo import MongoClient
41
+
42
+ from client_query_cache import CacheManager
43
+
44
+ with (
45
+ MongoClient("mongodb://localhost:27017") as client,
46
+ CacheManager(client) as cache,
47
+ ):
48
+ users = cache.cached(client["my_database"]["users"])
49
+ users.find_one({"_id": "alice"}) # Reads from MongoDB.
50
+ users.find_one({"_id": "alice"}) # Repeated reads can use the cache.
51
+ ```
52
+
53
+ ## References
54
+
55
+ [Getting started](https://alessio-locatelli.github.io/client-query-cache/getting-started/synchronous/) · [Benchmarks](https://alessio-locatelli.github.io/client-query-cache/benchmarks/) · [Examples](https://alessio-locatelli.github.io/client-query-cache/examples/)
56
+
57
+ ---
58
+
59
+ > MongoDB is a registered trademark of MongoDB, Inc. This project is independent and is not affiliated with or endorsed by MongoDB, Inc.
@@ -0,0 +1,45 @@
1
+ # Coherent client-side caching for PyMongo
2
+
3
+ [![Python 3.14.6+](https://img.shields.io/badge/python-3.14.6%2B-blue.svg)](https://www.python.org/downloads/)
4
+
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
+
7
+ **Built for production:** Designed for high-traffic, read-heavy applications, with cache hits served from memory to reduce latency and database load. Quality is backed by a 100% test-coverage requirement, real MongoDB integration and concurrency stress tests, and automated performance and memory regression checks.
8
+
9
+ ![Illustrative read latency on a logarithmic scale: direct local MongoDB read, 120 microseconds; direct Atlas M0 read, 79.4 milliseconds; cached read in either deployment, about 61 microseconds.](docs/user/assets/benchmark-latency-light.svg)
10
+
11
+ Results vary by workload and deployment. [Measurements and methodology](https://alessio-locatelli.github.io/client-query-cache/benchmarks/).
12
+
13
+ Caching requires [MongoDB 8.0+ on a replica set or sharded cluster](https://alessio-locatelli.github.io/client-query-cache/getting-started/installation/#requirements). Reads on standalone servers and older MongoDB versions run through PyMongo without caching.
14
+
15
+ Invalidation is asynchronous: a cached read can return a preceding value until the write's change-stream event is processed. Use PyMongo directly when a read must immediately observe a preceding write.
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ uv add client-query-cache
21
+ ```
22
+
23
+ Or use `pip install client-query-cache`.
24
+
25
+ ```python
26
+ from pymongo import MongoClient
27
+
28
+ from client_query_cache import CacheManager
29
+
30
+ with (
31
+ MongoClient("mongodb://localhost:27017") as client,
32
+ CacheManager(client) as cache,
33
+ ):
34
+ users = cache.cached(client["my_database"]["users"])
35
+ users.find_one({"_id": "alice"}) # Reads from MongoDB.
36
+ users.find_one({"_id": "alice"}) # Repeated reads can use the cache.
37
+ ```
38
+
39
+ ## References
40
+
41
+ [Getting started](https://alessio-locatelli.github.io/client-query-cache/getting-started/synchronous/) · [Benchmarks](https://alessio-locatelli.github.io/client-query-cache/benchmarks/) · [Examples](https://alessio-locatelli.github.io/client-query-cache/examples/)
42
+
43
+ ---
44
+
45
+ > MongoDB is a registered trademark of MongoDB, Inc. This project is independent and is not affiliated with or endorsed by MongoDB, Inc.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Client-side caching for PyMongo, kept coherent using MongoDB change streams."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14.6"
@@ -12,6 +12,7 @@ email = "<software.development@secure.mailbox.org>"
12
12
 
13
13
  [project.urls]
14
14
  Repository = "https://github.com/alessio-locatelli/client-query-cache"
15
+ Documentation = "https://alessio-locatelli.github.io/client-query-cache/"
15
16
 
16
17
  [project.optional-dependencies]
17
18
  otel = ["opentelemetry-api>=1.45.0"]
@@ -26,7 +27,6 @@ dev = [
26
27
  "types-pygments>=2.21.0.20260819",
27
28
  "types-pysocks>=1.7.1.20260518",
28
29
  "types-simplejson>=4.1.0.20260724",
29
- "coverage>=7.16.1",
30
30
  "faker>=40.38.0",
31
31
  "hypothesis>=6.168.0",
32
32
  "pytest>=9.1.1",
@@ -44,15 +44,30 @@ dev = [
44
44
  "types-psutil>=7.2.2.20260906",
45
45
  "dnspython>=2.8.0",
46
46
  "python-snappy>=0.7.3",
47
+ "pytest-xdist[psutil]>=3.8.0",
48
+ "pytest-cov>=7.1.0",
47
49
  ]
50
+ docs = [
51
+ "mike",
52
+ "tomli-w>=1.2.0",
53
+ "zensical>=0.0.68",
54
+ ]
55
+ memory = ["pytest-memray>=1.11.0"]
48
56
 
49
57
  [build-system]
50
58
  requires = ["uv_build>=0.12.18,<0.13.0"]
51
59
  build-backend = "uv_build"
52
60
 
61
+ [tool.uv]
62
+ environments = ["sys_platform == 'linux'"]
63
+
53
64
  [tool.uv.build-backend]
54
65
  module-root = "src"
55
66
 
67
+ [tool.uv.sources.mike]
68
+ git = "https://github.com/squidfunk/mike.git"
69
+ rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac"
70
+
56
71
  [tool.slotscheck]
57
72
  strict-imports = true
58
73
  require-superclass = true
@@ -60,6 +75,7 @@ require-subclass = true
60
75
  exclude-modules = """
61
76
  (
62
77
  ^specifications
78
+ | ^examples
63
79
  )
64
80
  """
65
81
 
@@ -87,3 +103,6 @@ level = "aggressive"
87
103
 
88
104
  [tool.ruff-extra-rules.redundant-dict-get]
89
105
  level = "aggressive"
106
+
107
+ [tool.ruff-extra-rules.defined-far-from-use]
108
+ level = "aggressive"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.1.0"
3
+ version = "0.3.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>" },
@@ -11,6 +11,7 @@ dependencies = ["pymongo>=4.18.1"]
11
11
 
12
12
  [project.urls]
13
13
  Repository = "https://github.com/alessio-locatelli/client-query-cache"
14
+ Documentation = "https://alessio-locatelli.github.io/client-query-cache/"
14
15
 
15
16
  [project.optional-dependencies]
16
17
  otel = ["opentelemetry-api>=1.45.0"]
@@ -25,7 +26,6 @@ dev = [
25
26
  "types-pygments>=2.21.0.20260819",
26
27
  "types-pysocks>=1.7.1.20260518",
27
28
  "types-simplejson>=4.1.0.20260724",
28
- "coverage>=7.16.1",
29
29
  "faker>=40.38.0",
30
30
  "hypothesis>=6.168.0",
31
31
  "pytest>=9.1.1",
@@ -43,7 +43,11 @@ dev = [
43
43
  "types-psutil>=7.2.2.20260906",
44
44
  "dnspython>=2.8.0",
45
45
  "python-snappy>=0.7.3",
46
+ "pytest-xdist[psutil]>=3.8.0",
47
+ "pytest-cov>=7.1.0",
46
48
  ]
49
+ docs = ["mike", "tomli-w>=1.2.0", "zensical>=0.0.68"]
50
+ memory = ["pytest-memray>=1.11.0"]
47
51
 
48
52
  [build-system]
49
53
  requires = ["uv_build>=0.12.18,<0.13.0"]
@@ -52,6 +56,12 @@ build-backend = "uv_build"
52
56
  [tool.uv.build-backend]
53
57
  module-root = "src"
54
58
 
59
+ [tool.uv]
60
+ environments = ["sys_platform == 'linux'"]
61
+
62
+ [tool.uv.sources]
63
+ mike = { git = "https://github.com/squidfunk/mike.git", rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac" } # pragma: allowlist secret
64
+
55
65
  [tool.slotscheck]
56
66
  strict-imports = true
57
67
  require-superclass = true
@@ -59,6 +69,7 @@ require-subclass = true
59
69
  exclude-modules = '''
60
70
  (
61
71
  ^specifications
72
+ | ^examples
62
73
  )
63
74
  '''
64
75
 
@@ -77,3 +88,5 @@ level = "aggressive"
77
88
  level = "aggressive"
78
89
  [tool.ruff-extra-rules.redundant-dict-get]
79
90
  level = "aggressive"
91
+ [tool.ruff-extra-rules.defined-far-from-use]
92
+ level = "aggressive"
@@ -0,0 +1,37 @@
1
+ from ._core.errors import (
2
+ CacheClosedError,
3
+ CacheConfigurationError,
4
+ CacheError,
5
+ UnsupportedCacheRequestError,
6
+ )
7
+ from .synchronous import (
8
+ BypassReason,
9
+ BypassReasonCount,
10
+ CacheCore,
11
+ CacheCoreConfig,
12
+ CachedCollection,
13
+ CachedDatabase,
14
+ CacheManager,
15
+ CacheSnapshot,
16
+ StreamCostSnapshot,
17
+ StreamHealthSnapshot,
18
+ StreamHealthStatus,
19
+ )
20
+
21
+ __all__ = [
22
+ "BypassReason",
23
+ "BypassReasonCount",
24
+ "CacheClosedError",
25
+ "CacheConfigurationError",
26
+ "CacheCore",
27
+ "CacheCoreConfig",
28
+ "CacheError",
29
+ "CacheManager",
30
+ "CacheSnapshot",
31
+ "CachedCollection",
32
+ "CachedDatabase",
33
+ "StreamCostSnapshot",
34
+ "StreamHealthSnapshot",
35
+ "StreamHealthStatus",
36
+ "UnsupportedCacheRequestError",
37
+ ]
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
+ from pymongo.collation import Collation
6
+
5
7
  if TYPE_CHECKING:
6
8
  from collections.abc import Mapping
7
9
 
@@ -20,3 +22,13 @@ def normalize_collation(
20
22
  if locale == _SIMPLE_LOCALE:
21
23
  return None
22
24
  return collation
25
+
26
+
27
+ def collation_document(
28
+ collation: Collation | Mapping[str, Any] | None,
29
+ ) -> Mapping[str, Any] | None:
30
+ if collation is None:
31
+ return None
32
+ if isinstance(collation, Collation):
33
+ return collation.document
34
+ return dict(collation)
@@ -5,6 +5,7 @@ from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from client_query_cache._core.collation import normalize_collation
8
+ from client_query_cache._core.snapshots import BypassReason
8
9
 
9
10
  if TYPE_CHECKING:
10
11
  from collections.abc import Mapping
@@ -12,10 +13,17 @@ if TYPE_CHECKING:
12
13
  from client_query_cache._core.keys import NamespaceId
13
14
 
14
15
 
16
+ _COLLECTION_REASONS = {
17
+ "collection": None,
18
+ "view": BypassReason.VIEW_COLLECTION,
19
+ "timeseries": BypassReason.TIME_SERIES_COLLECTION,
20
+ }
21
+
22
+
15
23
  @dataclass(frozen=True, slots=True)
16
24
  class CollectionMetadata:
17
25
  checked_epoch: int
18
- is_cacheable: bool
26
+ bypass_reason: BypassReason | None
19
27
  default_collation: Mapping[str, Any] | None
20
28
 
21
29
 
@@ -40,16 +48,24 @@ class CollectionMetadataCache:
40
48
 
41
49
  @dataclass(frozen=True, slots=True)
42
50
  class CollectionProbeResult:
43
- is_cacheable: bool
51
+ bypass_reason: BypassReason | None
44
52
  default_collation: Mapping[str, Any] | None
45
53
 
54
+ @property
55
+ def is_cacheable(self) -> bool:
56
+ return self.bypass_reason is None
57
+
46
58
 
47
59
  def interpret_list_collections_entry(
48
60
  entry: Mapping[str, Any] | None,
49
- ) -> CollectionProbeResult | None:
61
+ ) -> CollectionProbeResult:
50
62
  if entry is None:
51
- return None
63
+ return CollectionProbeResult(BypassReason.MISSING_COLLECTION, None)
52
64
  collection_type = entry["type"]
65
+ try:
66
+ bypass_reason = _COLLECTION_REASONS[collection_type]
67
+ except KeyError:
68
+ bypass_reason = BypassReason.METADATA_UNAVAILABLE
53
69
  try:
54
70
  options = entry["options"]
55
71
  except KeyError:
@@ -60,6 +76,6 @@ def interpret_list_collections_entry(
60
76
  collation = None
61
77
  default_collation = normalize_collation(collation)
62
78
  return CollectionProbeResult(
63
- is_cacheable=collection_type == "collection",
79
+ bypass_reason=bypass_reason,
64
80
  default_collation=default_collation,
65
81
  )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, TypedDict
4
+
5
+ from pymongo.collation import Collation
6
+
7
+ from client_query_cache._core.collation import collation_document
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+
12
+ _OMITTED = object()
13
+
14
+
15
+ class CountReadOptions(TypedDict):
16
+ cache_options: tuple[object, object, object, object]
17
+ extra_options: dict[str, object] # Can be empty for cacheable reads.
18
+
19
+
20
+ def count_read_options(kwargs: Mapping[str, object]) -> CountReadOptions:
21
+ extra_options = dict(kwargs)
22
+ skip = extra_options.pop("skip", 0)
23
+ limit = extra_options.pop("limit", _OMITTED)
24
+ collation = extra_options.pop("collation", None)
25
+ hint = extra_options.pop("hint", _OMITTED)
26
+ if isinstance(collation, Collation):
27
+ collation = collation_document(collation)
28
+ cache_options = (skip, limit, collation, hint)
29
+ return {
30
+ "cache_options": cache_options,
31
+ "extra_options": extra_options,
32
+ }
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from bson.raw_bson import DEFAULT_RAW_BSON_OPTIONS, RawBSONDocument
6
+
7
+ from client_query_cache._core.codec import encode_value
8
+ from client_query_cache._core.errors import CacheClosedError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ from bson.codec_options import CodecOptions
14
+
15
+ from client_query_cache._core.entries import AdmissionOutcome
16
+ from client_query_cache._core.find_reads import FindSource
17
+ from client_query_cache._core.manager import CacheCore, NamespaceCapture
18
+
19
+
20
+ class CursorCapture:
21
+ """Own immutable document snapshots until consumption completes."""
22
+
23
+ __slots__ = (
24
+ "_active",
25
+ "_capture",
26
+ "_codec_options",
27
+ "_core",
28
+ "_discriminator",
29
+ "_documents",
30
+ "_find_source",
31
+ "_max_entry_bytes",
32
+ "retained_bytes",
33
+ )
34
+
35
+ def __init__(
36
+ self,
37
+ core: CacheCore,
38
+ capture: NamespaceCapture,
39
+ discriminator: object,
40
+ codec_options: CodecOptions[Mapping[str, Any]],
41
+ *,
42
+ find_source: FindSource | None = None,
43
+ ) -> None:
44
+ self._core = core
45
+ self._capture = capture
46
+ self._discriminator = discriminator
47
+ self._codec_options = codec_options
48
+ self._find_source = find_source
49
+ self._max_entry_bytes = core.snapshot().max_entry_bytes
50
+ self._documents: list[RawBSONDocument] = [] # An empty result is cacheable.
51
+ self.retained_bytes = 0 # Empty and abandoned captures retain no payload.
52
+ self._active = True
53
+
54
+ def append(self, document: Mapping[str, Any]) -> None:
55
+ if not self._active:
56
+ return
57
+ try:
58
+ encoded = encode_value(document, self._codec_options)
59
+ except Exception: # noqa: BLE001 - Application BSON encoders can raise arbitrary errors.
60
+ self.abandon()
61
+ return
62
+ if self.retained_bytes + len(encoded) > self._max_entry_bytes:
63
+ self._core.record_oversized_bypass()
64
+ self.abandon()
65
+ return
66
+ # Access the envelope without replaying application codec transformations.
67
+ envelope = RawBSONDocument(encoded, DEFAULT_RAW_BSON_OPTIONS)
68
+ self._documents.append(envelope["v"])
69
+ self.retained_bytes += len(encoded)
70
+
71
+ def abandon(self) -> None:
72
+ self._active = False
73
+ self._documents.clear()
74
+ self.retained_bytes = 0
75
+
76
+ def finish(self) -> AdmissionOutcome | None:
77
+ if not self._active:
78
+ return None
79
+ self._active = False
80
+ try:
81
+ return self._core.admit_namespace(
82
+ self._capture,
83
+ self._discriminator,
84
+ self._documents,
85
+ codec_options=self._codec_options,
86
+ find_source=self._find_source,
87
+ )
88
+ except CacheClosedError:
89
+ # The cursor owns its native resources independently of the manager.
90
+ return None
91
+ finally:
92
+ self._documents.clear()
93
+ self.retained_bytes = 0
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from client_query_cache._core.canonical import Canonical
9
+ from client_query_cache._core.find_reads import FindSource
9
10
  from client_query_cache._core.keys import NamespaceId
10
11
 
11
12
 
@@ -24,6 +25,7 @@ class CacheEntry:
24
25
  value: bytes
25
26
  namespace: NamespaceId
26
27
  identity: Canonical | None
28
+ find_source: FindSource | None = None
27
29
 
28
30
 
29
31
  @dataclass(frozen=True, slots=True)
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ from pymongo.collation import Collation
7
+
8
+ from client_query_cache._core.collation import normalize_collation
9
+ from client_query_cache._core.order_sensitive_keys import (
10
+ order_sensitive_discriminator_key,
11
+ )
12
+
13
+ type CollationInput = Collation | Mapping[str, Any]
14
+
15
+ _COLLATION_FIELDS = frozenset(
16
+ {
17
+ "locale",
18
+ "caseLevel",
19
+ "caseFirst",
20
+ "strength",
21
+ "numericOrdering",
22
+ "alternate",
23
+ "maxVariable",
24
+ "normalization",
25
+ "backwards",
26
+ }
27
+ )
28
+
29
+
30
+ def normalize_find_one_filter(filter_query: object) -> Mapping[str, Any]:
31
+ if filter_query is None:
32
+ return {}
33
+ if isinstance(filter_query, Mapping):
34
+ return filter_query
35
+ return {"_id": filter_query}
36
+
37
+
38
+ def find_one_options_cacheable(
39
+ sort: Sequence[tuple[str, int]] | None,
40
+ collation: CollationInput | None,
41
+ ) -> bool:
42
+ if sort is not None and (
43
+ not isinstance(sort, (list, tuple))
44
+ or any(
45
+ not isinstance(pair, (list, tuple))
46
+ or len(pair) != 2
47
+ or not isinstance(pair[0], str)
48
+ or type(pair[1]) is not int
49
+ or pair[1] not in {-1, 1}
50
+ for pair in sort
51
+ )
52
+ ):
53
+ return False
54
+ if collation is None:
55
+ return True
56
+ document = collation.document if isinstance(collation, Collation) else collation
57
+ if not isinstance(document, dict) or not document.keys() <= _COLLATION_FIELDS:
58
+ return False
59
+ try:
60
+ validated = Collation(**document).document
61
+ except TypeError:
62
+ return False
63
+ except ValueError:
64
+ return False
65
+ if not validated["locale"]:
66
+ return False
67
+ if validated["locale"] == "simple":
68
+ return len(validated) == 1
69
+ for field, choices in (
70
+ ("strength", (1, 2, 3, 4, 5)),
71
+ ("caseFirst", ("upper", "lower", "off")),
72
+ ("alternate", ("non-ignorable", "shifted")),
73
+ ("maxVariable", ("punct", "space")),
74
+ ):
75
+ if field in validated and validated[field] not in choices:
76
+ return False
77
+ return True
78
+
79
+
80
+ def effective_find_one_collation(
81
+ collation: CollationInput | None,
82
+ default_collation: Mapping[str, Any] | None,
83
+ ) -> Mapping[str, Any] | None:
84
+ if collation is None:
85
+ return default_collation
86
+ document = collation.document if isinstance(collation, Collation) else collation
87
+ return normalize_collation(document)
88
+
89
+
90
+ def find_one_read_shape(
91
+ projection: Mapping[str, Any] | Sequence[str] | None,
92
+ sort: Sequence[tuple[str, int]] | None,
93
+ effective_collation: Mapping[str, Any] | None,
94
+ codec_identity: object,
95
+ ) -> object:
96
+ return order_sensitive_discriminator_key(
97
+ ("find_one", projection, sort, effective_collation, codec_identity)
98
+ )
99
+
100
+
101
+ def generic_find_one_discriminator(
102
+ filter_query: object,
103
+ read_shape: object,
104
+ index_generation: int, # Can be zero.
105
+ ) -> object:
106
+ return order_sensitive_discriminator_key(
107
+ ("find_one_generic", filter_query, read_shape, index_generation)
108
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ from client_query_cache._core.canonical import canonicalize
7
+ from client_query_cache._core.order_sensitive_keys import (
8
+ order_sensitive_discriminator_key,
9
+ )
10
+ from client_query_cache._core.query_filters import find_filter_key
11
+
12
+ if TYPE_CHECKING:
13
+ from client_query_cache._core.canonical import Canonical
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class FindSource:
18
+ family: int # A compact hash, possibly zero; collisions require key equality.
19
+ limit: int # Zero denotes unlimited; negative and boolean limits are excluded.
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class FindReadShape:
24
+ family: object
25
+ limit: int # Zero and negative limits retain exact-only lookup.
26
+
27
+ @property
28
+ def discriminator(self) -> object:
29
+ return (self.family, self.limit)
30
+
31
+ @property
32
+ def source(self) -> FindSource | None:
33
+ if isinstance(self.limit, bool) or self.limit < 0:
34
+ return None
35
+ return FindSource(hash(canonicalize(self.family)), self.limit)
36
+
37
+
38
+ def find_discriminator(
39
+ family: Canonical,
40
+ limit: int, # Zero and negative limits retain exact identity.
41
+ ) -> Canonical:
42
+ # Reuse the already canonical family without copying its query tree.
43
+ return canonicalize((family, limit))
44
+
45
+
46
+ def find_read_shape(
47
+ filter_document: object,
48
+ projection: object,
49
+ ordering: object,
50
+ skip: int, # Zero means no skipped documents.
51
+ limit: int, # Zero and negative limits use native semantics.
52
+ *,
53
+ collation: object,
54
+ codec: object,
55
+ ) -> FindReadShape:
56
+ return FindReadShape(
57
+ family=order_sensitive_discriminator_key(
58
+ (
59
+ "find",
60
+ find_filter_key(filter_document),
61
+ projection,
62
+ ordering,
63
+ skip,
64
+ collation,
65
+ codec,
66
+ )
67
+ ),
68
+ limit=limit,
69
+ )