tnfr 4.5.2__py3-none-any.whl → 7.0.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.
Potentially problematic release.
This version of tnfr might be problematic. Click here for more details.
- tnfr/__init__.py +275 -51
- tnfr/__init__.pyi +33 -0
- tnfr/_compat.py +10 -0
- tnfr/_generated_version.py +34 -0
- tnfr/_version.py +49 -0
- tnfr/_version.pyi +7 -0
- tnfr/alias.py +117 -31
- tnfr/alias.pyi +108 -0
- tnfr/cache.py +6 -572
- tnfr/cache.pyi +16 -0
- tnfr/callback_utils.py +16 -38
- tnfr/callback_utils.pyi +79 -0
- tnfr/cli/__init__.py +34 -14
- tnfr/cli/__init__.pyi +26 -0
- tnfr/cli/arguments.py +211 -28
- tnfr/cli/arguments.pyi +27 -0
- tnfr/cli/execution.py +470 -50
- tnfr/cli/execution.pyi +70 -0
- tnfr/cli/utils.py +18 -3
- tnfr/cli/utils.pyi +8 -0
- tnfr/config/__init__.py +13 -0
- tnfr/config/__init__.pyi +10 -0
- tnfr/{constants_glyphs.py → config/constants.py} +26 -20
- tnfr/config/constants.pyi +12 -0
- tnfr/config/feature_flags.py +83 -0
- tnfr/{config.py → config/init.py} +11 -7
- tnfr/config/init.pyi +8 -0
- tnfr/config/operator_names.py +93 -0
- tnfr/config/operator_names.pyi +28 -0
- tnfr/config/presets.py +84 -0
- tnfr/config/presets.pyi +7 -0
- tnfr/constants/__init__.py +80 -29
- tnfr/constants/__init__.pyi +92 -0
- tnfr/constants/aliases.py +31 -0
- tnfr/constants/core.py +4 -4
- tnfr/constants/core.pyi +17 -0
- tnfr/constants/init.py +1 -1
- tnfr/constants/init.pyi +12 -0
- tnfr/constants/metric.py +7 -15
- tnfr/constants/metric.pyi +19 -0
- tnfr/dynamics/__init__.py +165 -633
- tnfr/dynamics/__init__.pyi +82 -0
- tnfr/dynamics/adaptation.py +267 -0
- tnfr/dynamics/aliases.py +23 -0
- tnfr/dynamics/coordination.py +385 -0
- tnfr/dynamics/dnfr.py +2283 -400
- tnfr/dynamics/dnfr.pyi +24 -0
- tnfr/dynamics/integrators.py +406 -98
- tnfr/dynamics/integrators.pyi +34 -0
- tnfr/dynamics/runtime.py +881 -0
- tnfr/dynamics/sampling.py +10 -5
- tnfr/dynamics/sampling.pyi +7 -0
- tnfr/dynamics/selectors.py +719 -0
- tnfr/execution.py +70 -48
- tnfr/execution.pyi +45 -0
- tnfr/flatten.py +13 -9
- tnfr/flatten.pyi +21 -0
- tnfr/gamma.py +66 -53
- tnfr/gamma.pyi +34 -0
- tnfr/glyph_history.py +110 -52
- tnfr/glyph_history.pyi +35 -0
- tnfr/glyph_runtime.py +16 -0
- tnfr/glyph_runtime.pyi +9 -0
- tnfr/immutable.py +69 -28
- tnfr/immutable.pyi +34 -0
- tnfr/initialization.py +16 -16
- tnfr/initialization.pyi +65 -0
- tnfr/io.py +6 -240
- tnfr/io.pyi +16 -0
- tnfr/locking.pyi +7 -0
- tnfr/mathematics/__init__.py +81 -0
- tnfr/mathematics/backend.py +426 -0
- tnfr/mathematics/dynamics.py +398 -0
- tnfr/mathematics/epi.py +254 -0
- tnfr/mathematics/generators.py +222 -0
- tnfr/mathematics/metrics.py +119 -0
- tnfr/mathematics/operators.py +233 -0
- tnfr/mathematics/operators_factory.py +71 -0
- tnfr/mathematics/projection.py +78 -0
- tnfr/mathematics/runtime.py +173 -0
- tnfr/mathematics/spaces.py +247 -0
- tnfr/mathematics/transforms.py +292 -0
- tnfr/metrics/__init__.py +10 -10
- tnfr/metrics/__init__.pyi +20 -0
- tnfr/metrics/coherence.py +993 -324
- tnfr/metrics/common.py +23 -16
- tnfr/metrics/common.pyi +46 -0
- tnfr/metrics/core.py +251 -35
- tnfr/metrics/core.pyi +13 -0
- tnfr/metrics/diagnosis.py +708 -111
- tnfr/metrics/diagnosis.pyi +85 -0
- tnfr/metrics/export.py +27 -15
- tnfr/metrics/glyph_timing.py +232 -42
- tnfr/metrics/reporting.py +33 -22
- tnfr/metrics/reporting.pyi +12 -0
- tnfr/metrics/sense_index.py +987 -43
- tnfr/metrics/sense_index.pyi +9 -0
- tnfr/metrics/trig.py +214 -23
- tnfr/metrics/trig.pyi +13 -0
- tnfr/metrics/trig_cache.py +115 -22
- tnfr/metrics/trig_cache.pyi +10 -0
- tnfr/node.py +542 -136
- tnfr/node.pyi +178 -0
- tnfr/observers.py +152 -35
- tnfr/observers.pyi +31 -0
- tnfr/ontosim.py +23 -19
- tnfr/ontosim.pyi +28 -0
- tnfr/operators/__init__.py +601 -82
- tnfr/operators/__init__.pyi +45 -0
- tnfr/operators/definitions.py +513 -0
- tnfr/operators/definitions.pyi +78 -0
- tnfr/operators/grammar.py +760 -0
- tnfr/operators/jitter.py +107 -38
- tnfr/operators/jitter.pyi +11 -0
- tnfr/operators/registry.py +75 -0
- tnfr/operators/registry.pyi +13 -0
- tnfr/operators/remesh.py +149 -88
- tnfr/py.typed +0 -0
- tnfr/rng.py +46 -143
- tnfr/rng.pyi +14 -0
- tnfr/schemas/__init__.py +8 -0
- tnfr/schemas/grammar.json +94 -0
- tnfr/selector.py +25 -19
- tnfr/selector.pyi +19 -0
- tnfr/sense.py +72 -62
- tnfr/sense.pyi +23 -0
- tnfr/structural.py +522 -262
- tnfr/structural.pyi +69 -0
- tnfr/telemetry/__init__.py +35 -0
- tnfr/telemetry/cache_metrics.py +226 -0
- tnfr/telemetry/nu_f.py +423 -0
- tnfr/telemetry/nu_f.pyi +123 -0
- tnfr/telemetry/verbosity.py +37 -0
- tnfr/tokens.py +1 -3
- tnfr/tokens.pyi +36 -0
- tnfr/trace.py +270 -113
- tnfr/trace.pyi +40 -0
- tnfr/types.py +574 -6
- tnfr/types.pyi +331 -0
- tnfr/units.py +69 -0
- tnfr/units.pyi +16 -0
- tnfr/utils/__init__.py +217 -0
- tnfr/utils/__init__.pyi +202 -0
- tnfr/utils/cache.py +2395 -0
- tnfr/utils/cache.pyi +468 -0
- tnfr/utils/chunks.py +104 -0
- tnfr/utils/chunks.pyi +21 -0
- tnfr/{collections_utils.py → utils/data.py} +147 -90
- tnfr/utils/data.pyi +64 -0
- tnfr/utils/graph.py +85 -0
- tnfr/utils/graph.pyi +10 -0
- tnfr/utils/init.py +770 -0
- tnfr/utils/init.pyi +78 -0
- tnfr/utils/io.py +456 -0
- tnfr/{helpers → utils}/numeric.py +51 -24
- tnfr/utils/numeric.pyi +21 -0
- tnfr/validation/__init__.py +113 -0
- tnfr/validation/__init__.pyi +77 -0
- tnfr/validation/compatibility.py +95 -0
- tnfr/validation/compatibility.pyi +6 -0
- tnfr/validation/grammar.py +71 -0
- tnfr/validation/grammar.pyi +40 -0
- tnfr/validation/graph.py +138 -0
- tnfr/validation/graph.pyi +17 -0
- tnfr/validation/rules.py +281 -0
- tnfr/validation/rules.pyi +55 -0
- tnfr/validation/runtime.py +263 -0
- tnfr/validation/runtime.pyi +31 -0
- tnfr/validation/soft_filters.py +170 -0
- tnfr/validation/soft_filters.pyi +37 -0
- tnfr/validation/spectral.py +159 -0
- tnfr/validation/spectral.pyi +46 -0
- tnfr/validation/syntax.py +40 -0
- tnfr/validation/syntax.pyi +10 -0
- tnfr/validation/window.py +39 -0
- tnfr/validation/window.pyi +1 -0
- tnfr/viz/__init__.py +9 -0
- tnfr/viz/matplotlib.py +246 -0
- tnfr-7.0.0.dist-info/METADATA +179 -0
- tnfr-7.0.0.dist-info/RECORD +185 -0
- {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
- tnfr/grammar.py +0 -344
- tnfr/graph_utils.py +0 -84
- tnfr/helpers/__init__.py +0 -71
- tnfr/import_utils.py +0 -228
- tnfr/json_utils.py +0 -162
- tnfr/logging_utils.py +0 -116
- tnfr/presets.py +0 -60
- tnfr/validators.py +0 -84
- tnfr/value_utils.py +0 -59
- tnfr-4.5.2.dist-info/METADATA +0 -379
- tnfr-4.5.2.dist-info/RECORD +0 -67
- {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
- {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
- {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
tnfr/utils/cache.py
ADDED
|
@@ -0,0 +1,2395 @@
|
|
|
1
|
+
"""Cache infrastructure primitives and graph-level helpers for TNFR.
|
|
2
|
+
|
|
3
|
+
This module consolidates structural cache helpers that previously lived in
|
|
4
|
+
legacy helper modules and are now exposed under :mod:`tnfr.utils`. The
|
|
5
|
+
functions exposed here are responsible for maintaining deterministic node
|
|
6
|
+
digests, scoped graph caches guarded by locks, and version counters that keep
|
|
7
|
+
edge artifacts in sync with ΔNFR driven updates.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import pickle
|
|
16
|
+
import shelve
|
|
17
|
+
import threading
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import (
|
|
20
|
+
Callable,
|
|
21
|
+
Hashable,
|
|
22
|
+
Iterable,
|
|
23
|
+
Iterator,
|
|
24
|
+
Mapping,
|
|
25
|
+
MutableMapping,
|
|
26
|
+
)
|
|
27
|
+
from contextlib import contextmanager
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from functools import lru_cache
|
|
30
|
+
from time import perf_counter
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
|
|
32
|
+
|
|
33
|
+
import networkx as nx
|
|
34
|
+
from cachetools import LRUCache
|
|
35
|
+
|
|
36
|
+
from ..locking import get_lock
|
|
37
|
+
from ..types import GraphLike, NodeId, TimingContext, TNFRGraph
|
|
38
|
+
from .graph import get_graph, mark_dnfr_prep_dirty
|
|
39
|
+
|
|
40
|
+
K = TypeVar("K", bound=Hashable)
|
|
41
|
+
V = TypeVar("V")
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
__all__ = (
|
|
45
|
+
"CacheLayer",
|
|
46
|
+
"CacheManager",
|
|
47
|
+
"CacheCapacityConfig",
|
|
48
|
+
"CacheStatistics",
|
|
49
|
+
"InstrumentedLRUCache",
|
|
50
|
+
"ManagedLRUCache",
|
|
51
|
+
"MappingCacheLayer",
|
|
52
|
+
"RedisCacheLayer",
|
|
53
|
+
"ShelveCacheLayer",
|
|
54
|
+
"prune_lock_mapping",
|
|
55
|
+
"EdgeCacheManager",
|
|
56
|
+
"NODE_SET_CHECKSUM_KEY",
|
|
57
|
+
"cached_node_list",
|
|
58
|
+
"cached_nodes_and_A",
|
|
59
|
+
"clear_node_repr_cache",
|
|
60
|
+
"edge_version_cache",
|
|
61
|
+
"edge_version_update",
|
|
62
|
+
"ensure_node_index_map",
|
|
63
|
+
"ensure_node_offset_map",
|
|
64
|
+
"get_graph_version",
|
|
65
|
+
"increment_edge_version",
|
|
66
|
+
"increment_graph_version",
|
|
67
|
+
"node_set_checksum",
|
|
68
|
+
"stable_json",
|
|
69
|
+
"configure_graph_cache_limits",
|
|
70
|
+
"DNFR_PREP_STATE_KEY",
|
|
71
|
+
"DnfrPrepState",
|
|
72
|
+
"build_cache_manager",
|
|
73
|
+
"configure_global_cache_layers",
|
|
74
|
+
"reset_global_cache_manager",
|
|
75
|
+
"_GRAPH_CACHE_LAYERS_KEY",
|
|
76
|
+
"_SeedHashCache",
|
|
77
|
+
"ScopedCounterCache",
|
|
78
|
+
"DnfrCache",
|
|
79
|
+
"new_dnfr_cache",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class CacheCapacityConfig:
|
|
84
|
+
"""Configuration snapshot for cache capacity policies."""
|
|
85
|
+
|
|
86
|
+
default_capacity: int | None
|
|
87
|
+
overrides: dict[str, int | None]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class CacheStatistics:
|
|
92
|
+
"""Immutable snapshot of cache telemetry counters."""
|
|
93
|
+
|
|
94
|
+
hits: int = 0
|
|
95
|
+
misses: int = 0
|
|
96
|
+
evictions: int = 0
|
|
97
|
+
total_time: float = 0.0
|
|
98
|
+
timings: int = 0
|
|
99
|
+
|
|
100
|
+
def merge(self, other: CacheStatistics) -> CacheStatistics:
|
|
101
|
+
"""Return aggregated metrics combining ``self`` and ``other``."""
|
|
102
|
+
|
|
103
|
+
return CacheStatistics(
|
|
104
|
+
hits=self.hits + other.hits,
|
|
105
|
+
misses=self.misses + other.misses,
|
|
106
|
+
evictions=self.evictions + other.evictions,
|
|
107
|
+
total_time=self.total_time + other.total_time,
|
|
108
|
+
timings=self.timings + other.timings,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class DnfrCache:
|
|
114
|
+
idx: dict[Any, int]
|
|
115
|
+
theta: list[float]
|
|
116
|
+
epi: list[float]
|
|
117
|
+
vf: list[float]
|
|
118
|
+
cos_theta: list[float]
|
|
119
|
+
sin_theta: list[float]
|
|
120
|
+
neighbor_x: list[float]
|
|
121
|
+
neighbor_y: list[float]
|
|
122
|
+
neighbor_epi_sum: list[float]
|
|
123
|
+
neighbor_vf_sum: list[float]
|
|
124
|
+
neighbor_count: list[float]
|
|
125
|
+
neighbor_deg_sum: list[float] | None
|
|
126
|
+
th_bar: list[float] | None = None
|
|
127
|
+
epi_bar: list[float] | None = None
|
|
128
|
+
vf_bar: list[float] | None = None
|
|
129
|
+
deg_bar: list[float] | None = None
|
|
130
|
+
degs: dict[Any, float] | None = None
|
|
131
|
+
deg_list: list[float] | None = None
|
|
132
|
+
theta_np: Any | None = None
|
|
133
|
+
epi_np: Any | None = None
|
|
134
|
+
vf_np: Any | None = None
|
|
135
|
+
cos_theta_np: Any | None = None
|
|
136
|
+
sin_theta_np: Any | None = None
|
|
137
|
+
deg_array: Any | None = None
|
|
138
|
+
edge_src: Any | None = None
|
|
139
|
+
edge_dst: Any | None = None
|
|
140
|
+
checksum: Any | None = None
|
|
141
|
+
neighbor_x_np: Any | None = None
|
|
142
|
+
neighbor_y_np: Any | None = None
|
|
143
|
+
neighbor_epi_sum_np: Any | None = None
|
|
144
|
+
neighbor_vf_sum_np: Any | None = None
|
|
145
|
+
neighbor_count_np: Any | None = None
|
|
146
|
+
neighbor_deg_sum_np: Any | None = None
|
|
147
|
+
th_bar_np: Any | None = None
|
|
148
|
+
epi_bar_np: Any | None = None
|
|
149
|
+
vf_bar_np: Any | None = None
|
|
150
|
+
deg_bar_np: Any | None = None
|
|
151
|
+
grad_phase_np: Any | None = None
|
|
152
|
+
grad_epi_np: Any | None = None
|
|
153
|
+
grad_vf_np: Any | None = None
|
|
154
|
+
grad_topo_np: Any | None = None
|
|
155
|
+
grad_total_np: Any | None = None
|
|
156
|
+
dense_components_np: Any | None = None
|
|
157
|
+
dense_accum_np: Any | None = None
|
|
158
|
+
dense_degree_np: Any | None = None
|
|
159
|
+
neighbor_accum_np: Any | None = None
|
|
160
|
+
neighbor_inv_count_np: Any | None = None
|
|
161
|
+
neighbor_cos_avg_np: Any | None = None
|
|
162
|
+
neighbor_sin_avg_np: Any | None = None
|
|
163
|
+
neighbor_mean_tmp_np: Any | None = None
|
|
164
|
+
neighbor_mean_length_np: Any | None = None
|
|
165
|
+
edge_signature: Any | None = None
|
|
166
|
+
neighbor_accum_signature: Any | None = None
|
|
167
|
+
neighbor_edge_values_np: Any | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def new_dnfr_cache() -> DnfrCache:
|
|
171
|
+
"""Return an empty :class:`DnfrCache` prepared for ΔNFR orchestration."""
|
|
172
|
+
|
|
173
|
+
return DnfrCache(
|
|
174
|
+
idx={},
|
|
175
|
+
theta=[],
|
|
176
|
+
epi=[],
|
|
177
|
+
vf=[],
|
|
178
|
+
cos_theta=[],
|
|
179
|
+
sin_theta=[],
|
|
180
|
+
neighbor_x=[],
|
|
181
|
+
neighbor_y=[],
|
|
182
|
+
neighbor_epi_sum=[],
|
|
183
|
+
neighbor_vf_sum=[],
|
|
184
|
+
neighbor_count=[],
|
|
185
|
+
neighbor_deg_sum=[],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class _CacheMetrics:
|
|
191
|
+
hits: int = 0
|
|
192
|
+
misses: int = 0
|
|
193
|
+
evictions: int = 0
|
|
194
|
+
total_time: float = 0.0
|
|
195
|
+
timings: int = 0
|
|
196
|
+
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
197
|
+
|
|
198
|
+
def snapshot(self) -> CacheStatistics:
|
|
199
|
+
return CacheStatistics(
|
|
200
|
+
hits=self.hits,
|
|
201
|
+
misses=self.misses,
|
|
202
|
+
evictions=self.evictions,
|
|
203
|
+
total_time=self.total_time,
|
|
204
|
+
timings=self.timings,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class _CacheEntry:
|
|
210
|
+
factory: Callable[[], Any]
|
|
211
|
+
lock: threading.Lock
|
|
212
|
+
reset: Callable[[Any], Any] | None = None
|
|
213
|
+
encoder: Callable[[Any], Any] | None = None
|
|
214
|
+
decoder: Callable[[Any], Any] | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class CacheLayer(ABC):
|
|
218
|
+
"""Abstract interface implemented by storage backends orchestrated by :class:`CacheManager`."""
|
|
219
|
+
|
|
220
|
+
@abstractmethod
|
|
221
|
+
def load(self, name: str) -> Any:
|
|
222
|
+
"""Return the stored payload for ``name`` or raise :class:`KeyError`."""
|
|
223
|
+
|
|
224
|
+
@abstractmethod
|
|
225
|
+
def store(self, name: str, value: Any) -> None:
|
|
226
|
+
"""Persist ``value`` under ``name``."""
|
|
227
|
+
|
|
228
|
+
@abstractmethod
|
|
229
|
+
def delete(self, name: str) -> None:
|
|
230
|
+
"""Remove ``name`` from the backend if present."""
|
|
231
|
+
|
|
232
|
+
@abstractmethod
|
|
233
|
+
def clear(self) -> None:
|
|
234
|
+
"""Remove every entry maintained by the layer."""
|
|
235
|
+
|
|
236
|
+
def close(self) -> None: # pragma: no cover - optional hook
|
|
237
|
+
"""Release resources held by the backend."""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class MappingCacheLayer(CacheLayer):
|
|
241
|
+
"""In-memory cache layer backed by a mutable mapping."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, storage: MutableMapping[str, Any] | None = None) -> None:
|
|
244
|
+
self._storage: MutableMapping[str, Any] = {} if storage is None else storage
|
|
245
|
+
self._lock = threading.RLock()
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def storage(self) -> MutableMapping[str, Any]:
|
|
249
|
+
"""Return the mapping used to store cache entries."""
|
|
250
|
+
|
|
251
|
+
return self._storage
|
|
252
|
+
|
|
253
|
+
def load(self, name: str) -> Any:
|
|
254
|
+
with self._lock:
|
|
255
|
+
if name not in self._storage:
|
|
256
|
+
raise KeyError(name)
|
|
257
|
+
return self._storage[name]
|
|
258
|
+
|
|
259
|
+
def store(self, name: str, value: Any) -> None:
|
|
260
|
+
with self._lock:
|
|
261
|
+
self._storage[name] = value
|
|
262
|
+
|
|
263
|
+
def delete(self, name: str) -> None:
|
|
264
|
+
with self._lock:
|
|
265
|
+
self._storage.pop(name, None)
|
|
266
|
+
|
|
267
|
+
def clear(self) -> None:
|
|
268
|
+
with self._lock:
|
|
269
|
+
self._storage.clear()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class ShelveCacheLayer(CacheLayer):
|
|
273
|
+
"""Persistent cache layer backed by :mod:`shelve`."""
|
|
274
|
+
|
|
275
|
+
def __init__(
|
|
276
|
+
self,
|
|
277
|
+
path: str,
|
|
278
|
+
*,
|
|
279
|
+
flag: str = "c",
|
|
280
|
+
protocol: int | None = None,
|
|
281
|
+
writeback: bool = False,
|
|
282
|
+
) -> None:
|
|
283
|
+
self._path = path
|
|
284
|
+
self._flag = flag
|
|
285
|
+
self._protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
|
|
286
|
+
self._shelf = shelve.open(path, flag=flag, protocol=self._protocol, writeback=writeback)
|
|
287
|
+
self._lock = threading.RLock()
|
|
288
|
+
|
|
289
|
+
def load(self, name: str) -> Any:
|
|
290
|
+
with self._lock:
|
|
291
|
+
if name not in self._shelf:
|
|
292
|
+
raise KeyError(name)
|
|
293
|
+
return self._shelf[name]
|
|
294
|
+
|
|
295
|
+
def store(self, name: str, value: Any) -> None:
|
|
296
|
+
with self._lock:
|
|
297
|
+
self._shelf[name] = value
|
|
298
|
+
self._shelf.sync()
|
|
299
|
+
|
|
300
|
+
def delete(self, name: str) -> None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
try:
|
|
303
|
+
del self._shelf[name]
|
|
304
|
+
except KeyError:
|
|
305
|
+
return
|
|
306
|
+
self._shelf.sync()
|
|
307
|
+
|
|
308
|
+
def clear(self) -> None:
|
|
309
|
+
with self._lock:
|
|
310
|
+
self._shelf.clear()
|
|
311
|
+
self._shelf.sync()
|
|
312
|
+
|
|
313
|
+
def close(self) -> None: # pragma: no cover - exercised indirectly
|
|
314
|
+
with self._lock:
|
|
315
|
+
self._shelf.close()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class RedisCacheLayer(CacheLayer):
|
|
319
|
+
"""Distributed cache layer backed by a Redis client."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, client: Any | None = None, *, namespace: str = "tnfr:cache") -> None:
|
|
322
|
+
if client is None:
|
|
323
|
+
try: # pragma: no cover - import guarded for optional dependency
|
|
324
|
+
import redis # type: ignore
|
|
325
|
+
except Exception as exc: # pragma: no cover - defensive import
|
|
326
|
+
raise RuntimeError("redis-py is required to initialise RedisCacheLayer") from exc
|
|
327
|
+
client = redis.Redis()
|
|
328
|
+
self._client = client
|
|
329
|
+
self._namespace = namespace.rstrip(":") or "tnfr:cache"
|
|
330
|
+
self._lock = threading.RLock()
|
|
331
|
+
|
|
332
|
+
def _format_key(self, name: str) -> str:
|
|
333
|
+
return f"{self._namespace}:{name}"
|
|
334
|
+
|
|
335
|
+
def load(self, name: str) -> Any:
|
|
336
|
+
key = self._format_key(name)
|
|
337
|
+
with self._lock:
|
|
338
|
+
value = self._client.get(key)
|
|
339
|
+
if value is None:
|
|
340
|
+
raise KeyError(name)
|
|
341
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
342
|
+
return pickle.loads(bytes(value))
|
|
343
|
+
return value
|
|
344
|
+
|
|
345
|
+
def store(self, name: str, value: Any) -> None:
|
|
346
|
+
key = self._format_key(name)
|
|
347
|
+
payload = value
|
|
348
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
349
|
+
payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
|
350
|
+
with self._lock:
|
|
351
|
+
self._client.set(key, payload)
|
|
352
|
+
|
|
353
|
+
def delete(self, name: str) -> None:
|
|
354
|
+
key = self._format_key(name)
|
|
355
|
+
with self._lock:
|
|
356
|
+
self._client.delete(key)
|
|
357
|
+
|
|
358
|
+
def clear(self) -> None:
|
|
359
|
+
pattern = f"{self._namespace}:*"
|
|
360
|
+
with self._lock:
|
|
361
|
+
if hasattr(self._client, "scan_iter"):
|
|
362
|
+
keys = list(self._client.scan_iter(match=pattern))
|
|
363
|
+
elif hasattr(self._client, "keys"):
|
|
364
|
+
keys = list(self._client.keys(pattern))
|
|
365
|
+
else: # pragma: no cover - extremely defensive
|
|
366
|
+
keys = []
|
|
367
|
+
if keys:
|
|
368
|
+
self._client.delete(*keys)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class CacheManager:
|
|
372
|
+
"""Coordinate named caches guarded by per-entry locks."""
|
|
373
|
+
|
|
374
|
+
_MISSING = object()
|
|
375
|
+
|
|
376
|
+
def __init__(
|
|
377
|
+
self,
|
|
378
|
+
storage: MutableMapping[str, Any] | None = None,
|
|
379
|
+
*,
|
|
380
|
+
default_capacity: int | None = None,
|
|
381
|
+
overrides: Mapping[str, int | None] | None = None,
|
|
382
|
+
layers: Iterable[CacheLayer] | None = None,
|
|
383
|
+
) -> None:
|
|
384
|
+
mapping_layer = MappingCacheLayer(storage)
|
|
385
|
+
extra_layers: tuple[CacheLayer, ...]
|
|
386
|
+
if layers is None:
|
|
387
|
+
extra_layers = ()
|
|
388
|
+
else:
|
|
389
|
+
extra_layers = tuple(layers)
|
|
390
|
+
for layer in extra_layers:
|
|
391
|
+
if not isinstance(layer, CacheLayer): # pragma: no cover - defensive typing
|
|
392
|
+
raise TypeError(f"unsupported cache layer type: {type(layer)!r}")
|
|
393
|
+
self._layers: tuple[CacheLayer, ...] = (mapping_layer, *extra_layers)
|
|
394
|
+
self._storage_layer = mapping_layer
|
|
395
|
+
self._storage: MutableMapping[str, Any] = mapping_layer.storage
|
|
396
|
+
self._entries: dict[str, _CacheEntry] = {}
|
|
397
|
+
self._registry_lock = threading.RLock()
|
|
398
|
+
self._default_capacity = self._normalise_capacity(default_capacity)
|
|
399
|
+
self._capacity_overrides: dict[str, int | None] = {}
|
|
400
|
+
self._metrics: dict[str, _CacheMetrics] = {}
|
|
401
|
+
self._metrics_publishers: list[Callable[[str, CacheStatistics], None]] = []
|
|
402
|
+
if overrides:
|
|
403
|
+
self.configure(overrides=overrides)
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _normalise_capacity(value: int | None) -> int | None:
|
|
407
|
+
if value is None:
|
|
408
|
+
return None
|
|
409
|
+
size = int(value)
|
|
410
|
+
if size < 0:
|
|
411
|
+
raise ValueError("capacity must be non-negative or None")
|
|
412
|
+
return size
|
|
413
|
+
|
|
414
|
+
def register(
|
|
415
|
+
self,
|
|
416
|
+
name: str,
|
|
417
|
+
factory: Callable[[], Any],
|
|
418
|
+
*,
|
|
419
|
+
lock_factory: Callable[[], threading.Lock | threading.RLock] | None = None,
|
|
420
|
+
reset: Callable[[Any], Any] | None = None,
|
|
421
|
+
create: bool = True,
|
|
422
|
+
encoder: Callable[[Any], Any] | None = None,
|
|
423
|
+
decoder: Callable[[Any], Any] | None = None,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Register ``name`` with ``factory`` and optional lifecycle hooks."""
|
|
426
|
+
|
|
427
|
+
if lock_factory is None:
|
|
428
|
+
lock_factory = threading.RLock
|
|
429
|
+
with self._registry_lock:
|
|
430
|
+
entry = self._entries.get(name)
|
|
431
|
+
if entry is None:
|
|
432
|
+
entry = _CacheEntry(
|
|
433
|
+
factory=factory,
|
|
434
|
+
lock=lock_factory(),
|
|
435
|
+
reset=reset,
|
|
436
|
+
encoder=encoder,
|
|
437
|
+
decoder=decoder,
|
|
438
|
+
)
|
|
439
|
+
self._entries[name] = entry
|
|
440
|
+
else:
|
|
441
|
+
# Update hooks when re-registering the same cache name.
|
|
442
|
+
entry.factory = factory
|
|
443
|
+
entry.reset = reset
|
|
444
|
+
entry.encoder = encoder
|
|
445
|
+
entry.decoder = decoder
|
|
446
|
+
self._ensure_metrics(name)
|
|
447
|
+
if create:
|
|
448
|
+
self.get(name)
|
|
449
|
+
|
|
450
|
+
def configure(
|
|
451
|
+
self,
|
|
452
|
+
*,
|
|
453
|
+
default_capacity: int | None | object = _MISSING,
|
|
454
|
+
overrides: Mapping[str, int | None] | None = None,
|
|
455
|
+
replace_overrides: bool = False,
|
|
456
|
+
) -> None:
|
|
457
|
+
"""Update the cache capacity policy shared by registered entries."""
|
|
458
|
+
|
|
459
|
+
with self._registry_lock:
|
|
460
|
+
if default_capacity is not self._MISSING:
|
|
461
|
+
self._default_capacity = self._normalise_capacity(
|
|
462
|
+
default_capacity if default_capacity is not None else None
|
|
463
|
+
)
|
|
464
|
+
if overrides is not None:
|
|
465
|
+
if replace_overrides:
|
|
466
|
+
self._capacity_overrides.clear()
|
|
467
|
+
for key, value in overrides.items():
|
|
468
|
+
self._capacity_overrides[key] = self._normalise_capacity(value)
|
|
469
|
+
|
|
470
|
+
def configure_from_mapping(self, config: Mapping[str, Any]) -> None:
|
|
471
|
+
"""Load configuration produced by :meth:`export_config`."""
|
|
472
|
+
|
|
473
|
+
default = config.get("default_capacity", self._MISSING)
|
|
474
|
+
overrides = config.get("overrides")
|
|
475
|
+
overrides_mapping: Mapping[str, int | None] | None
|
|
476
|
+
overrides_mapping = overrides if isinstance(overrides, Mapping) else None
|
|
477
|
+
self.configure(default_capacity=default, overrides=overrides_mapping)
|
|
478
|
+
|
|
479
|
+
def export_config(self) -> CacheCapacityConfig:
|
|
480
|
+
"""Return a copy of the current capacity configuration."""
|
|
481
|
+
|
|
482
|
+
with self._registry_lock:
|
|
483
|
+
return CacheCapacityConfig(
|
|
484
|
+
default_capacity=self._default_capacity,
|
|
485
|
+
overrides=dict(self._capacity_overrides),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def get_capacity(
|
|
489
|
+
self,
|
|
490
|
+
name: str,
|
|
491
|
+
*,
|
|
492
|
+
requested: int | None = None,
|
|
493
|
+
fallback: int | None = None,
|
|
494
|
+
use_default: bool = True,
|
|
495
|
+
) -> int | None:
|
|
496
|
+
"""Return capacity for ``name`` considering overrides and defaults."""
|
|
497
|
+
|
|
498
|
+
with self._registry_lock:
|
|
499
|
+
override = self._capacity_overrides.get(name, self._MISSING)
|
|
500
|
+
default = self._default_capacity
|
|
501
|
+
if override is not self._MISSING:
|
|
502
|
+
return override
|
|
503
|
+
values: tuple[int | None, ...]
|
|
504
|
+
if use_default:
|
|
505
|
+
values = (requested, default, fallback)
|
|
506
|
+
else:
|
|
507
|
+
values = (requested, fallback)
|
|
508
|
+
for value in values:
|
|
509
|
+
if value is self._MISSING:
|
|
510
|
+
continue
|
|
511
|
+
normalised = self._normalise_capacity(value)
|
|
512
|
+
if normalised is not None:
|
|
513
|
+
return normalised
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
def has_override(self, name: str) -> bool:
|
|
517
|
+
"""Return ``True`` if ``name`` has an explicit capacity override."""
|
|
518
|
+
|
|
519
|
+
with self._registry_lock:
|
|
520
|
+
return name in self._capacity_overrides
|
|
521
|
+
|
|
522
|
+
def get_lock(self, name: str) -> threading.Lock | threading.RLock:
|
|
523
|
+
"""Return the lock guarding cache ``name`` for external coordination."""
|
|
524
|
+
|
|
525
|
+
entry = self._entries.get(name)
|
|
526
|
+
if entry is None:
|
|
527
|
+
raise KeyError(name)
|
|
528
|
+
return entry.lock
|
|
529
|
+
|
|
530
|
+
def names(self) -> Iterator[str]:
|
|
531
|
+
"""Iterate over registered cache names."""
|
|
532
|
+
|
|
533
|
+
with self._registry_lock:
|
|
534
|
+
return iter(tuple(self._entries))
|
|
535
|
+
|
|
536
|
+
def get(self, name: str, *, create: bool = True) -> Any:
|
|
537
|
+
"""Return cache ``name`` creating it on demand when ``create`` is true."""
|
|
538
|
+
|
|
539
|
+
entry = self._entries.get(name)
|
|
540
|
+
if entry is None:
|
|
541
|
+
raise KeyError(name)
|
|
542
|
+
with entry.lock:
|
|
543
|
+
value = self._load_from_layers(name, entry)
|
|
544
|
+
if create and value is None:
|
|
545
|
+
value = entry.factory()
|
|
546
|
+
self._persist_layers(name, entry, value)
|
|
547
|
+
return value
|
|
548
|
+
|
|
549
|
+
def peek(self, name: str) -> Any:
|
|
550
|
+
"""Return cache ``name`` without creating a missing entry."""
|
|
551
|
+
|
|
552
|
+
entry = self._entries.get(name)
|
|
553
|
+
if entry is None:
|
|
554
|
+
raise KeyError(name)
|
|
555
|
+
with entry.lock:
|
|
556
|
+
return self._load_from_layers(name, entry)
|
|
557
|
+
|
|
558
|
+
def store(self, name: str, value: Any) -> None:
|
|
559
|
+
"""Replace the stored value for cache ``name`` with ``value``."""
|
|
560
|
+
|
|
561
|
+
entry = self._entries.get(name)
|
|
562
|
+
if entry is None:
|
|
563
|
+
raise KeyError(name)
|
|
564
|
+
with entry.lock:
|
|
565
|
+
self._persist_layers(name, entry, value)
|
|
566
|
+
|
|
567
|
+
def update(
|
|
568
|
+
self,
|
|
569
|
+
name: str,
|
|
570
|
+
updater: Callable[[Any], Any],
|
|
571
|
+
*,
|
|
572
|
+
create: bool = True,
|
|
573
|
+
) -> Any:
|
|
574
|
+
"""Apply ``updater`` to cache ``name`` storing the resulting value."""
|
|
575
|
+
|
|
576
|
+
entry = self._entries.get(name)
|
|
577
|
+
if entry is None:
|
|
578
|
+
raise KeyError(name)
|
|
579
|
+
with entry.lock:
|
|
580
|
+
current = self._load_from_layers(name, entry)
|
|
581
|
+
if create and current is None:
|
|
582
|
+
current = entry.factory()
|
|
583
|
+
new_value = updater(current)
|
|
584
|
+
self._persist_layers(name, entry, new_value)
|
|
585
|
+
return new_value
|
|
586
|
+
|
|
587
|
+
def clear(self, name: str | None = None) -> None:
|
|
588
|
+
"""Reset caches either selectively or for every registered name."""
|
|
589
|
+
|
|
590
|
+
if name is not None:
|
|
591
|
+
names = (name,)
|
|
592
|
+
else:
|
|
593
|
+
with self._registry_lock:
|
|
594
|
+
names = tuple(self._entries)
|
|
595
|
+
for cache_name in names:
|
|
596
|
+
entry = self._entries.get(cache_name)
|
|
597
|
+
if entry is None:
|
|
598
|
+
continue
|
|
599
|
+
with entry.lock:
|
|
600
|
+
current = self._load_from_layers(cache_name, entry)
|
|
601
|
+
new_value = None
|
|
602
|
+
if entry.reset is not None:
|
|
603
|
+
try:
|
|
604
|
+
new_value = entry.reset(current)
|
|
605
|
+
except Exception: # pragma: no cover - defensive logging
|
|
606
|
+
_logger.exception("cache reset failed for %s", cache_name)
|
|
607
|
+
if new_value is None:
|
|
608
|
+
try:
|
|
609
|
+
new_value = entry.factory()
|
|
610
|
+
except Exception:
|
|
611
|
+
self._delete_from_layers(cache_name)
|
|
612
|
+
continue
|
|
613
|
+
self._persist_layers(cache_name, entry, new_value)
|
|
614
|
+
|
|
615
|
+
# ------------------------------------------------------------------
|
|
616
|
+
# Layer orchestration helpers
|
|
617
|
+
|
|
618
|
+
def _encode_value(self, entry: _CacheEntry, value: Any) -> Any:
|
|
619
|
+
encoder = entry.encoder
|
|
620
|
+
if encoder is None:
|
|
621
|
+
return value
|
|
622
|
+
return encoder(value)
|
|
623
|
+
|
|
624
|
+
def _decode_value(self, entry: _CacheEntry, payload: Any) -> Any:
|
|
625
|
+
decoder = entry.decoder
|
|
626
|
+
if decoder is None:
|
|
627
|
+
return payload
|
|
628
|
+
return decoder(payload)
|
|
629
|
+
|
|
630
|
+
def _store_layer(self, name: str, entry: _CacheEntry, value: Any, *, layer_index: int) -> None:
|
|
631
|
+
layer = self._layers[layer_index]
|
|
632
|
+
if layer_index == 0:
|
|
633
|
+
payload = value
|
|
634
|
+
else:
|
|
635
|
+
try:
|
|
636
|
+
payload = self._encode_value(entry, value)
|
|
637
|
+
except Exception: # pragma: no cover - defensive logging
|
|
638
|
+
_logger.exception("cache encoding failed for %s", name)
|
|
639
|
+
return
|
|
640
|
+
try:
|
|
641
|
+
layer.store(name, payload)
|
|
642
|
+
except Exception: # pragma: no cover - defensive logging
|
|
643
|
+
_logger.exception(
|
|
644
|
+
"cache layer store failed for %s on %s", name, layer.__class__.__name__
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _persist_layers(self, name: str, entry: _CacheEntry, value: Any) -> None:
|
|
648
|
+
for index in range(len(self._layers)):
|
|
649
|
+
self._store_layer(name, entry, value, layer_index=index)
|
|
650
|
+
|
|
651
|
+
def _delete_from_layers(self, name: str) -> None:
|
|
652
|
+
for layer in self._layers:
|
|
653
|
+
try:
|
|
654
|
+
layer.delete(name)
|
|
655
|
+
except KeyError:
|
|
656
|
+
continue
|
|
657
|
+
except Exception: # pragma: no cover - defensive logging
|
|
658
|
+
_logger.exception(
|
|
659
|
+
"cache layer delete failed for %s on %s", name, layer.__class__.__name__
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
def _load_from_layers(self, name: str, entry: _CacheEntry) -> Any:
|
|
663
|
+
# Primary in-memory layer first for fast-path lookups.
|
|
664
|
+
try:
|
|
665
|
+
value = self._layers[0].load(name)
|
|
666
|
+
except KeyError:
|
|
667
|
+
value = None
|
|
668
|
+
except Exception: # pragma: no cover - defensive logging
|
|
669
|
+
_logger.exception(
|
|
670
|
+
"cache layer load failed for %s on %s", name, self._layers[0].__class__.__name__
|
|
671
|
+
)
|
|
672
|
+
value = None
|
|
673
|
+
if value is not None:
|
|
674
|
+
return value
|
|
675
|
+
|
|
676
|
+
# Fall back to slower layers and hydrate preceding caches on success.
|
|
677
|
+
for index in range(1, len(self._layers)):
|
|
678
|
+
layer = self._layers[index]
|
|
679
|
+
try:
|
|
680
|
+
payload = layer.load(name)
|
|
681
|
+
except KeyError:
|
|
682
|
+
continue
|
|
683
|
+
except Exception: # pragma: no cover - defensive logging
|
|
684
|
+
_logger.exception(
|
|
685
|
+
"cache layer load failed for %s on %s", name, layer.__class__.__name__
|
|
686
|
+
)
|
|
687
|
+
continue
|
|
688
|
+
try:
|
|
689
|
+
value = self._decode_value(entry, payload)
|
|
690
|
+
except Exception: # pragma: no cover - defensive logging
|
|
691
|
+
_logger.exception("cache decoding failed for %s", name)
|
|
692
|
+
continue
|
|
693
|
+
if value is None:
|
|
694
|
+
continue
|
|
695
|
+
for prev_index in range(index):
|
|
696
|
+
self._store_layer(name, entry, value, layer_index=prev_index)
|
|
697
|
+
return value
|
|
698
|
+
return None
|
|
699
|
+
|
|
700
|
+
# ------------------------------------------------------------------
|
|
701
|
+
# Metrics helpers
|
|
702
|
+
|
|
703
|
+
def _ensure_metrics(self, name: str) -> _CacheMetrics:
|
|
704
|
+
metrics = self._metrics.get(name)
|
|
705
|
+
if metrics is None:
|
|
706
|
+
with self._registry_lock:
|
|
707
|
+
metrics = self._metrics.get(name)
|
|
708
|
+
if metrics is None:
|
|
709
|
+
metrics = _CacheMetrics()
|
|
710
|
+
self._metrics[name] = metrics
|
|
711
|
+
return metrics
|
|
712
|
+
|
|
713
|
+
def increment_hit(
|
|
714
|
+
self,
|
|
715
|
+
name: str,
|
|
716
|
+
*,
|
|
717
|
+
amount: int = 1,
|
|
718
|
+
duration: float | None = None,
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Increase cache hit counters for ``name`` (optionally logging latency)."""
|
|
721
|
+
|
|
722
|
+
metrics = self._ensure_metrics(name)
|
|
723
|
+
with metrics.lock:
|
|
724
|
+
metrics.hits += int(amount)
|
|
725
|
+
if duration is not None:
|
|
726
|
+
metrics.total_time += float(duration)
|
|
727
|
+
metrics.timings += 1
|
|
728
|
+
|
|
729
|
+
def increment_miss(
|
|
730
|
+
self,
|
|
731
|
+
name: str,
|
|
732
|
+
*,
|
|
733
|
+
amount: int = 1,
|
|
734
|
+
duration: float | None = None,
|
|
735
|
+
) -> None:
|
|
736
|
+
"""Increase cache miss counters for ``name`` (optionally logging latency)."""
|
|
737
|
+
|
|
738
|
+
metrics = self._ensure_metrics(name)
|
|
739
|
+
with metrics.lock:
|
|
740
|
+
metrics.misses += int(amount)
|
|
741
|
+
if duration is not None:
|
|
742
|
+
metrics.total_time += float(duration)
|
|
743
|
+
metrics.timings += 1
|
|
744
|
+
|
|
745
|
+
def increment_eviction(self, name: str, *, amount: int = 1) -> None:
|
|
746
|
+
"""Increase eviction count for cache ``name``."""
|
|
747
|
+
|
|
748
|
+
metrics = self._ensure_metrics(name)
|
|
749
|
+
with metrics.lock:
|
|
750
|
+
metrics.evictions += int(amount)
|
|
751
|
+
|
|
752
|
+
def record_timing(self, name: str, duration: float) -> None:
|
|
753
|
+
"""Accumulate ``duration`` into latency telemetry for ``name``."""
|
|
754
|
+
|
|
755
|
+
metrics = self._ensure_metrics(name)
|
|
756
|
+
with metrics.lock:
|
|
757
|
+
metrics.total_time += float(duration)
|
|
758
|
+
metrics.timings += 1
|
|
759
|
+
|
|
760
|
+
@contextmanager
|
|
761
|
+
def timer(self, name: str) -> TimingContext:
|
|
762
|
+
"""Context manager recording execution time for ``name``."""
|
|
763
|
+
|
|
764
|
+
start = perf_counter()
|
|
765
|
+
try:
|
|
766
|
+
yield
|
|
767
|
+
finally:
|
|
768
|
+
self.record_timing(name, perf_counter() - start)
|
|
769
|
+
|
|
770
|
+
def get_metrics(self, name: str) -> CacheStatistics:
|
|
771
|
+
"""Return a snapshot of telemetry collected for cache ``name``."""
|
|
772
|
+
|
|
773
|
+
metrics = self._metrics.get(name)
|
|
774
|
+
if metrics is None:
|
|
775
|
+
return CacheStatistics()
|
|
776
|
+
with metrics.lock:
|
|
777
|
+
return metrics.snapshot()
|
|
778
|
+
|
|
779
|
+
def iter_metrics(self) -> Iterator[tuple[str, CacheStatistics]]:
|
|
780
|
+
"""Yield ``(name, stats)`` pairs for every cache with telemetry."""
|
|
781
|
+
|
|
782
|
+
with self._registry_lock:
|
|
783
|
+
items = tuple(self._metrics.items())
|
|
784
|
+
for name, metrics in items:
|
|
785
|
+
with metrics.lock:
|
|
786
|
+
yield name, metrics.snapshot()
|
|
787
|
+
|
|
788
|
+
def aggregate_metrics(self) -> CacheStatistics:
|
|
789
|
+
"""Return aggregated telemetry statistics across all caches."""
|
|
790
|
+
|
|
791
|
+
aggregate = CacheStatistics()
|
|
792
|
+
for _, stats in self.iter_metrics():
|
|
793
|
+
aggregate = aggregate.merge(stats)
|
|
794
|
+
return aggregate
|
|
795
|
+
|
|
796
|
+
def register_metrics_publisher(
|
|
797
|
+
self, publisher: Callable[[str, CacheStatistics], None]
|
|
798
|
+
) -> None:
|
|
799
|
+
"""Register ``publisher`` to receive metrics snapshots on demand."""
|
|
800
|
+
|
|
801
|
+
with self._registry_lock:
|
|
802
|
+
self._metrics_publishers.append(publisher)
|
|
803
|
+
|
|
804
|
+
def publish_metrics(
|
|
805
|
+
self,
|
|
806
|
+
*,
|
|
807
|
+
publisher: Callable[[str, CacheStatistics], None] | None = None,
|
|
808
|
+
) -> None:
|
|
809
|
+
"""Send cached telemetry to ``publisher`` or all registered publishers."""
|
|
810
|
+
|
|
811
|
+
if publisher is None:
|
|
812
|
+
with self._registry_lock:
|
|
813
|
+
publishers = tuple(self._metrics_publishers)
|
|
814
|
+
else:
|
|
815
|
+
publishers = (publisher,)
|
|
816
|
+
if not publishers:
|
|
817
|
+
return
|
|
818
|
+
snapshot = tuple(self.iter_metrics())
|
|
819
|
+
for emit in publishers:
|
|
820
|
+
for name, stats in snapshot:
|
|
821
|
+
try:
|
|
822
|
+
emit(name, stats)
|
|
823
|
+
except Exception: # pragma: no cover - defensive logging
|
|
824
|
+
_logger.exception("Cache metrics publisher failed for %s", name)
|
|
825
|
+
|
|
826
|
+
def log_metrics(self, logger: logging.Logger, *, level: int = logging.INFO) -> None:
|
|
827
|
+
"""Emit cache metrics using ``logger`` for telemetry hooks."""
|
|
828
|
+
|
|
829
|
+
for name, stats in self.iter_metrics():
|
|
830
|
+
logger.log(
|
|
831
|
+
level,
|
|
832
|
+
"cache=%s hits=%d misses=%d evictions=%d timings=%d total_time=%.6f",
|
|
833
|
+
name,
|
|
834
|
+
stats.hits,
|
|
835
|
+
stats.misses,
|
|
836
|
+
stats.evictions,
|
|
837
|
+
stats.timings,
|
|
838
|
+
stats.total_time,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
from .init import get_logger as _get_logger
|
|
844
|
+
except ImportError: # pragma: no cover - circular bootstrap fallback
|
|
845
|
+
|
|
846
|
+
def _get_logger(name: str) -> logging.Logger:
|
|
847
|
+
return logging.getLogger(name)
|
|
848
|
+
|
|
849
|
+
_logger = _get_logger(__name__)
|
|
850
|
+
get_logger = _get_logger
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _normalise_callbacks(
|
|
854
|
+
callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
|
|
855
|
+
) -> tuple[Callable[[K, V], None], ...]:
|
|
856
|
+
if callbacks is None:
|
|
857
|
+
return ()
|
|
858
|
+
if callable(callbacks):
|
|
859
|
+
return (callbacks,)
|
|
860
|
+
return tuple(callbacks)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def prune_lock_mapping(
|
|
864
|
+
cache: Mapping[K, Any] | MutableMapping[K, Any] | None,
|
|
865
|
+
locks: MutableMapping[K, Any] | None,
|
|
866
|
+
) -> None:
|
|
867
|
+
"""Drop lock entries not present in ``cache``."""
|
|
868
|
+
|
|
869
|
+
if locks is None:
|
|
870
|
+
return
|
|
871
|
+
if cache is None:
|
|
872
|
+
cache_keys: set[K] = set()
|
|
873
|
+
else:
|
|
874
|
+
cache_keys = set(cache.keys())
|
|
875
|
+
for key in list(locks.keys()):
|
|
876
|
+
if key not in cache_keys:
|
|
877
|
+
locks.pop(key, None)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
class InstrumentedLRUCache(MutableMapping[K, V], Generic[K, V]):
|
|
881
|
+
"""LRU cache wrapper that synchronises telemetry, callbacks and locks.
|
|
882
|
+
|
|
883
|
+
The wrapper owns an internal :class:`cachetools.LRUCache` instance and
|
|
884
|
+
forwards all read operations to it. Mutating operations are instrumented to
|
|
885
|
+
update :class:`CacheManager` metrics, execute registered callbacks and keep
|
|
886
|
+
an optional lock mapping aligned with the stored keys. Telemetry callbacks
|
|
887
|
+
always execute before eviction callbacks, preserving the registration order
|
|
888
|
+
for deterministic side effects.
|
|
889
|
+
|
|
890
|
+
Callbacks can be extended or replaced after construction via
|
|
891
|
+
:meth:`set_telemetry_callbacks` and :meth:`set_eviction_callbacks`. When
|
|
892
|
+
``append`` is ``False`` (default) the provided callbacks replace the
|
|
893
|
+
existing sequence; otherwise they are appended at the end while keeping the
|
|
894
|
+
previous ordering intact.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
_MISSING = object()
|
|
898
|
+
|
|
899
|
+
def __init__(
|
|
900
|
+
self,
|
|
901
|
+
maxsize: int,
|
|
902
|
+
*,
|
|
903
|
+
manager: CacheManager | None = None,
|
|
904
|
+
metrics_key: str | None = None,
|
|
905
|
+
telemetry_callbacks: (
|
|
906
|
+
Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
|
|
907
|
+
) = None,
|
|
908
|
+
eviction_callbacks: (
|
|
909
|
+
Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
|
|
910
|
+
) = None,
|
|
911
|
+
locks: MutableMapping[K, Any] | None = None,
|
|
912
|
+
getsizeof: Callable[[V], int] | None = None,
|
|
913
|
+
count_overwrite_hit: bool = True,
|
|
914
|
+
) -> None:
|
|
915
|
+
self._cache: LRUCache[K, V] = LRUCache(maxsize, getsizeof=getsizeof)
|
|
916
|
+
original_popitem = self._cache.popitem
|
|
917
|
+
|
|
918
|
+
def _instrumented_popitem() -> tuple[K, V]:
|
|
919
|
+
key, value = original_popitem()
|
|
920
|
+
self._dispatch_removal(key, value)
|
|
921
|
+
return key, value
|
|
922
|
+
|
|
923
|
+
self._cache.popitem = _instrumented_popitem # type: ignore[assignment]
|
|
924
|
+
self._manager = manager
|
|
925
|
+
self._metrics_key = metrics_key
|
|
926
|
+
self._locks = locks
|
|
927
|
+
self._count_overwrite_hit = bool(count_overwrite_hit)
|
|
928
|
+
self._telemetry_callbacks: list[Callable[[K, V], None]]
|
|
929
|
+
self._telemetry_callbacks = list(_normalise_callbacks(telemetry_callbacks))
|
|
930
|
+
self._eviction_callbacks: list[Callable[[K, V], None]]
|
|
931
|
+
self._eviction_callbacks = list(_normalise_callbacks(eviction_callbacks))
|
|
932
|
+
|
|
933
|
+
# ------------------------------------------------------------------
|
|
934
|
+
# Callback registration helpers
|
|
935
|
+
|
|
936
|
+
@property
|
|
937
|
+
def telemetry_callbacks(self) -> tuple[Callable[[K, V], None], ...]:
|
|
938
|
+
"""Return currently registered telemetry callbacks."""
|
|
939
|
+
|
|
940
|
+
return tuple(self._telemetry_callbacks)
|
|
941
|
+
|
|
942
|
+
@property
|
|
943
|
+
def eviction_callbacks(self) -> tuple[Callable[[K, V], None], ...]:
|
|
944
|
+
"""Return currently registered eviction callbacks."""
|
|
945
|
+
|
|
946
|
+
return tuple(self._eviction_callbacks)
|
|
947
|
+
|
|
948
|
+
def set_telemetry_callbacks(
|
|
949
|
+
self,
|
|
950
|
+
callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
|
|
951
|
+
*,
|
|
952
|
+
append: bool = False,
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Update telemetry callbacks executed on removals.
|
|
955
|
+
|
|
956
|
+
When ``append`` is ``True`` the provided callbacks are added to the end
|
|
957
|
+
of the execution chain while preserving relative order. Otherwise, the
|
|
958
|
+
previous callbacks are replaced.
|
|
959
|
+
"""
|
|
960
|
+
|
|
961
|
+
new_callbacks = list(_normalise_callbacks(callbacks))
|
|
962
|
+
if append:
|
|
963
|
+
self._telemetry_callbacks.extend(new_callbacks)
|
|
964
|
+
else:
|
|
965
|
+
self._telemetry_callbacks = new_callbacks
|
|
966
|
+
|
|
967
|
+
def set_eviction_callbacks(
|
|
968
|
+
self,
|
|
969
|
+
callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
|
|
970
|
+
*,
|
|
971
|
+
append: bool = False,
|
|
972
|
+
) -> None:
|
|
973
|
+
"""Update eviction callbacks executed on removals.
|
|
974
|
+
|
|
975
|
+
Behaviour matches :meth:`set_telemetry_callbacks`.
|
|
976
|
+
"""
|
|
977
|
+
|
|
978
|
+
new_callbacks = list(_normalise_callbacks(callbacks))
|
|
979
|
+
if append:
|
|
980
|
+
self._eviction_callbacks.extend(new_callbacks)
|
|
981
|
+
else:
|
|
982
|
+
self._eviction_callbacks = new_callbacks
|
|
983
|
+
|
|
984
|
+
# ------------------------------------------------------------------
|
|
985
|
+
# MutableMapping interface
|
|
986
|
+
|
|
987
|
+
def __getitem__(self, key: K) -> V:
|
|
988
|
+
"""Return the cached value for ``key``."""
|
|
989
|
+
|
|
990
|
+
return self._cache[key]
|
|
991
|
+
|
|
992
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
993
|
+
"""Store ``value`` under ``key`` updating telemetry accordingly."""
|
|
994
|
+
|
|
995
|
+
exists = key in self._cache
|
|
996
|
+
self._cache[key] = value
|
|
997
|
+
if exists:
|
|
998
|
+
if self._count_overwrite_hit:
|
|
999
|
+
self._record_hit(1)
|
|
1000
|
+
else:
|
|
1001
|
+
self._record_miss(1)
|
|
1002
|
+
|
|
1003
|
+
def __delitem__(self, key: K) -> None:
|
|
1004
|
+
"""Remove ``key`` from the cache and dispatch removal callbacks."""
|
|
1005
|
+
|
|
1006
|
+
try:
|
|
1007
|
+
value = self._cache[key]
|
|
1008
|
+
except KeyError:
|
|
1009
|
+
self._record_miss(1)
|
|
1010
|
+
raise
|
|
1011
|
+
del self._cache[key]
|
|
1012
|
+
self._dispatch_removal(key, value, hits=1)
|
|
1013
|
+
|
|
1014
|
+
def __iter__(self) -> Iterator[K]:
|
|
1015
|
+
"""Iterate over cached keys in eviction order."""
|
|
1016
|
+
|
|
1017
|
+
return iter(self._cache)
|
|
1018
|
+
|
|
1019
|
+
def __len__(self) -> int:
|
|
1020
|
+
"""Return the number of cached entries."""
|
|
1021
|
+
|
|
1022
|
+
return len(self._cache)
|
|
1023
|
+
|
|
1024
|
+
def __contains__(self, key: object) -> bool:
|
|
1025
|
+
"""Return ``True`` when ``key`` is stored in the cache."""
|
|
1026
|
+
|
|
1027
|
+
return key in self._cache
|
|
1028
|
+
|
|
1029
|
+
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
1030
|
+
"""Return a debug representation including the underlying cache."""
|
|
1031
|
+
|
|
1032
|
+
return f"{self.__class__.__name__}({self._cache!r})"
|
|
1033
|
+
|
|
1034
|
+
# ------------------------------------------------------------------
|
|
1035
|
+
# Cache helpers
|
|
1036
|
+
|
|
1037
|
+
@property
|
|
1038
|
+
def maxsize(self) -> int:
|
|
1039
|
+
"""Return the configured maximum cache size."""
|
|
1040
|
+
|
|
1041
|
+
return self._cache.maxsize
|
|
1042
|
+
|
|
1043
|
+
@property
|
|
1044
|
+
def currsize(self) -> int:
|
|
1045
|
+
"""Return the current weighted size reported by :mod:`cachetools`."""
|
|
1046
|
+
|
|
1047
|
+
return self._cache.currsize
|
|
1048
|
+
|
|
1049
|
+
def get(self, key: K, default: V | None = None) -> V | None:
|
|
1050
|
+
"""Return ``key`` if present, otherwise ``default``."""
|
|
1051
|
+
|
|
1052
|
+
return self._cache.get(key, default)
|
|
1053
|
+
|
|
1054
|
+
def pop(self, key: K, default: Any = _MISSING) -> V:
|
|
1055
|
+
"""Remove ``key`` returning its value or ``default`` when provided."""
|
|
1056
|
+
|
|
1057
|
+
try:
|
|
1058
|
+
value = self._cache[key]
|
|
1059
|
+
except KeyError:
|
|
1060
|
+
self._record_miss(1)
|
|
1061
|
+
if default is self._MISSING:
|
|
1062
|
+
raise
|
|
1063
|
+
return cast(V, default)
|
|
1064
|
+
del self._cache[key]
|
|
1065
|
+
self._dispatch_removal(key, value, hits=1)
|
|
1066
|
+
return value
|
|
1067
|
+
|
|
1068
|
+
def popitem(self) -> tuple[K, V]:
|
|
1069
|
+
"""Remove and return the LRU entry ensuring instrumentation fires."""
|
|
1070
|
+
|
|
1071
|
+
return self._cache.popitem()
|
|
1072
|
+
|
|
1073
|
+
def clear(self) -> None: # type: ignore[override]
|
|
1074
|
+
"""Evict every entry while keeping telemetry and locks consistent."""
|
|
1075
|
+
|
|
1076
|
+
while True:
|
|
1077
|
+
try:
|
|
1078
|
+
self.popitem()
|
|
1079
|
+
except KeyError:
|
|
1080
|
+
break
|
|
1081
|
+
if self._locks is not None:
|
|
1082
|
+
try:
|
|
1083
|
+
self._locks.clear()
|
|
1084
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1085
|
+
_logger.exception("lock cleanup failed during cache clear")
|
|
1086
|
+
|
|
1087
|
+
# ------------------------------------------------------------------
|
|
1088
|
+
# Internal helpers
|
|
1089
|
+
|
|
1090
|
+
def _record_hit(self, amount: int) -> None:
|
|
1091
|
+
if amount and self._manager is not None and self._metrics_key is not None:
|
|
1092
|
+
self._manager.increment_hit(self._metrics_key, amount=amount)
|
|
1093
|
+
|
|
1094
|
+
def _record_miss(self, amount: int) -> None:
|
|
1095
|
+
if amount and self._manager is not None and self._metrics_key is not None:
|
|
1096
|
+
self._manager.increment_miss(self._metrics_key, amount=amount)
|
|
1097
|
+
|
|
1098
|
+
def _record_eviction(self, amount: int) -> None:
|
|
1099
|
+
if amount and self._manager is not None and self._metrics_key is not None:
|
|
1100
|
+
self._manager.increment_eviction(self._metrics_key, amount=amount)
|
|
1101
|
+
|
|
1102
|
+
def _dispatch_removal(
|
|
1103
|
+
self,
|
|
1104
|
+
key: K,
|
|
1105
|
+
value: V,
|
|
1106
|
+
*,
|
|
1107
|
+
hits: int = 0,
|
|
1108
|
+
misses: int = 0,
|
|
1109
|
+
eviction_amount: int = 1,
|
|
1110
|
+
purge_lock: bool = True,
|
|
1111
|
+
) -> None:
|
|
1112
|
+
if hits:
|
|
1113
|
+
self._record_hit(hits)
|
|
1114
|
+
if misses:
|
|
1115
|
+
self._record_miss(misses)
|
|
1116
|
+
if eviction_amount:
|
|
1117
|
+
self._record_eviction(eviction_amount)
|
|
1118
|
+
self._emit_callbacks(self._telemetry_callbacks, key, value, "telemetry")
|
|
1119
|
+
self._emit_callbacks(self._eviction_callbacks, key, value, "eviction")
|
|
1120
|
+
if purge_lock:
|
|
1121
|
+
self._purge_lock(key)
|
|
1122
|
+
|
|
1123
|
+
def _emit_callbacks(
|
|
1124
|
+
self,
|
|
1125
|
+
callbacks: Iterable[Callable[[K, V], None]],
|
|
1126
|
+
key: K,
|
|
1127
|
+
value: V,
|
|
1128
|
+
kind: str,
|
|
1129
|
+
) -> None:
|
|
1130
|
+
for callback in callbacks:
|
|
1131
|
+
try:
|
|
1132
|
+
callback(key, value)
|
|
1133
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1134
|
+
_logger.exception("%s callback failed for %r", kind, key)
|
|
1135
|
+
|
|
1136
|
+
def _purge_lock(self, key: K) -> None:
|
|
1137
|
+
if self._locks is None:
|
|
1138
|
+
return
|
|
1139
|
+
try:
|
|
1140
|
+
self._locks.pop(key, None)
|
|
1141
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1142
|
+
_logger.exception("lock cleanup failed for %r", key)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
class ManagedLRUCache(LRUCache[K, V]):
|
|
1146
|
+
"""LRU cache wrapper with telemetry hooks and lock synchronisation."""
|
|
1147
|
+
|
|
1148
|
+
def __init__(
|
|
1149
|
+
self,
|
|
1150
|
+
maxsize: int,
|
|
1151
|
+
*,
|
|
1152
|
+
manager: CacheManager | None = None,
|
|
1153
|
+
metrics_key: str | None = None,
|
|
1154
|
+
eviction_callbacks: (
|
|
1155
|
+
Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
|
|
1156
|
+
) = None,
|
|
1157
|
+
telemetry_callbacks: (
|
|
1158
|
+
Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
|
|
1159
|
+
) = None,
|
|
1160
|
+
locks: MutableMapping[K, Any] | None = None,
|
|
1161
|
+
) -> None:
|
|
1162
|
+
super().__init__(maxsize)
|
|
1163
|
+
self._manager = manager
|
|
1164
|
+
self._metrics_key = metrics_key
|
|
1165
|
+
self._locks = locks
|
|
1166
|
+
self._eviction_callbacks = _normalise_callbacks(eviction_callbacks)
|
|
1167
|
+
self._telemetry_callbacks = _normalise_callbacks(telemetry_callbacks)
|
|
1168
|
+
|
|
1169
|
+
def popitem(self) -> tuple[K, V]: # type: ignore[override]
|
|
1170
|
+
"""Evict the LRU entry while updating telemetry and lock state."""
|
|
1171
|
+
|
|
1172
|
+
key, value = super().popitem()
|
|
1173
|
+
if self._locks is not None:
|
|
1174
|
+
try:
|
|
1175
|
+
self._locks.pop(key, None)
|
|
1176
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1177
|
+
_logger.exception("lock cleanup failed for %r", key)
|
|
1178
|
+
if self._manager is not None and self._metrics_key is not None:
|
|
1179
|
+
self._manager.increment_eviction(self._metrics_key)
|
|
1180
|
+
for callback in self._telemetry_callbacks:
|
|
1181
|
+
try:
|
|
1182
|
+
callback(key, value)
|
|
1183
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1184
|
+
_logger.exception("telemetry callback failed for %r", key)
|
|
1185
|
+
for callback in self._eviction_callbacks:
|
|
1186
|
+
try:
|
|
1187
|
+
callback(key, value)
|
|
1188
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1189
|
+
_logger.exception("eviction callback failed for %r", key)
|
|
1190
|
+
return key, value
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
@dataclass
|
|
1194
|
+
class _SeedCacheState:
|
|
1195
|
+
"""Container tracking the state for :class:`_SeedHashCache`."""
|
|
1196
|
+
|
|
1197
|
+
cache: InstrumentedLRUCache[tuple[int, int], int] | None
|
|
1198
|
+
maxsize: int
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@dataclass
|
|
1202
|
+
class _CounterState(Generic[K]):
|
|
1203
|
+
"""State bundle used by :class:`ScopedCounterCache`."""
|
|
1204
|
+
|
|
1205
|
+
cache: InstrumentedLRUCache[K, int]
|
|
1206
|
+
locks: dict[K, threading.RLock]
|
|
1207
|
+
max_entries: int
|
|
1208
|
+
|
|
1209
|
+
# Key used to store the node set checksum in a graph's ``graph`` attribute.
|
|
1210
|
+
NODE_SET_CHECKSUM_KEY = "_node_set_checksum_cache"
|
|
1211
|
+
|
|
1212
|
+
logger = _logger
|
|
1213
|
+
|
|
1214
|
+
# Helper to avoid importing ``tnfr.utils.init`` at module import time and keep
|
|
1215
|
+
# circular dependencies at bay while still reusing the canonical numpy loader.
|
|
1216
|
+
def _require_numpy():
|
|
1217
|
+
from .init import get_numpy
|
|
1218
|
+
|
|
1219
|
+
return get_numpy()
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
# Graph key storing per-graph layer configuration overrides.
|
|
1223
|
+
_GRAPH_CACHE_LAYERS_KEY = "_tnfr_cache_layers"
|
|
1224
|
+
|
|
1225
|
+
# Process-wide configuration for shared cache layers (Shelve/Redis).
|
|
1226
|
+
_GLOBAL_CACHE_LAYER_CONFIG: dict[str, dict[str, Any]] = {}
|
|
1227
|
+
_GLOBAL_CACHE_LOCK = threading.RLock()
|
|
1228
|
+
_GLOBAL_CACHE_MANAGER: CacheManager | None = None
|
|
1229
|
+
|
|
1230
|
+
# Keys of cache entries dependent on the edge version. Any change to the edge
|
|
1231
|
+
# set requires these to be dropped to avoid stale data.
|
|
1232
|
+
EDGE_VERSION_CACHE_KEYS = ("_trig_version",)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def get_graph_version(graph: Any, key: str, default: int = 0) -> int:
|
|
1236
|
+
"""Return integer version stored in ``graph`` under ``key``."""
|
|
1237
|
+
|
|
1238
|
+
return int(graph.get(key, default))
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def increment_graph_version(graph: Any, key: str) -> int:
|
|
1242
|
+
"""Increment and store a version counter in ``graph`` under ``key``."""
|
|
1243
|
+
|
|
1244
|
+
version = get_graph_version(graph, key) + 1
|
|
1245
|
+
graph[key] = version
|
|
1246
|
+
return version
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def stable_json(obj: Any) -> str:
|
|
1250
|
+
"""Return a JSON string with deterministic ordering for ``obj``."""
|
|
1251
|
+
|
|
1252
|
+
from .io import json_dumps
|
|
1253
|
+
|
|
1254
|
+
return json_dumps(
|
|
1255
|
+
obj,
|
|
1256
|
+
sort_keys=True,
|
|
1257
|
+
ensure_ascii=False,
|
|
1258
|
+
to_bytes=False,
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
@lru_cache(maxsize=1024)
|
|
1263
|
+
def _node_repr_digest(obj: Any) -> tuple[str, bytes]:
|
|
1264
|
+
"""Return cached stable representation and digest for ``obj``."""
|
|
1265
|
+
|
|
1266
|
+
try:
|
|
1267
|
+
repr_ = stable_json(obj)
|
|
1268
|
+
except TypeError:
|
|
1269
|
+
repr_ = repr(obj)
|
|
1270
|
+
digest = hashlib.blake2b(repr_.encode("utf-8"), digest_size=16).digest()
|
|
1271
|
+
return repr_, digest
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def clear_node_repr_cache() -> None:
|
|
1275
|
+
"""Clear cached node representations used for checksums."""
|
|
1276
|
+
|
|
1277
|
+
_node_repr_digest.cache_clear()
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def configure_global_cache_layers(
|
|
1281
|
+
*,
|
|
1282
|
+
shelve: Mapping[str, Any] | None = None,
|
|
1283
|
+
redis: Mapping[str, Any] | None = None,
|
|
1284
|
+
replace: bool = False,
|
|
1285
|
+
) -> None:
|
|
1286
|
+
"""Update process-wide cache layer configuration.
|
|
1287
|
+
|
|
1288
|
+
Parameters mirror the per-layer specifications accepted via graph metadata.
|
|
1289
|
+
Passing ``replace=True`` clears previous settings before applying new ones.
|
|
1290
|
+
Providing ``None`` for a layer while ``replace`` is true removes that layer
|
|
1291
|
+
from the configuration.
|
|
1292
|
+
"""
|
|
1293
|
+
|
|
1294
|
+
global _GLOBAL_CACHE_MANAGER
|
|
1295
|
+
with _GLOBAL_CACHE_LOCK:
|
|
1296
|
+
manager = _GLOBAL_CACHE_MANAGER
|
|
1297
|
+
_GLOBAL_CACHE_MANAGER = None
|
|
1298
|
+
if replace:
|
|
1299
|
+
_GLOBAL_CACHE_LAYER_CONFIG.clear()
|
|
1300
|
+
if shelve is not None:
|
|
1301
|
+
_GLOBAL_CACHE_LAYER_CONFIG["shelve"] = dict(shelve)
|
|
1302
|
+
elif replace:
|
|
1303
|
+
_GLOBAL_CACHE_LAYER_CONFIG.pop("shelve", None)
|
|
1304
|
+
if redis is not None:
|
|
1305
|
+
_GLOBAL_CACHE_LAYER_CONFIG["redis"] = dict(redis)
|
|
1306
|
+
elif replace:
|
|
1307
|
+
_GLOBAL_CACHE_LAYER_CONFIG.pop("redis", None)
|
|
1308
|
+
_close_cache_layers(manager)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _resolve_layer_config(
|
|
1312
|
+
graph: MutableMapping[str, Any] | None,
|
|
1313
|
+
) -> dict[str, dict[str, Any]]:
|
|
1314
|
+
resolved: dict[str, dict[str, Any]] = {}
|
|
1315
|
+
with _GLOBAL_CACHE_LOCK:
|
|
1316
|
+
for name, spec in _GLOBAL_CACHE_LAYER_CONFIG.items():
|
|
1317
|
+
resolved[name] = dict(spec)
|
|
1318
|
+
if graph is not None:
|
|
1319
|
+
overrides = graph.get(_GRAPH_CACHE_LAYERS_KEY)
|
|
1320
|
+
if isinstance(overrides, Mapping):
|
|
1321
|
+
for name in ("shelve", "redis"):
|
|
1322
|
+
layer_spec = overrides.get(name)
|
|
1323
|
+
if isinstance(layer_spec, Mapping):
|
|
1324
|
+
resolved[name] = dict(layer_spec)
|
|
1325
|
+
elif layer_spec is None:
|
|
1326
|
+
resolved.pop(name, None)
|
|
1327
|
+
return resolved
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _build_shelve_layer(spec: Mapping[str, Any]) -> ShelveCacheLayer | None:
|
|
1331
|
+
path = spec.get("path")
|
|
1332
|
+
if not path:
|
|
1333
|
+
return None
|
|
1334
|
+
flag = spec.get("flag", "c")
|
|
1335
|
+
protocol = spec.get("protocol")
|
|
1336
|
+
writeback = bool(spec.get("writeback", False))
|
|
1337
|
+
try:
|
|
1338
|
+
proto_arg = None if protocol is None else int(protocol)
|
|
1339
|
+
except (TypeError, ValueError):
|
|
1340
|
+
logger.warning("Invalid shelve protocol %r; falling back to default", protocol)
|
|
1341
|
+
proto_arg = None
|
|
1342
|
+
try:
|
|
1343
|
+
return ShelveCacheLayer(
|
|
1344
|
+
str(path),
|
|
1345
|
+
flag=str(flag),
|
|
1346
|
+
protocol=proto_arg,
|
|
1347
|
+
writeback=writeback,
|
|
1348
|
+
)
|
|
1349
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1350
|
+
logger.exception("Failed to initialise ShelveCacheLayer for path %r", path)
|
|
1351
|
+
return None
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _build_redis_layer(spec: Mapping[str, Any]) -> RedisCacheLayer | None:
|
|
1355
|
+
enabled = spec.get("enabled", True)
|
|
1356
|
+
if not enabled:
|
|
1357
|
+
return None
|
|
1358
|
+
namespace = spec.get("namespace")
|
|
1359
|
+
client = spec.get("client")
|
|
1360
|
+
if client is None:
|
|
1361
|
+
factory = spec.get("client_factory")
|
|
1362
|
+
if callable(factory):
|
|
1363
|
+
try:
|
|
1364
|
+
client = factory()
|
|
1365
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1366
|
+
logger.exception("Redis cache client factory failed")
|
|
1367
|
+
return None
|
|
1368
|
+
else:
|
|
1369
|
+
kwargs = spec.get("client_kwargs")
|
|
1370
|
+
if isinstance(kwargs, Mapping):
|
|
1371
|
+
try: # pragma: no cover - optional dependency
|
|
1372
|
+
import redis # type: ignore
|
|
1373
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1374
|
+
logger.exception("redis-py is required to build the configured Redis client")
|
|
1375
|
+
return None
|
|
1376
|
+
try:
|
|
1377
|
+
client = redis.Redis(**dict(kwargs))
|
|
1378
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1379
|
+
logger.exception("Failed to initialise redis client with %r", kwargs)
|
|
1380
|
+
return None
|
|
1381
|
+
try:
|
|
1382
|
+
if namespace is None:
|
|
1383
|
+
return RedisCacheLayer(client=client)
|
|
1384
|
+
return RedisCacheLayer(client=client, namespace=str(namespace))
|
|
1385
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1386
|
+
logger.exception("Failed to initialise RedisCacheLayer")
|
|
1387
|
+
return None
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _build_cache_layers(config: Mapping[str, dict[str, Any]]) -> tuple[CacheLayer, ...]:
|
|
1391
|
+
layers: list[CacheLayer] = []
|
|
1392
|
+
shelve_spec = config.get("shelve")
|
|
1393
|
+
if isinstance(shelve_spec, Mapping):
|
|
1394
|
+
layer = _build_shelve_layer(shelve_spec)
|
|
1395
|
+
if layer is not None:
|
|
1396
|
+
layers.append(layer)
|
|
1397
|
+
redis_spec = config.get("redis")
|
|
1398
|
+
if isinstance(redis_spec, Mapping):
|
|
1399
|
+
layer = _build_redis_layer(redis_spec)
|
|
1400
|
+
if layer is not None:
|
|
1401
|
+
layers.append(layer)
|
|
1402
|
+
return tuple(layers)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _close_cache_layers(manager: CacheManager | None) -> None:
|
|
1406
|
+
if manager is None:
|
|
1407
|
+
return
|
|
1408
|
+
layers = getattr(manager, "_layers", ())
|
|
1409
|
+
for layer in layers:
|
|
1410
|
+
close = getattr(layer, "close", None)
|
|
1411
|
+
if callable(close):
|
|
1412
|
+
try:
|
|
1413
|
+
close()
|
|
1414
|
+
except Exception: # pragma: no cover - defensive logging
|
|
1415
|
+
logger.exception(
|
|
1416
|
+
"Cache layer close failed for %s", layer.__class__.__name__
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def reset_global_cache_manager() -> None:
|
|
1421
|
+
"""Dispose the shared cache manager and close attached layers."""
|
|
1422
|
+
|
|
1423
|
+
global _GLOBAL_CACHE_MANAGER
|
|
1424
|
+
with _GLOBAL_CACHE_LOCK:
|
|
1425
|
+
manager = _GLOBAL_CACHE_MANAGER
|
|
1426
|
+
_GLOBAL_CACHE_MANAGER = None
|
|
1427
|
+
_close_cache_layers(manager)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def build_cache_manager(
|
|
1431
|
+
*,
|
|
1432
|
+
graph: MutableMapping[str, Any] | None = None,
|
|
1433
|
+
storage: MutableMapping[str, Any] | None = None,
|
|
1434
|
+
default_capacity: int | None = None,
|
|
1435
|
+
overrides: Mapping[str, int | None] | None = None,
|
|
1436
|
+
) -> CacheManager:
|
|
1437
|
+
"""Construct a :class:`CacheManager` honouring configured cache layers."""
|
|
1438
|
+
|
|
1439
|
+
global _GLOBAL_CACHE_MANAGER
|
|
1440
|
+
if graph is None:
|
|
1441
|
+
with _GLOBAL_CACHE_LOCK:
|
|
1442
|
+
manager = _GLOBAL_CACHE_MANAGER
|
|
1443
|
+
if manager is not None:
|
|
1444
|
+
return manager
|
|
1445
|
+
|
|
1446
|
+
layers = _build_cache_layers(_resolve_layer_config(graph))
|
|
1447
|
+
manager = CacheManager(
|
|
1448
|
+
storage,
|
|
1449
|
+
default_capacity=default_capacity,
|
|
1450
|
+
overrides=overrides,
|
|
1451
|
+
layers=layers,
|
|
1452
|
+
)
|
|
1453
|
+
|
|
1454
|
+
if graph is None:
|
|
1455
|
+
with _GLOBAL_CACHE_LOCK:
|
|
1456
|
+
global_manager = _GLOBAL_CACHE_MANAGER
|
|
1457
|
+
if global_manager is None:
|
|
1458
|
+
_GLOBAL_CACHE_MANAGER = manager
|
|
1459
|
+
return manager
|
|
1460
|
+
_close_cache_layers(manager)
|
|
1461
|
+
return global_manager
|
|
1462
|
+
|
|
1463
|
+
return manager
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _node_repr(n: Any) -> str:
|
|
1467
|
+
"""Stable representation for node hashing and sorting."""
|
|
1468
|
+
|
|
1469
|
+
return _node_repr_digest(n)[0]
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _iter_node_digests(nodes: Iterable[Any], *, presorted: bool) -> Iterable[bytes]:
|
|
1473
|
+
"""Yield node digests in a deterministic order."""
|
|
1474
|
+
|
|
1475
|
+
if presorted:
|
|
1476
|
+
for node in nodes:
|
|
1477
|
+
yield _node_repr_digest(node)[1]
|
|
1478
|
+
else:
|
|
1479
|
+
for _, digest in sorted(
|
|
1480
|
+
(_node_repr_digest(n) for n in nodes), key=lambda x: x[0]
|
|
1481
|
+
):
|
|
1482
|
+
yield digest
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _node_set_checksum_no_nodes(
|
|
1486
|
+
G: nx.Graph,
|
|
1487
|
+
graph: Any,
|
|
1488
|
+
*,
|
|
1489
|
+
presorted: bool,
|
|
1490
|
+
store: bool,
|
|
1491
|
+
) -> str:
|
|
1492
|
+
"""Checksum helper when no explicit node set is provided."""
|
|
1493
|
+
|
|
1494
|
+
nodes_view = G.nodes()
|
|
1495
|
+
current_nodes = frozenset(nodes_view)
|
|
1496
|
+
cached = graph.get(NODE_SET_CHECKSUM_KEY)
|
|
1497
|
+
if cached and len(cached) == 3 and cached[2] == current_nodes:
|
|
1498
|
+
return cached[1]
|
|
1499
|
+
|
|
1500
|
+
hasher = hashlib.blake2b(digest_size=16)
|
|
1501
|
+
for digest in _iter_node_digests(nodes_view, presorted=presorted):
|
|
1502
|
+
hasher.update(digest)
|
|
1503
|
+
|
|
1504
|
+
checksum = hasher.hexdigest()
|
|
1505
|
+
if store:
|
|
1506
|
+
token = checksum[:16]
|
|
1507
|
+
if cached and cached[0] == token:
|
|
1508
|
+
return cached[1]
|
|
1509
|
+
graph[NODE_SET_CHECKSUM_KEY] = (token, checksum, current_nodes)
|
|
1510
|
+
else:
|
|
1511
|
+
graph.pop(NODE_SET_CHECKSUM_KEY, None)
|
|
1512
|
+
return checksum
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def node_set_checksum(
|
|
1516
|
+
G: nx.Graph,
|
|
1517
|
+
nodes: Iterable[Any] | None = None,
|
|
1518
|
+
*,
|
|
1519
|
+
presorted: bool = False,
|
|
1520
|
+
store: bool = True,
|
|
1521
|
+
) -> str:
|
|
1522
|
+
"""Return a BLAKE2b checksum of ``G``'s node set."""
|
|
1523
|
+
|
|
1524
|
+
graph = get_graph(G)
|
|
1525
|
+
if nodes is None:
|
|
1526
|
+
return _node_set_checksum_no_nodes(G, graph, presorted=presorted, store=store)
|
|
1527
|
+
|
|
1528
|
+
hasher = hashlib.blake2b(digest_size=16)
|
|
1529
|
+
for digest in _iter_node_digests(nodes, presorted=presorted):
|
|
1530
|
+
hasher.update(digest)
|
|
1531
|
+
|
|
1532
|
+
checksum = hasher.hexdigest()
|
|
1533
|
+
if store:
|
|
1534
|
+
token = checksum[:16]
|
|
1535
|
+
cached = graph.get(NODE_SET_CHECKSUM_KEY)
|
|
1536
|
+
if cached and cached[0] == token:
|
|
1537
|
+
return cached[1]
|
|
1538
|
+
graph[NODE_SET_CHECKSUM_KEY] = (token, checksum)
|
|
1539
|
+
else:
|
|
1540
|
+
graph.pop(NODE_SET_CHECKSUM_KEY, None)
|
|
1541
|
+
return checksum
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
@dataclass(slots=True)
|
|
1545
|
+
class NodeCache:
|
|
1546
|
+
"""Container for cached node data."""
|
|
1547
|
+
|
|
1548
|
+
checksum: str
|
|
1549
|
+
nodes: tuple[Any, ...]
|
|
1550
|
+
sorted_nodes: tuple[Any, ...] | None = None
|
|
1551
|
+
idx: dict[Any, int] | None = None
|
|
1552
|
+
offset: dict[Any, int] | None = None
|
|
1553
|
+
|
|
1554
|
+
@property
|
|
1555
|
+
def n(self) -> int:
|
|
1556
|
+
return len(self.nodes)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _update_node_cache(
|
|
1560
|
+
graph: Any,
|
|
1561
|
+
nodes: tuple[Any, ...],
|
|
1562
|
+
key: str,
|
|
1563
|
+
*,
|
|
1564
|
+
checksum: str,
|
|
1565
|
+
sorted_nodes: tuple[Any, ...] | None = None,
|
|
1566
|
+
) -> None:
|
|
1567
|
+
"""Store ``nodes`` and ``checksum`` in ``graph`` under ``key``."""
|
|
1568
|
+
|
|
1569
|
+
graph[f"{key}_cache"] = NodeCache(
|
|
1570
|
+
checksum=checksum, nodes=nodes, sorted_nodes=sorted_nodes
|
|
1571
|
+
)
|
|
1572
|
+
graph[f"{key}_checksum"] = checksum
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _refresh_node_list_cache(
|
|
1576
|
+
G: nx.Graph,
|
|
1577
|
+
graph: Any,
|
|
1578
|
+
*,
|
|
1579
|
+
sort_nodes: bool,
|
|
1580
|
+
current_n: int,
|
|
1581
|
+
) -> tuple[Any, ...]:
|
|
1582
|
+
"""Refresh the cached node list and return the nodes."""
|
|
1583
|
+
|
|
1584
|
+
nodes = tuple(G.nodes())
|
|
1585
|
+
checksum = node_set_checksum(G, nodes, store=True)
|
|
1586
|
+
sorted_nodes = tuple(sorted(nodes, key=_node_repr)) if sort_nodes else None
|
|
1587
|
+
_update_node_cache(
|
|
1588
|
+
graph,
|
|
1589
|
+
nodes,
|
|
1590
|
+
"_node_list",
|
|
1591
|
+
checksum=checksum,
|
|
1592
|
+
sorted_nodes=sorted_nodes,
|
|
1593
|
+
)
|
|
1594
|
+
graph["_node_list_len"] = current_n
|
|
1595
|
+
return nodes
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def _reuse_node_list_cache(
|
|
1599
|
+
graph: Any,
|
|
1600
|
+
cache: NodeCache,
|
|
1601
|
+
nodes: tuple[Any, ...],
|
|
1602
|
+
sorted_nodes: tuple[Any, ...] | None,
|
|
1603
|
+
*,
|
|
1604
|
+
sort_nodes: bool,
|
|
1605
|
+
new_checksum: str | None,
|
|
1606
|
+
) -> None:
|
|
1607
|
+
"""Reuse existing node cache and record its checksum if missing."""
|
|
1608
|
+
|
|
1609
|
+
checksum = cache.checksum if new_checksum is None else new_checksum
|
|
1610
|
+
if sort_nodes and sorted_nodes is None:
|
|
1611
|
+
sorted_nodes = tuple(sorted(nodes, key=_node_repr))
|
|
1612
|
+
_update_node_cache(
|
|
1613
|
+
graph,
|
|
1614
|
+
nodes,
|
|
1615
|
+
"_node_list",
|
|
1616
|
+
checksum=checksum,
|
|
1617
|
+
sorted_nodes=sorted_nodes,
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _cache_node_list(G: nx.Graph) -> tuple[Any, ...]:
|
|
1622
|
+
"""Cache and return the tuple of nodes for ``G``."""
|
|
1623
|
+
|
|
1624
|
+
graph = get_graph(G)
|
|
1625
|
+
cache: NodeCache | None = graph.get("_node_list_cache")
|
|
1626
|
+
nodes = cache.nodes if cache else None
|
|
1627
|
+
sorted_nodes = cache.sorted_nodes if cache else None
|
|
1628
|
+
stored_len = graph.get("_node_list_len")
|
|
1629
|
+
current_n = G.number_of_nodes()
|
|
1630
|
+
dirty = bool(graph.pop("_node_list_dirty", False))
|
|
1631
|
+
|
|
1632
|
+
invalid = nodes is None or stored_len != current_n or dirty
|
|
1633
|
+
new_checksum: str | None = None
|
|
1634
|
+
|
|
1635
|
+
if not invalid and cache:
|
|
1636
|
+
new_checksum = node_set_checksum(G)
|
|
1637
|
+
invalid = cache.checksum != new_checksum
|
|
1638
|
+
|
|
1639
|
+
sort_nodes = bool(graph.get("SORT_NODES", False))
|
|
1640
|
+
|
|
1641
|
+
if invalid:
|
|
1642
|
+
nodes = _refresh_node_list_cache(
|
|
1643
|
+
G, graph, sort_nodes=sort_nodes, current_n=current_n
|
|
1644
|
+
)
|
|
1645
|
+
elif cache and "_node_list_checksum" not in graph:
|
|
1646
|
+
_reuse_node_list_cache(
|
|
1647
|
+
graph,
|
|
1648
|
+
cache,
|
|
1649
|
+
nodes,
|
|
1650
|
+
sorted_nodes,
|
|
1651
|
+
sort_nodes=sort_nodes,
|
|
1652
|
+
new_checksum=new_checksum,
|
|
1653
|
+
)
|
|
1654
|
+
else:
|
|
1655
|
+
if sort_nodes and sorted_nodes is None and cache is not None:
|
|
1656
|
+
cache.sorted_nodes = tuple(sorted(nodes, key=_node_repr))
|
|
1657
|
+
return nodes
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def cached_node_list(G: nx.Graph) -> tuple[Any, ...]:
|
|
1661
|
+
"""Public wrapper returning the cached node tuple for ``G``."""
|
|
1662
|
+
|
|
1663
|
+
return _cache_node_list(G)
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def _ensure_node_map(
|
|
1667
|
+
G: TNFRGraph,
|
|
1668
|
+
*,
|
|
1669
|
+
attrs: tuple[str, ...],
|
|
1670
|
+
sort: bool = False,
|
|
1671
|
+
) -> dict[NodeId, int]:
|
|
1672
|
+
"""Return cached node-to-index/offset mappings stored on ``NodeCache``."""
|
|
1673
|
+
|
|
1674
|
+
graph = G.graph
|
|
1675
|
+
_cache_node_list(G)
|
|
1676
|
+
cache: NodeCache = graph["_node_list_cache"]
|
|
1677
|
+
|
|
1678
|
+
missing = [attr for attr in attrs if getattr(cache, attr) is None]
|
|
1679
|
+
if missing:
|
|
1680
|
+
if sort:
|
|
1681
|
+
nodes_opt = cache.sorted_nodes
|
|
1682
|
+
if nodes_opt is None:
|
|
1683
|
+
nodes_opt = tuple(sorted(cache.nodes, key=_node_repr))
|
|
1684
|
+
cache.sorted_nodes = nodes_opt
|
|
1685
|
+
nodes_seq = nodes_opt
|
|
1686
|
+
else:
|
|
1687
|
+
nodes_seq = cache.nodes
|
|
1688
|
+
node_ids = cast(tuple[NodeId, ...], nodes_seq)
|
|
1689
|
+
mappings: dict[str, dict[NodeId, int]] = {attr: {} for attr in missing}
|
|
1690
|
+
for idx, node in enumerate(node_ids):
|
|
1691
|
+
for attr in missing:
|
|
1692
|
+
mappings[attr][node] = idx
|
|
1693
|
+
for attr in missing:
|
|
1694
|
+
setattr(cache, attr, mappings[attr])
|
|
1695
|
+
return cast(dict[NodeId, int], getattr(cache, attrs[0]))
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def ensure_node_index_map(G: TNFRGraph) -> dict[NodeId, int]:
|
|
1699
|
+
"""Return cached node-to-index mapping for ``G``."""
|
|
1700
|
+
|
|
1701
|
+
return _ensure_node_map(G, attrs=("idx",), sort=False)
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def ensure_node_offset_map(G: TNFRGraph) -> dict[NodeId, int]:
|
|
1705
|
+
"""Return cached node-to-offset mapping for ``G``."""
|
|
1706
|
+
|
|
1707
|
+
sort = bool(G.graph.get("SORT_NODES", False))
|
|
1708
|
+
return _ensure_node_map(G, attrs=("offset",), sort=sort)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
@dataclass
|
|
1712
|
+
class EdgeCacheState:
|
|
1713
|
+
cache: MutableMapping[Hashable, Any]
|
|
1714
|
+
locks: defaultdict[Hashable, threading.RLock]
|
|
1715
|
+
max_entries: int | None
|
|
1716
|
+
dirty: bool = False
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
_GRAPH_CACHE_MANAGER_KEY = "_tnfr_cache_manager"
|
|
1720
|
+
_GRAPH_CACHE_CONFIG_KEY = "_tnfr_cache_config"
|
|
1721
|
+
DNFR_PREP_STATE_KEY = "_dnfr_prep_state"
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
@dataclass(slots=True)
|
|
1725
|
+
class DnfrPrepState:
|
|
1726
|
+
"""State container coordinating ΔNFR preparation caches."""
|
|
1727
|
+
|
|
1728
|
+
cache: DnfrCache
|
|
1729
|
+
cache_lock: threading.RLock
|
|
1730
|
+
vector_lock: threading.RLock
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def _build_dnfr_prep_state(
|
|
1734
|
+
graph: MutableMapping[str, Any],
|
|
1735
|
+
previous: DnfrPrepState | None = None,
|
|
1736
|
+
) -> DnfrPrepState:
|
|
1737
|
+
"""Construct a :class:`DnfrPrepState` and mirror it on ``graph``."""
|
|
1738
|
+
|
|
1739
|
+
cache_lock: threading.RLock
|
|
1740
|
+
vector_lock: threading.RLock
|
|
1741
|
+
if isinstance(previous, DnfrPrepState):
|
|
1742
|
+
cache_lock = previous.cache_lock
|
|
1743
|
+
vector_lock = previous.vector_lock
|
|
1744
|
+
else:
|
|
1745
|
+
cache_lock = threading.RLock()
|
|
1746
|
+
vector_lock = threading.RLock()
|
|
1747
|
+
state = DnfrPrepState(
|
|
1748
|
+
cache=new_dnfr_cache(),
|
|
1749
|
+
cache_lock=cache_lock,
|
|
1750
|
+
vector_lock=vector_lock,
|
|
1751
|
+
)
|
|
1752
|
+
graph["_dnfr_prep_cache"] = state.cache
|
|
1753
|
+
return state
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _coerce_dnfr_state(
|
|
1757
|
+
graph: MutableMapping[str, Any],
|
|
1758
|
+
current: Any,
|
|
1759
|
+
) -> DnfrPrepState:
|
|
1760
|
+
"""Return ``current`` normalised into :class:`DnfrPrepState`."""
|
|
1761
|
+
|
|
1762
|
+
if isinstance(current, DnfrPrepState):
|
|
1763
|
+
graph["_dnfr_prep_cache"] = current.cache
|
|
1764
|
+
return current
|
|
1765
|
+
if isinstance(current, DnfrCache):
|
|
1766
|
+
state = DnfrPrepState(
|
|
1767
|
+
cache=current,
|
|
1768
|
+
cache_lock=threading.RLock(),
|
|
1769
|
+
vector_lock=threading.RLock(),
|
|
1770
|
+
)
|
|
1771
|
+
graph["_dnfr_prep_cache"] = current
|
|
1772
|
+
return state
|
|
1773
|
+
return _build_dnfr_prep_state(graph)
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def _graph_cache_manager(graph: MutableMapping[str, Any]) -> CacheManager:
|
|
1777
|
+
manager = graph.get(_GRAPH_CACHE_MANAGER_KEY)
|
|
1778
|
+
if not isinstance(manager, CacheManager):
|
|
1779
|
+
manager = build_cache_manager(graph=graph, default_capacity=128)
|
|
1780
|
+
graph[_GRAPH_CACHE_MANAGER_KEY] = manager
|
|
1781
|
+
config = graph.get(_GRAPH_CACHE_CONFIG_KEY)
|
|
1782
|
+
if isinstance(config, dict):
|
|
1783
|
+
manager.configure_from_mapping(config)
|
|
1784
|
+
|
|
1785
|
+
def _dnfr_factory() -> DnfrPrepState:
|
|
1786
|
+
return _build_dnfr_prep_state(graph)
|
|
1787
|
+
|
|
1788
|
+
def _dnfr_reset(current: Any) -> DnfrPrepState:
|
|
1789
|
+
if isinstance(current, DnfrPrepState):
|
|
1790
|
+
return _build_dnfr_prep_state(graph, current)
|
|
1791
|
+
return _build_dnfr_prep_state(graph)
|
|
1792
|
+
|
|
1793
|
+
manager.register(
|
|
1794
|
+
DNFR_PREP_STATE_KEY,
|
|
1795
|
+
_dnfr_factory,
|
|
1796
|
+
reset=_dnfr_reset,
|
|
1797
|
+
)
|
|
1798
|
+
manager.update(
|
|
1799
|
+
DNFR_PREP_STATE_KEY,
|
|
1800
|
+
lambda current: _coerce_dnfr_state(graph, current),
|
|
1801
|
+
)
|
|
1802
|
+
return manager
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def configure_graph_cache_limits(
|
|
1806
|
+
G: GraphLike | TNFRGraph | MutableMapping[str, Any],
|
|
1807
|
+
*,
|
|
1808
|
+
default_capacity: int | None | object = CacheManager._MISSING,
|
|
1809
|
+
overrides: Mapping[str, int | None] | None = None,
|
|
1810
|
+
replace_overrides: bool = False,
|
|
1811
|
+
) -> CacheCapacityConfig:
|
|
1812
|
+
"""Update cache capacity policy stored on ``G.graph``."""
|
|
1813
|
+
|
|
1814
|
+
graph = get_graph(G)
|
|
1815
|
+
manager = _graph_cache_manager(graph)
|
|
1816
|
+
manager.configure(
|
|
1817
|
+
default_capacity=default_capacity,
|
|
1818
|
+
overrides=overrides,
|
|
1819
|
+
replace_overrides=replace_overrides,
|
|
1820
|
+
)
|
|
1821
|
+
snapshot = manager.export_config()
|
|
1822
|
+
graph[_GRAPH_CACHE_CONFIG_KEY] = {
|
|
1823
|
+
"default_capacity": snapshot.default_capacity,
|
|
1824
|
+
"overrides": dict(snapshot.overrides),
|
|
1825
|
+
}
|
|
1826
|
+
return snapshot
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
class EdgeCacheManager:
|
|
1830
|
+
"""Coordinate cache storage and per-key locks for edge version caches."""
|
|
1831
|
+
|
|
1832
|
+
_STATE_KEY = "_edge_version_state"
|
|
1833
|
+
|
|
1834
|
+
def __init__(self, graph: MutableMapping[str, Any]) -> None:
|
|
1835
|
+
self.graph: MutableMapping[str, Any] = graph
|
|
1836
|
+
self._manager = _graph_cache_manager(graph)
|
|
1837
|
+
|
|
1838
|
+
def _encode_state(state: EdgeCacheState) -> Mapping[str, Any]:
|
|
1839
|
+
if not isinstance(state, EdgeCacheState):
|
|
1840
|
+
raise TypeError("EdgeCacheState expected")
|
|
1841
|
+
return {
|
|
1842
|
+
"max_entries": state.max_entries,
|
|
1843
|
+
"entries": list(state.cache.items()),
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
def _decode_state(payload: Any) -> EdgeCacheState:
|
|
1847
|
+
if isinstance(payload, EdgeCacheState):
|
|
1848
|
+
return payload
|
|
1849
|
+
if not isinstance(payload, Mapping):
|
|
1850
|
+
raise TypeError("invalid edge cache payload")
|
|
1851
|
+
max_entries = payload.get("max_entries")
|
|
1852
|
+
state = self._build_state(max_entries)
|
|
1853
|
+
for key, value in payload.get("entries", []):
|
|
1854
|
+
state.cache[key] = value
|
|
1855
|
+
state.dirty = False
|
|
1856
|
+
return state
|
|
1857
|
+
|
|
1858
|
+
self._manager.register(
|
|
1859
|
+
self._STATE_KEY,
|
|
1860
|
+
self._default_state,
|
|
1861
|
+
reset=self._reset_state,
|
|
1862
|
+
encoder=_encode_state,
|
|
1863
|
+
decoder=_decode_state,
|
|
1864
|
+
)
|
|
1865
|
+
|
|
1866
|
+
def record_hit(self) -> None:
|
|
1867
|
+
"""Record a cache hit for telemetry."""
|
|
1868
|
+
|
|
1869
|
+
self._manager.increment_hit(self._STATE_KEY)
|
|
1870
|
+
|
|
1871
|
+
def record_miss(self, *, track_metrics: bool = True) -> None:
|
|
1872
|
+
"""Record a cache miss for telemetry.
|
|
1873
|
+
|
|
1874
|
+
When ``track_metrics`` is ``False`` the miss is acknowledged without
|
|
1875
|
+
mutating the aggregated metrics.
|
|
1876
|
+
"""
|
|
1877
|
+
|
|
1878
|
+
if track_metrics:
|
|
1879
|
+
self._manager.increment_miss(self._STATE_KEY)
|
|
1880
|
+
|
|
1881
|
+
def record_eviction(self, *, track_metrics: bool = True) -> None:
|
|
1882
|
+
"""Record cache eviction events for telemetry.
|
|
1883
|
+
|
|
1884
|
+
When ``track_metrics`` is ``False`` the underlying metrics counter is
|
|
1885
|
+
left untouched while still signalling that an eviction occurred.
|
|
1886
|
+
"""
|
|
1887
|
+
|
|
1888
|
+
if track_metrics:
|
|
1889
|
+
self._manager.increment_eviction(self._STATE_KEY)
|
|
1890
|
+
|
|
1891
|
+
def timer(self) -> TimingContext:
|
|
1892
|
+
"""Return a timing context linked to this cache."""
|
|
1893
|
+
|
|
1894
|
+
return self._manager.timer(self._STATE_KEY)
|
|
1895
|
+
|
|
1896
|
+
def _default_state(self) -> EdgeCacheState:
|
|
1897
|
+
return self._build_state(None)
|
|
1898
|
+
|
|
1899
|
+
def resolve_max_entries(self, max_entries: int | None | object) -> int | None:
|
|
1900
|
+
"""Return effective capacity for the edge cache."""
|
|
1901
|
+
|
|
1902
|
+
if max_entries is CacheManager._MISSING:
|
|
1903
|
+
return self._manager.get_capacity(self._STATE_KEY)
|
|
1904
|
+
return self._manager.get_capacity(
|
|
1905
|
+
self._STATE_KEY,
|
|
1906
|
+
requested=None if max_entries is None else int(max_entries),
|
|
1907
|
+
use_default=False,
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
def _build_state(self, max_entries: int | None) -> EdgeCacheState:
|
|
1911
|
+
locks: defaultdict[Hashable, threading.RLock] = defaultdict(threading.RLock)
|
|
1912
|
+
capacity = float("inf") if max_entries is None else int(max_entries)
|
|
1913
|
+
cache = InstrumentedLRUCache(
|
|
1914
|
+
capacity,
|
|
1915
|
+
manager=self._manager,
|
|
1916
|
+
metrics_key=self._STATE_KEY,
|
|
1917
|
+
locks=locks,
|
|
1918
|
+
count_overwrite_hit=False,
|
|
1919
|
+
)
|
|
1920
|
+
state = EdgeCacheState(cache=cache, locks=locks, max_entries=max_entries)
|
|
1921
|
+
|
|
1922
|
+
def _on_eviction(key: Hashable, _: Any) -> None:
|
|
1923
|
+
self.record_eviction(track_metrics=False)
|
|
1924
|
+
locks.pop(key, None)
|
|
1925
|
+
state.dirty = True
|
|
1926
|
+
|
|
1927
|
+
cache.set_eviction_callbacks(_on_eviction)
|
|
1928
|
+
return state
|
|
1929
|
+
|
|
1930
|
+
def _ensure_state(
|
|
1931
|
+
self, state: EdgeCacheState | None, max_entries: int | None | object
|
|
1932
|
+
) -> EdgeCacheState:
|
|
1933
|
+
target = self.resolve_max_entries(max_entries)
|
|
1934
|
+
if target is not None:
|
|
1935
|
+
target = int(target)
|
|
1936
|
+
if target < 0:
|
|
1937
|
+
raise ValueError("max_entries must be non-negative or None")
|
|
1938
|
+
if not isinstance(state, EdgeCacheState) or state.max_entries != target:
|
|
1939
|
+
return self._build_state(target)
|
|
1940
|
+
return state
|
|
1941
|
+
|
|
1942
|
+
def _reset_state(self, state: EdgeCacheState | None) -> EdgeCacheState:
|
|
1943
|
+
if isinstance(state, EdgeCacheState):
|
|
1944
|
+
state.cache.clear()
|
|
1945
|
+
state.dirty = False
|
|
1946
|
+
return state
|
|
1947
|
+
return self._build_state(None)
|
|
1948
|
+
|
|
1949
|
+
def get_cache(
|
|
1950
|
+
self,
|
|
1951
|
+
max_entries: int | None | object,
|
|
1952
|
+
*,
|
|
1953
|
+
create: bool = True,
|
|
1954
|
+
) -> EdgeCacheState | None:
|
|
1955
|
+
"""Return the cache state for the manager's graph."""
|
|
1956
|
+
|
|
1957
|
+
if not create:
|
|
1958
|
+
state = self._manager.peek(self._STATE_KEY)
|
|
1959
|
+
return state if isinstance(state, EdgeCacheState) else None
|
|
1960
|
+
|
|
1961
|
+
state = self._manager.update(
|
|
1962
|
+
self._STATE_KEY,
|
|
1963
|
+
lambda current: self._ensure_state(current, max_entries),
|
|
1964
|
+
)
|
|
1965
|
+
if not isinstance(state, EdgeCacheState):
|
|
1966
|
+
raise RuntimeError("edge cache state failed to initialise")
|
|
1967
|
+
return state
|
|
1968
|
+
|
|
1969
|
+
def flush_state(self, state: EdgeCacheState) -> None:
|
|
1970
|
+
"""Persist ``state`` through the configured cache layers when dirty."""
|
|
1971
|
+
|
|
1972
|
+
if not isinstance(state, EdgeCacheState) or not state.dirty:
|
|
1973
|
+
return
|
|
1974
|
+
self._manager.store(self._STATE_KEY, state)
|
|
1975
|
+
state.dirty = False
|
|
1976
|
+
|
|
1977
|
+
def clear(self) -> None:
|
|
1978
|
+
"""Reset cached data managed by this instance."""
|
|
1979
|
+
|
|
1980
|
+
self._manager.clear(self._STATE_KEY)
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
def edge_version_cache(
|
|
1984
|
+
G: Any,
|
|
1985
|
+
key: Hashable,
|
|
1986
|
+
builder: Callable[[], T],
|
|
1987
|
+
*,
|
|
1988
|
+
max_entries: int | None | object = CacheManager._MISSING,
|
|
1989
|
+
) -> T:
|
|
1990
|
+
"""Return cached ``builder`` output tied to the edge version of ``G``."""
|
|
1991
|
+
|
|
1992
|
+
graph = get_graph(G)
|
|
1993
|
+
manager = graph.get("_edge_cache_manager") # type: ignore[assignment]
|
|
1994
|
+
if not isinstance(manager, EdgeCacheManager) or manager.graph is not graph:
|
|
1995
|
+
manager = EdgeCacheManager(graph)
|
|
1996
|
+
graph["_edge_cache_manager"] = manager
|
|
1997
|
+
|
|
1998
|
+
resolved = manager.resolve_max_entries(max_entries)
|
|
1999
|
+
if resolved == 0:
|
|
2000
|
+
return builder()
|
|
2001
|
+
|
|
2002
|
+
state = manager.get_cache(resolved)
|
|
2003
|
+
if state is None:
|
|
2004
|
+
return builder()
|
|
2005
|
+
|
|
2006
|
+
cache = state.cache
|
|
2007
|
+
locks = state.locks
|
|
2008
|
+
edge_version = get_graph_version(graph, "_edge_version")
|
|
2009
|
+
lock = locks[key]
|
|
2010
|
+
|
|
2011
|
+
with lock:
|
|
2012
|
+
entry = cache.get(key)
|
|
2013
|
+
if entry is not None and entry[0] == edge_version:
|
|
2014
|
+
manager.record_hit()
|
|
2015
|
+
return entry[1]
|
|
2016
|
+
|
|
2017
|
+
try:
|
|
2018
|
+
with manager.timer():
|
|
2019
|
+
value = builder()
|
|
2020
|
+
except (RuntimeError, ValueError) as exc: # pragma: no cover - logging side effect
|
|
2021
|
+
logger.exception("edge_version_cache builder failed for %r: %s", key, exc)
|
|
2022
|
+
raise
|
|
2023
|
+
else:
|
|
2024
|
+
result = value
|
|
2025
|
+
with lock:
|
|
2026
|
+
entry = cache.get(key)
|
|
2027
|
+
if entry is not None:
|
|
2028
|
+
cached_version, cached_value = entry
|
|
2029
|
+
manager.record_miss()
|
|
2030
|
+
if cached_version == edge_version:
|
|
2031
|
+
manager.record_hit()
|
|
2032
|
+
return cached_value
|
|
2033
|
+
manager.record_eviction()
|
|
2034
|
+
cache[key] = (edge_version, value)
|
|
2035
|
+
state.dirty = True
|
|
2036
|
+
result = value
|
|
2037
|
+
if state.dirty:
|
|
2038
|
+
manager.flush_state(state)
|
|
2039
|
+
return result
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def cached_nodes_and_A(
|
|
2043
|
+
G: nx.Graph,
|
|
2044
|
+
*,
|
|
2045
|
+
cache_size: int | None = 1,
|
|
2046
|
+
require_numpy: bool = False,
|
|
2047
|
+
prefer_sparse: bool = False,
|
|
2048
|
+
nodes: tuple[Any, ...] | None = None,
|
|
2049
|
+
) -> tuple[tuple[Any, ...], Any]:
|
|
2050
|
+
"""Return cached nodes tuple and adjacency matrix for ``G``.
|
|
2051
|
+
|
|
2052
|
+
When ``prefer_sparse`` is true the adjacency matrix construction is skipped
|
|
2053
|
+
unless a caller later requests it explicitly. This lets ΔNFR reuse the
|
|
2054
|
+
edge-index buffers stored on :class:`~tnfr.dynamics.dnfr.DnfrCache` without
|
|
2055
|
+
paying for ``nx.to_numpy_array`` on sparse graphs while keeping the
|
|
2056
|
+
canonical cache interface unchanged.
|
|
2057
|
+
"""
|
|
2058
|
+
|
|
2059
|
+
if nodes is None:
|
|
2060
|
+
nodes = cached_node_list(G)
|
|
2061
|
+
graph = G.graph
|
|
2062
|
+
|
|
2063
|
+
checksum = getattr(graph.get("_node_list_cache"), "checksum", None)
|
|
2064
|
+
if checksum is None:
|
|
2065
|
+
checksum = graph.get("_node_list_checksum")
|
|
2066
|
+
if checksum is None:
|
|
2067
|
+
node_set_cache = graph.get(NODE_SET_CHECKSUM_KEY)
|
|
2068
|
+
if isinstance(node_set_cache, tuple) and len(node_set_cache) >= 2:
|
|
2069
|
+
checksum = node_set_cache[1]
|
|
2070
|
+
if checksum is None:
|
|
2071
|
+
checksum = ""
|
|
2072
|
+
|
|
2073
|
+
key = f"_dnfr_{len(nodes)}_{checksum}"
|
|
2074
|
+
graph["_dnfr_nodes_checksum"] = checksum
|
|
2075
|
+
|
|
2076
|
+
def builder() -> tuple[tuple[Any, ...], Any]:
|
|
2077
|
+
np = _require_numpy()
|
|
2078
|
+
if np is None or prefer_sparse:
|
|
2079
|
+
return nodes, None
|
|
2080
|
+
A = nx.to_numpy_array(G, nodelist=nodes, weight=None, dtype=float)
|
|
2081
|
+
return nodes, A
|
|
2082
|
+
|
|
2083
|
+
nodes, A = edge_version_cache(G, key, builder, max_entries=cache_size)
|
|
2084
|
+
|
|
2085
|
+
if require_numpy and A is None:
|
|
2086
|
+
raise RuntimeError("NumPy is required for adjacency caching")
|
|
2087
|
+
|
|
2088
|
+
return nodes, A
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _reset_edge_caches(graph: Any, G: Any) -> None:
|
|
2092
|
+
"""Clear caches affected by edge updates."""
|
|
2093
|
+
|
|
2094
|
+
EdgeCacheManager(graph).clear()
|
|
2095
|
+
_graph_cache_manager(graph).clear(DNFR_PREP_STATE_KEY)
|
|
2096
|
+
mark_dnfr_prep_dirty(G)
|
|
2097
|
+
clear_node_repr_cache()
|
|
2098
|
+
for key in EDGE_VERSION_CACHE_KEYS:
|
|
2099
|
+
graph.pop(key, None)
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def increment_edge_version(G: Any) -> None:
|
|
2103
|
+
"""Increment the edge version counter in ``G.graph``."""
|
|
2104
|
+
|
|
2105
|
+
graph = get_graph(G)
|
|
2106
|
+
increment_graph_version(graph, "_edge_version")
|
|
2107
|
+
_reset_edge_caches(graph, G)
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
@contextmanager
|
|
2111
|
+
def edge_version_update(G: TNFRGraph) -> Iterator[None]:
|
|
2112
|
+
"""Scope a batch of edge mutations."""
|
|
2113
|
+
|
|
2114
|
+
increment_edge_version(G)
|
|
2115
|
+
try:
|
|
2116
|
+
yield
|
|
2117
|
+
finally:
|
|
2118
|
+
increment_edge_version(G)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
class _SeedHashCache(MutableMapping[tuple[int, int], int]):
|
|
2122
|
+
"""Mutable mapping proxy exposing a configurable LRU cache."""
|
|
2123
|
+
|
|
2124
|
+
def __init__(
|
|
2125
|
+
self,
|
|
2126
|
+
*,
|
|
2127
|
+
manager: CacheManager | None = None,
|
|
2128
|
+
state_key: str = "seed_hash_cache",
|
|
2129
|
+
default_maxsize: int = 128,
|
|
2130
|
+
) -> None:
|
|
2131
|
+
self._default_maxsize = int(default_maxsize)
|
|
2132
|
+
self._manager = manager or build_cache_manager(
|
|
2133
|
+
default_capacity=self._default_maxsize
|
|
2134
|
+
)
|
|
2135
|
+
self._state_key = state_key
|
|
2136
|
+
if not self._manager.has_override(self._state_key):
|
|
2137
|
+
self._manager.configure(
|
|
2138
|
+
overrides={self._state_key: self._default_maxsize}
|
|
2139
|
+
)
|
|
2140
|
+
self._manager.register(
|
|
2141
|
+
self._state_key,
|
|
2142
|
+
self._create_state,
|
|
2143
|
+
reset=self._reset_state,
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
def _resolved_size(self, requested: int | None = None) -> int:
|
|
2147
|
+
size = self._manager.get_capacity(
|
|
2148
|
+
self._state_key,
|
|
2149
|
+
requested=requested,
|
|
2150
|
+
fallback=self._default_maxsize,
|
|
2151
|
+
)
|
|
2152
|
+
if size is None:
|
|
2153
|
+
return 0
|
|
2154
|
+
return int(size)
|
|
2155
|
+
|
|
2156
|
+
def _create_state(self) -> _SeedCacheState:
|
|
2157
|
+
size = self._resolved_size()
|
|
2158
|
+
if size <= 0:
|
|
2159
|
+
return _SeedCacheState(cache=None, maxsize=0)
|
|
2160
|
+
return _SeedCacheState(
|
|
2161
|
+
cache=InstrumentedLRUCache(
|
|
2162
|
+
size,
|
|
2163
|
+
manager=self._manager,
|
|
2164
|
+
metrics_key=self._state_key,
|
|
2165
|
+
),
|
|
2166
|
+
maxsize=size,
|
|
2167
|
+
)
|
|
2168
|
+
|
|
2169
|
+
def _reset_state(self, state: _SeedCacheState | None) -> _SeedCacheState:
|
|
2170
|
+
return self._create_state()
|
|
2171
|
+
|
|
2172
|
+
def _get_state(self, *, create: bool = True) -> _SeedCacheState | None:
|
|
2173
|
+
state = self._manager.get(self._state_key, create=create)
|
|
2174
|
+
if state is None:
|
|
2175
|
+
return None
|
|
2176
|
+
if not isinstance(state, _SeedCacheState):
|
|
2177
|
+
state = self._create_state()
|
|
2178
|
+
self._manager.store(self._state_key, state)
|
|
2179
|
+
return state
|
|
2180
|
+
|
|
2181
|
+
def configure(self, maxsize: int) -> None:
|
|
2182
|
+
size = int(maxsize)
|
|
2183
|
+
if size < 0:
|
|
2184
|
+
raise ValueError("maxsize must be non-negative")
|
|
2185
|
+
self._manager.configure(overrides={self._state_key: size})
|
|
2186
|
+
self._manager.update(self._state_key, lambda _: self._create_state())
|
|
2187
|
+
|
|
2188
|
+
def __getitem__(self, key: tuple[int, int]) -> int:
|
|
2189
|
+
state = self._get_state()
|
|
2190
|
+
if state is None or state.cache is None:
|
|
2191
|
+
raise KeyError(key)
|
|
2192
|
+
value = state.cache[key]
|
|
2193
|
+
self._manager.increment_hit(self._state_key)
|
|
2194
|
+
return value
|
|
2195
|
+
|
|
2196
|
+
def __setitem__(self, key: tuple[int, int], value: int) -> None:
|
|
2197
|
+
state = self._get_state()
|
|
2198
|
+
if state is not None and state.cache is not None:
|
|
2199
|
+
state.cache[key] = value
|
|
2200
|
+
|
|
2201
|
+
def __delitem__(self, key: tuple[int, int]) -> None:
|
|
2202
|
+
state = self._get_state()
|
|
2203
|
+
if state is None or state.cache is None:
|
|
2204
|
+
raise KeyError(key)
|
|
2205
|
+
del state.cache[key]
|
|
2206
|
+
|
|
2207
|
+
def __iter__(self) -> Iterator[tuple[int, int]]:
|
|
2208
|
+
state = self._get_state(create=False)
|
|
2209
|
+
if state is None or state.cache is None:
|
|
2210
|
+
return iter(())
|
|
2211
|
+
return iter(state.cache)
|
|
2212
|
+
|
|
2213
|
+
def __len__(self) -> int:
|
|
2214
|
+
state = self._get_state(create=False)
|
|
2215
|
+
if state is None or state.cache is None:
|
|
2216
|
+
return 0
|
|
2217
|
+
return len(state.cache)
|
|
2218
|
+
|
|
2219
|
+
def clear(self) -> None: # type: ignore[override]
|
|
2220
|
+
self._manager.clear(self._state_key)
|
|
2221
|
+
|
|
2222
|
+
@property
|
|
2223
|
+
def maxsize(self) -> int:
|
|
2224
|
+
state = self._get_state()
|
|
2225
|
+
return 0 if state is None else state.maxsize
|
|
2226
|
+
|
|
2227
|
+
@property
|
|
2228
|
+
def enabled(self) -> bool:
|
|
2229
|
+
state = self._get_state(create=False)
|
|
2230
|
+
return bool(state and state.cache is not None)
|
|
2231
|
+
|
|
2232
|
+
@property
|
|
2233
|
+
def data(self) -> InstrumentedLRUCache[tuple[int, int], int] | None:
|
|
2234
|
+
"""Expose the underlying cache for diagnostics/tests."""
|
|
2235
|
+
|
|
2236
|
+
state = self._get_state(create=False)
|
|
2237
|
+
return None if state is None else state.cache
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
class ScopedCounterCache(Generic[K]):
|
|
2241
|
+
"""Thread-safe LRU cache storing monotonic counters by ``key``."""
|
|
2242
|
+
|
|
2243
|
+
def __init__(
|
|
2244
|
+
self,
|
|
2245
|
+
name: str,
|
|
2246
|
+
max_entries: int | None = None,
|
|
2247
|
+
*,
|
|
2248
|
+
manager: CacheManager | None = None,
|
|
2249
|
+
default_max_entries: int = 128,
|
|
2250
|
+
) -> None:
|
|
2251
|
+
self._name = name
|
|
2252
|
+
self._state_key = f"scoped_counter:{name}"
|
|
2253
|
+
self._default_max_entries = int(default_max_entries)
|
|
2254
|
+
requested = None if max_entries is None else int(max_entries)
|
|
2255
|
+
if requested is not None and requested < 0:
|
|
2256
|
+
raise ValueError("max_entries must be non-negative")
|
|
2257
|
+
self._manager = manager or build_cache_manager(
|
|
2258
|
+
default_capacity=self._default_max_entries
|
|
2259
|
+
)
|
|
2260
|
+
if not self._manager.has_override(self._state_key):
|
|
2261
|
+
fallback = requested
|
|
2262
|
+
if fallback is None:
|
|
2263
|
+
fallback = self._default_max_entries
|
|
2264
|
+
self._manager.configure(overrides={self._state_key: fallback})
|
|
2265
|
+
elif requested is not None:
|
|
2266
|
+
self._manager.configure(overrides={self._state_key: requested})
|
|
2267
|
+
self._manager.register(
|
|
2268
|
+
self._state_key,
|
|
2269
|
+
self._create_state,
|
|
2270
|
+
lock_factory=lambda: get_lock(name),
|
|
2271
|
+
reset=self._reset_state,
|
|
2272
|
+
)
|
|
2273
|
+
|
|
2274
|
+
def _resolved_entries(self, requested: int | None = None) -> int:
|
|
2275
|
+
size = self._manager.get_capacity(
|
|
2276
|
+
self._state_key,
|
|
2277
|
+
requested=requested,
|
|
2278
|
+
fallback=self._default_max_entries,
|
|
2279
|
+
)
|
|
2280
|
+
if size is None:
|
|
2281
|
+
return 0
|
|
2282
|
+
return int(size)
|
|
2283
|
+
|
|
2284
|
+
def _create_state(self, requested: int | None = None) -> _CounterState[K]:
|
|
2285
|
+
size = self._resolved_entries(requested)
|
|
2286
|
+
locks: dict[K, threading.RLock] = {}
|
|
2287
|
+
return _CounterState(
|
|
2288
|
+
cache=InstrumentedLRUCache(
|
|
2289
|
+
size,
|
|
2290
|
+
manager=self._manager,
|
|
2291
|
+
metrics_key=self._state_key,
|
|
2292
|
+
locks=locks,
|
|
2293
|
+
),
|
|
2294
|
+
locks=locks,
|
|
2295
|
+
max_entries=size,
|
|
2296
|
+
)
|
|
2297
|
+
|
|
2298
|
+
def _reset_state(self, state: _CounterState[K] | None) -> _CounterState[K]:
|
|
2299
|
+
return self._create_state()
|
|
2300
|
+
|
|
2301
|
+
def _get_state(self) -> _CounterState[K]:
|
|
2302
|
+
state = self._manager.get(self._state_key)
|
|
2303
|
+
if not isinstance(state, _CounterState):
|
|
2304
|
+
state = self._create_state(0)
|
|
2305
|
+
self._manager.store(self._state_key, state)
|
|
2306
|
+
return state
|
|
2307
|
+
|
|
2308
|
+
@property
|
|
2309
|
+
def lock(self) -> threading.Lock | threading.RLock:
|
|
2310
|
+
"""Return the lock guarding access to the underlying cache."""
|
|
2311
|
+
|
|
2312
|
+
return self._manager.get_lock(self._state_key)
|
|
2313
|
+
|
|
2314
|
+
@property
|
|
2315
|
+
def max_entries(self) -> int:
|
|
2316
|
+
"""Return the configured maximum number of cached entries."""
|
|
2317
|
+
|
|
2318
|
+
return self._get_state().max_entries
|
|
2319
|
+
|
|
2320
|
+
@property
|
|
2321
|
+
def cache(self) -> InstrumentedLRUCache[K, int]:
|
|
2322
|
+
"""Expose the instrumented cache for inspection."""
|
|
2323
|
+
|
|
2324
|
+
return self._get_state().cache
|
|
2325
|
+
|
|
2326
|
+
@property
|
|
2327
|
+
def locks(self) -> dict[K, threading.RLock]:
|
|
2328
|
+
"""Return the mapping of per-key locks tracked by the cache."""
|
|
2329
|
+
|
|
2330
|
+
return self._get_state().locks
|
|
2331
|
+
|
|
2332
|
+
def configure(self, *, force: bool = False, max_entries: int | None = None) -> None:
|
|
2333
|
+
"""Resize or reset the cache keeping previous settings."""
|
|
2334
|
+
|
|
2335
|
+
if max_entries is None:
|
|
2336
|
+
size = self._resolved_entries()
|
|
2337
|
+
update_policy = False
|
|
2338
|
+
else:
|
|
2339
|
+
size = int(max_entries)
|
|
2340
|
+
if size < 0:
|
|
2341
|
+
raise ValueError("max_entries must be non-negative")
|
|
2342
|
+
update_policy = True
|
|
2343
|
+
|
|
2344
|
+
def _update(state: _CounterState[K] | None) -> _CounterState[K]:
|
|
2345
|
+
if (
|
|
2346
|
+
not isinstance(state, _CounterState)
|
|
2347
|
+
or force
|
|
2348
|
+
or state.max_entries != size
|
|
2349
|
+
):
|
|
2350
|
+
locks: dict[K, threading.RLock] = {}
|
|
2351
|
+
return _CounterState(
|
|
2352
|
+
cache=InstrumentedLRUCache(
|
|
2353
|
+
size,
|
|
2354
|
+
manager=self._manager,
|
|
2355
|
+
metrics_key=self._state_key,
|
|
2356
|
+
locks=locks,
|
|
2357
|
+
),
|
|
2358
|
+
locks=locks,
|
|
2359
|
+
max_entries=size,
|
|
2360
|
+
)
|
|
2361
|
+
return cast(_CounterState[K], state)
|
|
2362
|
+
|
|
2363
|
+
if update_policy:
|
|
2364
|
+
self._manager.configure(overrides={self._state_key: size})
|
|
2365
|
+
self._manager.update(self._state_key, _update)
|
|
2366
|
+
|
|
2367
|
+
def clear(self) -> None:
|
|
2368
|
+
"""Clear stored counters preserving ``max_entries``."""
|
|
2369
|
+
|
|
2370
|
+
self.configure(force=True)
|
|
2371
|
+
|
|
2372
|
+
def bump(self, key: K) -> int:
|
|
2373
|
+
"""Return current counter for ``key`` and increment it atomically."""
|
|
2374
|
+
|
|
2375
|
+
result: dict[str, Any] = {}
|
|
2376
|
+
|
|
2377
|
+
def _update(state: _CounterState[K] | None) -> _CounterState[K]:
|
|
2378
|
+
if not isinstance(state, _CounterState):
|
|
2379
|
+
state = self._create_state(0)
|
|
2380
|
+
cache = state.cache
|
|
2381
|
+
locks = state.locks
|
|
2382
|
+
if key not in locks:
|
|
2383
|
+
locks[key] = threading.RLock()
|
|
2384
|
+
value = int(cache.get(key, 0))
|
|
2385
|
+
cache[key] = value + 1
|
|
2386
|
+
result["value"] = value
|
|
2387
|
+
return state
|
|
2388
|
+
|
|
2389
|
+
self._manager.update(self._state_key, _update)
|
|
2390
|
+
return int(result.get("value", 0))
|
|
2391
|
+
|
|
2392
|
+
def __len__(self) -> int:
|
|
2393
|
+
"""Return the number of tracked counters."""
|
|
2394
|
+
|
|
2395
|
+
return len(self.cache)
|