runfox 0.0.2__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.
- runfox/__init__.py +5 -0
- runfox/_version.py +34 -0
- runfox/backend/__init__.py +9 -0
- runfox/backend/base.py +268 -0
- runfox/backend/inmemory_store.py +36 -0
- runfox/backend/inprocess_runner.py +74 -0
- runfox/backend/inprocess_worker.py +68 -0
- runfox/backend/models.py +76 -0
- runfox/backend/runner.py +69 -0
- runfox/backend/sqlite_runner.py +169 -0
- runfox/backend/sqlite_store.py +116 -0
- runfox/backend/store.py +31 -0
- runfox/results.py +89 -0
- runfox/status.py +57 -0
- runfox/workflow.py +514 -0
- runfox-0.0.2.dist-info/METADATA +528 -0
- runfox-0.0.2.dist-info/RECORD +20 -0
- runfox-0.0.2.dist-info/WHEEL +5 -0
- runfox-0.0.2.dist-info/licenses/LICENSE +21 -0
- runfox-0.0.2.dist-info/top_level.txt +1 -0
runfox/__init__.py
ADDED
runfox/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 2)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = 'gf1470cbc4'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .models import StepRecord, WorkflowRecord
|
|
2
|
+
from .base import Backend
|
|
3
|
+
from .inprocess_runner import InProcessRunner
|
|
4
|
+
from .inprocess_worker import InProcessWorker
|
|
5
|
+
from .runner import Runner
|
|
6
|
+
from .sqlite_runner import SqliteRunner
|
|
7
|
+
from .store import Store
|
|
8
|
+
from .inmemory_store import InMemoryStore
|
|
9
|
+
from .sqlite_store import SqliteStore
|
runfox/backend/base.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
base.py -- Backend
|
|
3
|
+
|
|
4
|
+
Composes a Store and a Runner. All workflow lifecycle operations are
|
|
5
|
+
implemented here in terms of self._store.load() and self._store.write().
|
|
6
|
+
dispatch() and gather() delegate to self._runner.
|
|
7
|
+
|
|
8
|
+
Construction
|
|
9
|
+
------------
|
|
10
|
+
Backend(store=s, runner=r) -- explicit store and runner
|
|
11
|
+
|
|
12
|
+
Composite key accessors
|
|
13
|
+
-----------------------
|
|
14
|
+
workflow_execution_id(record)
|
|
15
|
+
step_key(wf_exec_id, step_id)
|
|
16
|
+
step_run_key(wf_exec_id, step_id, run_id)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import datetime
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import random
|
|
24
|
+
import socket
|
|
25
|
+
import string
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from runfox.status import StepStatus, WorkflowStatus
|
|
29
|
+
|
|
30
|
+
from .models import StepRecord, WorkflowRecord
|
|
31
|
+
from .inprocess_runner import InProcessRunner
|
|
32
|
+
from .inprocess_worker import InProcessWorker
|
|
33
|
+
from .inmemory_store import InMemoryStore
|
|
34
|
+
|
|
35
|
+
class Backend:
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
executor=None,
|
|
40
|
+
store=None,
|
|
41
|
+
runner=None,
|
|
42
|
+
poll_interval: float = 0.1,
|
|
43
|
+
on_state_change=None,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
on_state_change: optional callback fired after every state merge.
|
|
47
|
+
Signature: (workflow_execution_id, previous_state, new_state) -> None.
|
|
48
|
+
Must be pure: no side effects, no exceptions, no backend calls.
|
|
49
|
+
The callback fires inside a write cycle; any mutation of backend
|
|
50
|
+
state from within it will produce inconsistent records.
|
|
51
|
+
"""
|
|
52
|
+
if store is None:
|
|
53
|
+
store = InMemoryStore()
|
|
54
|
+
if runner is None:
|
|
55
|
+
runner = InProcessRunner()
|
|
56
|
+
self._store = store
|
|
57
|
+
self._runner = runner
|
|
58
|
+
self._worker = InProcessWorker(runner, executor) if executor else None
|
|
59
|
+
self.poll_interval = poll_interval
|
|
60
|
+
self._on_state_change = on_state_change
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Private ID generation
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def _make_workflow_id(self, spec: dict) -> str:
|
|
67
|
+
canonical = json.dumps(spec, sort_keys=True, separators=(",", ":"))
|
|
68
|
+
return hashlib.md5(canonical.encode()).hexdigest()
|
|
69
|
+
|
|
70
|
+
def _make_execution_id(self) -> str:
|
|
71
|
+
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
72
|
+
suffix = "".join(random.choices(string.hexdigits[:16], k=4)).lower()
|
|
73
|
+
return f"{ts}-{suffix}"
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Public composite key accessors
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def workflow_execution_id(self, record: WorkflowRecord) -> str:
|
|
80
|
+
return f"{record.workflow_id}#{record.execution_id}"
|
|
81
|
+
|
|
82
|
+
def step_key(self, wf_exec_id: str, step_id: str) -> str:
|
|
83
|
+
return f"{wf_exec_id}#{step_id}"
|
|
84
|
+
|
|
85
|
+
def step_run_key(self, wf_exec_id: str, step_id: str, run_id: int) -> str:
|
|
86
|
+
return f"{wf_exec_id}#{step_id}#{run_id}"
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Utilities
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _now_iso(self) -> str:
|
|
93
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
94
|
+
|
|
95
|
+
def _make_step_record(self, step_id: str) -> StepRecord:
|
|
96
|
+
return StepRecord(id=step_id)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Store pass-throughs (used by Workflow and tests)
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def load(self, workflow_execution_id: str) -> WorkflowRecord:
|
|
103
|
+
return self._store.load(workflow_execution_id)
|
|
104
|
+
|
|
105
|
+
def write(self, record: WorkflowRecord) -> None:
|
|
106
|
+
self._store.write(record)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# create
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def create(self, spec: dict, inputs: dict = None) -> str:
|
|
113
|
+
record = WorkflowRecord(
|
|
114
|
+
workflow_id=self._make_workflow_id(spec),
|
|
115
|
+
execution_id=self._make_execution_id(),
|
|
116
|
+
spec=spec,
|
|
117
|
+
inputs=inputs or {},
|
|
118
|
+
state={},
|
|
119
|
+
steps={
|
|
120
|
+
step["id"]: self._make_step_record(step["id"]) for step in spec["steps"]
|
|
121
|
+
},
|
|
122
|
+
status=WorkflowStatus.PENDING,
|
|
123
|
+
)
|
|
124
|
+
self._store.write(record)
|
|
125
|
+
return self.workflow_execution_id(record)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Named step operations
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def mark_in_progress(self, workflow_execution_id: str, step_id: str) -> None:
|
|
132
|
+
record = self._store.load(workflow_execution_id)
|
|
133
|
+
new_step = dataclasses.replace(
|
|
134
|
+
record.steps[step_id],
|
|
135
|
+
status=StepStatus.IN_PROGRESS,
|
|
136
|
+
start_time=self._now_iso(),
|
|
137
|
+
host=socket.gethostname(),
|
|
138
|
+
)
|
|
139
|
+
self._store.write(
|
|
140
|
+
dataclasses.replace(
|
|
141
|
+
record,
|
|
142
|
+
status=WorkflowStatus.IN_PROGRESS,
|
|
143
|
+
steps={**record.steps, step_id: new_step},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def mark_complete(self, workflow_execution_id: str, step_id: str) -> None:
|
|
148
|
+
record = self._store.load(workflow_execution_id)
|
|
149
|
+
new_step = dataclasses.replace(
|
|
150
|
+
record.steps[step_id],
|
|
151
|
+
status=StepStatus.COMPLETE,
|
|
152
|
+
end_time=self._now_iso(),
|
|
153
|
+
)
|
|
154
|
+
self._store.write(
|
|
155
|
+
dataclasses.replace(
|
|
156
|
+
record,
|
|
157
|
+
steps={**record.steps, step_id: new_step},
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def mark_halted(self, workflow_execution_id: str, step_id: str) -> None:
|
|
162
|
+
record = self._store.load(workflow_execution_id)
|
|
163
|
+
new_step = dataclasses.replace(
|
|
164
|
+
record.steps[step_id],
|
|
165
|
+
status=StepStatus.HALTED,
|
|
166
|
+
end_time=self._now_iso(),
|
|
167
|
+
)
|
|
168
|
+
self._store.write(
|
|
169
|
+
dataclasses.replace(
|
|
170
|
+
record,
|
|
171
|
+
status=WorkflowStatus.HALTED,
|
|
172
|
+
steps={**record.steps, step_id: new_step},
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def write_step_output(
|
|
177
|
+
self, workflow_execution_id: str, step_id: str, output: dict
|
|
178
|
+
) -> None:
|
|
179
|
+
record = self._store.load(workflow_execution_id)
|
|
180
|
+
new_step = dataclasses.replace(record.steps[step_id], output=output)
|
|
181
|
+
self._store.write(
|
|
182
|
+
dataclasses.replace(
|
|
183
|
+
record,
|
|
184
|
+
steps={**record.steps, step_id: new_step},
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def merge_workflow_state(self, workflow_execution_id, output, event=None):
|
|
189
|
+
"""
|
|
190
|
+
Update the internal workflow state with a new output.
|
|
191
|
+
Latest-wins update of keys.
|
|
192
|
+
|
|
193
|
+
on_state_change is called with
|
|
194
|
+
(workflow_execution_id, prev_state, new_state, event) if set.
|
|
195
|
+
event is a StateChangeEvent identifying the step that triggered
|
|
196
|
+
the merge, or None if called outside a step result context.
|
|
197
|
+
"""
|
|
198
|
+
if not output:
|
|
199
|
+
return
|
|
200
|
+
record = self._store.load(workflow_execution_id)
|
|
201
|
+
new_state = {**record.state, **output}
|
|
202
|
+
if self._on_state_change:
|
|
203
|
+
# Callback must be pure. Side effects here are undefined behaviour --
|
|
204
|
+
# the store write has completed but the workflow has not advanced.
|
|
205
|
+
self._on_state_change(workflow_execution_id, record.state, new_state, event)
|
|
206
|
+
self._store.write(dataclasses.replace(record, state=new_state))
|
|
207
|
+
|
|
208
|
+
def write_workflow_outcome(self, workflow_execution_id: str, outcome: Any) -> None:
|
|
209
|
+
"""send the result of the workflow to the store for writing"""
|
|
210
|
+
record = self._store.load(workflow_execution_id)
|
|
211
|
+
status = (
|
|
212
|
+
WorkflowStatus.HALTED
|
|
213
|
+
if record.status == WorkflowStatus.HALTED
|
|
214
|
+
else WorkflowStatus.COMPLETE
|
|
215
|
+
)
|
|
216
|
+
self._store.write(dataclasses.replace(record, outcome=outcome, status=status))
|
|
217
|
+
|
|
218
|
+
def reset_step(self, workflow_execution_id: str, step_id: str) -> None:
|
|
219
|
+
record = self._store.load(workflow_execution_id)
|
|
220
|
+
new_step = dataclasses.replace(
|
|
221
|
+
record.steps[step_id],
|
|
222
|
+
status=StepStatus.READY,
|
|
223
|
+
output=None,
|
|
224
|
+
start_time=None,
|
|
225
|
+
end_time=None,
|
|
226
|
+
run_id=record.steps[step_id].run_id + 1,
|
|
227
|
+
)
|
|
228
|
+
self._store.write(
|
|
229
|
+
dataclasses.replace(
|
|
230
|
+
record,
|
|
231
|
+
steps={**record.steps, step_id: new_step},
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def reset_for_retry(self, workflow_execution_id: str, step_id: str) -> None:
|
|
236
|
+
record = self._store.load(workflow_execution_id)
|
|
237
|
+
new_step = dataclasses.replace(
|
|
238
|
+
record.steps[step_id],
|
|
239
|
+
status=StepStatus.RETRY,
|
|
240
|
+
run_id=record.steps[step_id].run_id + 1,
|
|
241
|
+
start_time=None,
|
|
242
|
+
end_time=None,
|
|
243
|
+
)
|
|
244
|
+
self._store.write(
|
|
245
|
+
dataclasses.replace(
|
|
246
|
+
record,
|
|
247
|
+
steps={**record.steps, step_id: new_step},
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Runner pass-throughs
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
|
|
256
|
+
self._runner.dispatch(workflow_execution_id, jobs)
|
|
257
|
+
|
|
258
|
+
def gather(self, workflow_execution_id: str) -> list:
|
|
259
|
+
return self._runner.gather(workflow_execution_id)
|
|
260
|
+
|
|
261
|
+
def pending_tasks(self) -> list:
|
|
262
|
+
return self._runner.list_pending_jobs()
|
|
263
|
+
|
|
264
|
+
def take_tasks(self) -> list:
|
|
265
|
+
return self._runner.take_pending_jobs()
|
|
266
|
+
|
|
267
|
+
def submit_result(self, workflow_execution_id, step_id, output):
|
|
268
|
+
self._runner.submit_work_result(workflow_execution_id, step_id, output)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
store.py -- Store
|
|
3
|
+
|
|
4
|
+
Two primitives:
|
|
5
|
+
|
|
6
|
+
load(workflow_execution_id) -> WorkflowRecord
|
|
7
|
+
write(record) -> None
|
|
8
|
+
|
|
9
|
+
Implementations: InMemoryStore, SqliteStore.
|
|
10
|
+
SqliteStore manages the workflows table only. The tasks table belongs to SqliteRunner.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import dataclasses
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
|
|
18
|
+
from runfox.status import StepStatus, WorkflowStatus
|
|
19
|
+
|
|
20
|
+
from .models import StepRecord, WorkflowRecord
|
|
21
|
+
from .store import Store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InMemoryStore(Store):
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self._store: dict[str, WorkflowRecord] = {}
|
|
28
|
+
|
|
29
|
+
def load(self, workflow_execution_id: str) -> WorkflowRecord:
|
|
30
|
+
if workflow_execution_id not in self._store:
|
|
31
|
+
raise KeyError(workflow_execution_id)
|
|
32
|
+
return copy.deepcopy(self._store[workflow_execution_id])
|
|
33
|
+
|
|
34
|
+
def write(self, record: WorkflowRecord) -> None:
|
|
35
|
+
key = f"{record.workflow_id}#{record.execution_id}"
|
|
36
|
+
self._store[key] = copy.deepcopy(record)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
runner.py -- Runner
|
|
3
|
+
|
|
4
|
+
Two primitives:
|
|
5
|
+
|
|
6
|
+
dispatch(workflow_execution_id, jobs) -> None
|
|
7
|
+
gather(workflow_execution_id) -> list[tuple[str, dict]]
|
|
8
|
+
|
|
9
|
+
gather() always returns immediately. Returns an empty list if no results
|
|
10
|
+
are ready. The runner never calls on_step_result; Workflow.run() does that.
|
|
11
|
+
|
|
12
|
+
The runner is a job queue. dispatch() enqueues; gather() dequeues results.
|
|
13
|
+
The caller drives execution between those two calls -- a local function, a
|
|
14
|
+
thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
|
|
15
|
+
no runfox dependency regardless of which runner is used.
|
|
16
|
+
|
|
17
|
+
InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
|
|
18
|
+
the dict is the tasks table. Use InProcessWorker to drive
|
|
19
|
+
local execution against it.
|
|
20
|
+
|
|
21
|
+
SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
|
|
22
|
+
See worker protocol in class docstring.
|
|
23
|
+
|
|
24
|
+
InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
|
|
25
|
+
SqliteRunner worker protocol. The executor remains a plain
|
|
26
|
+
callable with no runfox dependency.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import json
|
|
31
|
+
import sqlite3
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
from ..results import DispatchJob
|
|
35
|
+
from .runner import Runner
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InProcessRunner(Runner):
|
|
39
|
+
"""
|
|
40
|
+
Dict-backed job queue.
|
|
41
|
+
|
|
42
|
+
Semantically identical to SqliteRunner: dispatch() writes to _pending
|
|
43
|
+
(equivalent to INSERT PENDING), gather() reads from _results (equivalent
|
|
44
|
+
to SELECT COMPLETE and mark PROCESSED). InProcessWorker drives execution
|
|
45
|
+
between those two calls, mirroring what an external worker does against
|
|
46
|
+
the SQLite tasks table.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self._pending: dict[str, list] = {}
|
|
51
|
+
self._results: dict[str, list] = {}
|
|
52
|
+
|
|
53
|
+
def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
|
|
54
|
+
existing = self._pending.get(workflow_execution_id, [])
|
|
55
|
+
self._pending[workflow_execution_id] = existing + list(jobs)
|
|
56
|
+
|
|
57
|
+
def gather(self, workflow_execution_id: str) -> list:
|
|
58
|
+
return self._results.pop(workflow_execution_id, [])
|
|
59
|
+
|
|
60
|
+
def list_pending_jobs(self) -> list:
|
|
61
|
+
"""Non-destructive snapshot. Does not alter queue state."""
|
|
62
|
+
return [job for jobs in self._pending.values() for job in jobs]
|
|
63
|
+
|
|
64
|
+
def take_pending_jobs(self) -> list:
|
|
65
|
+
"""Consume all pending jobs. Clears the queue."""
|
|
66
|
+
jobs = [job for jobs in self._pending.values() for job in jobs]
|
|
67
|
+
self._pending.clear()
|
|
68
|
+
return jobs
|
|
69
|
+
|
|
70
|
+
def submit_work_result(
|
|
71
|
+
self, workflow_execution_id: str, step_id: str, output: dict
|
|
72
|
+
) -> None:
|
|
73
|
+
existing = self._results.get(workflow_execution_id, [])
|
|
74
|
+
self._results[workflow_execution_id] = existing + [(step_id, output)]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
runner.py -- Runner
|
|
3
|
+
|
|
4
|
+
Two primitives:
|
|
5
|
+
|
|
6
|
+
dispatch(workflow_execution_id, jobs) -> None
|
|
7
|
+
gather(workflow_execution_id) -> list[tuple[str, dict]]
|
|
8
|
+
|
|
9
|
+
gather() always returns immediately. Returns an empty list if no results
|
|
10
|
+
are ready. The runner never calls on_step_result; Workflow.run() does that.
|
|
11
|
+
|
|
12
|
+
The runner is a job queue. dispatch() enqueues; gather() dequeues results.
|
|
13
|
+
The caller drives execution between those two calls -- a local function, a
|
|
14
|
+
thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
|
|
15
|
+
no runfox dependency regardless of which runner is used.
|
|
16
|
+
|
|
17
|
+
InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
|
|
18
|
+
the dict is the tasks table. Use InProcessWorker to drive
|
|
19
|
+
local execution against it.
|
|
20
|
+
|
|
21
|
+
SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
|
|
22
|
+
See worker protocol in class docstring.
|
|
23
|
+
|
|
24
|
+
InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
|
|
25
|
+
SqliteRunner worker protocol. The executor remains a plain
|
|
26
|
+
callable with no runfox dependency.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import json
|
|
31
|
+
import sqlite3
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
from .inprocess_runner import InProcessRunner
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InProcessWorker:
|
|
38
|
+
"""
|
|
39
|
+
Local worker harness for InProcessRunner.
|
|
40
|
+
|
|
41
|
+
Mirrors the SqliteRunner worker protocol using the runner's internal
|
|
42
|
+
dicts in place of the tasks table. The executor remains a plain callable
|
|
43
|
+
with no runfox dependency.
|
|
44
|
+
|
|
45
|
+
Equivalent remote pattern
|
|
46
|
+
-------------------------
|
|
47
|
+
This harness:
|
|
48
|
+
for job in runner.pending(wf_exec_id):
|
|
49
|
+
output = executor(job.fn, job.inputs)
|
|
50
|
+
runner.submit_work_result(wf_exec_id, job.step_id, output)
|
|
51
|
+
|
|
52
|
+
SQS/Lambda equivalent:
|
|
53
|
+
message = sqs.receive()
|
|
54
|
+
output = executor(message.fn, message.inputs)
|
|
55
|
+
dynamodb.put(task_key, output, status="COMPLETE")
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, runner: InProcessRunner, executor: Callable[[str, dict], dict]):
|
|
59
|
+
self._runner = runner
|
|
60
|
+
self._executor = executor
|
|
61
|
+
|
|
62
|
+
def run(self, workflow_execution_id: str) -> None:
|
|
63
|
+
for job in self._runner.take_pending_jobs():
|
|
64
|
+
try:
|
|
65
|
+
output = self._executor(job.fn, job.inputs)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
output = {"error": str(exc), "ok": False}
|
|
68
|
+
self._runner.submit_work_result(workflow_execution_id, job.step_id, output)
|
runfox/backend/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
base.py -- Backend
|
|
3
|
+
|
|
4
|
+
Composes a Store and a Runner. All workflow lifecycle operations are
|
|
5
|
+
implemented here in terms of self._store.load() and self._store.write().
|
|
6
|
+
dispatch() and gather() delegate to self._runner.
|
|
7
|
+
|
|
8
|
+
Construction
|
|
9
|
+
------------
|
|
10
|
+
Backend(store=s, runner=r) -- explicit store and runner
|
|
11
|
+
|
|
12
|
+
Composite key accessors
|
|
13
|
+
-----------------------
|
|
14
|
+
workflow_execution_id(record)
|
|
15
|
+
step_key(wf_exec_id, step_id)
|
|
16
|
+
step_run_key(wf_exec_id, step_id, run_id)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import datetime
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import random
|
|
24
|
+
import socket
|
|
25
|
+
import string
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from runfox.status import StepStatus, WorkflowStatus
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass
|
|
32
|
+
class StepRecord:
|
|
33
|
+
"""
|
|
34
|
+
Runtime state of a single step.
|
|
35
|
+
|
|
36
|
+
id -- step identifier
|
|
37
|
+
status -- current lifecycle status
|
|
38
|
+
output -- written by executor on completion; None until then
|
|
39
|
+
start_time -- ISO-8601 UTC; set when claimed
|
|
40
|
+
end_time -- ISO-8601 UTC; set on terminal status
|
|
41
|
+
host -- hostname of the claiming process
|
|
42
|
+
run_id -- incremented on every dispatch (retry or set-branch reset)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
status: StepStatus = StepStatus.READY
|
|
47
|
+
output: dict | None = None
|
|
48
|
+
start_time: str | None = None
|
|
49
|
+
end_time: str | None = None
|
|
50
|
+
host: str | None = None
|
|
51
|
+
run_id: int = 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass
|
|
55
|
+
class WorkflowRecord:
|
|
56
|
+
"""
|
|
57
|
+
Runtime state of a workflow execution as returned by store.load().
|
|
58
|
+
|
|
59
|
+
workflow_id -- MD5 of canonical spec JSON
|
|
60
|
+
execution_id -- timestamp + short suffix; identifies one run
|
|
61
|
+
spec -- parsed workflow definition (immutable after create)
|
|
62
|
+
inputs -- workflow-level inputs (immutable after create)
|
|
63
|
+
state -- mutable shared accumulator
|
|
64
|
+
steps -- dict[step_id -> StepRecord]
|
|
65
|
+
status -- current workflow lifecycle status
|
|
66
|
+
outcome -- resolved outputs on completion, branch payload on halt
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
workflow_id: str
|
|
70
|
+
execution_id: str
|
|
71
|
+
spec: dict
|
|
72
|
+
inputs: dict
|
|
73
|
+
state: dict
|
|
74
|
+
steps: dict
|
|
75
|
+
status: WorkflowStatus
|
|
76
|
+
outcome: Any = None
|
runfox/backend/runner.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
runner.py -- Runner
|
|
3
|
+
|
|
4
|
+
Two primitives:
|
|
5
|
+
|
|
6
|
+
dispatch(workflow_execution_id, jobs) -> None
|
|
7
|
+
gather(workflow_execution_id) -> list[tuple[str, dict]]
|
|
8
|
+
|
|
9
|
+
gather() always returns immediately. Returns an empty list if no results
|
|
10
|
+
are ready. The runner never calls on_step_result; Workflow.run() does that.
|
|
11
|
+
|
|
12
|
+
The runner is a job queue. dispatch() enqueues; gather() dequeues results.
|
|
13
|
+
The caller drives execution between those two calls -- a local function, a
|
|
14
|
+
thread, a Lambda, an SQS consumer. The executor (fn, inputs -> dict) has
|
|
15
|
+
no runfox dependency regardless of which runner is used.
|
|
16
|
+
|
|
17
|
+
InProcessRunner -- dict-backed queue. Semantically identical to SqliteRunner;
|
|
18
|
+
the dict is the tasks table. Use InProcessWorker to drive
|
|
19
|
+
local execution against it.
|
|
20
|
+
|
|
21
|
+
SqliteRunner -- SQLite tasks-table queue. An external worker owns execution.
|
|
22
|
+
See worker protocol in class docstring.
|
|
23
|
+
|
|
24
|
+
InProcessWorker -- local worker harness for InProcessRunner. Mirrors the
|
|
25
|
+
SqliteRunner worker protocol. The executor remains a plain
|
|
26
|
+
callable with no runfox dependency.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import json
|
|
31
|
+
import sqlite3
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
from ..results import DispatchJob
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Runner:
|
|
38
|
+
|
|
39
|
+
def dispatch(self, workflow_execution_id: str, jobs: list) -> None:
|
|
40
|
+
"""Enqueue jobs. jobs is a list of DispatchJob."""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
def gather(self, workflow_execution_id: str) -> list:
|
|
44
|
+
"""
|
|
45
|
+
Return completed (step_id, output) pairs. Always returns immediately.
|
|
46
|
+
Returns an empty list if no results are ready.
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def list_pending_jobs(self) -> list:
|
|
51
|
+
"""
|
|
52
|
+
Non-destructive snapshot of all pending jobs across all workflows.
|
|
53
|
+
Safe to call for diagnostics; does not affect queue state.
|
|
54
|
+
"""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def take_pending_jobs(self) -> list:
|
|
58
|
+
"""
|
|
59
|
+
Consume and return all pending jobs across all workflows.
|
|
60
|
+
Called by worker harnesses. Each returned job will not be
|
|
61
|
+
returned again by a subsequent call.
|
|
62
|
+
"""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
def submit_work_result(
|
|
66
|
+
self, workflow_execution_id: str, step_id: str, output: dict
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Write a result back from a worker."""
|
|
69
|
+
raise NotImplementedError
|