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,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)
|