graphrefly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Backoff strategies for :func:`~graphrefly.extra.resilience.retry` and circuit tooling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
type JitterMode = Literal["none", "full", "equal"]
|
|
10
|
+
type BackoffPreset = Literal[
|
|
11
|
+
"constant", "linear", "exponential", "fibonacci", "decorrelated_jitter"
|
|
12
|
+
]
|
|
13
|
+
type BackoffStrategy = Callable[[int, BaseException | None, int | None], int | None]
|
|
14
|
+
|
|
15
|
+
NS_PER_MS = 1_000_000
|
|
16
|
+
NS_PER_SEC = 1_000_000_000
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BackoffPreset",
|
|
20
|
+
"BackoffStrategy",
|
|
21
|
+
"JitterMode",
|
|
22
|
+
"NS_PER_MS",
|
|
23
|
+
"NS_PER_SEC",
|
|
24
|
+
"constant",
|
|
25
|
+
"decorrelated_jitter",
|
|
26
|
+
"exponential",
|
|
27
|
+
"fibonacci",
|
|
28
|
+
"linear",
|
|
29
|
+
"resolve_backoff_preset",
|
|
30
|
+
"with_max_attempts",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clamp_non_negative(value: int) -> int:
|
|
35
|
+
return 0 if value < 0 else value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _apply_jitter(delay: int, jitter: JitterMode) -> int:
|
|
39
|
+
if jitter == "none":
|
|
40
|
+
return delay
|
|
41
|
+
if jitter == "full":
|
|
42
|
+
return random.randint(0, delay) if delay > 0 else 0
|
|
43
|
+
# "equal" — half deterministic, half random
|
|
44
|
+
half = delay // 2
|
|
45
|
+
return half + random.randint(0, delay - half) if delay > 0 else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def constant(delay_ns: int) -> BackoffStrategy:
|
|
49
|
+
"""Create a backoff strategy that always returns the same delay.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
delay_ns: Fixed delay in nanoseconds (clamped to 0 if negative).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A :data:`BackoffStrategy` callable.
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
```python
|
|
59
|
+
from graphrefly.extra.backoff import constant, NS_PER_SEC
|
|
60
|
+
s = constant(2 * NS_PER_SEC)
|
|
61
|
+
assert s(0, None, None) == 2_000_000_000
|
|
62
|
+
assert s(5, None, None) == 2_000_000_000
|
|
63
|
+
```
|
|
64
|
+
"""
|
|
65
|
+
safe = _clamp_non_negative(delay_ns)
|
|
66
|
+
|
|
67
|
+
def _strategy(
|
|
68
|
+
_attempt: int,
|
|
69
|
+
_error: BaseException | None = None,
|
|
70
|
+
_prev_delay: int | None = None,
|
|
71
|
+
) -> int:
|
|
72
|
+
return safe
|
|
73
|
+
|
|
74
|
+
return _strategy
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def linear(base_ns: int, step_ns: int | None = None) -> BackoffStrategy:
|
|
78
|
+
"""Create a backoff strategy with linearly increasing delay.
|
|
79
|
+
|
|
80
|
+
Delay is ``base_ns + step_ns * attempt`` where *step_ns* defaults to *base_ns*.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
base_ns: Starting delay in nanoseconds.
|
|
84
|
+
step_ns: Increment per attempt in nanoseconds (defaults to *base_ns*).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A :data:`BackoffStrategy` callable.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
```python
|
|
91
|
+
from graphrefly.extra.backoff import linear, NS_PER_SEC
|
|
92
|
+
s = linear(1 * NS_PER_SEC)
|
|
93
|
+
assert s(0, None, None) == 1_000_000_000
|
|
94
|
+
assert s(2, None, None) == 3_000_000_000
|
|
95
|
+
```
|
|
96
|
+
"""
|
|
97
|
+
safe_base = _clamp_non_negative(base_ns)
|
|
98
|
+
safe_step = safe_base if step_ns is None else _clamp_non_negative(step_ns)
|
|
99
|
+
|
|
100
|
+
def _strategy(
|
|
101
|
+
attempt: int,
|
|
102
|
+
_error: BaseException | None = None,
|
|
103
|
+
_prev_delay: int | None = None,
|
|
104
|
+
) -> int:
|
|
105
|
+
return safe_base + safe_step * max(0, attempt)
|
|
106
|
+
|
|
107
|
+
return _strategy
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def exponential(
|
|
111
|
+
*,
|
|
112
|
+
base_ns: int = 100_000_000,
|
|
113
|
+
factor: float = 2.0,
|
|
114
|
+
max_delay_ns: int = 30_000_000_000,
|
|
115
|
+
jitter: JitterMode = "none",
|
|
116
|
+
) -> BackoffStrategy:
|
|
117
|
+
"""Create an exponential backoff strategy capped at ``max_delay_ns``.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
base_ns: Initial delay in nanoseconds (default ``100_000_000`` = 100 ms).
|
|
121
|
+
factor: Multiplicative growth factor >= 1.0 (default ``2.0``).
|
|
122
|
+
max_delay_ns: Upper bound on delay in nanoseconds (default ``30_000_000_000`` = 30 s).
|
|
123
|
+
jitter: Jitter mode: ``"none"`` (default), ``"full"``, or ``"equal"``.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A :data:`BackoffStrategy` callable.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
```python
|
|
130
|
+
from graphrefly.extra.backoff import exponential
|
|
131
|
+
s = exponential(base_ns=100_000_000, factor=2.0, max_delay_ns=30_000_000_000)
|
|
132
|
+
assert s(0, None, None) == 100_000_000
|
|
133
|
+
assert s(1, None, None) == 200_000_000
|
|
134
|
+
```
|
|
135
|
+
"""
|
|
136
|
+
safe_base = _clamp_non_negative(base_ns)
|
|
137
|
+
safe_factor = 1.0 if factor < 1.0 else factor
|
|
138
|
+
safe_max = _clamp_non_negative(max_delay_ns)
|
|
139
|
+
|
|
140
|
+
def _strategy(
|
|
141
|
+
attempt: int,
|
|
142
|
+
_error: BaseException | None = None,
|
|
143
|
+
_prev_delay: int | None = None,
|
|
144
|
+
) -> int:
|
|
145
|
+
if safe_base == 0:
|
|
146
|
+
return _apply_jitter(0, jitter)
|
|
147
|
+
if safe_factor == 1.0:
|
|
148
|
+
return _apply_jitter(safe_base, jitter)
|
|
149
|
+
|
|
150
|
+
cap_ratio = safe_max / safe_base if safe_base > 0 else 0.0
|
|
151
|
+
raw_attempt = max(0, attempt)
|
|
152
|
+
growth = 1.0
|
|
153
|
+
for _ in range(raw_attempt):
|
|
154
|
+
if growth >= cap_ratio:
|
|
155
|
+
growth = cap_ratio
|
|
156
|
+
break
|
|
157
|
+
growth *= safe_factor
|
|
158
|
+
delay = int(safe_base * growth)
|
|
159
|
+
if delay > safe_max:
|
|
160
|
+
delay = safe_max
|
|
161
|
+
return _apply_jitter(delay, jitter)
|
|
162
|
+
|
|
163
|
+
return _strategy
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def fibonacci(base_ns: int = 100_000_000, *, max_delay_ns: int = 30_000_000_000) -> BackoffStrategy:
|
|
167
|
+
"""Create a backoff strategy with Fibonacci-scaled delays.
|
|
168
|
+
|
|
169
|
+
Delays follow the sequence ``1, 2, 3, 5, 8, ... * base_ns``, capped at
|
|
170
|
+
``max_delay_ns``.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
base_ns: Multiplier in nanoseconds (default ``100_000_000`` = 100 ms).
|
|
174
|
+
max_delay_ns: Upper bound in nanoseconds (default ``30_000_000_000`` = 30 s).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
A :data:`BackoffStrategy` callable.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
```python
|
|
181
|
+
from graphrefly.extra.backoff import fibonacci, NS_PER_SEC
|
|
182
|
+
s = fibonacci(1 * NS_PER_SEC)
|
|
183
|
+
assert s(0, None, None) == 1_000_000_000 # 1 * 1s
|
|
184
|
+
assert s(1, None, None) == 2_000_000_000 # 2 * 1s
|
|
185
|
+
```
|
|
186
|
+
"""
|
|
187
|
+
safe_base = _clamp_non_negative(base_ns)
|
|
188
|
+
safe_max = _clamp_non_negative(max_delay_ns)
|
|
189
|
+
|
|
190
|
+
def _fib_unit(attempt: int) -> int:
|
|
191
|
+
if attempt <= 0:
|
|
192
|
+
return 1
|
|
193
|
+
prev, cur = 1, 2
|
|
194
|
+
for _ in range(1, attempt):
|
|
195
|
+
prev, cur = cur, prev + cur
|
|
196
|
+
return cur
|
|
197
|
+
|
|
198
|
+
def _strategy(
|
|
199
|
+
attempt: int,
|
|
200
|
+
_error: BaseException | None = None,
|
|
201
|
+
_prev_delay: int | None = None,
|
|
202
|
+
) -> int:
|
|
203
|
+
raw = _fib_unit(attempt) * safe_base
|
|
204
|
+
return raw if raw <= safe_max else safe_max
|
|
205
|
+
|
|
206
|
+
return _strategy
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def decorrelated_jitter(
|
|
210
|
+
base_ns: int = 100_000_000,
|
|
211
|
+
max_delay_ns: int = 30_000_000_000,
|
|
212
|
+
) -> BackoffStrategy:
|
|
213
|
+
"""Decorrelated jitter (AWS-recommended): ``random(base_ns, min(max, prev * 3))``.
|
|
214
|
+
|
|
215
|
+
Stateless — uses ``prev_delay`` (passed by the consumer) instead of closure state.
|
|
216
|
+
Safe to share across concurrent retry sequences.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
base_ns: Floor of the random range (nanoseconds, default ``100_000_000`` = 100 ms).
|
|
220
|
+
max_delay_ns: Ceiling cap (nanoseconds, default ``30_000_000_000`` = 30 s).
|
|
221
|
+
"""
|
|
222
|
+
safe_base = _clamp_non_negative(base_ns)
|
|
223
|
+
safe_max = _clamp_non_negative(max_delay_ns)
|
|
224
|
+
|
|
225
|
+
def _strategy(
|
|
226
|
+
_attempt: int,
|
|
227
|
+
_error: BaseException | None = None,
|
|
228
|
+
prev_delay: int | None = None,
|
|
229
|
+
) -> int:
|
|
230
|
+
last = prev_delay if prev_delay is not None else safe_base
|
|
231
|
+
ceiling = min(safe_max, last * 3)
|
|
232
|
+
if ceiling <= safe_base:
|
|
233
|
+
return safe_base
|
|
234
|
+
return random.randint(safe_base, ceiling)
|
|
235
|
+
|
|
236
|
+
return _strategy
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def with_max_attempts(strategy: BackoffStrategy, max_attempts: int) -> BackoffStrategy:
|
|
240
|
+
"""Cap any strategy at *max_attempts*; returns ``None`` after the cap.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
strategy: Inner strategy to wrap.
|
|
244
|
+
max_attempts: Maximum number of attempts (inclusive).
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def _strategy(
|
|
248
|
+
attempt: int,
|
|
249
|
+
error: BaseException | None = None,
|
|
250
|
+
prev_delay: int | None = None,
|
|
251
|
+
) -> int | None:
|
|
252
|
+
if attempt >= max_attempts:
|
|
253
|
+
return None
|
|
254
|
+
return strategy(attempt, error, prev_delay)
|
|
255
|
+
|
|
256
|
+
return _strategy
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resolve_backoff_preset(name: BackoffPreset) -> BackoffStrategy:
|
|
260
|
+
"""Resolve a preset name string to a :data:`BackoffStrategy` with default parameters.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
name: One of ``"constant"``, ``"linear"``, ``"exponential"``,
|
|
264
|
+
``"fibonacci"``, or ``"decorrelated_jitter"``.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
A :data:`BackoffStrategy` configured with default nanosecond parameters.
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
```python
|
|
271
|
+
from graphrefly.extra.backoff import resolve_backoff_preset
|
|
272
|
+
s = resolve_backoff_preset("exponential")
|
|
273
|
+
assert s(0, None, None) == 100_000_000
|
|
274
|
+
```
|
|
275
|
+
"""
|
|
276
|
+
if name == "constant":
|
|
277
|
+
return constant(NS_PER_SEC)
|
|
278
|
+
if name == "linear":
|
|
279
|
+
return linear(NS_PER_SEC)
|
|
280
|
+
if name == "exponential":
|
|
281
|
+
return exponential()
|
|
282
|
+
if name == "fibonacci":
|
|
283
|
+
return fibonacci()
|
|
284
|
+
if name == "decorrelated_jitter":
|
|
285
|
+
return decorrelated_jitter()
|
|
286
|
+
msg = f"Unknown backoff preset: {name!r}"
|
|
287
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Watermark-based backpressure controller — reactive PAUSE/RESUME flow control.
|
|
2
|
+
|
|
3
|
+
Purely synchronous, event-driven. No timers, no polling, no async.
|
|
4
|
+
Each controller instance uses a unique lock_id so multiple controllers
|
|
5
|
+
on the same upstream node do not collide.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, NamedTuple, Protocol
|
|
11
|
+
|
|
12
|
+
from graphrefly.core.protocol import MessageType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WatermarkOptions(NamedTuple):
|
|
16
|
+
"""Thresholds for watermark-based backpressure."""
|
|
17
|
+
|
|
18
|
+
high_water_mark: int
|
|
19
|
+
low_water_mark: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WatermarkController(Protocol):
|
|
23
|
+
"""Watermark-based backpressure controller interface."""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def pending(self) -> int: ...
|
|
27
|
+
@property
|
|
28
|
+
def paused(self) -> bool: ...
|
|
29
|
+
def on_enqueue(self) -> bool: ...
|
|
30
|
+
def on_dequeue(self) -> bool: ...
|
|
31
|
+
def dispose(self) -> None: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _WatermarkControllerImpl:
|
|
35
|
+
"""Internal implementation — use :func:`create_watermark_controller`."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_send_up", "_high", "_low", "_lock_id", "_pending", "_paused")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
send_up: Any,
|
|
42
|
+
opts: WatermarkOptions,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._send_up = send_up
|
|
45
|
+
self._high = opts.high_water_mark
|
|
46
|
+
self._low = opts.low_water_mark
|
|
47
|
+
self._lock_id: object = object() # unforgeable identity, like TS Symbol
|
|
48
|
+
self._pending = 0
|
|
49
|
+
self._paused = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def pending(self) -> int:
|
|
53
|
+
"""Number of un-consumed items."""
|
|
54
|
+
return self._pending
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def paused(self) -> bool:
|
|
58
|
+
"""Whether upstream is currently paused by this controller."""
|
|
59
|
+
return self._paused
|
|
60
|
+
|
|
61
|
+
def on_enqueue(self) -> bool:
|
|
62
|
+
"""Call when a DATA message is buffered. Returns ``True`` if PAUSE was sent."""
|
|
63
|
+
self._pending += 1
|
|
64
|
+
if not self._paused and self._pending >= self._high:
|
|
65
|
+
self._paused = True
|
|
66
|
+
self._send_up([(MessageType.PAUSE, self._lock_id)])
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def on_dequeue(self) -> bool:
|
|
71
|
+
"""Call when a buffered item is consumed. Returns ``True`` if RESUME was sent."""
|
|
72
|
+
if self._pending > 0:
|
|
73
|
+
self._pending -= 1
|
|
74
|
+
if self._paused and self._pending <= self._low:
|
|
75
|
+
self._paused = False
|
|
76
|
+
self._send_up([(MessageType.RESUME, self._lock_id)])
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def dispose(self) -> None:
|
|
81
|
+
"""If paused, send RESUME to unblock upstream."""
|
|
82
|
+
if self._paused:
|
|
83
|
+
self._paused = False
|
|
84
|
+
self._send_up([(MessageType.RESUME, self._lock_id)])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_watermark_controller(
|
|
88
|
+
send_up: Any,
|
|
89
|
+
opts: WatermarkOptions,
|
|
90
|
+
) -> WatermarkController:
|
|
91
|
+
"""Create a watermark-based backpressure controller.
|
|
92
|
+
|
|
93
|
+
Purely synchronous, event-driven. No timers, no polling, no async.
|
|
94
|
+
Each controller instance uses a unique ``lock_id`` (``object()``) so
|
|
95
|
+
multiple controllers on the same upstream node do not collide.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
send_up: Callback that delivers messages upstream (e.g. ``handle.up``).
|
|
99
|
+
opts: High/low watermark thresholds (item counts).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A :class:`WatermarkController`.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If watermark options are invalid.
|
|
106
|
+
"""
|
|
107
|
+
if opts.high_water_mark < 1:
|
|
108
|
+
raise ValueError("high_water_mark must be >= 1")
|
|
109
|
+
if opts.low_water_mark < 0:
|
|
110
|
+
raise ValueError("low_water_mark must be >= 0")
|
|
111
|
+
if opts.low_water_mark >= opts.high_water_mark:
|
|
112
|
+
raise ValueError("low_water_mark must be < high_water_mark")
|
|
113
|
+
return _WatermarkControllerImpl(send_up, opts)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Checkpoint adapters and :class:`~graphrefly.graph.Graph` save/restore helpers (roadmap §3.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import tempfile
|
|
9
|
+
import warnings
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from graphrefly.core.node import Node
|
|
16
|
+
from graphrefly.graph.graph import Graph
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CheckpointAdapter",
|
|
20
|
+
"DictCheckpointAdapter",
|
|
21
|
+
"FileCheckpointAdapter",
|
|
22
|
+
"MemoryCheckpointAdapter",
|
|
23
|
+
"SqliteCheckpointAdapter",
|
|
24
|
+
"checkpoint_node_value",
|
|
25
|
+
"restore_graph_checkpoint",
|
|
26
|
+
"save_graph_checkpoint",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class CheckpointAdapter(Protocol):
|
|
32
|
+
"""JSON-friendly snapshot persistence (single blob in / out)."""
|
|
33
|
+
|
|
34
|
+
def save(self, data: dict[str, Any]) -> None: ...
|
|
35
|
+
def load(self) -> dict[str, Any] | None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemoryCheckpointAdapter:
|
|
39
|
+
"""In-memory checkpoint adapter (process-local; useful for tests and embedding).
|
|
40
|
+
|
|
41
|
+
Stores a deep-copy of the snapshot so mutations to the saved dict do not
|
|
42
|
+
affect the stored state.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
```python
|
|
46
|
+
from graphrefly import Graph, state
|
|
47
|
+
from graphrefly.extra.checkpoint import MemoryCheckpointAdapter, save_graph_checkpoint
|
|
48
|
+
g = Graph("g"); g.add("x", state(1))
|
|
49
|
+
adapter = MemoryCheckpointAdapter()
|
|
50
|
+
save_graph_checkpoint(g, adapter)
|
|
51
|
+
assert adapter.load()["nodes"]["x"]["value"] == 1
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("_data",)
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self._data: dict[str, Any] | None = None
|
|
59
|
+
|
|
60
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
61
|
+
self._data = json.loads(json.dumps(data, ensure_ascii=False))
|
|
62
|
+
|
|
63
|
+
def load(self) -> dict[str, Any] | None:
|
|
64
|
+
return None if self._data is None else dict(self._data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DictCheckpointAdapter:
|
|
68
|
+
"""Store a checkpoint under a fixed key inside a caller-owned ``dict``.
|
|
69
|
+
|
|
70
|
+
Useful for tests or environments where you already manage a shared dict.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
storage: The dict to store the checkpoint in.
|
|
74
|
+
key: Key under which the snapshot is stored (default ``"graphrefly_checkpoint"``).
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
```python
|
|
78
|
+
from graphrefly.extra.checkpoint import DictCheckpointAdapter
|
|
79
|
+
store = {}
|
|
80
|
+
adapter = DictCheckpointAdapter(store)
|
|
81
|
+
adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
|
|
82
|
+
assert "graphrefly_checkpoint" in store
|
|
83
|
+
```
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
__slots__ = ("_key", "_storage")
|
|
87
|
+
|
|
88
|
+
def __init__(self, storage: dict[str, Any], *, key: str = "graphrefly_checkpoint") -> None:
|
|
89
|
+
self._storage = storage
|
|
90
|
+
self._key = key
|
|
91
|
+
|
|
92
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
93
|
+
self._storage[self._key] = json.loads(json.dumps(data, ensure_ascii=False))
|
|
94
|
+
|
|
95
|
+
def load(self) -> dict[str, Any] | None:
|
|
96
|
+
raw = self._storage.get(self._key)
|
|
97
|
+
return dict(raw) if isinstance(raw, dict) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FileCheckpointAdapter:
|
|
101
|
+
"""Persist checkpoint data as JSON to a file using atomic write-then-replace.
|
|
102
|
+
|
|
103
|
+
Writes to a temporary file in the same directory, then renames it over the
|
|
104
|
+
target path to avoid partial writes.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
path: Destination file path (``str`` or :class:`pathlib.Path`).
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
```python
|
|
111
|
+
import tempfile, os
|
|
112
|
+
from graphrefly.extra.checkpoint import FileCheckpointAdapter
|
|
113
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
114
|
+
tmp = f.name
|
|
115
|
+
adapter = FileCheckpointAdapter(tmp)
|
|
116
|
+
adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
|
|
117
|
+
assert os.path.exists(tmp)
|
|
118
|
+
os.unlink(tmp)
|
|
119
|
+
```
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
__slots__ = ("_path",)
|
|
123
|
+
|
|
124
|
+
def __init__(self, path: str | Path) -> None:
|
|
125
|
+
self._path = Path(path)
|
|
126
|
+
|
|
127
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
128
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
130
|
+
fd, tmp = tempfile.mkstemp(
|
|
131
|
+
dir=self._path.parent,
|
|
132
|
+
prefix=f".{self._path.name}.",
|
|
133
|
+
suffix=".tmp",
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
137
|
+
f.write(payload)
|
|
138
|
+
f.write("\n")
|
|
139
|
+
os.replace(tmp, self._path)
|
|
140
|
+
except BaseException:
|
|
141
|
+
with suppress(FileNotFoundError):
|
|
142
|
+
os.unlink(tmp)
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def load(self) -> dict[str, Any] | None:
|
|
146
|
+
if not self._path.is_file():
|
|
147
|
+
return None
|
|
148
|
+
text = self._path.read_text(encoding="utf-8")
|
|
149
|
+
if not text.strip():
|
|
150
|
+
return None
|
|
151
|
+
data = json.loads(text)
|
|
152
|
+
return data if isinstance(data, dict) else None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _stable_snapshot_json(data: dict[str, Any]) -> str:
|
|
156
|
+
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SqliteCheckpointAdapter:
|
|
160
|
+
"""Persist one checkpoint blob under a fixed key using :mod:`sqlite3` (stdlib, zero deps).
|
|
161
|
+
|
|
162
|
+
Uses a single-row table. Call :meth:`close` when the adapter is no longer needed.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
path: Path to the SQLite database file (``str`` or :class:`pathlib.Path`).
|
|
166
|
+
key: Row key for the checkpoint (default ``"graphrefly_checkpoint"``).
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
```python
|
|
170
|
+
import tempfile, os
|
|
171
|
+
from graphrefly.extra.checkpoint import SqliteCheckpointAdapter
|
|
172
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
173
|
+
tmp = f.name
|
|
174
|
+
adapter = SqliteCheckpointAdapter(tmp)
|
|
175
|
+
adapter.save({"version": 1, "nodes": {}, "edges": [], "subgraphs": [], "name": "g"})
|
|
176
|
+
assert adapter.load()["version"] == 1
|
|
177
|
+
adapter.close()
|
|
178
|
+
os.unlink(tmp)
|
|
179
|
+
```
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("_conn", "_key")
|
|
183
|
+
|
|
184
|
+
def __init__(self, path: str | Path, *, key: str = "graphrefly_checkpoint") -> None:
|
|
185
|
+
self._conn = sqlite3.connect(str(path))
|
|
186
|
+
self._key = key
|
|
187
|
+
self._conn.execute(
|
|
188
|
+
"CREATE TABLE IF NOT EXISTS graphrefly_checkpoint (k TEXT PRIMARY KEY, v TEXT NOT NULL)"
|
|
189
|
+
)
|
|
190
|
+
self._conn.commit()
|
|
191
|
+
|
|
192
|
+
def save(self, data: dict[str, Any]) -> None:
|
|
193
|
+
payload = _stable_snapshot_json(data)
|
|
194
|
+
self._conn.execute(
|
|
195
|
+
"INSERT OR REPLACE INTO graphrefly_checkpoint (k, v) VALUES (?, ?)",
|
|
196
|
+
(self._key, payload),
|
|
197
|
+
)
|
|
198
|
+
self._conn.commit()
|
|
199
|
+
|
|
200
|
+
def load(self) -> dict[str, Any] | None:
|
|
201
|
+
row = self._conn.execute(
|
|
202
|
+
"SELECT v FROM graphrefly_checkpoint WHERE k = ?", (self._key,)
|
|
203
|
+
).fetchone()
|
|
204
|
+
if row is None or not isinstance(row[0], str) or not row[0].strip():
|
|
205
|
+
return None
|
|
206
|
+
parsed = json.loads(row[0])
|
|
207
|
+
return parsed if isinstance(parsed, dict) else None
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
"""Close the underlying SQLite connection (safe to call more than once)."""
|
|
211
|
+
with suppress(Exception):
|
|
212
|
+
self._conn.close()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _check_json_serializable(data: dict[str, Any]) -> None:
|
|
216
|
+
"""Warn when snapshot values are not JSON-serializable."""
|
|
217
|
+
try:
|
|
218
|
+
json.dumps(data, ensure_ascii=False)
|
|
219
|
+
except (TypeError, ValueError) as exc:
|
|
220
|
+
warnings.warn(
|
|
221
|
+
f"Snapshot contains non-JSON-serializable values: {exc}. "
|
|
222
|
+
"This may cause errors when persisting to JSON-based adapters.",
|
|
223
|
+
stacklevel=3,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def save_graph_checkpoint(graph: Graph, adapter: CheckpointAdapter) -> None:
|
|
228
|
+
"""Persist a :meth:`~graphrefly.graph.Graph.snapshot` via a :class:`CheckpointAdapter`.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
graph: The :class:`~graphrefly.graph.Graph` to snapshot.
|
|
232
|
+
adapter: Any :class:`CheckpointAdapter` (memory, file, SQLite, etc.).
|
|
233
|
+
|
|
234
|
+
Example:
|
|
235
|
+
```python
|
|
236
|
+
from graphrefly import Graph, state
|
|
237
|
+
from graphrefly.extra.checkpoint import MemoryCheckpointAdapter, save_graph_checkpoint
|
|
238
|
+
g = Graph("g"); g.add("x", state(5))
|
|
239
|
+
adapter = MemoryCheckpointAdapter()
|
|
240
|
+
save_graph_checkpoint(g, adapter)
|
|
241
|
+
```
|
|
242
|
+
"""
|
|
243
|
+
snap = graph.snapshot()
|
|
244
|
+
_check_json_serializable(snap)
|
|
245
|
+
adapter.save(snap)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def restore_graph_checkpoint(graph: Graph, adapter: CheckpointAdapter) -> bool:
|
|
249
|
+
"""Load a snapshot from *adapter* and apply it to *graph*.
|
|
250
|
+
|
|
251
|
+
Uses :meth:`~graphrefly.graph.Graph.restore` for application.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
graph: The target :class:`~graphrefly.graph.Graph`.
|
|
255
|
+
adapter: Any :class:`CheckpointAdapter` to load from.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
``True`` if snapshot data existed and was applied; ``False`` if the adapter
|
|
259
|
+
had no saved data.
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
```python
|
|
263
|
+
from graphrefly import Graph, state
|
|
264
|
+
from graphrefly.extra.checkpoint import (
|
|
265
|
+
MemoryCheckpointAdapter,
|
|
266
|
+
restore_graph_checkpoint,
|
|
267
|
+
save_graph_checkpoint,
|
|
268
|
+
)
|
|
269
|
+
g = Graph("g"); x = state(0); g.add("x", x)
|
|
270
|
+
adapter = MemoryCheckpointAdapter()
|
|
271
|
+
save_graph_checkpoint(g, adapter)
|
|
272
|
+
x.down([("DATA", 99)])
|
|
273
|
+
restored = restore_graph_checkpoint(g, adapter)
|
|
274
|
+
assert restored and g.get("x") == 0
|
|
275
|
+
```
|
|
276
|
+
"""
|
|
277
|
+
data = adapter.load()
|
|
278
|
+
if data is None:
|
|
279
|
+
return False
|
|
280
|
+
graph.restore(data)
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def checkpoint_node_value(node: Node[Any]) -> dict[str, Any]:
|
|
285
|
+
"""Build a minimal versioned JSON payload for a single node's last cached value.
|
|
286
|
+
|
|
287
|
+
Useful for custom adapters that persist individual nodes rather than whole
|
|
288
|
+
graph snapshots. Emits a warning when the value is not JSON-serializable.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
node: Any :class:`~graphrefly.core.node.Node` whose ``get()`` value to capture.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
A ``dict`` with ``version`` (``1``) and ``value`` keys.
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
```python
|
|
298
|
+
from graphrefly import state
|
|
299
|
+
from graphrefly.extra.checkpoint import checkpoint_node_value
|
|
300
|
+
x = state(42)
|
|
301
|
+
payload = checkpoint_node_value(x)
|
|
302
|
+
assert payload == {"version": 1, "value": 42}
|
|
303
|
+
```
|
|
304
|
+
"""
|
|
305
|
+
result: dict[str, Any] = {"version": 1, "value": node.get()}
|
|
306
|
+
_check_json_serializable(result)
|
|
307
|
+
return result
|