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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. 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"])