client-query-cache 0.1.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.1.0/PKG-INFO +114 -0
- client_query_cache-0.1.0/README.md +101 -0
- client_query_cache-0.1.0/pyproject.toml +89 -0
- client_query_cache-0.1.0/pyproject.toml.orig +79 -0
- client_query_cache-0.1.0/src/client_query_cache/__init__.py +15 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/__init__.py +44 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/canonical.py +63 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/codec.py +55 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/collation.py +22 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/collection_metadata.py +65 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/entries.py +32 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/errors.py +25 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/identity_reads.py +64 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/keys.py +43 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/lifecycle.py +8 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/locking.py +40 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/lru.py +109 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/manager.py +892 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/namespace.py +35 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/order_sensitive_keys.py +69 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/projection.py +59 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/read_validation.py +89 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/snapshots.py +67 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/stream_cost.py +242 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/stream_events.py +157 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/stream_health.py +47 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/stream_options.py +14 -0
- client_query_cache-0.1.0/src/client_query_cache/_core/unique_keys.py +119 -0
- client_query_cache-0.1.0/src/client_query_cache/asynchronous/__init__.py +16 -0
- client_query_cache-0.1.0/src/client_query_cache/asynchronous/collection.py +682 -0
- client_query_cache-0.1.0/src/client_query_cache/asynchronous/database.py +59 -0
- client_query_cache-0.1.0/src/client_query_cache/asynchronous/manager.py +118 -0
- client_query_cache-0.1.0/src/client_query_cache/asynchronous/streams.py +300 -0
- client_query_cache-0.1.0/src/client_query_cache/otel.py +206 -0
- client_query_cache-0.1.0/src/client_query_cache/py.typed +0 -0
- client_query_cache-0.1.0/src/client_query_cache/synchronous/__init__.py +16 -0
- client_query_cache-0.1.0/src/client_query_cache/synchronous/collection.py +678 -0
- client_query_cache-0.1.0/src/client_query_cache/synchronous/database.py +53 -0
- client_query_cache-0.1.0/src/client_query_cache/synchronous/manager.py +118 -0
- client_query_cache-0.1.0/src/client_query_cache/synchronous/streams.py +297 -0
|
@@ -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
|
+

|
|
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,101 @@
|
|
|
1
|
+
# client-query-cache
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
**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.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
`client-query-cache` requires Python 3.14.6 or newer.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add client-query-cache
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or with pip: `pip install client-query-cache`.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
`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:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pymongo import MongoClient
|
|
35
|
+
|
|
36
|
+
from client_query_cache import CacheManager
|
|
37
|
+
|
|
38
|
+
with (
|
|
39
|
+
MongoClient("mongodb://localhost:27017") as client,
|
|
40
|
+
CacheManager(client) as manager,
|
|
41
|
+
):
|
|
42
|
+
collection = manager["my_database"]["my_collection"]
|
|
43
|
+
collection.insert_one({"_id": "example", "value": 42})
|
|
44
|
+
|
|
45
|
+
collection.find_one({"_id": "example"}) # cache miss: reads from MongoDB
|
|
46
|
+
collection.find_one({"_id": "example"}) # cache hit: served from the cache
|
|
47
|
+
|
|
48
|
+
# Bridge stats like this into OpenTelemetry:
|
|
49
|
+
# docs/architecture.md#opentelemetry-metrics
|
|
50
|
+
print(manager.cache_core.snapshot().hits) # 1
|
|
51
|
+
|
|
52
|
+
collection.create_index("email", unique=True)
|
|
53
|
+
collection.insert_one({"_id": "user-1", "email": "a@example.com"})
|
|
54
|
+
collection.find_one({"email": "a@example.com"}) # also cached, like an `_id` lookup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`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.
|
|
58
|
+
|
|
59
|
+
`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.
|
|
60
|
+
|
|
61
|
+
The same facades are available for `pymongo.AsyncMongoClient` under `client_query_cache.asynchronous`, with the same methods as coroutines:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
|
|
66
|
+
from pymongo import AsyncMongoClient
|
|
67
|
+
|
|
68
|
+
from client_query_cache.asynchronous import CacheManager
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def main() -> None:
|
|
72
|
+
async with (
|
|
73
|
+
AsyncMongoClient("mongodb://localhost:27017") as client,
|
|
74
|
+
CacheManager(client) as manager,
|
|
75
|
+
):
|
|
76
|
+
collection = manager["my_database"]["my_collection"]
|
|
77
|
+
await collection.insert_one({"_id": "example", "value": 42})
|
|
78
|
+
|
|
79
|
+
await collection.find_one({"_id": "example"}) # cache miss
|
|
80
|
+
await collection.find_one({"_id": "example"}) # cache hit
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
## Scope and constraints
|
|
89
|
+
|
|
90
|
+
- **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.
|
|
91
|
+
- **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.
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
- [API reference](docs/api-reference.md) — the complete public surface: construction, configuration, limits, ownership, and raw fallback.
|
|
96
|
+
- [Architecture and operations](docs/architecture.md) — system requirements, capacity planning, retry/error handling, observability, security, and recovery behavior.
|
|
97
|
+
- [Stream cost benchmarks](docs/stream-cost-benchmarks.md) — whether caching fits your workload, and the controlled benchmark reports backing that guidance.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
> 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,89 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "client-query-cache"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Client-side caching for PyMongo, kept coherent using MongoDB change streams."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14.6"
|
|
7
|
+
dependencies = ["pymongo>=4.18.1"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Alessio Locatelli"
|
|
11
|
+
email = "<software.development@secure.mailbox.org>"
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/alessio-locatelli/client-query-cache"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
otel = ["opentelemetry-api>=1.45.0"]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"covdefaults>=2.3.0",
|
|
22
|
+
"mypy>=2.3.1",
|
|
23
|
+
"types-docker>=7.2.0.20260827",
|
|
24
|
+
"types-paramiko>=5.0.0.20260724",
|
|
25
|
+
"types-pexpect>=4.9.0.20260518",
|
|
26
|
+
"types-pygments>=2.21.0.20260819",
|
|
27
|
+
"types-pysocks>=1.7.1.20260518",
|
|
28
|
+
"types-simplejson>=4.1.0.20260724",
|
|
29
|
+
"coverage>=7.16.1",
|
|
30
|
+
"faker>=40.38.0",
|
|
31
|
+
"hypothesis>=6.168.0",
|
|
32
|
+
"pytest>=9.1.1",
|
|
33
|
+
"pytest-asyncio>=1.4.0",
|
|
34
|
+
"pytest-timeout>=2.4.0",
|
|
35
|
+
"slotscheck>=0.21.0",
|
|
36
|
+
"strict-no-cover>=0.1.1",
|
|
37
|
+
"testcontainers[mongodb]>=4.15.0",
|
|
38
|
+
"tox>=4.63.0",
|
|
39
|
+
"tox-uv>=1.36.0",
|
|
40
|
+
"jsonschema>=4.26.0",
|
|
41
|
+
"types-jsonschema>=4.26.0.20260518",
|
|
42
|
+
"opentelemetry-api>=1.45.0",
|
|
43
|
+
"opentelemetry-sdk>=1.45.0",
|
|
44
|
+
"types-psutil>=7.2.2.20260906",
|
|
45
|
+
"dnspython>=2.8.0",
|
|
46
|
+
"python-snappy>=0.7.3",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.12.18,<0.13.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[tool.uv.build-backend]
|
|
54
|
+
module-root = "src"
|
|
55
|
+
|
|
56
|
+
[tool.slotscheck]
|
|
57
|
+
strict-imports = true
|
|
58
|
+
require-superclass = true
|
|
59
|
+
require-subclass = true
|
|
60
|
+
exclude-modules = """
|
|
61
|
+
(
|
|
62
|
+
^specifications
|
|
63
|
+
)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
[tool.vulture]
|
|
67
|
+
paths = [
|
|
68
|
+
"src",
|
|
69
|
+
"tests",
|
|
70
|
+
"benchmarks",
|
|
71
|
+
".vulture_whitelist",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.ruff-extra-rules]
|
|
75
|
+
fix = true
|
|
76
|
+
exclude = ["specifications"]
|
|
77
|
+
extend-select = ["unused-pytriage"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff-extra-rules.meaningless-vars]
|
|
80
|
+
level = "aggressive"
|
|
81
|
+
|
|
82
|
+
[tool.ruff-extra-rules.redundant-assignment]
|
|
83
|
+
level = "aggressive"
|
|
84
|
+
|
|
85
|
+
[tool.ruff-extra-rules.redundant-type-conversion]
|
|
86
|
+
level = "aggressive"
|
|
87
|
+
|
|
88
|
+
[tool.ruff-extra-rules.redundant-dict-get]
|
|
89
|
+
level = "aggressive"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "client-query-cache"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Client-side caching for PyMongo, kept coherent using MongoDB change streams."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Alessio Locatelli", email = "<software.development@secure.mailbox.org>" },
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.14.6"
|
|
10
|
+
dependencies = ["pymongo>=4.18.1"]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/alessio-locatelli/client-query-cache"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
otel = ["opentelemetry-api>=1.45.0"]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"covdefaults>=2.3.0",
|
|
21
|
+
"mypy>=2.3.1",
|
|
22
|
+
"types-docker>=7.2.0.20260827",
|
|
23
|
+
"types-paramiko>=5.0.0.20260724",
|
|
24
|
+
"types-pexpect>=4.9.0.20260518",
|
|
25
|
+
"types-pygments>=2.21.0.20260819",
|
|
26
|
+
"types-pysocks>=1.7.1.20260518",
|
|
27
|
+
"types-simplejson>=4.1.0.20260724",
|
|
28
|
+
"coverage>=7.16.1",
|
|
29
|
+
"faker>=40.38.0",
|
|
30
|
+
"hypothesis>=6.168.0",
|
|
31
|
+
"pytest>=9.1.1",
|
|
32
|
+
"pytest-asyncio>=1.4.0",
|
|
33
|
+
"pytest-timeout>=2.4.0",
|
|
34
|
+
"slotscheck>=0.21.0",
|
|
35
|
+
"strict-no-cover>=0.1.1",
|
|
36
|
+
"testcontainers[mongodb]>=4.15.0",
|
|
37
|
+
"tox>=4.63.0",
|
|
38
|
+
"tox-uv>=1.36.0",
|
|
39
|
+
"jsonschema>=4.26.0",
|
|
40
|
+
"types-jsonschema>=4.26.0.20260518",
|
|
41
|
+
"opentelemetry-api>=1.45.0",
|
|
42
|
+
"opentelemetry-sdk>=1.45.0",
|
|
43
|
+
"types-psutil>=7.2.2.20260906",
|
|
44
|
+
"dnspython>=2.8.0",
|
|
45
|
+
"python-snappy>=0.7.3",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.12.18,<0.13.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
51
|
+
|
|
52
|
+
[tool.uv.build-backend]
|
|
53
|
+
module-root = "src"
|
|
54
|
+
|
|
55
|
+
[tool.slotscheck]
|
|
56
|
+
strict-imports = true
|
|
57
|
+
require-superclass = true
|
|
58
|
+
require-subclass = true
|
|
59
|
+
exclude-modules = '''
|
|
60
|
+
(
|
|
61
|
+
^specifications
|
|
62
|
+
)
|
|
63
|
+
'''
|
|
64
|
+
|
|
65
|
+
[tool.vulture]
|
|
66
|
+
paths = ["src", "tests", "benchmarks", ".vulture_whitelist"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff-extra-rules]
|
|
69
|
+
fix = true
|
|
70
|
+
exclude = ["specifications"]
|
|
71
|
+
extend-select = ["unused-pytriage"]
|
|
72
|
+
[tool.ruff-extra-rules.meaningless-vars]
|
|
73
|
+
level = "aggressive"
|
|
74
|
+
[tool.ruff-extra-rules.redundant-assignment]
|
|
75
|
+
level = "aggressive"
|
|
76
|
+
[tool.ruff-extra-rules.redundant-type-conversion]
|
|
77
|
+
level = "aggressive"
|
|
78
|
+
[tool.ruff-extra-rules.redundant-dict-get]
|
|
79
|
+
level = "aggressive"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from client_query_cache._core.entries import AdmissionOutcome, LookupResult
|
|
2
|
+
from client_query_cache._core.errors import (
|
|
3
|
+
CacheClosedError,
|
|
4
|
+
CacheConfigurationError,
|
|
5
|
+
CacheError,
|
|
6
|
+
StreamLifecycleError,
|
|
7
|
+
StreamStartupError,
|
|
8
|
+
UnsupportedCacheRequestError,
|
|
9
|
+
)
|
|
10
|
+
from client_query_cache._core.keys import NamespaceId
|
|
11
|
+
from client_query_cache._core.lifecycle import CacheLifecycleState
|
|
12
|
+
from client_query_cache._core.manager import (
|
|
13
|
+
CacheCore,
|
|
14
|
+
CacheCoreConfig,
|
|
15
|
+
IdentityCapture,
|
|
16
|
+
NamespaceCapture,
|
|
17
|
+
)
|
|
18
|
+
from client_query_cache._core.snapshots import CacheSnapshot
|
|
19
|
+
from client_query_cache._core.stream_cost import (
|
|
20
|
+
LagCaptureWindowConfig,
|
|
21
|
+
StreamCostSnapshot,
|
|
22
|
+
)
|
|
23
|
+
from client_query_cache._core.stream_health import StreamHealth
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AdmissionOutcome",
|
|
27
|
+
"CacheClosedError",
|
|
28
|
+
"CacheConfigurationError",
|
|
29
|
+
"CacheCore",
|
|
30
|
+
"CacheCoreConfig",
|
|
31
|
+
"CacheError",
|
|
32
|
+
"CacheLifecycleState",
|
|
33
|
+
"CacheSnapshot",
|
|
34
|
+
"IdentityCapture",
|
|
35
|
+
"LagCaptureWindowConfig",
|
|
36
|
+
"LookupResult",
|
|
37
|
+
"NamespaceCapture",
|
|
38
|
+
"NamespaceId",
|
|
39
|
+
"StreamCostSnapshot",
|
|
40
|
+
"StreamHealth",
|
|
41
|
+
"StreamLifecycleError",
|
|
42
|
+
"StreamStartupError",
|
|
43
|
+
"UnsupportedCacheRequestError",
|
|
44
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
4
|
+
|
|
5
|
+
from client_query_cache._core.errors import UnsupportedCacheRequestError
|
|
6
|
+
|
|
7
|
+
type Canonical = Hashable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _CanonicalTag:
|
|
11
|
+
__slots__ = ("_name",)
|
|
12
|
+
|
|
13
|
+
def __init__(self, name: str) -> None:
|
|
14
|
+
self._name = name
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return f"<canonical:{self._name}>"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_MAPPING_TAG = _CanonicalTag("map")
|
|
21
|
+
_SEQUENCE_TAG = _CanonicalTag("seq")
|
|
22
|
+
_BOOL_TAG = _CanonicalTag("bool")
|
|
23
|
+
_OWN_TAGS = (_MAPPING_TAG, _SEQUENCE_TAG, _BOOL_TAG)
|
|
24
|
+
_TAGGED_TUPLE_SIZE = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonicalize(value: object) -> Canonical:
|
|
28
|
+
if (
|
|
29
|
+
isinstance(value, tuple)
|
|
30
|
+
and len(value) == _TAGGED_TUPLE_SIZE
|
|
31
|
+
and any(value[0] is tag for tag in _OWN_TAGS)
|
|
32
|
+
):
|
|
33
|
+
return value
|
|
34
|
+
if isinstance(value, Mapping):
|
|
35
|
+
pairs = [(canonicalize(key), canonicalize(item)) for key, item in value.items()]
|
|
36
|
+
items = tuple(sorted(pairs, key=lambda pair: repr(pair[0])))
|
|
37
|
+
return (_MAPPING_TAG, items)
|
|
38
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
39
|
+
return (_SEQUENCE_TAG, tuple(canonicalize(item) for item in value))
|
|
40
|
+
if isinstance(value, bool):
|
|
41
|
+
return (_BOOL_TAG, value)
|
|
42
|
+
try:
|
|
43
|
+
hash(value)
|
|
44
|
+
except TypeError:
|
|
45
|
+
type_name = type(value).__name__
|
|
46
|
+
message = f"cannot canonicalize value of type {type_name!r} for a cache key"
|
|
47
|
+
raise UnsupportedCacheRequestError(message) from None
|
|
48
|
+
if value != value: # noqa: PLR0124 (deliberate self-inequality check for NaN-like values)
|
|
49
|
+
type_name = type(value).__name__
|
|
50
|
+
message = (
|
|
51
|
+
f"cannot canonicalize a non-reflexive value of type {type_name!r} "
|
|
52
|
+
"for a cache key"
|
|
53
|
+
)
|
|
54
|
+
raise UnsupportedCacheRequestError(message)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_canonicalizable(value: object) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
canonicalize(value)
|
|
61
|
+
except UnsupportedCacheRequestError:
|
|
62
|
+
return False
|
|
63
|
+
return True
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
import bson
|
|
6
|
+
from bson.codec_options import CodecOptions
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from bson.codec_options import TypeRegistry
|
|
12
|
+
|
|
13
|
+
_ENVELOPE_FIELD = "v"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _TypeRegistryIdentity:
|
|
17
|
+
__slots__ = ("_registry",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, registry: TypeRegistry) -> None:
|
|
20
|
+
self._registry = registry
|
|
21
|
+
|
|
22
|
+
def __eq__(self, other: object) -> bool:
|
|
23
|
+
return (
|
|
24
|
+
isinstance(other, _TypeRegistryIdentity)
|
|
25
|
+
and self._registry is other._registry
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def __hash__(self) -> int:
|
|
29
|
+
return id(self._registry)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encode_value(
|
|
33
|
+
value: object, codec_options: CodecOptions[Mapping[str, Any]] | None = None
|
|
34
|
+
) -> bytes:
|
|
35
|
+
return bson.encode(
|
|
36
|
+
{_ENVELOPE_FIELD: value}, codec_options=codec_options or CodecOptions()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode_value(
|
|
41
|
+
encoded: bytes, codec_options: CodecOptions[Mapping[str, Any]] | None = None
|
|
42
|
+
) -> object:
|
|
43
|
+
return bson.decode(encoded, codec_options=codec_options)[_ENVELOPE_FIELD]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def codec_fingerprint(codec_options: CodecOptions[Mapping[str, Any]]) -> object:
|
|
47
|
+
return (
|
|
48
|
+
codec_options.document_class,
|
|
49
|
+
codec_options.tz_aware,
|
|
50
|
+
codec_options.uuid_representation,
|
|
51
|
+
codec_options.unicode_decode_error_handler,
|
|
52
|
+
codec_options.tzinfo,
|
|
53
|
+
codec_options.datetime_conversion,
|
|
54
|
+
_TypeRegistryIdentity(codec_options.type_registry),
|
|
55
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
_SIMPLE_LOCALE = "simple"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_collation(
|
|
12
|
+
collation: Mapping[str, Any] | None,
|
|
13
|
+
) -> Mapping[str, Any] | None:
|
|
14
|
+
if collation is None:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
locale = collation["locale"]
|
|
18
|
+
except KeyError:
|
|
19
|
+
locale = None
|
|
20
|
+
if locale == _SIMPLE_LOCALE:
|
|
21
|
+
return None
|
|
22
|
+
return collation
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from client_query_cache._core.collation import normalize_collation
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
from client_query_cache._core.keys import NamespaceId
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class CollectionMetadata:
|
|
17
|
+
checked_epoch: int
|
|
18
|
+
is_cacheable: bool
|
|
19
|
+
default_collation: Mapping[str, Any] | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CollectionMetadataCache:
|
|
23
|
+
__slots__ = ("_entries", "_lock")
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._entries: dict[NamespaceId, CollectionMetadata] = {}
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def get(self, namespace: NamespaceId) -> CollectionMetadata | None:
|
|
30
|
+
with self._lock:
|
|
31
|
+
try:
|
|
32
|
+
return self._entries[namespace]
|
|
33
|
+
except KeyError:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def put(self, namespace: NamespaceId, metadata: CollectionMetadata) -> None:
|
|
37
|
+
with self._lock:
|
|
38
|
+
self._entries[namespace] = metadata
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class CollectionProbeResult:
|
|
43
|
+
is_cacheable: bool
|
|
44
|
+
default_collation: Mapping[str, Any] | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def interpret_list_collections_entry(
|
|
48
|
+
entry: Mapping[str, Any] | None,
|
|
49
|
+
) -> CollectionProbeResult | None:
|
|
50
|
+
if entry is None:
|
|
51
|
+
return None
|
|
52
|
+
collection_type = entry["type"]
|
|
53
|
+
try:
|
|
54
|
+
options = entry["options"]
|
|
55
|
+
except KeyError:
|
|
56
|
+
options = {}
|
|
57
|
+
try:
|
|
58
|
+
collation = options["collation"]
|
|
59
|
+
except KeyError:
|
|
60
|
+
collation = None
|
|
61
|
+
default_collation = normalize_collation(collation)
|
|
62
|
+
return CollectionProbeResult(
|
|
63
|
+
is_cacheable=collection_type == "collection",
|
|
64
|
+
default_collation=default_collation,
|
|
65
|
+
)
|