client-query-cache 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- client_query_cache/__init__.py +15 -0
- client_query_cache/_core/__init__.py +44 -0
- client_query_cache/_core/canonical.py +63 -0
- client_query_cache/_core/codec.py +55 -0
- client_query_cache/_core/collation.py +22 -0
- client_query_cache/_core/collection_metadata.py +65 -0
- client_query_cache/_core/entries.py +32 -0
- client_query_cache/_core/errors.py +25 -0
- client_query_cache/_core/identity_reads.py +64 -0
- client_query_cache/_core/keys.py +43 -0
- client_query_cache/_core/lifecycle.py +8 -0
- client_query_cache/_core/locking.py +40 -0
- client_query_cache/_core/lru.py +109 -0
- client_query_cache/_core/manager.py +892 -0
- client_query_cache/_core/namespace.py +35 -0
- client_query_cache/_core/order_sensitive_keys.py +69 -0
- client_query_cache/_core/projection.py +59 -0
- client_query_cache/_core/read_validation.py +89 -0
- client_query_cache/_core/snapshots.py +67 -0
- client_query_cache/_core/stream_cost.py +242 -0
- client_query_cache/_core/stream_events.py +157 -0
- client_query_cache/_core/stream_health.py +47 -0
- client_query_cache/_core/stream_options.py +14 -0
- client_query_cache/_core/unique_keys.py +119 -0
- client_query_cache/asynchronous/__init__.py +16 -0
- client_query_cache/asynchronous/collection.py +682 -0
- client_query_cache/asynchronous/database.py +59 -0
- client_query_cache/asynchronous/manager.py +118 -0
- client_query_cache/asynchronous/streams.py +300 -0
- client_query_cache/otel.py +206 -0
- client_query_cache/py.typed +0 -0
- client_query_cache/synchronous/__init__.py +16 -0
- client_query_cache/synchronous/collection.py +678 -0
- client_query_cache/synchronous/database.py +53 -0
- client_query_cache/synchronous/manager.py +118 -0
- client_query_cache/synchronous/streams.py +297 -0
- client_query_cache-0.1.0.dist-info/METADATA +114 -0
- client_query_cache-0.1.0.dist-info/RECORD +39 -0
- client_query_cache-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from bson.datetime_ms import DatetimeMS
|
|
8
|
+
|
|
9
|
+
from client_query_cache._core.keys import NamespaceId
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
from pymongo.errors import OperationFailure
|
|
15
|
+
|
|
16
|
+
from client_query_cache._core.manager import CacheCore
|
|
17
|
+
|
|
18
|
+
NONRESUMABLE_CHANGE_STREAM_ERROR_LABEL = "NonResumableChangeStreamError"
|
|
19
|
+
CHANGE_STREAM_HISTORY_LOST_CODE = 286
|
|
20
|
+
|
|
21
|
+
WRITE_OPERATION_TYPES = frozenset({"insert", "update", "replace", "delete"})
|
|
22
|
+
|
|
23
|
+
INDEX_OPERATION_TYPES = frozenset({"createIndexes", "dropIndexes"})
|
|
24
|
+
|
|
25
|
+
RELEVANT_OPERATION_TYPES = (
|
|
26
|
+
WRITE_OPERATION_TYPES
|
|
27
|
+
| INDEX_OPERATION_TYPES
|
|
28
|
+
| frozenset({"drop", "dropDatabase", "rename", "create", "invalidate"})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CHANGE_STREAM_PROJECTION: Mapping[str, int] = {
|
|
32
|
+
"_id": 1,
|
|
33
|
+
"operationType": 1,
|
|
34
|
+
"ns": 1,
|
|
35
|
+
"documentKey": 1,
|
|
36
|
+
"to": 1,
|
|
37
|
+
"clusterTime": 1,
|
|
38
|
+
"wallTime": 1,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_change_stream_pipeline() -> list[Mapping[str, Any]]:
|
|
43
|
+
return [
|
|
44
|
+
{"$match": {"operationType": {"$in": sorted(RELEVANT_OPERATION_TYPES)}}},
|
|
45
|
+
{"$project": dict(CHANGE_STREAM_PROJECTION)},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _namespace_from_ns(ns: Mapping[str, Any]) -> NamespaceId:
|
|
50
|
+
return NamespaceId(ns["db"], ns["coll"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wall_time_seconds(value: datetime.datetime | DatetimeMS) -> float:
|
|
54
|
+
if isinstance(value, DatetimeMS):
|
|
55
|
+
return int(value) / 1000.0
|
|
56
|
+
if value.tzinfo is None:
|
|
57
|
+
value = value.replace(tzinfo=datetime.UTC)
|
|
58
|
+
return value.timestamp()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _record_invalidation(
|
|
62
|
+
cache: CacheCore, database: str, event: Mapping[str, Any]
|
|
63
|
+
) -> None:
|
|
64
|
+
wall_seconds = time.time()
|
|
65
|
+
monotonic_seconds = time.monotonic()
|
|
66
|
+
raw_lag_seconds = wall_seconds - _wall_time_seconds(event["wallTime"])
|
|
67
|
+
cache.record_invalidation_applied(
|
|
68
|
+
database, raw_lag_seconds, wall_seconds, monotonic_seconds
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _route_write(cache: CacheCore, database: str, event: Mapping[str, Any]) -> None:
|
|
73
|
+
namespace = _namespace_from_ns(event["ns"])
|
|
74
|
+
if cache.has_namespace(namespace):
|
|
75
|
+
cache.record_write(namespace, event["documentKey"]["_id"])
|
|
76
|
+
_record_invalidation(cache, database, event)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _route_create(cache: CacheCore, database: str, event: Mapping[str, Any]) -> None:
|
|
80
|
+
namespace = _namespace_from_ns(event["ns"])
|
|
81
|
+
if cache.has_namespace(namespace):
|
|
82
|
+
cache.create_namespace(namespace)
|
|
83
|
+
_record_invalidation(cache, database, event)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _route_drop(cache: CacheCore, database: str, event: Mapping[str, Any]) -> None:
|
|
87
|
+
namespace = _namespace_from_ns(event["ns"])
|
|
88
|
+
if cache.has_namespace(namespace):
|
|
89
|
+
cache.clear_namespace(namespace)
|
|
90
|
+
_record_invalidation(cache, database, event)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _route_index_change(
|
|
94
|
+
cache: CacheCore, database: str, event: Mapping[str, Any]
|
|
95
|
+
) -> None:
|
|
96
|
+
namespace = _namespace_from_ns(event["ns"])
|
|
97
|
+
if cache.has_namespace(namespace):
|
|
98
|
+
cache.record_index_change(namespace)
|
|
99
|
+
_record_invalidation(cache, database, event)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _route_rename(cache: CacheCore, database: str, event: Mapping[str, Any]) -> None:
|
|
103
|
+
source = _namespace_from_ns(event["ns"])
|
|
104
|
+
destination = _namespace_from_ns(event["to"])
|
|
105
|
+
invalidated = False
|
|
106
|
+
if cache.has_namespace(source):
|
|
107
|
+
cache.clear_namespace(source)
|
|
108
|
+
invalidated = True
|
|
109
|
+
if destination.database == database and cache.has_namespace(destination):
|
|
110
|
+
cache.clear_namespace(destination)
|
|
111
|
+
invalidated = True
|
|
112
|
+
if invalidated:
|
|
113
|
+
_record_invalidation(cache, database, event)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _route_invalidate(
|
|
117
|
+
cache: CacheCore, database: str, event: Mapping[str, Any]
|
|
118
|
+
) -> None:
|
|
119
|
+
cache.set_database_available(database, available=False)
|
|
120
|
+
namespaces = cache.namespaces_for_database(database)
|
|
121
|
+
for namespace in namespaces:
|
|
122
|
+
cache.clear_namespace(namespace)
|
|
123
|
+
if namespaces:
|
|
124
|
+
_record_invalidation(cache, database, event)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def route_change_event(
|
|
128
|
+
cache: CacheCore, database: str, event: Mapping[str, Any]
|
|
129
|
+
) -> bool:
|
|
130
|
+
operation_type = event["operationType"]
|
|
131
|
+
if operation_type in WRITE_OPERATION_TYPES:
|
|
132
|
+
_route_write(cache, database, event)
|
|
133
|
+
return False
|
|
134
|
+
if operation_type in INDEX_OPERATION_TYPES:
|
|
135
|
+
_route_index_change(cache, database, event)
|
|
136
|
+
return False
|
|
137
|
+
if operation_type == "create":
|
|
138
|
+
_route_create(cache, database, event)
|
|
139
|
+
return False
|
|
140
|
+
if operation_type == "drop":
|
|
141
|
+
_route_drop(cache, database, event)
|
|
142
|
+
return False
|
|
143
|
+
if operation_type == "rename":
|
|
144
|
+
_route_rename(cache, database, event)
|
|
145
|
+
return False
|
|
146
|
+
if operation_type == "dropDatabase":
|
|
147
|
+
_route_invalidate(cache, database, event)
|
|
148
|
+
return True
|
|
149
|
+
_route_invalidate(cache, database, event)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_unresumable_change_stream_error(error: OperationFailure) -> bool:
|
|
154
|
+
return (
|
|
155
|
+
error.has_error_label(NONRESUMABLE_CHANGE_STREAM_ERROR_LABEL)
|
|
156
|
+
or error.code == CHANGE_STREAM_HISTORY_LOST_CODE
|
|
157
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import random
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_DELAY_SECONDS = 0.1
|
|
12
|
+
DEFAULT_MAX_DELAY_SECONDS = 30.0
|
|
13
|
+
DEFAULT_MULTIPLIER = 2.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StreamHealth(enum.Enum):
|
|
17
|
+
STARTING = "starting"
|
|
18
|
+
CONNECTING = "connecting"
|
|
19
|
+
HEALTHY = "healthy"
|
|
20
|
+
RECONNECTING = "reconnecting"
|
|
21
|
+
CLOSED = "closed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class RetryBackoff:
|
|
26
|
+
base_seconds: float = DEFAULT_BASE_DELAY_SECONDS
|
|
27
|
+
max_seconds: float = DEFAULT_MAX_DELAY_SECONDS
|
|
28
|
+
multiplier: float = DEFAULT_MULTIPLIER
|
|
29
|
+
_attempt: int = field(default=0, init=False)
|
|
30
|
+
_saturated: bool = field(default=False, init=False)
|
|
31
|
+
|
|
32
|
+
def reset(self) -> None:
|
|
33
|
+
self._attempt = 0
|
|
34
|
+
self._saturated = False
|
|
35
|
+
|
|
36
|
+
def next_delay(
|
|
37
|
+
self, random_uniform: Callable[[float, float], float] = random.uniform
|
|
38
|
+
) -> float:
|
|
39
|
+
if self._saturated:
|
|
40
|
+
return random_uniform(0.0, self.max_seconds)
|
|
41
|
+
cap = self.base_seconds * (self.multiplier**self._attempt)
|
|
42
|
+
if cap >= self.max_seconds:
|
|
43
|
+
self._saturated = True
|
|
44
|
+
cap = self.max_seconds
|
|
45
|
+
else:
|
|
46
|
+
self._attempt += 1
|
|
47
|
+
return random_uniform(0.0, cap)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from client_query_cache._core.errors import CacheConfigurationError
|
|
4
|
+
|
|
5
|
+
DEFAULT_MAX_AWAIT_TIME_MS = 1_000
|
|
6
|
+
MAX_AWAIT_TIME_MS = 2**31 - 1
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_max_await_time_ms(value: int) -> None:
|
|
10
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
11
|
+
raise CacheConfigurationError("max_await_time_ms must be a positive integer")
|
|
12
|
+
if not 1 <= value <= MAX_AWAIT_TIME_MS:
|
|
13
|
+
message = f"max_await_time_ms must be between 1 and {MAX_AWAIT_TIME_MS}"
|
|
14
|
+
raise CacheConfigurationError(message)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from client_query_cache._core.canonical import is_canonicalizable
|
|
9
|
+
from client_query_cache._core.collation import normalize_collation
|
|
10
|
+
from client_query_cache._core.identity_reads import NO_IDENTITY, extract_equality_value
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from client_query_cache._core.keys import NamespaceId
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class UniqueKeyDefinition:
|
|
18
|
+
fields: tuple[str, ...]
|
|
19
|
+
collation: Mapping[str, Any] | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_eligible_index(index_spec: Mapping[str, Any]) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
is_unique = index_spec["unique"]
|
|
25
|
+
except KeyError:
|
|
26
|
+
is_unique = False
|
|
27
|
+
if not is_unique:
|
|
28
|
+
return False
|
|
29
|
+
if "partialFilterExpression" in index_spec:
|
|
30
|
+
return False
|
|
31
|
+
try:
|
|
32
|
+
is_sparse = index_spec["sparse"]
|
|
33
|
+
except KeyError:
|
|
34
|
+
is_sparse = False
|
|
35
|
+
if is_sparse:
|
|
36
|
+
return False
|
|
37
|
+
return not any(value == "hashed" for value in index_spec["key"].values())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def discover_unique_keys(
|
|
41
|
+
index_specs: Sequence[Mapping[str, Any]],
|
|
42
|
+
) -> tuple[UniqueKeyDefinition, ...]:
|
|
43
|
+
unique_keys = []
|
|
44
|
+
for index_spec in index_specs:
|
|
45
|
+
if not _is_eligible_index(index_spec):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
collation = index_spec["collation"]
|
|
49
|
+
except KeyError:
|
|
50
|
+
collation = None
|
|
51
|
+
unique_keys.append(
|
|
52
|
+
UniqueKeyDefinition(
|
|
53
|
+
fields=tuple(index_spec["key"]),
|
|
54
|
+
collation=normalize_collation(collation),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return tuple(unique_keys)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_ordered_values(
|
|
61
|
+
filter_query: Mapping[str, Any], fields: tuple[str, ...]
|
|
62
|
+
) -> tuple[Any, ...] | None:
|
|
63
|
+
values: list[Any] = []
|
|
64
|
+
for field in fields:
|
|
65
|
+
value = extract_equality_value(filter_query[field])
|
|
66
|
+
if value is NO_IDENTITY or not is_canonicalizable(value):
|
|
67
|
+
return None
|
|
68
|
+
if (
|
|
69
|
+
isinstance(value, Sequence)
|
|
70
|
+
and not isinstance(value, (str, bytes, bytearray))
|
|
71
|
+
and not value
|
|
72
|
+
):
|
|
73
|
+
return None
|
|
74
|
+
values.append(value)
|
|
75
|
+
return tuple(values)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def match_unique_key(
|
|
79
|
+
filter_query: object,
|
|
80
|
+
keys: Sequence[UniqueKeyDefinition],
|
|
81
|
+
effective_collation: Mapping[str, Any] | None,
|
|
82
|
+
) -> tuple[UniqueKeyDefinition, tuple[Any, ...]] | None:
|
|
83
|
+
if not isinstance(filter_query, Mapping):
|
|
84
|
+
return None
|
|
85
|
+
filter_fields = set(filter_query)
|
|
86
|
+
for key in keys:
|
|
87
|
+
if key.collation != effective_collation:
|
|
88
|
+
continue
|
|
89
|
+
if filter_fields != set(key.fields):
|
|
90
|
+
continue
|
|
91
|
+
values = _extract_ordered_values(filter_query, key.fields)
|
|
92
|
+
if values is not None:
|
|
93
|
+
return key, values
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class UniqueKeyMetadata:
|
|
99
|
+
checked_index_generation: int
|
|
100
|
+
keys: tuple[UniqueKeyDefinition, ...]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UniqueKeyMetadataCache:
|
|
104
|
+
__slots__ = ("_entries", "_lock")
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
self._entries: dict[NamespaceId, UniqueKeyMetadata] = {}
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
|
|
110
|
+
def get(self, namespace: NamespaceId) -> UniqueKeyMetadata | None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
try:
|
|
113
|
+
return self._entries[namespace]
|
|
114
|
+
except KeyError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def put(self, namespace: NamespaceId, metadata: UniqueKeyMetadata) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._entries[namespace] = metadata
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from client_query_cache._core.manager import CacheCore, CacheCoreConfig
|
|
2
|
+
|
|
3
|
+
from .collection import CachedCollection
|
|
4
|
+
from .database import CachedDatabase
|
|
5
|
+
from .manager import CacheManager
|
|
6
|
+
from .streams import ChangeStreamCoordinator, DatabaseStreamSupervisor
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CacheCore",
|
|
10
|
+
"CacheCoreConfig",
|
|
11
|
+
"CacheManager",
|
|
12
|
+
"CachedCollection",
|
|
13
|
+
"CachedDatabase",
|
|
14
|
+
"ChangeStreamCoordinator",
|
|
15
|
+
"DatabaseStreamSupervisor",
|
|
16
|
+
]
|