|
17 | 17 |
|
18 | 18 | from graphrefly.core.node import NO_VALUE, Node, NodeActions, node |
19 | 19 | from graphrefly.core.protocol import Messages, MessageType |
| 20 | +from graphrefly.core.sugar import state |
20 | 21 |
|
21 | 22 |
|
22 | 23 | def _source_initial_kwargs(source: Node[Any]) -> dict[str, Any]: |
@@ -850,12 +851,92 @@ def on_msg(msg: tuple[Any, ...], _dep_index: int, actions: NodeActions) -> bool: |
850 | 851 | "from_cron", |
851 | 852 | "from_iter", |
852 | 853 | "from_timer", |
| 854 | + "keepalive", |
853 | 855 | "never", |
854 | 856 | "of", |
| 857 | + "ReactiveCounterBundle", |
| 858 | + "reactive_counter", |
855 | 859 | "replay", |
856 | 860 | "share", |
857 | 861 | "share_replay", |
858 | 862 | "throw_error", |
859 | 863 | "to_array", |
860 | 864 | "to_list", |
861 | 865 | ] |
| 866 | + |
| 867 | + |
| 868 | +# --------------------------------------------------------------------------- |
| 869 | +# keepalive |
| 870 | +# --------------------------------------------------------------------------- |
| 871 | + |
| 872 | + |
| 873 | +def keepalive(n: Node[Any]) -> Any: |
| 874 | + """Activate a compute node's upstream wiring without a real sink. |
| 875 | +
|
| 876 | + Derived/effect nodes are lazy — they don't compute until at least one |
| 877 | + subscriber exists (COMPOSITION-GUIDE §5). ``keepalive`` subscribes with |
| 878 | + an empty sink so the node stays wired for ``.get()`` and upstream |
| 879 | + propagation. |
| 880 | +
|
| 881 | + Returns the unsubscribe handle. Common usage:: |
| 882 | +
|
| 883 | + graph.add_disposer(keepalive(node)) |
| 884 | + """ |
| 885 | + return n.subscribe(lambda _msgs: None) |
| 886 | + |
| 887 | + |
| 888 | +# --------------------------------------------------------------------------- |
| 889 | +# reactive_counter |
| 890 | +# --------------------------------------------------------------------------- |
| 891 | + |
| 892 | + |
| 893 | +class ReactiveCounterBundle: |
| 894 | + """Typed bundle returned by :func:`reactive_counter`. |
| 895 | +
|
| 896 | + Attributes mirror the TS ``ReactiveCounterBundle`` type for cross-language parity. |
| 897 | + """ |
| 898 | + |
| 899 | + __slots__ = ("_node", "_cap") |
| 900 | + |
| 901 | + def __init__(self, counter: Node[Any], cap: int) -> None: |
| 902 | + self._node = counter |
| 903 | + self._cap = cap |
| 904 | + |
| 905 | + @property |
| 906 | + def node(self) -> Node[Any]: |
| 907 | + """Reactive node holding the current count.""" |
| 908 | + return self._node |
| 909 | + |
| 910 | + def increment(self) -> bool: |
| 911 | + """Increment by 1. Returns ``False`` if cap would be exceeded.""" |
| 912 | + current = self._node.get() |
| 913 | + if current is None: |
| 914 | + current = 0 |
| 915 | + if current >= self._cap: |
| 916 | + return False |
| 917 | + self._node.down([(MessageType.DIRTY,), (MessageType.DATA, current + 1)]) |
| 918 | + return True |
| 919 | + |
| 920 | + def get(self) -> int: |
| 921 | + """Current count (synchronous read).""" |
| 922 | + v = self._node.get() |
| 923 | + return v if v is not None else 0 |
| 924 | + |
| 925 | + def at_cap(self) -> bool: |
| 926 | + """Whether the counter has reached its cap.""" |
| 927 | + v = self._node.get() |
| 928 | + return (v if v is not None else 0) >= self._cap |
| 929 | + |
| 930 | + |
| 931 | +def reactive_counter(cap: int) -> ReactiveCounterBundle: |
| 932 | + """Reactive counter with a cap — the building block for circuit breakers. |
| 933 | +
|
| 934 | + Wraps a ``state(0)`` node with ``increment()`` that respects a maximum. |
| 935 | + The ``node`` is subscribable and composable like any reactive node. When |
| 936 | + the cap is reached, ``increment()`` returns ``False``. |
| 937 | +
|
| 938 | + Returns a :class:`ReactiveCounterBundle` with ``node``, ``increment``, |
| 939 | + ``get``, and ``at_cap`` members. |
| 940 | + """ |
| 941 | + counter = state(0) |
| 942 | + return ReactiveCounterBundle(counter, cap) |
0 commit comments