client-query-cache 0.2.0__tar.gz → 0.4.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 (51) hide show
  1. client_query_cache-0.4.0/PKG-INFO +59 -0
  2. client_query_cache-0.4.0/README.md +45 -0
  3. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/pyproject.toml +20 -7
  4. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/pyproject.toml.orig +23 -7
  5. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/collation.py +12 -0
  6. client_query_cache-0.4.0/src/client_query_cache/_core/count_reads.py +32 -0
  7. client_query_cache-0.4.0/src/client_query_cache/_core/cursor_capture.py +93 -0
  8. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/entries.py +2 -0
  9. client_query_cache-0.4.0/src/client_query_cache/_core/find_reads.py +69 -0
  10. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/manager.py +96 -7
  11. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/namespace.py +9 -1
  12. client_query_cache-0.4.0/src/client_query_cache/_core/query_filters.py +27 -0
  13. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/read_classification.py +9 -1
  14. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/read_validation.py +3 -7
  15. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/asynchronous/collection.py +72 -174
  16. client_query_cache-0.4.0/src/client_query_cache/asynchronous/cursors.py +315 -0
  17. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/asynchronous/manager.py +8 -15
  18. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/synchronous/collection.py +78 -188
  19. client_query_cache-0.4.0/src/client_query_cache/synchronous/cursors.py +312 -0
  20. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/synchronous/manager.py +8 -13
  21. client_query_cache-0.2.0/PKG-INFO +0 -141
  22. client_query_cache-0.2.0/README.md +0 -127
  23. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/__init__.py +0 -0
  24. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/__init__.py +0 -0
  25. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/canonical.py +0 -0
  26. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/codec.py +0 -0
  27. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/collection_metadata.py +0 -0
  28. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/errors.py +0 -0
  29. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/find_one_reads.py +0 -0
  30. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/identity_reads.py +0 -0
  31. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/keys.py +0 -0
  32. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/lifecycle.py +0 -0
  33. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/locking.py +0 -0
  34. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/lru.py +0 -0
  35. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
  36. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/projection.py +0 -0
  37. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/snapshots.py +0 -0
  38. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/stream_cost.py +0 -0
  39. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/stream_events.py +0 -0
  40. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/stream_health.py +0 -0
  41. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/stream_options.py +0 -0
  42. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/traversal.py +0 -0
  43. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/_core/unique_keys.py +0 -0
  44. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/asynchronous/__init__.py +0 -0
  45. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/asynchronous/database.py +0 -0
  46. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/asynchronous/streams.py +0 -0
  47. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/otel.py +0 -0
  48. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/py.typed +0 -0
  49. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/synchronous/__init__.py +0 -0
  50. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/synchronous/database.py +0 -0
  51. {client_query_cache-0.2.0 → client_query_cache-0.4.0}/src/client_query_cache/synchronous/streams.py +0 -0
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.3
2
+ Name: client-query-cache
3
+ Version: 0.4.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.get_cached_collection(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.get_cached_collection(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.2.0"
3
+ version = "0.4.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"
@@ -27,7 +27,6 @@ dev = [
27
27
  "types-pygments>=2.21.0.20260819",
28
28
  "types-pysocks>=1.7.1.20260518",
29
29
  "types-simplejson>=4.1.0.20260724",
30
- "coverage>=7.16.1",
31
30
  "faker>=40.38.0",
32
31
  "hypothesis>=6.168.0",
33
32
  "pytest>=9.1.1",
@@ -45,19 +44,30 @@ dev = [
45
44
  "types-psutil>=7.2.2.20260906",
46
45
  "dnspython>=2.8.0",
47
46
  "python-snappy>=0.7.3",
47
+ "pytest-xdist[psutil]>=3.8.0",
48
+ "pytest-cov>=7.1.0",
49
+ "pyyaml>=6.0.3",
50
+ "types-pyyaml>=6.0.12.20260906",
48
51
  ]
49
- docs = ["zensical>=0.0.67"]
52
+ docs = [
53
+ "mike",
54
+ "tomli-w>=1.2.0",
55
+ "zensical>=0.0.68",
56
+ ]
57
+ memory = ["pytest-memray>=1.11.0"]
50
58
 
51
59
  [build-system]
52
60
  requires = ["uv_build>=0.12.18,<0.13.0"]
53
61
  build-backend = "uv_build"
54
62
 
55
- [tool.uv.build-backend]
56
- module-root = "src"
63
+ [tool.uv]
64
+ environments = ["sys_platform == 'linux'"]
65
+
66
+ [tool.uv.sources.mike]
67
+ git = "https://github.com/squidfunk/mike.git"
68
+ rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac"
57
69
 
58
70
  [tool.slotscheck]
59
- strict-imports = true
60
- require-superclass = true
61
71
  require-subclass = true
62
72
  exclude-modules = """
63
73
  (
@@ -90,3 +100,6 @@ level = "aggressive"
90
100
 
91
101
  [tool.ruff-extra-rules.redundant-dict-get]
92
102
  level = "aggressive"
103
+
104
+ [tool.ruff-extra-rules.defined-far-from-use]
105
+ level = "aggressive"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "client-query-cache"
3
- version = "0.2.0"
3
+ version = "0.4.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>" },
@@ -26,7 +26,6 @@ dev = [
26
26
  "types-pygments>=2.21.0.20260819",
27
27
  "types-pysocks>=1.7.1.20260518",
28
28
  "types-simplejson>=4.1.0.20260724",
29
- "coverage>=7.16.1",
30
29
  "faker>=40.38.0",
31
30
  "hypothesis>=6.168.0",
32
31
  "pytest>=9.1.1",
@@ -44,19 +43,29 @@ dev = [
44
43
  "types-psutil>=7.2.2.20260906",
45
44
  "dnspython>=2.8.0",
46
45
  "python-snappy>=0.7.3",
46
+ "pytest-xdist[psutil]>=3.8.0",
47
+ "pytest-cov>=7.1.0",
48
+ "pyyaml>=6.0.3",
49
+ "types-pyyaml>=6.0.12.20260906",
47
50
  ]
48
- docs = ["zensical>=0.0.67"]
51
+ docs = ["mike", "tomli-w>=1.2.0", "zensical>=0.0.68"]
52
+ memory = ["pytest-memray>=1.11.0"]
49
53
 
50
54
  [build-system]
51
55
  requires = ["uv_build>=0.12.18,<0.13.0"]
52
56
  build-backend = "uv_build"
53
57
 
54
- [tool.uv.build-backend]
55
- module-root = "src"
58
+ [tool.uv]
59
+ # uv 0.12.9 / lock context: https://docs.astral.sh/uv/reference/settings/#environments
60
+ # The default is all environments. We override it because development dependencies and their lock resolution target Linux.
61
+ environments = ["sys_platform == 'linux'"]
62
+
63
+ [tool.uv.sources]
64
+ mike = { git = "https://github.com/squidfunk/mike.git", rev = "2d4ad799442f4592db8ad53b179bfb33db8c69ac" } # pragma: allowlist secret
56
65
 
57
66
  [tool.slotscheck]
58
- strict-imports = true
59
- require-superclass = true
67
+ # slotscheck 0.21.0: https://github.com/ariebovenberg/slotscheck/blob/v0.21.0/src/slotscheck/config.py
68
+ # require-subclass: The default is false. We override it because subclasses must preserve their slotted parent's memory contract.
60
69
  require-subclass = true
61
70
  exclude-modules = '''
62
71
  (
@@ -69,10 +78,15 @@ exclude-modules = '''
69
78
  paths = ["src", "tests", "benchmarks", ".vulture_whitelist"]
70
79
 
71
80
  [tool.ruff-extra-rules]
81
+ # ruff-extra-rules v0.5.0: https://github.com/alessio-locatelli/ruff-extra-rules/blob/v0.5.0/README.md
82
+ # The default is false. We override it because hook runs should apply available repairs.
72
83
  fix = true
73
84
  exclude = ["specifications"]
74
85
  extend-select = ["unused-pytriage"]
75
86
  [tool.ruff-extra-rules.meaningless-vars]
87
+ # ruff-extra-rules v0.5.0: https://github.com/alessio-locatelli/ruff-extra-rules/tree/v0.5.0/src/pre_commit_hooks/ast_checks
88
+ # For meaningless-vars, redundant-assignment, redundant-type-conversion, redundant-dict-get, and defined-far-from-use level:
89
+ # The default is conservative. We override it because repository quality checks must include the broader supported simplifications and naming diagnostics.
76
90
  level = "aggressive"
77
91
  [tool.ruff-extra-rules.redundant-assignment]
78
92
  level = "aggressive"
@@ -80,3 +94,5 @@ level = "aggressive"
80
94
  level = "aggressive"
81
95
  [tool.ruff-extra-rules.redundant-dict-get]
82
96
  level = "aggressive"
97
+ [tool.ruff-extra-rules.defined-far-from-use]
98
+ level = "aggressive"
@@ -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)
@@ -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,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
+ )