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,35 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from client_query_cache._core.canonical import Canonical
9
+ from client_query_cache._core.entries import CacheEntry
10
+ from client_query_cache._core.keys import AliasKey, CacheKey, NamespaceId
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class IdentityState:
15
+ generation: int = 0
16
+ cached_ref_count: int = 0
17
+ inflight_ref_count: int = 0
18
+ alias_keys: set[AliasKey] = field(default_factory=set)
19
+
20
+ @property
21
+ def is_referenced(self) -> bool:
22
+ return self.cached_ref_count > 0 or self.inflight_ref_count > 0
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class NamespaceState:
27
+ namespace: NamespaceId
28
+ lock: threading.Lock = field(default_factory=threading.Lock)
29
+ generation: int = 0
30
+ epoch: int = 0
31
+ index_generation: int = 0
32
+ identities: dict[Canonical, IdentityState] = field(default_factory=dict)
33
+ aliases: dict[AliasKey, Canonical] = field(default_factory=dict)
34
+ entry_index: dict[CacheEntry, CacheKey] = field(default_factory=dict)
35
+ identity_generation_watermark: int = 0
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+
5
+ from bson.int64 import Int64
6
+
7
+ from client_query_cache._core.canonical import _OWN_TAGS as _CANONICAL_OWN_TAGS
8
+
9
+
10
+ class _OrderTag:
11
+ __slots__ = ()
12
+
13
+
14
+ _MAPPING_TAG = _OrderTag()
15
+ _SEQUENCE_TAG = _OrderTag()
16
+ _FLOAT_TAG = _OrderTag()
17
+ _INT64_TAG = _OrderTag()
18
+ _OWN_TAGS = (_MAPPING_TAG, _SEQUENCE_TAG, _FLOAT_TAG, _INT64_TAG, *_CANONICAL_OWN_TAGS)
19
+ _TAGGED_TUPLE_SIZE = 2
20
+
21
+
22
+ def _order_sensitive_key(
23
+ value: object, *, distinguish_numeric_subtypes: bool
24
+ ) -> object:
25
+ if (
26
+ isinstance(value, tuple)
27
+ and len(value) == _TAGGED_TUPLE_SIZE
28
+ and any(value[0] is tag for tag in _OWN_TAGS)
29
+ ):
30
+ return value
31
+ if isinstance(value, Mapping):
32
+ return (
33
+ _MAPPING_TAG,
34
+ tuple(
35
+ (
36
+ _order_sensitive_key(
37
+ key, distinguish_numeric_subtypes=distinguish_numeric_subtypes
38
+ ),
39
+ _order_sensitive_key(
40
+ item, distinguish_numeric_subtypes=distinguish_numeric_subtypes
41
+ ),
42
+ )
43
+ for key, item in value.items()
44
+ ),
45
+ )
46
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
47
+ return (
48
+ _SEQUENCE_TAG,
49
+ tuple(
50
+ _order_sensitive_key(
51
+ item, distinguish_numeric_subtypes=distinguish_numeric_subtypes
52
+ )
53
+ for item in value
54
+ ),
55
+ )
56
+ if distinguish_numeric_subtypes:
57
+ if isinstance(value, Int64):
58
+ return (_INT64_TAG, value)
59
+ if isinstance(value, float):
60
+ return (_FLOAT_TAG, value)
61
+ return value
62
+
63
+
64
+ def order_sensitive_key(value: object) -> object:
65
+ return _order_sensitive_key(value, distinguish_numeric_subtypes=False)
66
+
67
+
68
+ def order_sensitive_discriminator_key(value: object) -> object:
69
+ return _order_sensitive_key(value, distinguish_numeric_subtypes=True)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, MutableMapping
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import bson
7
+ from bson.errors import BSONError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+ from bson.codec_options import CodecOptions
13
+
14
+ _ID_FIELD = "_id"
15
+
16
+
17
+ def without_id(
18
+ document: Mapping[str, Any],
19
+ codec_options: CodecOptions[Any] | None = None,
20
+ ) -> Mapping[str, Any]:
21
+ if isinstance(document, MutableMapping):
22
+ document.pop(_ID_FIELD, None)
23
+ return document
24
+ stripped = {key: value for key, value in document.items() if key != _ID_FIELD}
25
+ if codec_options is None:
26
+ return stripped
27
+ try:
28
+ return bson.decode(
29
+ bson.encode(stripped, codec_options=codec_options),
30
+ codec_options=codec_options,
31
+ )
32
+ except BSONError, OverflowError:
33
+ return stripped
34
+
35
+
36
+ def _is_include_value(value: object) -> bool:
37
+ if isinstance(value, bool):
38
+ return value
39
+ if isinstance(value, int | float):
40
+ return value != 0
41
+ return True
42
+
43
+
44
+ def ensure_id_present_for_resolution(
45
+ projection: Mapping[str, Any] | Sequence[str] | None,
46
+ ) -> tuple[Mapping[str, Any] | Sequence[str] | None, bool]:
47
+ if not isinstance(projection, Mapping) or _ID_FIELD not in projection:
48
+ return projection, False
49
+ if _is_include_value(projection[_ID_FIELD]):
50
+ return projection, False
51
+ other_values = [value for key, value in projection.items() if key != _ID_FIELD]
52
+ if any(_is_include_value(value) for value in other_values):
53
+ server_projection = dict(projection)
54
+ server_projection[_ID_FIELD] = 1
55
+ return server_projection, True
56
+ server_projection = {
57
+ key: value for key, value in projection.items() if key != _ID_FIELD
58
+ }
59
+ return server_projection, True
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ PIPELINE_UNSAFE_KEYS = frozenset(
7
+ {
8
+ "$lookup",
9
+ "$unionWith",
10
+ "$graphLookup",
11
+ "$out",
12
+ "$merge",
13
+ "$sample",
14
+ "$function",
15
+ "$accumulator",
16
+ "$rand",
17
+ "$sampleRate",
18
+ "$collStats",
19
+ "$indexStats",
20
+ "$planCacheStats",
21
+ "$meta",
22
+ "$text",
23
+ }
24
+ )
25
+
26
+ PIPELINE_BLOCKING_KEYS = frozenset({"$changeStream"})
27
+
28
+ FILTER_UNSAFE_KEYS = frozenset(
29
+ {"$where", "$rand", "$sampleRate", "$function", "$accumulator", "$text"}
30
+ )
31
+
32
+ PROJECTION_UNSAFE_KEYS = frozenset({"$meta"})
33
+
34
+ NONDETERMINISTIC_SYSTEM_VARIABLES = frozenset({"$$NOW", "$$CLUSTER_TIME"})
35
+
36
+ _NO_UNSAFE_VARIABLES: frozenset[str] = frozenset()
37
+
38
+
39
+ def _contains_unsafe_construct(
40
+ node: object,
41
+ unsafe_keys: frozenset[str],
42
+ unsafe_variables: frozenset[str] = _NO_UNSAFE_VARIABLES,
43
+ ) -> bool:
44
+ if isinstance(node, Mapping):
45
+ for key, value in node.items():
46
+ if key in unsafe_keys:
47
+ return True
48
+ if _contains_unsafe_construct(value, unsafe_keys, unsafe_variables):
49
+ return True
50
+ return False
51
+ if isinstance(node, str):
52
+ try:
53
+ if node in unsafe_variables:
54
+ return True
55
+ except TypeError:
56
+ return False
57
+ return any(node.startswith(f"{variable}.") for variable in unsafe_variables)
58
+ if isinstance(node, Sequence) and not isinstance(node, (bytes, bytearray)):
59
+ return any(
60
+ _contains_unsafe_construct(item, unsafe_keys, unsafe_variables)
61
+ for item in node
62
+ )
63
+ return False
64
+
65
+
66
+ def is_pipeline_cacheable(pipeline: Sequence[Mapping[str, Any]]) -> bool:
67
+ return not _contains_unsafe_construct(
68
+ pipeline, PIPELINE_UNSAFE_KEYS, NONDETERMINISTIC_SYSTEM_VARIABLES
69
+ )
70
+
71
+
72
+ def pipeline_blocks_full_materialization(pipeline: Sequence[Mapping[str, Any]]) -> bool:
73
+ return any(not PIPELINE_BLOCKING_KEYS.isdisjoint(stage) for stage in pipeline)
74
+
75
+
76
+ def is_filter_cacheable(filter_query: Mapping[str, Any] | None) -> bool:
77
+ if filter_query is None:
78
+ return True
79
+ return not _contains_unsafe_construct(
80
+ filter_query, FILTER_UNSAFE_KEYS, NONDETERMINISTIC_SYSTEM_VARIABLES
81
+ )
82
+
83
+
84
+ def is_projection_cacheable(
85
+ projection: Mapping[str, Any] | Sequence[str] | None,
86
+ ) -> bool:
87
+ if projection is None:
88
+ return True
89
+ return not _contains_unsafe_construct(projection, PROJECTION_UNSAFE_KEYS)
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class CacheSnapshot:
9
+ lifecycle: str
10
+ used_bytes: int
11
+ shared_budget_bytes: int
12
+ max_entry_bytes: int
13
+ entry_count: int
14
+ hits: int
15
+ misses: int
16
+ evictions: int
17
+ bypasses: int
18
+ oversized_bypasses: int
19
+
20
+
21
+ class CacheStatistics:
22
+ __slots__ = (
23
+ "_bypasses",
24
+ "_evictions",
25
+ "_hits",
26
+ "_lock",
27
+ "_misses",
28
+ "_oversized_bypasses",
29
+ )
30
+
31
+ def __init__(self) -> None:
32
+ self._lock = threading.Lock()
33
+ self._hits = 0
34
+ self._misses = 0
35
+ self._evictions = 0
36
+ self._bypasses = 0
37
+ self._oversized_bypasses = 0
38
+
39
+ def record_hit(self) -> None:
40
+ with self._lock:
41
+ self._hits += 1
42
+
43
+ def record_miss(self) -> None:
44
+ with self._lock:
45
+ self._misses += 1
46
+
47
+ def record_evictions(self, count: int) -> None:
48
+ with self._lock:
49
+ self._evictions += count
50
+
51
+ def record_bypass(self) -> None:
52
+ with self._lock:
53
+ self._bypasses += 1
54
+
55
+ def record_oversized_bypass(self) -> None:
56
+ with self._lock:
57
+ self._oversized_bypasses += 1
58
+
59
+ def snapshot(self) -> tuple[int, int, int, int, int]:
60
+ with self._lock:
61
+ return (
62
+ self._hits,
63
+ self._misses,
64
+ self._evictions,
65
+ self._bypasses,
66
+ self._oversized_bypasses,
67
+ )
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections import deque
5
+ from dataclasses import dataclass
6
+
7
+ from client_query_cache._core.errors import CacheConfigurationError
8
+
9
+ INVALIDATION_LAG_CLOCK_SKEW_LIMITATION = (
10
+ "raw invalidation-delivery-lag values include unmeasured clock offset between "
11
+ "the mongod that originated the event and the observing host; they are not a "
12
+ "substitute for a same-clock, single-host latency measurement"
13
+ )
14
+
15
+ RESIDENT_BYTES_SCOPE = (
16
+ "manager-wide resident bytes across the shared LRU budget; not scoped to a "
17
+ "single namespace, collection, or stream"
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class LagCaptureWindowConfig:
23
+ window_count: int
24
+ events_per_window: int
25
+ min_separation_events: int
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.window_count <= 0:
29
+ message = "window_count must be positive"
30
+ raise CacheConfigurationError(message)
31
+ if self.events_per_window <= 0:
32
+ message = "events_per_window must be positive"
33
+ raise CacheConfigurationError(message)
34
+ if self.min_separation_events < 0:
35
+ message = "min_separation_events must not be negative"
36
+ raise CacheConfigurationError(message)
37
+
38
+
39
+ DEFAULT_LAG_CAPTURE_WINDOW_CONFIG = LagCaptureWindowConfig(
40
+ window_count=10, events_per_window=100, min_separation_events=0
41
+ )
42
+
43
+
44
+ class LagCaptureWindows:
45
+ __slots__ = ("_config", "_current", "_lock", "_separation_remaining", "_windows")
46
+
47
+ def __init__(self, config: LagCaptureWindowConfig) -> None:
48
+ self._config = config
49
+ self._lock = threading.Lock()
50
+ self._windows: deque[tuple[float, ...]] = deque(maxlen=config.window_count)
51
+ self._current: list[float] = []
52
+ self._separation_remaining = 0
53
+
54
+ def record(self, raw_lag_seconds: float) -> bool:
55
+ with self._lock:
56
+ if self._separation_remaining > 0:
57
+ self._separation_remaining -= 1
58
+ return False
59
+ self._current.append(raw_lag_seconds)
60
+ if len(self._current) < self._config.events_per_window:
61
+ return True
62
+ self._windows.append(tuple(self._current))
63
+ self._current = []
64
+ self._separation_remaining = self._config.min_separation_events
65
+ return True
66
+
67
+ def reset(self) -> None:
68
+ with self._lock:
69
+ self._windows.clear()
70
+ self._current = []
71
+ self._separation_remaining = 0
72
+
73
+ def snapshot(self) -> tuple[tuple[float, ...], ...]:
74
+ with self._lock:
75
+ return tuple(self._windows)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class InvalidationApplyReading:
80
+ wall_seconds: float
81
+ monotonic_seconds: float
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class StreamCostSnapshot:
86
+ database: str
87
+ stream_polls: int
88
+ logical_event_bytes: int
89
+ invalidations: int
90
+ invalidation_lag_windows: tuple[tuple[float, ...], ...]
91
+ invalidation_lag_clock_skew_limitation: str
92
+ invalidation_apply_readings: tuple[InvalidationApplyReading, ...]
93
+ resident_bytes: int
94
+ resident_bytes_scope: str
95
+
96
+
97
+ class StreamCostStatistics:
98
+ __slots__ = (
99
+ "_apply_readings",
100
+ "_invalidations",
101
+ "_lag",
102
+ "_lock",
103
+ "_logical_event_bytes",
104
+ "_stream_polls",
105
+ )
106
+
107
+ def __init__(self, lag_config: LagCaptureWindowConfig) -> None:
108
+ self._lock = threading.Lock()
109
+ self._stream_polls = 0
110
+ self._logical_event_bytes = 0
111
+ self._invalidations = 0
112
+ self._lag = LagCaptureWindows(lag_config)
113
+ self._apply_readings: deque[InvalidationApplyReading] = deque(
114
+ maxlen=(lag_config.window_count + 1) * lag_config.events_per_window
115
+ )
116
+
117
+ def record_poll(self) -> None:
118
+ with self._lock:
119
+ self._stream_polls += 1
120
+
121
+ def record_logical_event_bytes(self, count: int) -> None:
122
+ with self._lock:
123
+ self._logical_event_bytes += count
124
+
125
+ def record_invalidation(
126
+ self, raw_lag_seconds: float, wall_seconds: float, monotonic_seconds: float
127
+ ) -> None:
128
+ with self._lock:
129
+ self._invalidations += 1
130
+ if self._lag.record(raw_lag_seconds):
131
+ self._apply_readings.append(
132
+ InvalidationApplyReading(wall_seconds, monotonic_seconds)
133
+ )
134
+
135
+ def reset(self) -> None:
136
+ with self._lock:
137
+ self._stream_polls = 0
138
+ self._logical_event_bytes = 0
139
+ self._invalidations = 0
140
+ self._lag.reset()
141
+ self._apply_readings.clear()
142
+
143
+ def snapshot(
144
+ self,
145
+ ) -> tuple[
146
+ int,
147
+ int,
148
+ int,
149
+ tuple[tuple[float, ...], ...],
150
+ tuple[InvalidationApplyReading, ...],
151
+ ]:
152
+ with self._lock:
153
+ return (
154
+ self._stream_polls,
155
+ self._logical_event_bytes,
156
+ self._invalidations,
157
+ self._lag.snapshot(),
158
+ tuple(self._apply_readings),
159
+ )
160
+
161
+
162
+ class StreamCostRegistry:
163
+ __slots__ = ("_lag_config", "_lock", "_streams")
164
+
165
+ def __init__(self, lag_config: LagCaptureWindowConfig) -> None:
166
+ self._lag_config = lag_config
167
+ self._lock = threading.Lock()
168
+ self._streams: dict[str, StreamCostStatistics] = {}
169
+
170
+ def _get_or_create(self, database: str) -> StreamCostStatistics:
171
+ with self._lock:
172
+ try:
173
+ return self._streams[database]
174
+ except KeyError:
175
+ stats = StreamCostStatistics(self._lag_config)
176
+ self._streams[database] = stats
177
+ return stats
178
+
179
+ def _get(self, database: str) -> StreamCostStatistics | None:
180
+ with self._lock:
181
+ try:
182
+ return self._streams[database]
183
+ except KeyError:
184
+ return None
185
+
186
+ def record_poll(self, database: str) -> None:
187
+ self._get_or_create(database).record_poll()
188
+
189
+ def record_logical_event_bytes(self, database: str, count: int) -> None:
190
+ self._get_or_create(database).record_logical_event_bytes(count)
191
+
192
+ def record_invalidation(
193
+ self,
194
+ database: str,
195
+ raw_lag_seconds: float,
196
+ wall_seconds: float,
197
+ monotonic_seconds: float,
198
+ ) -> None:
199
+ self._get_or_create(database).record_invalidation(
200
+ raw_lag_seconds, wall_seconds, monotonic_seconds
201
+ )
202
+
203
+ def reset(self, database: str) -> None:
204
+ stats = self._get(database)
205
+ if stats is not None:
206
+ stats.reset()
207
+
208
+ def reset_all(self) -> None:
209
+ with self._lock:
210
+ all_stats = list(self._streams.values())
211
+ for stats in all_stats:
212
+ stats.reset()
213
+
214
+ def snapshot(self, database: str, *, resident_bytes: int) -> StreamCostSnapshot:
215
+ stats = self._get(database)
216
+ if stats is None:
217
+ stream_polls, logical_event_bytes, invalidations = 0, 0, 0
218
+ lag_windows: tuple[tuple[float, ...], ...] = ()
219
+ apply_readings: tuple[InvalidationApplyReading, ...] = ()
220
+ else:
221
+ (
222
+ stream_polls,
223
+ logical_event_bytes,
224
+ invalidations,
225
+ lag_windows,
226
+ apply_readings,
227
+ ) = stats.snapshot()
228
+ return StreamCostSnapshot(
229
+ database=database,
230
+ stream_polls=stream_polls,
231
+ logical_event_bytes=logical_event_bytes,
232
+ invalidations=invalidations,
233
+ invalidation_lag_windows=lag_windows,
234
+ invalidation_lag_clock_skew_limitation=INVALIDATION_LAG_CLOCK_SKEW_LIMITATION,
235
+ invalidation_apply_readings=apply_readings,
236
+ resident_bytes=resident_bytes,
237
+ resident_bytes_scope=RESIDENT_BYTES_SCOPE,
238
+ )
239
+
240
+ def active_databases(self) -> list[str]:
241
+ with self._lock:
242
+ return list(self._streams)