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.
Files changed (39) hide show
  1. client_query_cache/__init__.py +15 -0
  2. client_query_cache/_core/__init__.py +44 -0
  3. client_query_cache/_core/canonical.py +63 -0
  4. client_query_cache/_core/codec.py +55 -0
  5. client_query_cache/_core/collation.py +22 -0
  6. client_query_cache/_core/collection_metadata.py +65 -0
  7. client_query_cache/_core/entries.py +32 -0
  8. client_query_cache/_core/errors.py +25 -0
  9. client_query_cache/_core/identity_reads.py +64 -0
  10. client_query_cache/_core/keys.py +43 -0
  11. client_query_cache/_core/lifecycle.py +8 -0
  12. client_query_cache/_core/locking.py +40 -0
  13. client_query_cache/_core/lru.py +109 -0
  14. client_query_cache/_core/manager.py +892 -0
  15. client_query_cache/_core/namespace.py +35 -0
  16. client_query_cache/_core/order_sensitive_keys.py +69 -0
  17. client_query_cache/_core/projection.py +59 -0
  18. client_query_cache/_core/read_validation.py +89 -0
  19. client_query_cache/_core/snapshots.py +67 -0
  20. client_query_cache/_core/stream_cost.py +242 -0
  21. client_query_cache/_core/stream_events.py +157 -0
  22. client_query_cache/_core/stream_health.py +47 -0
  23. client_query_cache/_core/stream_options.py +14 -0
  24. client_query_cache/_core/unique_keys.py +119 -0
  25. client_query_cache/asynchronous/__init__.py +16 -0
  26. client_query_cache/asynchronous/collection.py +682 -0
  27. client_query_cache/asynchronous/database.py +59 -0
  28. client_query_cache/asynchronous/manager.py +118 -0
  29. client_query_cache/asynchronous/streams.py +300 -0
  30. client_query_cache/otel.py +206 -0
  31. client_query_cache/py.typed +0 -0
  32. client_query_cache/synchronous/__init__.py +16 -0
  33. client_query_cache/synchronous/collection.py +678 -0
  34. client_query_cache/synchronous/database.py +53 -0
  35. client_query_cache/synchronous/manager.py +118 -0
  36. client_query_cache/synchronous/streams.py +297 -0
  37. client_query_cache-0.1.0.dist-info/METADATA +114 -0
  38. client_query_cache-0.1.0.dist-info/RECORD +39 -0
  39. 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
+ ]