loopgraph 0.2.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.
- loopgraph/__init__.py +38 -0
- loopgraph/_debug.py +45 -0
- loopgraph/bus/__init__.py +5 -0
- loopgraph/bus/eventbus.py +186 -0
- loopgraph/concurrency/__init__.py +5 -0
- loopgraph/concurrency/policies.py +181 -0
- loopgraph/core/__init__.py +18 -0
- loopgraph/core/graph.py +425 -0
- loopgraph/core/state.py +443 -0
- loopgraph/core/types.py +72 -0
- loopgraph/diagnostics/__init__.py +5 -0
- loopgraph/diagnostics/inspect.py +70 -0
- loopgraph/persistence/__init__.py +6 -0
- loopgraph/persistence/event_log.py +63 -0
- loopgraph/persistence/snapshot.py +52 -0
- loopgraph/py.typed +0 -0
- loopgraph/registry/__init__.py +1 -0
- loopgraph/registry/function_registry.py +117 -0
- loopgraph/scheduler/__init__.py +5 -0
- loopgraph/scheduler/scheduler.py +569 -0
- loopgraph-0.2.0.dist-info/METADATA +165 -0
- loopgraph-0.2.0.dist-info/RECORD +24 -0
- loopgraph-0.2.0.dist-info/WHEEL +5 -0
- loopgraph-0.2.0.dist-info/top_level.txt +1 -0
loopgraph/core/state.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Runtime execution state helpers for LoopGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
from .._debug import (
|
|
9
|
+
log_branch,
|
|
10
|
+
log_loop_iteration,
|
|
11
|
+
log_parameter,
|
|
12
|
+
log_variable_change,
|
|
13
|
+
)
|
|
14
|
+
from .graph import Graph
|
|
15
|
+
from .types import NodeKind, NodeStatus, VisitOutcome
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class NodeVisit:
|
|
20
|
+
"""Track visit metadata for a node.
|
|
21
|
+
|
|
22
|
+
>>> visit = NodeVisit()
|
|
23
|
+
>>> visit.increment("evt-1").count
|
|
24
|
+
1
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
count: int = 0
|
|
28
|
+
last_event_id: Optional[str] = None
|
|
29
|
+
|
|
30
|
+
def increment(self, event_id: Optional[str]) -> "NodeVisit":
|
|
31
|
+
"""Return a new `NodeVisit` with an incremented count."""
|
|
32
|
+
func_name = "NodeVisit.increment"
|
|
33
|
+
log_parameter(func_name, event_id=event_id)
|
|
34
|
+
new_visit = NodeVisit(count=self.count + 1, last_event_id=event_id)
|
|
35
|
+
log_variable_change(func_name, "new_visit", new_visit)
|
|
36
|
+
return new_visit
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class NodeRuntimeState:
|
|
41
|
+
"""Mutable execution state for a single node."""
|
|
42
|
+
|
|
43
|
+
status: NodeStatus = NodeStatus.PENDING
|
|
44
|
+
outcome: Optional[VisitOutcome] = None
|
|
45
|
+
visits: NodeVisit = field(default_factory=NodeVisit)
|
|
46
|
+
upstream_completed: Set[str] = field(default_factory=set)
|
|
47
|
+
last_payload: Optional[Any] = None
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
"""Serialize the runtime state to a dictionary payload.
|
|
51
|
+
|
|
52
|
+
>>> state = NodeRuntimeState()
|
|
53
|
+
>>> state.status = NodeStatus.RUNNING
|
|
54
|
+
>>> payload = state.to_dict()
|
|
55
|
+
>>> payload["status"]
|
|
56
|
+
'running'
|
|
57
|
+
"""
|
|
58
|
+
func_name = "NodeRuntimeState.to_dict"
|
|
59
|
+
log_parameter(func_name)
|
|
60
|
+
payload = {
|
|
61
|
+
"status": self.status.value,
|
|
62
|
+
"outcome": None
|
|
63
|
+
if self.outcome is None
|
|
64
|
+
else {
|
|
65
|
+
"status": self.outcome.status.value,
|
|
66
|
+
"detail": self.outcome.detail,
|
|
67
|
+
"payload": self.outcome.payload,
|
|
68
|
+
},
|
|
69
|
+
"visits": {
|
|
70
|
+
"count": self.visits.count,
|
|
71
|
+
"last_event_id": self.visits.last_event_id,
|
|
72
|
+
},
|
|
73
|
+
"upstream_completed": sorted(self.upstream_completed),
|
|
74
|
+
"last_payload": self.last_payload,
|
|
75
|
+
}
|
|
76
|
+
log_variable_change(func_name, "payload", payload)
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, payload: Dict[str, Any]) -> "NodeRuntimeState":
|
|
81
|
+
"""Restore runtime state from a dictionary payload.
|
|
82
|
+
|
|
83
|
+
>>> original = NodeRuntimeState(status=NodeStatus.COMPLETED)
|
|
84
|
+
>>> restored = NodeRuntimeState.from_dict(original.to_dict())
|
|
85
|
+
>>> restored.status
|
|
86
|
+
<NodeStatus.COMPLETED: 'completed'>
|
|
87
|
+
"""
|
|
88
|
+
func_name = "NodeRuntimeState.from_dict"
|
|
89
|
+
log_parameter(func_name, payload=payload)
|
|
90
|
+
status = NodeStatus(payload["status"])
|
|
91
|
+
log_variable_change(func_name, "status", status)
|
|
92
|
+
outcome_payload = payload.get("outcome")
|
|
93
|
+
if outcome_payload is None:
|
|
94
|
+
log_branch(func_name, "no_outcome")
|
|
95
|
+
outcome = None
|
|
96
|
+
else:
|
|
97
|
+
log_branch(func_name, "with_outcome")
|
|
98
|
+
outcome = VisitOutcome(
|
|
99
|
+
status=NodeStatus(outcome_payload["status"]),
|
|
100
|
+
detail=outcome_payload.get("detail"),
|
|
101
|
+
payload=outcome_payload.get("payload"),
|
|
102
|
+
)
|
|
103
|
+
log_variable_change(func_name, "outcome", outcome)
|
|
104
|
+
visits_payload = payload.get("visits", {})
|
|
105
|
+
log_variable_change(func_name, "visits_payload", visits_payload)
|
|
106
|
+
visits = NodeVisit(
|
|
107
|
+
count=int(visits_payload.get("count", 0)),
|
|
108
|
+
last_event_id=visits_payload.get("last_event_id"),
|
|
109
|
+
)
|
|
110
|
+
log_variable_change(func_name, "visits", visits)
|
|
111
|
+
upstream_completed = set(payload.get("upstream_completed", []))
|
|
112
|
+
log_variable_change(func_name, "upstream_completed", upstream_completed)
|
|
113
|
+
state = cls(
|
|
114
|
+
status=status,
|
|
115
|
+
outcome=outcome,
|
|
116
|
+
visits=visits,
|
|
117
|
+
upstream_completed=upstream_completed,
|
|
118
|
+
last_payload=payload.get("last_payload"),
|
|
119
|
+
)
|
|
120
|
+
log_variable_change(func_name, "state", state)
|
|
121
|
+
return state
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ExecutionState:
|
|
125
|
+
"""Container for per-node runtime state tracking.
|
|
126
|
+
|
|
127
|
+
>>> from loopgraph.core.graph import Edge, Node
|
|
128
|
+
>>> from loopgraph.core.types import NodeKind
|
|
129
|
+
>>> graph = Graph(
|
|
130
|
+
... nodes={
|
|
131
|
+
... "a": Node(id="a", kind=NodeKind.TASK, handler="A"),
|
|
132
|
+
... "b": Node(id="b", kind=NodeKind.TASK, handler="B"),
|
|
133
|
+
... },
|
|
134
|
+
... edges={"e": Edge(id="e", source="a", target="b")},
|
|
135
|
+
... )
|
|
136
|
+
>>> runtime = ExecutionState()
|
|
137
|
+
>>> runtime.is_ready(graph, "a")
|
|
138
|
+
True
|
|
139
|
+
>>> runtime.mark_running("a")
|
|
140
|
+
>>> runtime.mark_complete("a", event_id="evt-1", outcome=VisitOutcome.success())
|
|
141
|
+
>>> runtime.note_upstream_completion("b", "a")
|
|
142
|
+
>>> runtime.is_ready(graph, "b")
|
|
143
|
+
True
|
|
144
|
+
>>> agg_graph = Graph(
|
|
145
|
+
... nodes={
|
|
146
|
+
... "x": Node(id="x", kind=NodeKind.TASK, handler="X"),
|
|
147
|
+
... "y": Node(id="y", kind=NodeKind.TASK, handler="Y"),
|
|
148
|
+
... "agg": Node(
|
|
149
|
+
... id="agg",
|
|
150
|
+
... kind=NodeKind.AGGREGATE,
|
|
151
|
+
... handler="Aggregate",
|
|
152
|
+
... config={"required": 2},
|
|
153
|
+
... ),
|
|
154
|
+
... },
|
|
155
|
+
... edges={
|
|
156
|
+
... "e1": Edge(id="e1", source="x", target="agg"),
|
|
157
|
+
... "e2": Edge(id="e2", source="y", target="agg"),
|
|
158
|
+
... },
|
|
159
|
+
... )
|
|
160
|
+
>>> runtime_agg = ExecutionState()
|
|
161
|
+
>>> runtime_agg.note_upstream_completion("agg", "x")
|
|
162
|
+
>>> runtime_agg.is_ready(agg_graph, "agg")
|
|
163
|
+
False
|
|
164
|
+
>>> runtime_agg.note_upstream_completion("agg", "y")
|
|
165
|
+
>>> runtime_agg.is_ready(agg_graph, "agg")
|
|
166
|
+
True
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
func_name = "ExecutionState.__init__"
|
|
171
|
+
log_parameter(func_name)
|
|
172
|
+
self._states: Dict[str, NodeRuntimeState] = {}
|
|
173
|
+
log_variable_change(func_name, "self._states", self._states)
|
|
174
|
+
self._completed_nodes: Set[str] = set()
|
|
175
|
+
log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
|
|
176
|
+
|
|
177
|
+
def _ensure_state(self, node_id: str) -> NodeRuntimeState:
|
|
178
|
+
"""Ensure a runtime state entry exists for the given node."""
|
|
179
|
+
func_name = "ExecutionState._ensure_state"
|
|
180
|
+
log_parameter(func_name, node_id=node_id)
|
|
181
|
+
if node_id not in self._states:
|
|
182
|
+
log_branch(func_name, "create_state")
|
|
183
|
+
self._states[node_id] = NodeRuntimeState()
|
|
184
|
+
log_variable_change(
|
|
185
|
+
func_name, f"self._states[{node_id!r}]", self._states[node_id]
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
log_branch(func_name, "state_exists")
|
|
189
|
+
return self._states[node_id]
|
|
190
|
+
|
|
191
|
+
def is_ready(self, graph: Graph, node_id: str) -> bool:
|
|
192
|
+
"""Check whether a node can be scheduled."""
|
|
193
|
+
func_name = "ExecutionState.is_ready"
|
|
194
|
+
log_parameter(func_name, node_id=node_id, graph=graph)
|
|
195
|
+
state = self._ensure_state(node_id)
|
|
196
|
+
log_variable_change(func_name, "state", state)
|
|
197
|
+
if state.status is not NodeStatus.PENDING:
|
|
198
|
+
log_branch(func_name, "not_pending")
|
|
199
|
+
return False
|
|
200
|
+
log_branch(func_name, "pending")
|
|
201
|
+
|
|
202
|
+
node = graph.nodes[node_id]
|
|
203
|
+
log_variable_change(func_name, "node", node)
|
|
204
|
+
if node.max_visits is not None and state.visits.count >= node.max_visits:
|
|
205
|
+
log_branch(func_name, "visit_limit_reached")
|
|
206
|
+
return False
|
|
207
|
+
log_branch(func_name, "visit_limit_available")
|
|
208
|
+
|
|
209
|
+
upstream_nodes = graph.upstream_nodes(node_id)
|
|
210
|
+
log_variable_change(func_name, "upstream_nodes", upstream_nodes)
|
|
211
|
+
if not upstream_nodes:
|
|
212
|
+
log_branch(func_name, "no_upstream")
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
if node.kind is NodeKind.AGGREGATE:
|
|
216
|
+
log_branch(func_name, "aggregate_check")
|
|
217
|
+
required_raw = node.config.get("required")
|
|
218
|
+
log_variable_change(func_name, "required_raw", required_raw)
|
|
219
|
+
if isinstance(required_raw, int) and required_raw > 0:
|
|
220
|
+
required = required_raw
|
|
221
|
+
else:
|
|
222
|
+
required = len(upstream_nodes)
|
|
223
|
+
log_variable_change(func_name, "required", required)
|
|
224
|
+
completed_count = len(state.upstream_completed)
|
|
225
|
+
log_variable_change(func_name, "completed_count", completed_count)
|
|
226
|
+
if completed_count >= required:
|
|
227
|
+
log_branch(func_name, "aggregate_ready")
|
|
228
|
+
return True
|
|
229
|
+
log_branch(func_name, "aggregate_waiting")
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
completed_required = True
|
|
233
|
+
log_variable_change(func_name, "completed_required", completed_required)
|
|
234
|
+
for iteration, upstream in enumerate(upstream_nodes):
|
|
235
|
+
log_loop_iteration(func_name, "upstream_required_check", iteration)
|
|
236
|
+
if upstream.id not in state.upstream_completed:
|
|
237
|
+
log_branch(func_name, "upstream_missing")
|
|
238
|
+
completed_required = False
|
|
239
|
+
log_variable_change(func_name, "completed_required", completed_required)
|
|
240
|
+
break
|
|
241
|
+
else:
|
|
242
|
+
log_branch(func_name, "all_marked_complete")
|
|
243
|
+
log_variable_change(func_name, "completed_required", completed_required)
|
|
244
|
+
|
|
245
|
+
if completed_required:
|
|
246
|
+
log_branch(func_name, "all_upstream_completed")
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
if node.allow_partial_upstream:
|
|
250
|
+
log_branch(func_name, "allow_partial")
|
|
251
|
+
any_completed = False
|
|
252
|
+
log_variable_change(func_name, "any_completed", any_completed)
|
|
253
|
+
for iteration, upstream in enumerate(upstream_nodes):
|
|
254
|
+
log_loop_iteration(func_name, "upstream_partial_check", iteration)
|
|
255
|
+
if upstream.id in state.upstream_completed:
|
|
256
|
+
any_completed = True
|
|
257
|
+
log_variable_change(func_name, "any_completed", any_completed)
|
|
258
|
+
break
|
|
259
|
+
log_variable_change(func_name, "any_completed", any_completed)
|
|
260
|
+
return any_completed
|
|
261
|
+
|
|
262
|
+
log_branch(func_name, "upstream_incomplete")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def mark_running(self, node_id: str) -> None:
|
|
266
|
+
"""Transition a node into the running state.
|
|
267
|
+
|
|
268
|
+
>>> runtime = ExecutionState()
|
|
269
|
+
>>> runtime.mark_running("node-1")
|
|
270
|
+
>>> runtime.snapshot()["states"]["node-1"]["status"]
|
|
271
|
+
'running'
|
|
272
|
+
"""
|
|
273
|
+
func_name = "ExecutionState.mark_running"
|
|
274
|
+
log_parameter(func_name, node_id=node_id)
|
|
275
|
+
state = self._ensure_state(node_id)
|
|
276
|
+
log_variable_change(func_name, "state_before", state)
|
|
277
|
+
state.status = NodeStatus.RUNNING
|
|
278
|
+
log_variable_change(func_name, "state_after", state)
|
|
279
|
+
|
|
280
|
+
def mark_complete(
|
|
281
|
+
self,
|
|
282
|
+
node_id: str,
|
|
283
|
+
event_id: Optional[str],
|
|
284
|
+
outcome: VisitOutcome,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Mark a node as completed successfully.
|
|
287
|
+
|
|
288
|
+
>>> runtime = ExecutionState()
|
|
289
|
+
>>> runtime.mark_complete(
|
|
290
|
+
... "node-1",
|
|
291
|
+
... event_id="evt-1",
|
|
292
|
+
... outcome=VisitOutcome.success({"value": 42}),
|
|
293
|
+
... )
|
|
294
|
+
>>> runtime.snapshot()["completed_nodes"]
|
|
295
|
+
['node-1']
|
|
296
|
+
"""
|
|
297
|
+
func_name = "ExecutionState.mark_complete"
|
|
298
|
+
log_parameter(func_name, node_id=node_id, event_id=event_id, outcome=outcome)
|
|
299
|
+
state = self._ensure_state(node_id)
|
|
300
|
+
log_variable_change(func_name, "state_before", state)
|
|
301
|
+
state.status = NodeStatus.COMPLETED
|
|
302
|
+
log_variable_change(func_name, "state_status", state.status)
|
|
303
|
+
state.outcome = outcome
|
|
304
|
+
log_variable_change(func_name, "state_outcome", state.outcome)
|
|
305
|
+
state.last_payload = outcome.payload
|
|
306
|
+
log_variable_change(func_name, "state_last_payload", state.last_payload)
|
|
307
|
+
state.visits = state.visits.increment(event_id)
|
|
308
|
+
log_variable_change(func_name, "state_visits", state.visits)
|
|
309
|
+
self._completed_nodes.add(node_id)
|
|
310
|
+
log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
|
|
311
|
+
|
|
312
|
+
def mark_failed(
|
|
313
|
+
self,
|
|
314
|
+
node_id: str,
|
|
315
|
+
event_id: Optional[str],
|
|
316
|
+
outcome: VisitOutcome,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Mark a node as failed.
|
|
319
|
+
|
|
320
|
+
>>> runtime = ExecutionState()
|
|
321
|
+
>>> runtime.mark_failed(
|
|
322
|
+
... "node-1",
|
|
323
|
+
... event_id="evt-1",
|
|
324
|
+
... outcome=VisitOutcome.failure("boom"),
|
|
325
|
+
... )
|
|
326
|
+
>>> runtime.snapshot()["states"]["node-1"]["status"]
|
|
327
|
+
'failed'
|
|
328
|
+
"""
|
|
329
|
+
func_name = "ExecutionState.mark_failed"
|
|
330
|
+
log_parameter(func_name, node_id=node_id, event_id=event_id, outcome=outcome)
|
|
331
|
+
state = self._ensure_state(node_id)
|
|
332
|
+
log_variable_change(func_name, "state_before", state)
|
|
333
|
+
state.status = NodeStatus.FAILED
|
|
334
|
+
log_variable_change(func_name, "state_status", state.status)
|
|
335
|
+
state.outcome = outcome
|
|
336
|
+
log_variable_change(func_name, "state_outcome", state.outcome)
|
|
337
|
+
state.last_payload = outcome.payload
|
|
338
|
+
log_variable_change(func_name, "state_last_payload", state.last_payload)
|
|
339
|
+
state.visits = state.visits.increment(event_id)
|
|
340
|
+
log_variable_change(func_name, "state_visits", state.visits)
|
|
341
|
+
|
|
342
|
+
def reset_for_reentry(self, node_id: str) -> None:
|
|
343
|
+
"""Reset a node to pending so it can be scheduled again.
|
|
344
|
+
|
|
345
|
+
>>> runtime = ExecutionState()
|
|
346
|
+
>>> runtime.mark_complete("node-1", "evt-1", VisitOutcome.success("ok"))
|
|
347
|
+
>>> before = runtime.snapshot()["states"]["node-1"]["visits"]["count"]
|
|
348
|
+
>>> runtime.reset_for_reentry("node-1")
|
|
349
|
+
>>> snapshot = runtime.snapshot()
|
|
350
|
+
>>> snapshot["states"]["node-1"]["status"]
|
|
351
|
+
'pending'
|
|
352
|
+
>>> snapshot["states"]["node-1"]["upstream_completed"]
|
|
353
|
+
[]
|
|
354
|
+
>>> snapshot["states"]["node-1"]["visits"]["count"] == before
|
|
355
|
+
True
|
|
356
|
+
>>> "node-1" in snapshot["completed_nodes"]
|
|
357
|
+
False
|
|
358
|
+
"""
|
|
359
|
+
func_name = "ExecutionState.reset_for_reentry"
|
|
360
|
+
log_parameter(func_name, node_id=node_id)
|
|
361
|
+
state = self._ensure_state(node_id)
|
|
362
|
+
log_variable_change(func_name, "state_before", state)
|
|
363
|
+
state.status = NodeStatus.PENDING
|
|
364
|
+
log_variable_change(func_name, "state_status", state.status)
|
|
365
|
+
state.upstream_completed.clear()
|
|
366
|
+
log_variable_change(
|
|
367
|
+
func_name, "state_upstream_completed", state.upstream_completed
|
|
368
|
+
)
|
|
369
|
+
if node_id in self._completed_nodes:
|
|
370
|
+
log_branch(func_name, "remove_from_completed")
|
|
371
|
+
self._completed_nodes.remove(node_id)
|
|
372
|
+
else:
|
|
373
|
+
log_branch(func_name, "not_in_completed")
|
|
374
|
+
log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
|
|
375
|
+
log_variable_change(func_name, "state_after", state)
|
|
376
|
+
|
|
377
|
+
def note_upstream_completion(self, node_id: str, upstream_id: str) -> None:
|
|
378
|
+
"""Record that an upstream dependency has completed.
|
|
379
|
+
|
|
380
|
+
>>> runtime = ExecutionState()
|
|
381
|
+
>>> runtime.note_upstream_completion("node-1", "up-1")
|
|
382
|
+
>>> runtime.snapshot()["states"]["node-1"]["upstream_completed"]
|
|
383
|
+
['up-1']
|
|
384
|
+
"""
|
|
385
|
+
func_name = "ExecutionState.note_upstream_completion"
|
|
386
|
+
log_parameter(func_name, node_id=node_id, upstream_id=upstream_id)
|
|
387
|
+
state = self._ensure_state(node_id)
|
|
388
|
+
log_variable_change(func_name, "state_before", state)
|
|
389
|
+
state.upstream_completed.add(upstream_id)
|
|
390
|
+
log_variable_change(func_name, "state_after", state)
|
|
391
|
+
|
|
392
|
+
def snapshot(self) -> Dict[str, Any]:
|
|
393
|
+
"""Produce a JSON-serializable snapshot of execution state."""
|
|
394
|
+
func_name = "ExecutionState.snapshot"
|
|
395
|
+
log_parameter(func_name)
|
|
396
|
+
states_payload: Dict[str, Dict[str, Any]] = {}
|
|
397
|
+
log_variable_change(func_name, "states_payload", states_payload)
|
|
398
|
+
for iteration, (node_id, state) in enumerate(self._states.items()):
|
|
399
|
+
log_loop_iteration(func_name, "states", iteration)
|
|
400
|
+
states_payload[node_id] = state.to_dict()
|
|
401
|
+
log_variable_change(
|
|
402
|
+
func_name, f"states_payload[{node_id!r}]", states_payload[node_id]
|
|
403
|
+
)
|
|
404
|
+
payload = {
|
|
405
|
+
"states": states_payload,
|
|
406
|
+
"completed_nodes": sorted(self._completed_nodes),
|
|
407
|
+
}
|
|
408
|
+
log_variable_change(func_name, "payload", payload)
|
|
409
|
+
return payload
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def restore(cls, payload: Dict[str, Any]) -> "ExecutionState":
|
|
413
|
+
"""Restore execution state from a snapshot payload.
|
|
414
|
+
|
|
415
|
+
>>> runtime = ExecutionState()
|
|
416
|
+
>>> runtime.mark_complete(
|
|
417
|
+
... "node-1",
|
|
418
|
+
... event_id="evt-1",
|
|
419
|
+
... outcome=VisitOutcome.success(),
|
|
420
|
+
... )
|
|
421
|
+
>>> snapshot = runtime.snapshot()
|
|
422
|
+
>>> restored = ExecutionState.restore(snapshot)
|
|
423
|
+
>>> restored.snapshot() == snapshot
|
|
424
|
+
True
|
|
425
|
+
"""
|
|
426
|
+
func_name = "ExecutionState.restore"
|
|
427
|
+
log_parameter(func_name, payload=payload)
|
|
428
|
+
instance = cls()
|
|
429
|
+
for iteration, (node_id, state_payload) in enumerate(
|
|
430
|
+
payload.get("states", {}).items()
|
|
431
|
+
):
|
|
432
|
+
log_loop_iteration(func_name, "states", iteration)
|
|
433
|
+
state = NodeRuntimeState.from_dict(state_payload)
|
|
434
|
+
log_variable_change(func_name, "restored_state", state)
|
|
435
|
+
instance._states[node_id] = state
|
|
436
|
+
log_variable_change(
|
|
437
|
+
func_name, f"instance._states[{node_id!r}]", instance._states[node_id]
|
|
438
|
+
)
|
|
439
|
+
instance._completed_nodes = set(payload.get("completed_nodes", []))
|
|
440
|
+
log_variable_change(
|
|
441
|
+
func_name, "instance._completed_nodes", instance._completed_nodes
|
|
442
|
+
)
|
|
443
|
+
return instance
|
loopgraph/core/types.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Fundamental enums and dataclasses shared across LoopGraph modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .._debug import log_parameter, log_variable_change
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeKind(str, Enum):
|
|
13
|
+
"""Enumerated node kinds supported by LoopGraph."""
|
|
14
|
+
|
|
15
|
+
TASK = "task"
|
|
16
|
+
SWITCH = "switch"
|
|
17
|
+
AGGREGATE = "aggregate"
|
|
18
|
+
TERMINAL = "terminal"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NodeStatus(str, Enum):
|
|
22
|
+
"""Execution states tracked for each node."""
|
|
23
|
+
|
|
24
|
+
PENDING = "pending"
|
|
25
|
+
RUNNING = "running"
|
|
26
|
+
COMPLETED = "completed"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
SKIPPED = "skipped"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EventType(str, Enum):
|
|
32
|
+
"""Event kinds emitted during workflow execution."""
|
|
33
|
+
|
|
34
|
+
NODE_SCHEDULED = "node_scheduled"
|
|
35
|
+
NODE_COMPLETED = "node_completed"
|
|
36
|
+
NODE_FAILED = "node_failed"
|
|
37
|
+
WORKFLOW_COMPLETED = "workflow_completed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class VisitOutcome:
|
|
42
|
+
"""Represents the result of a node execution attempt."""
|
|
43
|
+
|
|
44
|
+
status: NodeStatus
|
|
45
|
+
detail: Optional[str] = None
|
|
46
|
+
payload: Optional[Any] = None
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def success(cls, payload: Optional[Any] = None) -> "VisitOutcome":
|
|
50
|
+
"""Construct a successful outcome.
|
|
51
|
+
|
|
52
|
+
>>> VisitOutcome.success({"count": 2}).status
|
|
53
|
+
<NodeStatus.COMPLETED: 'completed'>
|
|
54
|
+
"""
|
|
55
|
+
func_name = "VisitOutcome.success"
|
|
56
|
+
log_parameter(func_name, payload=payload)
|
|
57
|
+
outcome = cls(status=NodeStatus.COMPLETED, payload=payload)
|
|
58
|
+
log_variable_change(func_name, "outcome", outcome)
|
|
59
|
+
return outcome
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def failure(cls, detail: Optional[str] = None) -> "VisitOutcome":
|
|
63
|
+
"""Construct a failed outcome.
|
|
64
|
+
|
|
65
|
+
>>> VisitOutcome.failure("boom").detail
|
|
66
|
+
'boom'
|
|
67
|
+
"""
|
|
68
|
+
func_name = "VisitOutcome.failure"
|
|
69
|
+
log_parameter(func_name, detail=detail)
|
|
70
|
+
outcome = cls(status=NodeStatus.FAILED, detail=detail)
|
|
71
|
+
log_variable_change(func_name, "outcome", outcome)
|
|
72
|
+
return outcome
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Diagnostics helpers for inspecting graphs and execution state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from .._debug import log_loop_iteration, log_parameter, log_variable_change
|
|
8
|
+
from ..core.graph import Graph
|
|
9
|
+
from ..core.state import ExecutionState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def describe_graph(graph: Graph) -> Dict[str, object]:
|
|
13
|
+
"""Return a summary of the graph structure.
|
|
14
|
+
|
|
15
|
+
>>> from loopgraph.core.graph import Edge, Node
|
|
16
|
+
>>> from loopgraph.core.types import NodeKind
|
|
17
|
+
>>> graph = Graph(
|
|
18
|
+
... nodes={
|
|
19
|
+
... "start": Node(id="start", kind=NodeKind.TASK, handler="start_handler"),
|
|
20
|
+
... "end": Node(id="end", kind=NodeKind.TERMINAL, handler="end_handler"),
|
|
21
|
+
... },
|
|
22
|
+
... edges={"e": Edge(id="e", source="start", target="end")},
|
|
23
|
+
... )
|
|
24
|
+
>>> describe_graph(graph)["node_count"]
|
|
25
|
+
2
|
|
26
|
+
"""
|
|
27
|
+
func_name = "describe_graph"
|
|
28
|
+
log_parameter(func_name, graph=graph)
|
|
29
|
+
summary = {
|
|
30
|
+
"node_count": len(graph.nodes),
|
|
31
|
+
"edge_count": len(graph.edges),
|
|
32
|
+
"nodes": list(graph.nodes.keys()),
|
|
33
|
+
}
|
|
34
|
+
log_variable_change(func_name, "summary", summary)
|
|
35
|
+
return summary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def describe_execution_state(state: ExecutionState) -> Dict[str, object]:
|
|
39
|
+
"""Summarise execution state snapshot data.
|
|
40
|
+
|
|
41
|
+
>>> from loopgraph.core.types import VisitOutcome, NodeStatus
|
|
42
|
+
>>> execution = ExecutionState()
|
|
43
|
+
>>> execution.mark_complete(
|
|
44
|
+
... "node-1",
|
|
45
|
+
... event_id="evt-1",
|
|
46
|
+
... outcome=VisitOutcome.success(),
|
|
47
|
+
... )
|
|
48
|
+
>>> describe_execution_state(execution)["completed"]
|
|
49
|
+
['node-1']
|
|
50
|
+
"""
|
|
51
|
+
func_name = "describe_execution_state"
|
|
52
|
+
log_parameter(func_name, state=state)
|
|
53
|
+
snapshot = state.snapshot()
|
|
54
|
+
log_variable_change(func_name, "snapshot", snapshot)
|
|
55
|
+
completed_nodes = list(snapshot["completed_nodes"])
|
|
56
|
+
log_variable_change(func_name, "completed_nodes", completed_nodes)
|
|
57
|
+
statuses: Dict[str, str] = {}
|
|
58
|
+
log_variable_change(func_name, "statuses", statuses)
|
|
59
|
+
for iteration, (node_id, node_state) in enumerate(snapshot["states"].items()):
|
|
60
|
+
log_loop_iteration(func_name, "states", iteration)
|
|
61
|
+
status = str(node_state["status"])
|
|
62
|
+
log_variable_change(func_name, "status", status)
|
|
63
|
+
statuses[node_id] = status
|
|
64
|
+
log_variable_change(func_name, f"statuses[{node_id!r}]", statuses[node_id])
|
|
65
|
+
summary: Dict[str, object] = {
|
|
66
|
+
"completed": completed_nodes,
|
|
67
|
+
"statuses": statuses,
|
|
68
|
+
}
|
|
69
|
+
log_variable_change(func_name, "summary", summary)
|
|
70
|
+
return summary
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Persistence interfaces and in-memory event log implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Iterable, List, Protocol
|
|
7
|
+
|
|
8
|
+
from .._debug import (
|
|
9
|
+
log_branch,
|
|
10
|
+
log_loop_iteration,
|
|
11
|
+
log_parameter,
|
|
12
|
+
log_variable_change,
|
|
13
|
+
)
|
|
14
|
+
from ..bus.eventbus import Event
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventLog(Protocol):
|
|
18
|
+
"""Protocol for append-only event logs.
|
|
19
|
+
|
|
20
|
+
>>> from loopgraph.core.types import EventType
|
|
21
|
+
>>> log = InMemoryEventLog()
|
|
22
|
+
>>> log.append(Event(id="evt", graph_id="g", node_id=None, type=EventType.NODE_COMPLETED))
|
|
23
|
+
>>> [evt.type for evt in log.iter("g")]
|
|
24
|
+
[<EventType.NODE_COMPLETED: 'node_completed'>]
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def append(self, event: Event) -> None:
|
|
28
|
+
"""Persist an event in the log."""
|
|
29
|
+
|
|
30
|
+
def iter(self, graph_id: str) -> Iterable[Event]:
|
|
31
|
+
"""Replay events for a graph."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class InMemoryEventLog(EventLog):
|
|
37
|
+
"""Simple in-memory event log.
|
|
38
|
+
|
|
39
|
+
>>> from ..core.types import EventType
|
|
40
|
+
>>> log = InMemoryEventLog()
|
|
41
|
+
>>> log.append(Event(id="evt-1", graph_id="graph", node_id=None, type=EventType.WORKFLOW_COMPLETED))
|
|
42
|
+
>>> [evt.id for evt in log.iter("graph")]
|
|
43
|
+
['evt-1']
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_events: List[Event] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def append(self, event: Event) -> None:
|
|
49
|
+
func_name = "InMemoryEventLog.append"
|
|
50
|
+
log_parameter(func_name, event=event)
|
|
51
|
+
self._events.append(event)
|
|
52
|
+
log_variable_change(func_name, "self._events", self._events)
|
|
53
|
+
|
|
54
|
+
def iter(self, graph_id: str) -> Iterable[Event]:
|
|
55
|
+
func_name = "InMemoryEventLog.iter"
|
|
56
|
+
log_parameter(func_name, graph_id=graph_id)
|
|
57
|
+
for iteration, event in enumerate(self._events):
|
|
58
|
+
log_loop_iteration(func_name, "events", iteration)
|
|
59
|
+
if event.graph_id == graph_id:
|
|
60
|
+
log_branch(func_name, "graph_match")
|
|
61
|
+
yield event
|
|
62
|
+
else:
|
|
63
|
+
log_branch(func_name, "graph_mismatch")
|