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,553 @@
|
|
|
1
|
+
"""Messaging patterns (roadmap §4.2).
|
|
2
|
+
|
|
3
|
+
Pulsar-inspired messaging features modeled as graph factories:
|
|
4
|
+
- ``topic()`` for append-only topic streams
|
|
5
|
+
- ``subscription()`` for cursor-based consumers
|
|
6
|
+
- ``job_queue()`` for queue claim/ack flow
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from graphrefly.core.protocol import MessageType
|
|
16
|
+
from graphrefly.core.sugar import derived, effect, state
|
|
17
|
+
from graphrefly.extra.data_structures import (
|
|
18
|
+
Versioned,
|
|
19
|
+
reactive_list,
|
|
20
|
+
reactive_log,
|
|
21
|
+
reactive_map,
|
|
22
|
+
)
|
|
23
|
+
from graphrefly.graph.graph import Graph
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
|
|
28
|
+
DEFAULT_MAX_PER_PUMP = 2_147_483_647
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _messaging_meta(kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
32
|
+
out: dict[str, Any] = {"messaging": True, "messaging_type": kind}
|
|
33
|
+
if extra:
|
|
34
|
+
out.update(extra)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _keepalive(n: Any) -> None:
|
|
39
|
+
n.subscribe(lambda _msgs: None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tuple_snapshot(raw: Any) -> tuple[Any, ...]:
|
|
43
|
+
if isinstance(raw, tuple):
|
|
44
|
+
return raw
|
|
45
|
+
if isinstance(raw, list):
|
|
46
|
+
return tuple(raw)
|
|
47
|
+
return ()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_non_negative_int(value: Any, *, label: str) -> int:
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
raise ValueError(f"{label} must be a non-negative integer")
|
|
53
|
+
if not isinstance(value, int):
|
|
54
|
+
raise ValueError(f"{label} must be a non-negative integer")
|
|
55
|
+
if value < 0:
|
|
56
|
+
raise ValueError(f"{label} must be a non-negative integer")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TopicGraph(Graph):
|
|
61
|
+
"""Append-only topic graph with retained event log and latest value node."""
|
|
62
|
+
|
|
63
|
+
__slots__ = ("_log", "events", "latest")
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
*,
|
|
69
|
+
opts: dict[str, Any] | None = None,
|
|
70
|
+
retained_limit: int | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(name, opts)
|
|
73
|
+
self._log = reactive_log(max_size=retained_limit, name="events")
|
|
74
|
+
self.events = self._log.entries
|
|
75
|
+
self.add("events", self.events)
|
|
76
|
+
|
|
77
|
+
def compute_latest(deps: list[Any], _actions: Any) -> Any:
|
|
78
|
+
raw = deps[0]
|
|
79
|
+
entries = _tuple_snapshot(raw.value if isinstance(raw, Versioned) else ())
|
|
80
|
+
return entries[-1] if len(entries) > 0 else None
|
|
81
|
+
|
|
82
|
+
self.latest = derived(
|
|
83
|
+
[self.events],
|
|
84
|
+
compute_latest,
|
|
85
|
+
initial=None,
|
|
86
|
+
name="latest",
|
|
87
|
+
meta=_messaging_meta("topic_latest"),
|
|
88
|
+
)
|
|
89
|
+
self.add("latest", self.latest)
|
|
90
|
+
self.connect("events", "latest")
|
|
91
|
+
_keepalive(self.latest)
|
|
92
|
+
|
|
93
|
+
def publish(self, value: Any) -> None:
|
|
94
|
+
self._log.append(value)
|
|
95
|
+
|
|
96
|
+
def retained(self) -> tuple[Any, ...]:
|
|
97
|
+
snap = self.events.get()
|
|
98
|
+
entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
|
|
99
|
+
return entries
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SubscriptionGraph(Graph):
|
|
103
|
+
"""Cursor-based consumer graph over a topic."""
|
|
104
|
+
|
|
105
|
+
__slots__ = ("available", "cursor", "source")
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
name: str,
|
|
110
|
+
topic_graph: TopicGraph,
|
|
111
|
+
*,
|
|
112
|
+
opts: dict[str, Any] | None = None,
|
|
113
|
+
cursor: int = 0,
|
|
114
|
+
) -> None:
|
|
115
|
+
super().__init__(name, opts)
|
|
116
|
+
initial_cursor = _require_non_negative_int(cursor, label="subscription cursor")
|
|
117
|
+
self.mount("topic", topic_graph)
|
|
118
|
+
topic_events = topic_graph.events
|
|
119
|
+
self.source = derived(
|
|
120
|
+
[topic_events],
|
|
121
|
+
lambda deps, _a: deps[0],
|
|
122
|
+
initial=topic_events.get(),
|
|
123
|
+
name="source",
|
|
124
|
+
meta=_messaging_meta("subscription_source"),
|
|
125
|
+
)
|
|
126
|
+
self.add("source", self.source)
|
|
127
|
+
self.cursor = state(
|
|
128
|
+
initial_cursor,
|
|
129
|
+
name="cursor",
|
|
130
|
+
describe_kind="state",
|
|
131
|
+
meta=_messaging_meta("subscription_cursor"),
|
|
132
|
+
)
|
|
133
|
+
self.add("cursor", self.cursor)
|
|
134
|
+
|
|
135
|
+
def compute_available(deps: list[Any], _actions: Any) -> tuple[Any, ...]:
|
|
136
|
+
source_snapshot = deps[0]
|
|
137
|
+
idx = max(0, int(deps[1] if deps[1] is not None else 0))
|
|
138
|
+
entries = _tuple_snapshot(
|
|
139
|
+
source_snapshot.value if isinstance(source_snapshot, Versioned) else (),
|
|
140
|
+
)
|
|
141
|
+
return entries[idx:]
|
|
142
|
+
|
|
143
|
+
self.available = derived(
|
|
144
|
+
[self.source, self.cursor],
|
|
145
|
+
compute_available,
|
|
146
|
+
initial=(),
|
|
147
|
+
name="available",
|
|
148
|
+
meta=_messaging_meta("subscription_available"),
|
|
149
|
+
)
|
|
150
|
+
self.add("available", self.available)
|
|
151
|
+
self.connect("topic::events", "source")
|
|
152
|
+
self.connect("source", "available")
|
|
153
|
+
self.connect("cursor", "available")
|
|
154
|
+
_keepalive(self.source)
|
|
155
|
+
_keepalive(self.available)
|
|
156
|
+
|
|
157
|
+
def ack(self, count: int | None = None) -> int:
|
|
158
|
+
available = _tuple_snapshot(self.available.get())
|
|
159
|
+
total = len(available)
|
|
160
|
+
requested = (
|
|
161
|
+
total
|
|
162
|
+
if count is None
|
|
163
|
+
else _require_non_negative_int(
|
|
164
|
+
count,
|
|
165
|
+
label="subscription ack count",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
step = min(total, requested)
|
|
169
|
+
current_raw = self.cursor.get()
|
|
170
|
+
current = int(current_raw) if current_raw is not None else 0
|
|
171
|
+
if step <= 0:
|
|
172
|
+
return current
|
|
173
|
+
next_cursor = current + step
|
|
174
|
+
self.cursor.down([(MessageType.DATA, next_cursor)])
|
|
175
|
+
return next_cursor
|
|
176
|
+
|
|
177
|
+
def pull(self, limit: int | None = None, *, ack: bool = False) -> tuple[Any, ...]:
|
|
178
|
+
available = _tuple_snapshot(self.available.get())
|
|
179
|
+
n = (
|
|
180
|
+
len(available)
|
|
181
|
+
if limit is None
|
|
182
|
+
else _require_non_negative_int(
|
|
183
|
+
limit,
|
|
184
|
+
label="subscription pull limit",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
out = available[:n]
|
|
188
|
+
if ack and len(out) > 0:
|
|
189
|
+
self.ack(len(out))
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
type JobState = str
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class JobEnvelope:
|
|
198
|
+
"""Queued/inflight job record used by :class:`JobQueueGraph`."""
|
|
199
|
+
|
|
200
|
+
id: str
|
|
201
|
+
payload: Any
|
|
202
|
+
attempts: int
|
|
203
|
+
metadata: Mapping[str, Any]
|
|
204
|
+
state: JobState
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class JobQueueGraph(Graph):
|
|
208
|
+
"""Queue graph with claim/ack/nack primitives."""
|
|
209
|
+
|
|
210
|
+
__slots__ = ("_jobs", "_pending", "_seq", "depth", "jobs", "pending")
|
|
211
|
+
|
|
212
|
+
def __init__(self, name: str, *, opts: dict[str, Any] | None = None) -> None:
|
|
213
|
+
super().__init__(name, opts)
|
|
214
|
+
self._pending = reactive_list(name="pending")
|
|
215
|
+
self._jobs = reactive_map(name="jobs")
|
|
216
|
+
self._seq = 0
|
|
217
|
+
self.pending = self._pending.items
|
|
218
|
+
self.jobs = self._jobs.data
|
|
219
|
+
self.add("pending", self.pending)
|
|
220
|
+
self.add("jobs", self.jobs)
|
|
221
|
+
self.depth = derived(
|
|
222
|
+
[self.pending],
|
|
223
|
+
lambda deps, _a: len(deps[0].value if isinstance(deps[0], Versioned) else ()),
|
|
224
|
+
initial=0,
|
|
225
|
+
name="depth",
|
|
226
|
+
meta=_messaging_meta("queue_depth"),
|
|
227
|
+
)
|
|
228
|
+
self.add("depth", self.depth)
|
|
229
|
+
self.connect("pending", "depth")
|
|
230
|
+
_keepalive(self.depth)
|
|
231
|
+
|
|
232
|
+
def _job_get(self, job_id: str) -> JobEnvelope | None:
|
|
233
|
+
snapshot = self._jobs.data.get()
|
|
234
|
+
rows = snapshot.value if isinstance(snapshot, Versioned) else {}
|
|
235
|
+
return rows.get(job_id)
|
|
236
|
+
|
|
237
|
+
def enqueue(
|
|
238
|
+
self,
|
|
239
|
+
payload: Any,
|
|
240
|
+
*,
|
|
241
|
+
job_id: str | None = None,
|
|
242
|
+
metadata: dict[str, Any] | None = None,
|
|
243
|
+
) -> str:
|
|
244
|
+
if job_id is None:
|
|
245
|
+
self._seq += 1
|
|
246
|
+
job_id = f"{self.name}-{self._seq}"
|
|
247
|
+
if self._job_get(job_id) is not None:
|
|
248
|
+
raise ValueError(f'job_queue("{self.name}"): duplicate job id {job_id!r}')
|
|
249
|
+
job = JobEnvelope(
|
|
250
|
+
id=job_id,
|
|
251
|
+
payload=payload,
|
|
252
|
+
attempts=0,
|
|
253
|
+
metadata=MappingProxyType(dict(metadata or {})),
|
|
254
|
+
state="queued",
|
|
255
|
+
)
|
|
256
|
+
self._jobs.set(job_id, job)
|
|
257
|
+
self._pending.append(job_id)
|
|
258
|
+
return job_id
|
|
259
|
+
|
|
260
|
+
def claim(self, limit: int = 1) -> tuple[JobEnvelope, ...]:
|
|
261
|
+
max_items = _require_non_negative_int(limit, label="job queue claim limit")
|
|
262
|
+
if max_items == 0:
|
|
263
|
+
return ()
|
|
264
|
+
out: list[JobEnvelope] = []
|
|
265
|
+
while len(out) < max_items:
|
|
266
|
+
pending_snapshot = self.pending.get()
|
|
267
|
+
ids = pending_snapshot.value if isinstance(pending_snapshot, Versioned) else ()
|
|
268
|
+
if len(ids) == 0:
|
|
269
|
+
break
|
|
270
|
+
job_id = self._pending.pop(0)
|
|
271
|
+
current = self._job_get(job_id)
|
|
272
|
+
if current is None or current.state != "queued":
|
|
273
|
+
continue
|
|
274
|
+
inflight = JobEnvelope(
|
|
275
|
+
id=current.id,
|
|
276
|
+
payload=current.payload,
|
|
277
|
+
attempts=current.attempts + 1,
|
|
278
|
+
metadata=current.metadata,
|
|
279
|
+
state="inflight",
|
|
280
|
+
)
|
|
281
|
+
self._jobs.set(job_id, inflight)
|
|
282
|
+
out.append(inflight)
|
|
283
|
+
return tuple(out)
|
|
284
|
+
|
|
285
|
+
def ack(self, job_id: str) -> bool:
|
|
286
|
+
current = self._job_get(job_id)
|
|
287
|
+
if current is None or current.state != "inflight":
|
|
288
|
+
return False
|
|
289
|
+
self._jobs.delete(job_id)
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
def nack(self, job_id: str, *, requeue: bool = True) -> bool:
|
|
293
|
+
current = self._job_get(job_id)
|
|
294
|
+
if current is None or current.state != "inflight":
|
|
295
|
+
return False
|
|
296
|
+
if requeue:
|
|
297
|
+
self._jobs.set(
|
|
298
|
+
job_id,
|
|
299
|
+
JobEnvelope(
|
|
300
|
+
id=current.id,
|
|
301
|
+
payload=current.payload,
|
|
302
|
+
attempts=current.attempts,
|
|
303
|
+
metadata=current.metadata,
|
|
304
|
+
state="queued",
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
self._pending.append(job_id)
|
|
308
|
+
return True
|
|
309
|
+
self._jobs.delete(job_id)
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class JobFlowGraph(Graph):
|
|
314
|
+
"""Autonomous multi-stage queue chain graph."""
|
|
315
|
+
|
|
316
|
+
__slots__ = ("_completed", "_queues", "_stage_names", "completed", "completed_count")
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
name: str,
|
|
321
|
+
*,
|
|
322
|
+
opts: dict[str, Any] | None = None,
|
|
323
|
+
stages: tuple[str, ...] | list[str] | None = None,
|
|
324
|
+
max_per_pump: int | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
super().__init__(name, opts)
|
|
327
|
+
raw = tuple(stages or ("incoming", "processing", "done"))
|
|
328
|
+
stage_names = tuple(v.strip() for v in raw)
|
|
329
|
+
if len(stage_names) < 2:
|
|
330
|
+
raise ValueError(f'job_flow("{name}"): requires at least 2 stages')
|
|
331
|
+
if len(set(stage_names)) != len(stage_names):
|
|
332
|
+
raise ValueError(f'job_flow("{name}"): stage names must be unique')
|
|
333
|
+
self._stage_names = stage_names
|
|
334
|
+
self._queues: dict[str, JobQueueGraph] = {}
|
|
335
|
+
for stage in self._stage_names:
|
|
336
|
+
queue = job_queue(f"{name}-{stage}")
|
|
337
|
+
self._queues[stage] = queue
|
|
338
|
+
self.mount(stage, queue)
|
|
339
|
+
self._completed = reactive_log(name="completed")
|
|
340
|
+
self.completed = self._completed.entries
|
|
341
|
+
self.add("completed", self.completed)
|
|
342
|
+
self.completed_count = derived(
|
|
343
|
+
[self.completed],
|
|
344
|
+
lambda deps, _a: len(deps[0].value if isinstance(deps[0], Versioned) else ()),
|
|
345
|
+
initial=0,
|
|
346
|
+
name="completed_count",
|
|
347
|
+
meta=_messaging_meta("job_flow_completed_count"),
|
|
348
|
+
)
|
|
349
|
+
self.add("completed_count", self.completed_count)
|
|
350
|
+
self.connect("completed", "completed_count")
|
|
351
|
+
_keepalive(self.completed_count)
|
|
352
|
+
|
|
353
|
+
raw_max = DEFAULT_MAX_PER_PUMP if max_per_pump is None else max_per_pump
|
|
354
|
+
max_items = max(1, _require_non_negative_int(raw_max, label="job flow max_per_pump"))
|
|
355
|
+
for index, stage in enumerate(self._stage_names):
|
|
356
|
+
current = self.queue(stage)
|
|
357
|
+
nxt = (
|
|
358
|
+
self.queue(self._stage_names[index + 1])
|
|
359
|
+
if index + 1 < len(self._stage_names)
|
|
360
|
+
else None
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def run_stage(
|
|
364
|
+
_deps: list[Any],
|
|
365
|
+
_actions: Any,
|
|
366
|
+
*,
|
|
367
|
+
s: str = stage,
|
|
368
|
+
cur: JobQueueGraph = current,
|
|
369
|
+
nxt_q: JobQueueGraph | None = nxt,
|
|
370
|
+
m: int = max_items,
|
|
371
|
+
) -> None:
|
|
372
|
+
moved = 0
|
|
373
|
+
while moved < m:
|
|
374
|
+
claimed = cur.claim(1)
|
|
375
|
+
if len(claimed) == 0:
|
|
376
|
+
break
|
|
377
|
+
job = claimed[0]
|
|
378
|
+
if nxt_q is not None:
|
|
379
|
+
nxt_q.enqueue(
|
|
380
|
+
job.payload,
|
|
381
|
+
metadata={**dict(job.metadata), "job_flow_from": s},
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
self._completed.append(job)
|
|
385
|
+
cur.ack(job.id)
|
|
386
|
+
moved += 1
|
|
387
|
+
|
|
388
|
+
pump = effect(
|
|
389
|
+
[current.pending],
|
|
390
|
+
run_stage,
|
|
391
|
+
name=f"pump_{stage}",
|
|
392
|
+
meta=_messaging_meta("job_flow_pump"),
|
|
393
|
+
)
|
|
394
|
+
self.add(f"pump_{stage}", pump)
|
|
395
|
+
self.connect(f"{stage}::pending", f"pump_{stage}")
|
|
396
|
+
_keepalive(pump)
|
|
397
|
+
|
|
398
|
+
def stages(self) -> tuple[str, ...]:
|
|
399
|
+
return self._stage_names
|
|
400
|
+
|
|
401
|
+
def queue(self, stage: str) -> JobQueueGraph:
|
|
402
|
+
try:
|
|
403
|
+
return self._queues[stage]
|
|
404
|
+
except KeyError as exc:
|
|
405
|
+
raise ValueError(f'job_flow("{self.name}"): unknown stage {stage!r}') from exc
|
|
406
|
+
|
|
407
|
+
def enqueue(
|
|
408
|
+
self,
|
|
409
|
+
payload: Any,
|
|
410
|
+
*,
|
|
411
|
+
job_id: str | None = None,
|
|
412
|
+
metadata: dict[str, Any] | None = None,
|
|
413
|
+
) -> str:
|
|
414
|
+
first = self._stage_names[0]
|
|
415
|
+
return self.queue(first).enqueue(payload, job_id=job_id, metadata=metadata)
|
|
416
|
+
|
|
417
|
+
def retained_completed(self) -> tuple[JobEnvelope, ...]:
|
|
418
|
+
snap = self.completed.get()
|
|
419
|
+
entries = _tuple_snapshot(snap.value if isinstance(snap, Versioned) else ())
|
|
420
|
+
return tuple(entries)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class TopicBridgeGraph(Graph):
|
|
424
|
+
"""Autonomous cursor-based topic relay graph."""
|
|
425
|
+
|
|
426
|
+
__slots__ = ("_source_sub", "_target", "bridged_count")
|
|
427
|
+
|
|
428
|
+
def __init__(
|
|
429
|
+
self,
|
|
430
|
+
name: str,
|
|
431
|
+
source_topic: TopicGraph,
|
|
432
|
+
target_topic: TopicGraph,
|
|
433
|
+
*,
|
|
434
|
+
opts: dict[str, Any] | None = None,
|
|
435
|
+
cursor: int = 0,
|
|
436
|
+
max_per_pump: int | None = None,
|
|
437
|
+
map_fn: Any | None = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
super().__init__(name, opts)
|
|
440
|
+
self._source_sub = subscription(f"{name}-subscription", source_topic, cursor=cursor)
|
|
441
|
+
self._target = target_topic
|
|
442
|
+
self.mount("subscription", self._source_sub)
|
|
443
|
+
self.bridged_count = state(
|
|
444
|
+
0,
|
|
445
|
+
name="bridged_count",
|
|
446
|
+
describe_kind="state",
|
|
447
|
+
meta=_messaging_meta("topic_bridge_count"),
|
|
448
|
+
)
|
|
449
|
+
self.add("bridged_count", self.bridged_count)
|
|
450
|
+
|
|
451
|
+
raw_max = DEFAULT_MAX_PER_PUMP if max_per_pump is None else max_per_pump
|
|
452
|
+
max_items = max(1, _require_non_negative_int(raw_max, label="topic bridge max_per_pump"))
|
|
453
|
+
mapper = map_fn or (lambda value: value)
|
|
454
|
+
|
|
455
|
+
def run_bridge(_deps: list[Any], _actions: Any) -> None:
|
|
456
|
+
available = self._source_sub.pull(max_items, ack=True)
|
|
457
|
+
if len(available) == 0:
|
|
458
|
+
return
|
|
459
|
+
bridged = 0
|
|
460
|
+
for value in available:
|
|
461
|
+
mapped = mapper(value)
|
|
462
|
+
if mapped is None:
|
|
463
|
+
continue
|
|
464
|
+
self._target.publish(mapped)
|
|
465
|
+
bridged += 1
|
|
466
|
+
if bridged > 0:
|
|
467
|
+
current_raw = self.bridged_count.get()
|
|
468
|
+
current = int(current_raw) if current_raw is not None else 0
|
|
469
|
+
self.bridged_count.down([(MessageType.DATA, current + bridged)])
|
|
470
|
+
|
|
471
|
+
pump = effect(
|
|
472
|
+
[self._source_sub.available],
|
|
473
|
+
run_bridge,
|
|
474
|
+
name="pump",
|
|
475
|
+
meta=_messaging_meta("topic_bridge_pump"),
|
|
476
|
+
)
|
|
477
|
+
self.add("pump", pump)
|
|
478
|
+
self.connect("subscription::available", "pump")
|
|
479
|
+
_keepalive(pump)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def topic(
|
|
483
|
+
name: str,
|
|
484
|
+
*,
|
|
485
|
+
opts: dict[str, Any] | None = None,
|
|
486
|
+
retained_limit: int | None = None,
|
|
487
|
+
) -> TopicGraph:
|
|
488
|
+
"""Create a Pulsar-inspired topic graph (retained append-only stream)."""
|
|
489
|
+
return TopicGraph(name, opts=opts, retained_limit=retained_limit)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def subscription(
|
|
493
|
+
name: str,
|
|
494
|
+
topic_graph: TopicGraph,
|
|
495
|
+
*,
|
|
496
|
+
opts: dict[str, Any] | None = None,
|
|
497
|
+
cursor: int = 0,
|
|
498
|
+
) -> SubscriptionGraph:
|
|
499
|
+
"""Create a cursor-based subscription graph over a topic."""
|
|
500
|
+
return SubscriptionGraph(name, topic_graph, opts=opts, cursor=cursor)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def job_queue(name: str, *, opts: dict[str, Any] | None = None) -> JobQueueGraph:
|
|
504
|
+
"""Create a Pulsar-inspired job queue graph with claim/ack/nack workflow."""
|
|
505
|
+
return JobQueueGraph(name, opts=opts)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def job_flow(
|
|
509
|
+
name: str,
|
|
510
|
+
*,
|
|
511
|
+
opts: dict[str, Any] | None = None,
|
|
512
|
+
stages: tuple[str, ...] | list[str] | None = None,
|
|
513
|
+
max_per_pump: int | None = None,
|
|
514
|
+
) -> JobFlowGraph:
|
|
515
|
+
"""Create an autonomous multi-stage queue chain graph."""
|
|
516
|
+
return JobFlowGraph(name, opts=opts, stages=stages, max_per_pump=max_per_pump)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def topic_bridge(
|
|
520
|
+
name: str,
|
|
521
|
+
source_topic: TopicGraph,
|
|
522
|
+
target_topic: TopicGraph,
|
|
523
|
+
*,
|
|
524
|
+
opts: dict[str, Any] | None = None,
|
|
525
|
+
cursor: int = 0,
|
|
526
|
+
max_per_pump: int | None = None,
|
|
527
|
+
map_fn: Any | None = None,
|
|
528
|
+
) -> TopicBridgeGraph:
|
|
529
|
+
"""Create an autonomous cursor-based topic relay graph."""
|
|
530
|
+
return TopicBridgeGraph(
|
|
531
|
+
name,
|
|
532
|
+
source_topic,
|
|
533
|
+
target_topic,
|
|
534
|
+
opts=opts,
|
|
535
|
+
cursor=cursor,
|
|
536
|
+
max_per_pump=max_per_pump,
|
|
537
|
+
map_fn=map_fn,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
__all__ = [
|
|
542
|
+
"JobEnvelope",
|
|
543
|
+
"JobFlowGraph",
|
|
544
|
+
"JobQueueGraph",
|
|
545
|
+
"SubscriptionGraph",
|
|
546
|
+
"TopicBridgeGraph",
|
|
547
|
+
"TopicGraph",
|
|
548
|
+
"job_flow",
|
|
549
|
+
"job_queue",
|
|
550
|
+
"subscription",
|
|
551
|
+
"topic",
|
|
552
|
+
"topic_bridge",
|
|
553
|
+
]
|