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.
- client_query_cache-0.3.0/PKG-INFO +59 -0
- client_query_cache-0.3.0/README.md +45 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/pyproject.toml +21 -2
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/pyproject.toml.orig +15 -2
- client_query_cache-0.3.0/src/client_query_cache/__init__.py +37 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/collation.py +12 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/collection_metadata.py +21 -5
- client_query_cache-0.3.0/src/client_query_cache/_core/count_reads.py +32 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/cursor_capture.py +93 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/entries.py +2 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/find_one_reads.py +108 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/find_reads.py +69 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/locking.py +4 -4
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/manager.py +125 -21
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/namespace.py +9 -1
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/projection.py +3 -1
- client_query_cache-0.3.0/src/client_query_cache/_core/query_filters.py +27 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/read_classification.py +85 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/read_validation.py +4 -8
- client_query_cache-0.3.0/src/client_query_cache/_core/snapshots.py +112 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/stream_health.py +116 -0
- client_query_cache-0.3.0/src/client_query_cache/_core/traversal.py +20 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/unique_keys.py +14 -1
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/__init__.py +16 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/collection.py +269 -262
- client_query_cache-0.3.0/src/client_query_cache/asynchronous/cursors.py +315 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/database.py +10 -22
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/manager.py +64 -6
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/asynchronous/streams.py +44 -9
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/otel.py +30 -10
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/__init__.py +16 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/collection.py +273 -264
- client_query_cache-0.3.0/src/client_query_cache/synchronous/cursors.py +312 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/database.py +10 -16
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/manager.py +61 -6
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/synchronous/streams.py +36 -7
- client_query_cache-0.1.0/PKG-INFO +0 -114
- client_query_cache-0.1.0/README.md +0 -101
- client_query_cache-0.1.0/src/client_query_cache/__init__.py +0 -15
- client_query_cache-0.1.0/src/client_query_cache/_core/snapshots.py +0 -67
- client_query_cache-0.1.0/src/client_query_cache/_core/stream_health.py +0 -47
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/__init__.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/canonical.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/codec.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/errors.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/identity_reads.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/keys.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/lifecycle.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/lru.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/order_sensitive_keys.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_cost.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_events.py +0 -0
- {client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/stream_options.py +0 -0
- {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
|
+
[](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
|
+

|
|
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
|
+
[](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
|
+

|
|
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.
|
|
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.
|
|
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
|
+
]
|
{client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/collation.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
{client_query_cache-0.1.0 → client_query_cache-0.3.0}/src/client_query_cache/_core/entries.py
RENAMED
|
@@ -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
|
+
)
|