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,727 @@
|
|
|
1
|
+
"""Resilience utilities — roadmap §3.1 (retry, breaker, rate limit, status companions)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from graphrefly.core.clock import monotonic_ns
|
|
12
|
+
from graphrefly.core.node import Node, NodeActions, node
|
|
13
|
+
from graphrefly.core.protocol import Messages, MessageType, batch
|
|
14
|
+
from graphrefly.extra.backoff import (
|
|
15
|
+
BackoffPreset,
|
|
16
|
+
BackoffStrategy,
|
|
17
|
+
resolve_backoff_preset,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CircuitBreaker",
|
|
25
|
+
"CircuitOpenError",
|
|
26
|
+
"TokenBucket",
|
|
27
|
+
"WithBreakerBundle",
|
|
28
|
+
"WithStatusBundle",
|
|
29
|
+
"circuit_breaker",
|
|
30
|
+
"rate_limiter",
|
|
31
|
+
"retry",
|
|
32
|
+
"token_bucket",
|
|
33
|
+
"token_tracker",
|
|
34
|
+
"with_breaker",
|
|
35
|
+
"with_status",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _msg_val(m: tuple[Any, ...]) -> Any:
|
|
40
|
+
assert len(m) >= 2
|
|
41
|
+
return m[1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _coerce_delay_ns(raw_delay: Any) -> int:
|
|
45
|
+
if isinstance(raw_delay, bool) or not isinstance(raw_delay, int | float):
|
|
46
|
+
msg = "backoff strategy must return an int/float nanosecond delay or None"
|
|
47
|
+
raise TypeError(msg)
|
|
48
|
+
if not math.isfinite(float(raw_delay)):
|
|
49
|
+
msg = "backoff strategy delay must be finite"
|
|
50
|
+
raise TypeError(msg)
|
|
51
|
+
delay = int(raw_delay)
|
|
52
|
+
return 0 if delay < 0 else delay
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def retry(
|
|
56
|
+
source: Node[Any],
|
|
57
|
+
count: int | None = None,
|
|
58
|
+
*,
|
|
59
|
+
backoff: BackoffStrategy | BackoffPreset | None = None,
|
|
60
|
+
) -> Node[Any]:
|
|
61
|
+
"""Retry upstream after ``ERROR`` with optional backoff between attempts.
|
|
62
|
+
|
|
63
|
+
Unsubscribes from the source after each terminal ``ERROR``, waits (possibly
|
|
64
|
+
zero), then resubscribes. Successful ``DATA`` resets the attempt counter.
|
|
65
|
+
For a useful retry, the source should use ``resubscribable=True`` so a new
|
|
66
|
+
subscription can deliver again after a terminal error.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
source: The upstream :class:`~graphrefly.core.node.Node` to retry.
|
|
70
|
+
count: Maximum retry attempts (``None`` = unlimited when *backoff* is set,
|
|
71
|
+
0 otherwise).
|
|
72
|
+
backoff: Optional :data:`~graphrefly.extra.backoff.BackoffStrategy` or
|
|
73
|
+
:data:`~graphrefly.extra.backoff.BackoffPreset` name for delay between
|
|
74
|
+
retries.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A new :class:`~graphrefly.core.node.Node` wrapping the retry logic.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
```python
|
|
81
|
+
from graphrefly.extra.resilience import retry
|
|
82
|
+
from graphrefly.extra import of
|
|
83
|
+
n = retry(of(1), count=3)
|
|
84
|
+
```
|
|
85
|
+
"""
|
|
86
|
+
max_retries = count if count is not None else (0 if backoff is None else 2_147_483_647)
|
|
87
|
+
if max_retries < 0:
|
|
88
|
+
msg = "count must be >= 0"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
|
|
91
|
+
strategy: BackoffStrategy | None = (
|
|
92
|
+
resolve_backoff_preset(backoff) if isinstance(backoff, str) else backoff
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
96
|
+
attempt = [0]
|
|
97
|
+
stopped = [False]
|
|
98
|
+
prev_delay: list[int | None] = [None]
|
|
99
|
+
upstream_unsub: list[Callable[[], None] | None] = [None]
|
|
100
|
+
timer_holder: list[threading.Timer | None] = [None]
|
|
101
|
+
timer_generation = [0]
|
|
102
|
+
lock = threading.Lock()
|
|
103
|
+
|
|
104
|
+
def cancel_timer() -> None:
|
|
105
|
+
if timer_holder[0] is not None:
|
|
106
|
+
timer_holder[0].cancel()
|
|
107
|
+
timer_holder[0] = None
|
|
108
|
+
|
|
109
|
+
def disconnect_upstream() -> None:
|
|
110
|
+
if upstream_unsub[0] is not None:
|
|
111
|
+
upstream_unsub[0]()
|
|
112
|
+
upstream_unsub[0] = None
|
|
113
|
+
|
|
114
|
+
def connect() -> None:
|
|
115
|
+
cancel_timer()
|
|
116
|
+
disconnect_upstream()
|
|
117
|
+
|
|
118
|
+
def sink(msgs: Messages) -> None:
|
|
119
|
+
for m in msgs:
|
|
120
|
+
t = m[0]
|
|
121
|
+
if t is MessageType.DIRTY:
|
|
122
|
+
actions.down([(MessageType.DIRTY,)])
|
|
123
|
+
elif t is MessageType.DATA:
|
|
124
|
+
attempt[0] = 0
|
|
125
|
+
prev_delay[0] = None
|
|
126
|
+
actions.emit(_msg_val(m))
|
|
127
|
+
elif t is MessageType.RESOLVED:
|
|
128
|
+
actions.down([(MessageType.RESOLVED,)])
|
|
129
|
+
elif t is MessageType.COMPLETE:
|
|
130
|
+
disconnect_upstream()
|
|
131
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
132
|
+
elif t is MessageType.ERROR:
|
|
133
|
+
schedule_retry_or_finish(_msg_val(m))
|
|
134
|
+
return
|
|
135
|
+
else:
|
|
136
|
+
actions.down([m])
|
|
137
|
+
|
|
138
|
+
upstream_unsub[0] = source.subscribe(sink)
|
|
139
|
+
|
|
140
|
+
def schedule_retry_or_finish(err: BaseException) -> None:
|
|
141
|
+
with lock:
|
|
142
|
+
if stopped[0]:
|
|
143
|
+
return
|
|
144
|
+
if attempt[0] >= max_retries:
|
|
145
|
+
disconnect_upstream()
|
|
146
|
+
actions.down([(MessageType.ERROR, err)])
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
raw = 0 if strategy is None else strategy(attempt[0], err, prev_delay[0])
|
|
150
|
+
delay_ns = _coerce_delay_ns(0 if raw is None else raw)
|
|
151
|
+
prev_delay[0] = delay_ns
|
|
152
|
+
attempt[0] += 1
|
|
153
|
+
timer_generation[0] += 1
|
|
154
|
+
current_gen = timer_generation[0]
|
|
155
|
+
disconnect_upstream()
|
|
156
|
+
|
|
157
|
+
safe_delay = delay_ns / 1_000_000_000 if delay_ns > 0 else 1e-6
|
|
158
|
+
|
|
159
|
+
def fire() -> None:
|
|
160
|
+
with lock:
|
|
161
|
+
if stopped[0] or current_gen != timer_generation[0]:
|
|
162
|
+
return
|
|
163
|
+
connect()
|
|
164
|
+
|
|
165
|
+
tt = threading.Timer(safe_delay, fire)
|
|
166
|
+
tt.daemon = True
|
|
167
|
+
tt.start()
|
|
168
|
+
timer_holder[0] = tt
|
|
169
|
+
|
|
170
|
+
connect()
|
|
171
|
+
|
|
172
|
+
def cleanup() -> None:
|
|
173
|
+
with lock:
|
|
174
|
+
stopped[0] = True
|
|
175
|
+
timer_generation[0] += 1
|
|
176
|
+
cancel_timer()
|
|
177
|
+
disconnect_upstream()
|
|
178
|
+
|
|
179
|
+
return cleanup
|
|
180
|
+
|
|
181
|
+
return node(
|
|
182
|
+
start,
|
|
183
|
+
describe_kind="operator",
|
|
184
|
+
complete_when_deps_complete=False,
|
|
185
|
+
initial=source.get(),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
CircuitState = Literal["closed", "open", "half-open"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CircuitOpenError(RuntimeError):
|
|
193
|
+
"""Raised when ``with_breaker(..., on_open="error")`` sees an open circuit.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
```python
|
|
197
|
+
from graphrefly.extra.resilience import CircuitOpenError, circuit_breaker, with_breaker
|
|
198
|
+
from graphrefly import state
|
|
199
|
+
from graphrefly.extra.sources import first_value_from
|
|
200
|
+
breaker = circuit_breaker(failure_threshold=1)
|
|
201
|
+
breaker.record_failure() # open the circuit
|
|
202
|
+
src = state(1)
|
|
203
|
+
bundle = with_breaker(breaker, on_open="error")(src)
|
|
204
|
+
# next DATA from src will raise CircuitOpenError
|
|
205
|
+
```
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
super().__init__("Circuit breaker is open")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@runtime_checkable
|
|
213
|
+
class CircuitBreaker(Protocol):
|
|
214
|
+
"""Protocol for circuit breaker instances (use :func:`circuit_breaker` to create)."""
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def state(self) -> CircuitState: ...
|
|
218
|
+
@property
|
|
219
|
+
def failure_count(self) -> int: ...
|
|
220
|
+
def can_execute(self) -> bool: ...
|
|
221
|
+
def record_success(self) -> None: ...
|
|
222
|
+
def record_failure(self, _error: BaseException | None = None) -> None: ...
|
|
223
|
+
def reset(self) -> None: ...
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _CircuitBreakerImpl:
|
|
227
|
+
"""Thread-safe circuit breaker (closed / open / half-open) with optional cooldown escalation."""
|
|
228
|
+
|
|
229
|
+
__slots__ = (
|
|
230
|
+
"_cooldown_base",
|
|
231
|
+
"_cooldown_strategy",
|
|
232
|
+
"_failures",
|
|
233
|
+
"_half_open_max",
|
|
234
|
+
"_last_cooldown",
|
|
235
|
+
"_lock",
|
|
236
|
+
"_open_cycle",
|
|
237
|
+
"_opened_at",
|
|
238
|
+
"_state",
|
|
239
|
+
"_threshold",
|
|
240
|
+
"_trials",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
failure_threshold: int = 5,
|
|
247
|
+
cooldown_ns: int = 30_000_000_000,
|
|
248
|
+
cooldown_strategy: BackoffStrategy | None = None,
|
|
249
|
+
half_open_max: int = 1,
|
|
250
|
+
) -> None:
|
|
251
|
+
self._threshold = max(1, failure_threshold)
|
|
252
|
+
self._cooldown_base = max(0, cooldown_ns)
|
|
253
|
+
self._cooldown_strategy = cooldown_strategy
|
|
254
|
+
self._half_open_max = max(1, half_open_max)
|
|
255
|
+
self._state: CircuitState = "closed"
|
|
256
|
+
self._failures = 0
|
|
257
|
+
self._opened_at = 0
|
|
258
|
+
self._trials = 0
|
|
259
|
+
self._open_cycle = 0
|
|
260
|
+
self._last_cooldown = self._cooldown_base
|
|
261
|
+
self._lock = threading.Lock()
|
|
262
|
+
|
|
263
|
+
def _get_cooldown(self) -> int:
|
|
264
|
+
if self._cooldown_strategy is None:
|
|
265
|
+
return self._cooldown_base
|
|
266
|
+
raw = self._cooldown_strategy(self._open_cycle, None, None)
|
|
267
|
+
return int(raw) if raw is not None else self._cooldown_base
|
|
268
|
+
|
|
269
|
+
def _transition_to_open(self) -> None:
|
|
270
|
+
self._state = "open"
|
|
271
|
+
self._last_cooldown = self._get_cooldown()
|
|
272
|
+
self._opened_at = monotonic_ns()
|
|
273
|
+
self._trials = 0
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def state(self) -> CircuitState:
|
|
277
|
+
with self._lock:
|
|
278
|
+
return self._state
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def failure_count(self) -> int:
|
|
282
|
+
with self._lock:
|
|
283
|
+
return self._failures
|
|
284
|
+
|
|
285
|
+
def can_execute(self) -> bool:
|
|
286
|
+
with self._lock:
|
|
287
|
+
if self._state == "closed":
|
|
288
|
+
return True
|
|
289
|
+
if self._state == "open":
|
|
290
|
+
if (monotonic_ns() - self._opened_at) >= self._last_cooldown:
|
|
291
|
+
self._state = "half-open"
|
|
292
|
+
self._trials = 1
|
|
293
|
+
return True
|
|
294
|
+
return False
|
|
295
|
+
if self._trials < self._half_open_max:
|
|
296
|
+
self._trials += 1
|
|
297
|
+
return True
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
def record_success(self) -> None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
if self._state == "half-open":
|
|
303
|
+
self._state = "closed"
|
|
304
|
+
self._failures = 0
|
|
305
|
+
self._open_cycle = 0
|
|
306
|
+
elif self._state == "closed":
|
|
307
|
+
self._failures = 0
|
|
308
|
+
|
|
309
|
+
def record_failure(self, _error: BaseException | None = None) -> None:
|
|
310
|
+
with self._lock:
|
|
311
|
+
if self._state == "half-open":
|
|
312
|
+
self._open_cycle += 1
|
|
313
|
+
self._transition_to_open()
|
|
314
|
+
return
|
|
315
|
+
self._failures += 1
|
|
316
|
+
if self._failures >= self._threshold:
|
|
317
|
+
self._transition_to_open()
|
|
318
|
+
|
|
319
|
+
def reset(self) -> None:
|
|
320
|
+
with self._lock:
|
|
321
|
+
self._state = "closed"
|
|
322
|
+
self._failures = 0
|
|
323
|
+
self._open_cycle = 0
|
|
324
|
+
self._trials = 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def circuit_breaker(
|
|
328
|
+
*,
|
|
329
|
+
failure_threshold: int = 5,
|
|
330
|
+
cooldown_ns: int = 30_000_000_000,
|
|
331
|
+
cooldown_strategy: BackoffStrategy | None = None,
|
|
332
|
+
half_open_max: int = 1,
|
|
333
|
+
) -> CircuitBreaker:
|
|
334
|
+
"""Thread-safe circuit breaker (closed / open / half-open).
|
|
335
|
+
|
|
336
|
+
Supports escalating cooldown via an optional backoff strategy.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
failure_threshold: Failures before opening (default ``5``).
|
|
340
|
+
cooldown_ns: Base cooldown in nanoseconds (default ``30_000_000_000`` = 30 s).
|
|
341
|
+
cooldown_strategy: Backoff for cooldown escalation.
|
|
342
|
+
half_open_max: Trials in half-open (default ``1``).
|
|
343
|
+
"""
|
|
344
|
+
return _CircuitBreakerImpl(
|
|
345
|
+
failure_threshold=failure_threshold,
|
|
346
|
+
cooldown_ns=cooldown_ns,
|
|
347
|
+
cooldown_strategy=cooldown_strategy,
|
|
348
|
+
half_open_max=half_open_max,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True, slots=True)
|
|
353
|
+
class WithBreakerBundle:
|
|
354
|
+
"""Result of :func:`with_breaker`: main output and a ``state`` companion node."""
|
|
355
|
+
|
|
356
|
+
node: Node[Any]
|
|
357
|
+
breaker_state: Node[str]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def with_breaker(
|
|
361
|
+
breaker: CircuitBreaker,
|
|
362
|
+
*,
|
|
363
|
+
on_open: Literal["skip", "error"] = "skip",
|
|
364
|
+
) -> Callable[[Node[Any]], WithBreakerBundle]:
|
|
365
|
+
"""Guard a source node with a :class:`CircuitBreaker`.
|
|
366
|
+
|
|
367
|
+
On each upstream ``DATA``, if the breaker refuses work, either emit
|
|
368
|
+
``RESOLVED`` (``on_open="skip"``) or ``ERROR`` with :exc:`CircuitOpenError`
|
|
369
|
+
(``on_open="error"``). ``COMPLETE`` records success; ``ERROR`` records
|
|
370
|
+
failure and is forwarded. The ``breaker_state`` companion is wired into
|
|
371
|
+
``node.meta`` so it appears in ``describe()``.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
breaker: A :class:`CircuitBreaker` instance (see :func:`circuit_breaker`).
|
|
375
|
+
on_open: ``"skip"`` (emit ``RESOLVED``) or ``"error"`` (emit
|
|
376
|
+
:exc:`CircuitOpenError`) when the circuit is open.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
A unary operator ``(Node) -> WithBreakerBundle``.
|
|
380
|
+
|
|
381
|
+
Example:
|
|
382
|
+
```python
|
|
383
|
+
from graphrefly import state
|
|
384
|
+
from graphrefly.extra.resilience import circuit_breaker, with_breaker
|
|
385
|
+
breaker = circuit_breaker(failure_threshold=3)
|
|
386
|
+
src = state(1)
|
|
387
|
+
bundle = with_breaker(breaker)(src)
|
|
388
|
+
```
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def _op(src: Node[Any]) -> WithBreakerBundle:
|
|
392
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
393
|
+
def sync_state() -> None:
|
|
394
|
+
out.meta["breaker_state"].down([(MessageType.DATA, breaker.state)])
|
|
395
|
+
|
|
396
|
+
def sink(msgs: Messages) -> None:
|
|
397
|
+
for m in msgs:
|
|
398
|
+
t = m[0]
|
|
399
|
+
if t is MessageType.DIRTY:
|
|
400
|
+
actions.down([(MessageType.DIRTY,)])
|
|
401
|
+
elif t is MessageType.DATA:
|
|
402
|
+
if breaker.can_execute():
|
|
403
|
+
sync_state()
|
|
404
|
+
actions.emit(_msg_val(m))
|
|
405
|
+
else:
|
|
406
|
+
sync_state()
|
|
407
|
+
if on_open == "error":
|
|
408
|
+
actions.down([(MessageType.ERROR, CircuitOpenError())])
|
|
409
|
+
else:
|
|
410
|
+
actions.down([(MessageType.RESOLVED,)])
|
|
411
|
+
elif t is MessageType.RESOLVED:
|
|
412
|
+
actions.down([(MessageType.RESOLVED,)])
|
|
413
|
+
elif t is MessageType.COMPLETE:
|
|
414
|
+
breaker.record_success()
|
|
415
|
+
sync_state()
|
|
416
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
417
|
+
elif t is MessageType.ERROR:
|
|
418
|
+
breaker.record_failure(_msg_val(m))
|
|
419
|
+
sync_state()
|
|
420
|
+
actions.down([m])
|
|
421
|
+
else:
|
|
422
|
+
actions.down([m])
|
|
423
|
+
|
|
424
|
+
unsub = src.subscribe(sink)
|
|
425
|
+
sync_state()
|
|
426
|
+
|
|
427
|
+
def cleanup() -> None:
|
|
428
|
+
unsub()
|
|
429
|
+
|
|
430
|
+
return cleanup
|
|
431
|
+
|
|
432
|
+
out = node(
|
|
433
|
+
start,
|
|
434
|
+
describe_kind="operator",
|
|
435
|
+
meta={"breaker_state": breaker.state},
|
|
436
|
+
complete_when_deps_complete=False,
|
|
437
|
+
initial=src.get(),
|
|
438
|
+
)
|
|
439
|
+
return WithBreakerBundle(node=out, breaker_state=out.meta["breaker_state"])
|
|
440
|
+
|
|
441
|
+
return _op
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@runtime_checkable
|
|
445
|
+
class TokenBucket(Protocol):
|
|
446
|
+
"""Protocol for token bucket instances (use :func:`token_bucket` to create)."""
|
|
447
|
+
|
|
448
|
+
def available(self) -> float: ...
|
|
449
|
+
def try_consume(self, cost: float = 1.0) -> bool: ...
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class _TokenBucketImpl:
|
|
453
|
+
"""Thread-safe token bucket (for pairing with custom gates or metrics)."""
|
|
454
|
+
|
|
455
|
+
__slots__ = ("_capacity", "_lock", "_refill_per_sec", "_tokens", "_updated_at")
|
|
456
|
+
|
|
457
|
+
def __init__(self, capacity: float, refill_per_second: float) -> None:
|
|
458
|
+
if capacity <= 0:
|
|
459
|
+
msg = "capacity must be > 0"
|
|
460
|
+
raise ValueError(msg)
|
|
461
|
+
if refill_per_second < 0:
|
|
462
|
+
msg = "refill_per_second must be >= 0"
|
|
463
|
+
raise ValueError(msg)
|
|
464
|
+
self._capacity = float(capacity)
|
|
465
|
+
self._refill_per_sec = float(refill_per_second)
|
|
466
|
+
self._tokens = float(capacity)
|
|
467
|
+
self._updated_at = monotonic_ns()
|
|
468
|
+
self._lock = threading.Lock()
|
|
469
|
+
|
|
470
|
+
def _refill_locked(self) -> None:
|
|
471
|
+
now = monotonic_ns()
|
|
472
|
+
if self._refill_per_sec > 0:
|
|
473
|
+
elapsed_sec = (now - self._updated_at) / 1_000_000_000
|
|
474
|
+
self._tokens = min(self._capacity, self._tokens + elapsed_sec * self._refill_per_sec)
|
|
475
|
+
self._updated_at = now
|
|
476
|
+
|
|
477
|
+
def available(self) -> float:
|
|
478
|
+
with self._lock:
|
|
479
|
+
self._refill_locked()
|
|
480
|
+
return self._tokens
|
|
481
|
+
|
|
482
|
+
def try_consume(self, cost: float = 1.0) -> bool:
|
|
483
|
+
if cost <= 0:
|
|
484
|
+
return True
|
|
485
|
+
with self._lock:
|
|
486
|
+
self._refill_locked()
|
|
487
|
+
if self._tokens >= cost:
|
|
488
|
+
self._tokens -= cost
|
|
489
|
+
return True
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def token_bucket(capacity: float, refill_per_second: float) -> TokenBucket:
|
|
494
|
+
"""Factory for a thread-safe :class:`TokenBucket`.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
capacity: Maximum tokens.
|
|
498
|
+
refill_per_second: Tokens restored per second (``0`` = no refill).
|
|
499
|
+
"""
|
|
500
|
+
return _TokenBucketImpl(capacity, refill_per_second)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def token_tracker(capacity: float, refill_per_second: float) -> TokenBucket:
|
|
504
|
+
"""Create a token bucket (alias for :func:`token_bucket`, kept for backward compat).
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
capacity: Maximum number of tokens.
|
|
508
|
+
refill_per_second: Tokens restored per second (``0`` = no refill).
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
A :class:`TokenBucket` instance.
|
|
512
|
+
|
|
513
|
+
Example:
|
|
514
|
+
```python
|
|
515
|
+
from graphrefly.extra.resilience import token_tracker
|
|
516
|
+
tb = token_tracker(10, 2.0)
|
|
517
|
+
assert tb.try_consume(1)
|
|
518
|
+
```
|
|
519
|
+
"""
|
|
520
|
+
return token_bucket(capacity, refill_per_second)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def rate_limiter(source: Node[Any], max_events: int, window_ns: int) -> Node[Any]:
|
|
524
|
+
"""Limit upstream ``DATA`` to at most *max_events* per *window_ns* (sliding window).
|
|
525
|
+
|
|
526
|
+
Values exceeding the budget are queued (FIFO) and emitted as slots free.
|
|
527
|
+
``DIRTY`` and ``RESOLVED`` still propagate immediately when not blocked.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
source: The upstream :class:`~graphrefly.core.node.Node` to rate-limit.
|
|
531
|
+
max_events: Maximum ``DATA`` emissions allowed per window.
|
|
532
|
+
window_ns: Window duration in nanoseconds.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
A new :class:`~graphrefly.core.node.Node` with rate-limiting applied.
|
|
536
|
+
|
|
537
|
+
Example:
|
|
538
|
+
```python
|
|
539
|
+
from graphrefly import state
|
|
540
|
+
from graphrefly.extra.resilience import rate_limiter
|
|
541
|
+
from graphrefly.extra.sources import to_list
|
|
542
|
+
from graphrefly.extra import of
|
|
543
|
+
n = rate_limiter(of(1, 2, 3), max_events=2, window_ns=60_000_000_000)
|
|
544
|
+
```
|
|
545
|
+
"""
|
|
546
|
+
if max_events <= 0:
|
|
547
|
+
msg = "max_events must be > 0"
|
|
548
|
+
raise ValueError(msg)
|
|
549
|
+
if window_ns <= 0:
|
|
550
|
+
msg = "window_ns must be > 0"
|
|
551
|
+
raise ValueError(msg)
|
|
552
|
+
|
|
553
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
554
|
+
times: deque[int] = deque()
|
|
555
|
+
pending: deque[Any] = deque()
|
|
556
|
+
timer: list[threading.Timer | None] = [None]
|
|
557
|
+
timer_gen = [0]
|
|
558
|
+
|
|
559
|
+
def cancel_timer() -> None:
|
|
560
|
+
if timer[0] is not None:
|
|
561
|
+
timer[0].cancel()
|
|
562
|
+
timer[0] = None
|
|
563
|
+
|
|
564
|
+
def prune(now: int) -> None:
|
|
565
|
+
boundary = now - window_ns
|
|
566
|
+
while times and times[0] <= boundary:
|
|
567
|
+
times.popleft()
|
|
568
|
+
|
|
569
|
+
def schedule_emit(when_ns: int) -> None:
|
|
570
|
+
cancel_timer()
|
|
571
|
+
timer_gen[0] += 1
|
|
572
|
+
gen = timer_gen[0]
|
|
573
|
+
delay = max(0.0, (when_ns - monotonic_ns()) / 1_000_000_000)
|
|
574
|
+
|
|
575
|
+
def fire() -> None:
|
|
576
|
+
if gen != timer_gen[0]:
|
|
577
|
+
return
|
|
578
|
+
timer[0] = None
|
|
579
|
+
try_emit()
|
|
580
|
+
|
|
581
|
+
tt = threading.Timer(delay, fire)
|
|
582
|
+
tt.daemon = True
|
|
583
|
+
tt.start()
|
|
584
|
+
timer[0] = tt
|
|
585
|
+
|
|
586
|
+
def try_emit() -> None:
|
|
587
|
+
while pending:
|
|
588
|
+
now = monotonic_ns()
|
|
589
|
+
prune(now)
|
|
590
|
+
if len(times) < max_events:
|
|
591
|
+
times.append(now)
|
|
592
|
+
actions.emit(pending.popleft())
|
|
593
|
+
else:
|
|
594
|
+
oldest = times[0]
|
|
595
|
+
schedule_emit(oldest + int(window_ns))
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
def sink(msgs: Messages) -> None:
|
|
599
|
+
for m in msgs:
|
|
600
|
+
t = m[0]
|
|
601
|
+
if t is MessageType.DIRTY:
|
|
602
|
+
actions.down([(MessageType.DIRTY,)])
|
|
603
|
+
elif t is MessageType.DATA:
|
|
604
|
+
pending.append(_msg_val(m))
|
|
605
|
+
try_emit()
|
|
606
|
+
elif t is MessageType.RESOLVED:
|
|
607
|
+
actions.down([(MessageType.RESOLVED,)])
|
|
608
|
+
elif t is MessageType.COMPLETE:
|
|
609
|
+
cancel_timer()
|
|
610
|
+
pending.clear()
|
|
611
|
+
times.clear()
|
|
612
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
613
|
+
elif t is MessageType.ERROR:
|
|
614
|
+
cancel_timer()
|
|
615
|
+
pending.clear()
|
|
616
|
+
times.clear()
|
|
617
|
+
actions.down([m])
|
|
618
|
+
else:
|
|
619
|
+
actions.down([m])
|
|
620
|
+
|
|
621
|
+
unsub = source.subscribe(sink)
|
|
622
|
+
|
|
623
|
+
def cleanup() -> None:
|
|
624
|
+
timer_gen[0] += 1
|
|
625
|
+
cancel_timer()
|
|
626
|
+
unsub()
|
|
627
|
+
|
|
628
|
+
return cleanup
|
|
629
|
+
|
|
630
|
+
return node(
|
|
631
|
+
start,
|
|
632
|
+
describe_kind="operator",
|
|
633
|
+
complete_when_deps_complete=False,
|
|
634
|
+
initial=source.get(),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
StatusValue = Literal["pending", "active", "completed", "errored"]
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@dataclass(frozen=True, slots=True)
|
|
642
|
+
class WithStatusBundle:
|
|
643
|
+
"""Result of :func:`with_status`: pass-through value plus companion nodes."""
|
|
644
|
+
|
|
645
|
+
node: Node[Any]
|
|
646
|
+
status: Node[StatusValue]
|
|
647
|
+
error: Node[Any]
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def with_status(
|
|
651
|
+
src: Node[Any],
|
|
652
|
+
*,
|
|
653
|
+
initial_status: StatusValue = "pending",
|
|
654
|
+
) -> WithStatusBundle:
|
|
655
|
+
"""Mirror *src* with ``status`` and ``error`` companion state nodes.
|
|
656
|
+
|
|
657
|
+
``status`` moves ``pending`` → ``active`` on ``DATA``, ``completed`` on
|
|
658
|
+
``COMPLETE``, and ``errored`` on ``ERROR`` (``error`` holds the exception).
|
|
659
|
+
After ``errored``, the next ``DATA`` clears ``error`` and sets ``active``
|
|
660
|
+
inside :func:`~graphrefly.core.protocol.batch`. Both companions are wired
|
|
661
|
+
into ``node.meta`` so they appear in ``describe()``.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
src: The upstream :class:`~graphrefly.core.node.Node` to track.
|
|
665
|
+
initial_status: Initial value of the ``status`` companion (default
|
|
666
|
+
``"pending"``).
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
A :class:`WithStatusBundle` with ``node``, ``status``, and ``error`` fields.
|
|
670
|
+
|
|
671
|
+
Example:
|
|
672
|
+
```python
|
|
673
|
+
from graphrefly import state
|
|
674
|
+
from graphrefly.extra.resilience import with_status
|
|
675
|
+
src = state(None)
|
|
676
|
+
bundle = with_status(src)
|
|
677
|
+
assert bundle.status.get() == "pending"
|
|
678
|
+
```
|
|
679
|
+
"""
|
|
680
|
+
|
|
681
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
682
|
+
out.meta["status"].down([(MessageType.DATA, initial_status)])
|
|
683
|
+
out.meta["error"].down([(MessageType.DATA, None)])
|
|
684
|
+
|
|
685
|
+
def sink(msgs: Messages) -> None:
|
|
686
|
+
for m in msgs:
|
|
687
|
+
t = m[0]
|
|
688
|
+
if t is MessageType.DIRTY:
|
|
689
|
+
actions.down([(MessageType.DIRTY,)])
|
|
690
|
+
elif t is MessageType.DATA:
|
|
691
|
+
if out.meta["status"].get() == "errored":
|
|
692
|
+
with batch():
|
|
693
|
+
out.meta["error"].down([(MessageType.DATA, None)])
|
|
694
|
+
out.meta["status"].down([(MessageType.DATA, "active")])
|
|
695
|
+
else:
|
|
696
|
+
out.meta["status"].down([(MessageType.DATA, "active")])
|
|
697
|
+
actions.emit(_msg_val(m))
|
|
698
|
+
elif t is MessageType.RESOLVED:
|
|
699
|
+
actions.down([(MessageType.RESOLVED,)])
|
|
700
|
+
elif t is MessageType.COMPLETE:
|
|
701
|
+
out.meta["status"].down([(MessageType.DATA, "completed")])
|
|
702
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
703
|
+
elif t is MessageType.ERROR:
|
|
704
|
+
err = _msg_val(m)
|
|
705
|
+
with batch():
|
|
706
|
+
out.meta["error"].down([(MessageType.DATA, err)])
|
|
707
|
+
out.meta["status"].down([(MessageType.DATA, "errored")])
|
|
708
|
+
actions.down([m])
|
|
709
|
+
else:
|
|
710
|
+
actions.down([m])
|
|
711
|
+
|
|
712
|
+
unsub = src.subscribe(sink)
|
|
713
|
+
|
|
714
|
+
def cleanup() -> None:
|
|
715
|
+
unsub()
|
|
716
|
+
|
|
717
|
+
return cleanup
|
|
718
|
+
|
|
719
|
+
out = node(
|
|
720
|
+
start,
|
|
721
|
+
describe_kind="operator",
|
|
722
|
+
meta={"status": initial_status, "error": None},
|
|
723
|
+
complete_when_deps_complete=False,
|
|
724
|
+
resubscribable=True,
|
|
725
|
+
initial=src.get(),
|
|
726
|
+
)
|
|
727
|
+
return WithStatusBundle(node=out, status=out.meta["status"], error=out.meta["error"])
|