substantial 0.0.1__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.
- pyproject.toml +51 -0
- substantial/__init__.py +14 -0
- substantial/agent.py +70 -0
- substantial/backends/backend.py +54 -0
- substantial/backends/fs.py +160 -0
- substantial/backends/redis.py +181 -0
- substantial/conductor.py +50 -0
- substantial/protos/__init__.py +0 -0
- substantial/protos/events/__init__.py +58 -0
- substantial/protos/metadata/__init__.py +35 -0
- substantial/types.py +134 -0
- substantial/workflows/__init__.py +15 -0
- substantial/workflows/context.py +146 -0
- substantial/workflows/run.py +200 -0
- substantial/workflows/workflow.py +29 -0
- substantial-0.0.1.dist-info/LICENSE +373 -0
- substantial-0.0.1.dist-info/METADATA +156 -0
- substantial-0.0.1.dist-info/RECORD +19 -0
- substantial-0.0.1.dist-info/WHEEL +4 -0
pyproject.toml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "substantial"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Brokerless durable execution for Python."
|
|
5
|
+
authors = []
|
|
6
|
+
license = "MPL-2.0"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
include = [
|
|
9
|
+
"dataconf", "pyproject.toml"
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
13
|
+
"Programming Language :: Python",
|
|
14
|
+
"Programming Language :: Python :: 3.9",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12"
|
|
18
|
+
]
|
|
19
|
+
keywords = [
|
|
20
|
+
"durable",
|
|
21
|
+
"execution",
|
|
22
|
+
"redis",
|
|
23
|
+
"s3",
|
|
24
|
+
"filesystem",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = "^3.9"
|
|
29
|
+
asyncio = "^3.4.3"
|
|
30
|
+
uvloop = "^0.21.0"
|
|
31
|
+
aioprocessing = { version = "2.0.1", extras = ["dill"] }
|
|
32
|
+
pydantic = "^2.9.2"
|
|
33
|
+
redis = "^5.2.0"
|
|
34
|
+
betterproto = { version = "2.0.0b7", extras = ["compiler"]}
|
|
35
|
+
|
|
36
|
+
[tool.poetry.group.dev.dependencies]
|
|
37
|
+
pytest = "^8.3.3"
|
|
38
|
+
pytest-asyncio = "^0.23.7"
|
|
39
|
+
commitizen = ">=3.29.0"
|
|
40
|
+
|
|
41
|
+
[tool.commitizen]
|
|
42
|
+
name = "cz_conventional_commits"
|
|
43
|
+
version = "3.3.0"
|
|
44
|
+
tag_format = "v$major.$minor.$patch$prerelease"
|
|
45
|
+
version_files = [
|
|
46
|
+
"pyproject.toml:version",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["poetry-core"]
|
|
51
|
+
build-backend = "poetry.core.masonry.api"
|
substantial/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from substantial.workflows.workflow import workflow, Workflow
|
|
2
|
+
from substantial.workflows.context import Context
|
|
3
|
+
from substantial.workflows.run import Run
|
|
4
|
+
from substantial.conductor import Conductor
|
|
5
|
+
from substantial.backends.fs import FSBackend
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"workflow",
|
|
9
|
+
"Workflow",
|
|
10
|
+
"Context",
|
|
11
|
+
"Conductor",
|
|
12
|
+
"Run",
|
|
13
|
+
"FSBackend",
|
|
14
|
+
]
|
substantial/agent.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
from substantial.backends.backend import Backend
|
|
4
|
+
import uvloop
|
|
5
|
+
|
|
6
|
+
from substantial.workflows.run import Run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# move to backend?
|
|
10
|
+
lease_seconds = 10
|
|
11
|
+
renew_seconds = 8
|
|
12
|
+
pool_interval = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Agent:
|
|
16
|
+
def __init__(self, backend: Backend, queue: str):
|
|
17
|
+
self.backend = backend
|
|
18
|
+
self.queue = queue
|
|
19
|
+
|
|
20
|
+
def run_sync(self):
|
|
21
|
+
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
|
|
22
|
+
runner.run(self.run())
|
|
23
|
+
|
|
24
|
+
async def run(self):
|
|
25
|
+
while True:
|
|
26
|
+
await self.poll_with_lease()
|
|
27
|
+
await asyncio.sleep(pool_interval)
|
|
28
|
+
|
|
29
|
+
async def poll_with_lease(self):
|
|
30
|
+
active_leases = await self.backend.active_leases(lease_seconds)
|
|
31
|
+
next_run = await self.backend.next_run(self.queue, active_leases)
|
|
32
|
+
print("run_id", next_run)
|
|
33
|
+
|
|
34
|
+
if next_run is None:
|
|
35
|
+
print("no runs")
|
|
36
|
+
await asyncio.sleep(1)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
run_id, schedule = next_run
|
|
40
|
+
|
|
41
|
+
async def heartbeat():
|
|
42
|
+
while True:
|
|
43
|
+
await asyncio.sleep(renew_seconds)
|
|
44
|
+
renewed = await self.backend.renew_lease(run_id, lease_seconds)
|
|
45
|
+
if not renewed:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
leased = await self.backend.acquire_lease(run_id, lease_seconds)
|
|
49
|
+
if not leased:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
renew_task = asyncio.create_task(heartbeat())
|
|
53
|
+
run = Run(run_id, self.queue, self.backend)
|
|
54
|
+
process_task = asyncio.create_task(run.replay(schedule))
|
|
55
|
+
|
|
56
|
+
done, pending = await asyncio.wait(
|
|
57
|
+
[
|
|
58
|
+
renew_task,
|
|
59
|
+
process_task,
|
|
60
|
+
],
|
|
61
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
62
|
+
)
|
|
63
|
+
done = done.pop()
|
|
64
|
+
pending = pending.pop()
|
|
65
|
+
|
|
66
|
+
pending.cancel()
|
|
67
|
+
with suppress(asyncio.CancelledError):
|
|
68
|
+
await pending
|
|
69
|
+
|
|
70
|
+
await self.backend.remove_lease(run_id, lease_seconds)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import List, Tuple, Union
|
|
3
|
+
from substantial.protos.events import Event, Records
|
|
4
|
+
from substantial.protos.metadata import Metadata
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Backend:
|
|
8
|
+
# run related
|
|
9
|
+
|
|
10
|
+
async def read_events(self, run_id: str) -> Union[Records, None]:
|
|
11
|
+
raise NotImplementedError()
|
|
12
|
+
|
|
13
|
+
async def write_events(self, run_id: str, content: Records) -> None:
|
|
14
|
+
raise NotImplementedError()
|
|
15
|
+
|
|
16
|
+
async def read_all_metadata(self, run_id: str) -> List[Metadata]:
|
|
17
|
+
raise NotImplementedError()
|
|
18
|
+
|
|
19
|
+
async def append_metadata(
|
|
20
|
+
self, run_id: str, schedule: datetime, content: str
|
|
21
|
+
) -> None:
|
|
22
|
+
raise NotImplementedError()
|
|
23
|
+
|
|
24
|
+
async def add_schedule(
|
|
25
|
+
self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
|
|
26
|
+
) -> None:
|
|
27
|
+
raise NotImplementedError()
|
|
28
|
+
|
|
29
|
+
async def read_schedule(
|
|
30
|
+
self, queue: str, run_id: str, schedule: datetime
|
|
31
|
+
) -> Union[Event, None]:
|
|
32
|
+
raise NotImplementedError()
|
|
33
|
+
|
|
34
|
+
async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
|
|
35
|
+
raise NotImplementedError()
|
|
36
|
+
|
|
37
|
+
# agent related
|
|
38
|
+
|
|
39
|
+
async def next_run(
|
|
40
|
+
self, queue: str, excludes: list[str]
|
|
41
|
+
) -> Union[Tuple[str, datetime], None]:
|
|
42
|
+
raise NotImplementedError()
|
|
43
|
+
|
|
44
|
+
async def active_leases(self, lease_seconds: int) -> List[str]:
|
|
45
|
+
raise NotImplementedError()
|
|
46
|
+
|
|
47
|
+
async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
48
|
+
raise NotImplementedError()
|
|
49
|
+
|
|
50
|
+
async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
51
|
+
raise NotImplementedError()
|
|
52
|
+
|
|
53
|
+
async def remove_lease(self, run_id: str, lease_seconds: int) -> None:
|
|
54
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Tuple, Union
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from substantial.backends.backend import Backend
|
|
6
|
+
from substantial.protos.events import Event, Records
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FSBackend(Backend):
|
|
10
|
+
"""
|
|
11
|
+
This backend is for testing purposes only as it is not scalable:
|
|
12
|
+
- there is no file order in POSIX
|
|
13
|
+
- all the runs need to be loaded in memory
|
|
14
|
+
- not all required operations are atomic or using compare-and-swap
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, root: str):
|
|
18
|
+
self.root = Path(root)
|
|
19
|
+
for d in ["runs", "schedules", "leases"]:
|
|
20
|
+
(self.root / d).mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
async def read_events(self, run_id: str) -> Union[Records, None]:
|
|
23
|
+
f = self.root / "runs" / run_id / "events"
|
|
24
|
+
if not f.exists():
|
|
25
|
+
return None
|
|
26
|
+
return Records().from_json(f.read_text())
|
|
27
|
+
|
|
28
|
+
async def write_events(self, run_id: str, content: Records) -> None:
|
|
29
|
+
f = self.root / "runs" / run_id / "events"
|
|
30
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
f.write_text(content.to_json(indent=4))
|
|
32
|
+
|
|
33
|
+
async def read_all_metadata(self, run_id: str) -> List[str]:
|
|
34
|
+
f = self.root / "runs" / run_id / "logs"
|
|
35
|
+
ret = []
|
|
36
|
+
for log in f.iterdir():
|
|
37
|
+
# could be polymorphic
|
|
38
|
+
ret.append(log.read_text())
|
|
39
|
+
return ret
|
|
40
|
+
|
|
41
|
+
async def append_metadata(self, run_id: str, schedule: datetime, content: str):
|
|
42
|
+
f = self.root / "runs" / run_id / "logs" / schedule.isoformat()
|
|
43
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
f.write_text(content)
|
|
45
|
+
|
|
46
|
+
async def next_run(
|
|
47
|
+
self, queue: str, excludes: list[str]
|
|
48
|
+
) -> Union[Tuple[str, datetime], None]:
|
|
49
|
+
f = self.root / "schedules" / queue
|
|
50
|
+
excludes_set = set(excludes) # Note: lease related
|
|
51
|
+
|
|
52
|
+
for schedule in sorted(f.iterdir()):
|
|
53
|
+
for run_id in schedule.iterdir():
|
|
54
|
+
if run_id.name not in excludes_set:
|
|
55
|
+
return run_id.name, datetime.fromisoformat(schedule.name)
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
async def add_schedule(
|
|
60
|
+
self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
|
|
61
|
+
) -> None:
|
|
62
|
+
q = self.root / "schedules" / queue
|
|
63
|
+
|
|
64
|
+
if q.exists():
|
|
65
|
+
# Note: new schedule should always overwrite replays (but not scheduled events)
|
|
66
|
+
# This is for keeping the run consistent,
|
|
67
|
+
# Case 1: ======= r1 == ev1 == r2 ====== r3 ========>
|
|
68
|
+
# x r4
|
|
69
|
+
# Result: ============== ev1 = r4 ====== r3 ========>
|
|
70
|
+
# Rationale being that the scheduled replays are often triggered by Interrupts and such
|
|
71
|
+
# for more sane runs, fuse planned replays (induced by add_schedule calls)?
|
|
72
|
+
|
|
73
|
+
for sched in sorted(q.iterdir()):
|
|
74
|
+
for planned in sched.iterdir():
|
|
75
|
+
planned_date = datetime.fromisoformat(sched.name)
|
|
76
|
+
if planned.name == run_id and planned_date <= schedule:
|
|
77
|
+
event = await self.read_schedule(queue, run_id, planned_date)
|
|
78
|
+
if event is None: # event => None == scheduled replays..
|
|
79
|
+
await self.close_schedule(queue, run_id, planned_date)
|
|
80
|
+
|
|
81
|
+
f1 = q / schedule.isoformat() / run_id
|
|
82
|
+
f1.parent.mkdir(parents=True, exist_ok=False)
|
|
83
|
+
f1.write_text("" if content is None else content.to_json())
|
|
84
|
+
|
|
85
|
+
async def read_schedule(
|
|
86
|
+
self, queue: str, run_id: str, schedule: datetime
|
|
87
|
+
) -> Union[Event, None]:
|
|
88
|
+
f = self.root / "schedules" / queue / schedule.isoformat() / run_id
|
|
89
|
+
if not f.exists():
|
|
90
|
+
raise Exception(f"run not found: {f}")
|
|
91
|
+
ret = f.read_text()
|
|
92
|
+
return None if ret == "" else Event().from_json(ret)
|
|
93
|
+
|
|
94
|
+
async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
|
|
95
|
+
f = self.root / "schedules" / queue / schedule.isoformat() / run_id
|
|
96
|
+
if not f.exists():
|
|
97
|
+
# raise Exception(f"run not found: {f}")
|
|
98
|
+
return
|
|
99
|
+
print(f"closed {f}")
|
|
100
|
+
f.unlink()
|
|
101
|
+
|
|
102
|
+
async def active_leases(self, lease_seconds: int) -> List[str]:
|
|
103
|
+
f = self.root / "leases"
|
|
104
|
+
ret = []
|
|
105
|
+
for lease in f.iterdir():
|
|
106
|
+
if not lease_held(lease, lease_seconds):
|
|
107
|
+
continue
|
|
108
|
+
ret.append(lease.name)
|
|
109
|
+
return ret
|
|
110
|
+
|
|
111
|
+
async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
112
|
+
f = self.root / "leases" / run_id
|
|
113
|
+
return leasing_cas(f, "acquire", lease_seconds)
|
|
114
|
+
|
|
115
|
+
async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
116
|
+
f = self.root / "leases" / run_id
|
|
117
|
+
|
|
118
|
+
if not f.exists():
|
|
119
|
+
raise Exception(f"lease not found: {f}")
|
|
120
|
+
|
|
121
|
+
return leasing_cas(f, "renew", lease_seconds)
|
|
122
|
+
|
|
123
|
+
async def remove_lease(self, run_id: str, lease_seconds: int):
|
|
124
|
+
f = self.root / "leases" / run_id
|
|
125
|
+
|
|
126
|
+
if not f.exists():
|
|
127
|
+
raise Exception(f"lease not found: {f}")
|
|
128
|
+
|
|
129
|
+
still_holding = leasing_cas(f, "remove", lease_seconds)
|
|
130
|
+
if still_holding:
|
|
131
|
+
f.unlink()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def leasing_cas(f: Path, suffix: str, lease_seconds: int) -> bool:
|
|
135
|
+
if lease_held(f, lease_seconds):
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return cas(f, suffix)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def lease_held(f: Path, lease_seconds: int) -> bool:
|
|
142
|
+
return (
|
|
143
|
+
f.exists()
|
|
144
|
+
and datetime.fromtimestamp(f.stat().st_mtime) + timedelta(seconds=lease_seconds)
|
|
145
|
+
> datetime.now()
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cas(f: Path, suffix: str) -> bool:
|
|
150
|
+
nonce = str(uuid4())
|
|
151
|
+
witness = f.parent / f"{f.name}.{suffix}"
|
|
152
|
+
witness.write_text(nonce)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
witness.rename(f)
|
|
156
|
+
return f.read_text() == nonce
|
|
157
|
+
except FileExistsError:
|
|
158
|
+
# another rename has already happened
|
|
159
|
+
witness.unlink()
|
|
160
|
+
return False
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Tuple, Union
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import redis
|
|
7
|
+
from substantial.backends.backend import Backend
|
|
8
|
+
from substantial.protos.events import Event, Records
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisBackend(Backend):
|
|
12
|
+
def __init__(self, host: str, port: int, **kwargs):
|
|
13
|
+
self.redis = redis.Redis(host=host, port=port, decode_responses=True, **kwargs)
|
|
14
|
+
# self.redis.flushall()
|
|
15
|
+
|
|
16
|
+
async def read_events(self, run_id: str) -> Union[Records, None]:
|
|
17
|
+
key = "_".join(["runs", run_id, "events"])
|
|
18
|
+
val = self.redis.get(key)
|
|
19
|
+
return None if val is None else Records().from_json(val)
|
|
20
|
+
|
|
21
|
+
async def write_events(self, run_id: str, content: Records) -> None:
|
|
22
|
+
key = "_".join(["runs", run_id, "events"])
|
|
23
|
+
self.redis.set(key, content.to_json(indent=4))
|
|
24
|
+
|
|
25
|
+
async def read_all_metadata(self, run_id: str) -> List[str]:
|
|
26
|
+
base_key = "_".join(["runs", run_id, "logs"]) # queue
|
|
27
|
+
ret = []
|
|
28
|
+
for schedule in self.redis.lrange(base_key, 0, -1):
|
|
29
|
+
log = self.redis.get("_".join[run_id, schedule])
|
|
30
|
+
# could be polymorphic
|
|
31
|
+
ret.append(log)
|
|
32
|
+
return ret
|
|
33
|
+
|
|
34
|
+
async def append_metadata(self, run_id: str, schedule: datetime, content: str):
|
|
35
|
+
base_key = "_".join(["runs", run_id, "logs"]) # queue
|
|
36
|
+
sched_key = "_".join([run_id, schedule.isoformat()])
|
|
37
|
+
|
|
38
|
+
self.redis.register_script("""
|
|
39
|
+
local base_key = KEYS[1]
|
|
40
|
+
local sched_key = KEYS[2]
|
|
41
|
+
local content = ARGV[1]
|
|
42
|
+
|
|
43
|
+
redis.call("LPUSH", base_key, sched_key)
|
|
44
|
+
redis.call("ZREM", sched_key, content)
|
|
45
|
+
""")(keys=[base_key, sched_key], args=[content])
|
|
46
|
+
|
|
47
|
+
async def next_run(
|
|
48
|
+
self, queue: str, excludes: list[str]
|
|
49
|
+
) -> Union[Tuple[str, datetime], None]:
|
|
50
|
+
q_key = "_".join(["schedules", queue]) # priority queue
|
|
51
|
+
excludes_set = set(excludes)
|
|
52
|
+
|
|
53
|
+
for schedule in self.redis.zrange(q_key, 0, -1):
|
|
54
|
+
for run_id in self.redis.zrange(schedule, 0, -1):
|
|
55
|
+
if run_id not in excludes_set:
|
|
56
|
+
return run_id, datetime.fromisoformat(schedule)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
async def add_schedule(
|
|
61
|
+
self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
|
|
62
|
+
) -> None:
|
|
63
|
+
q_key = "_".join(["schedules", queue]) # priority queue
|
|
64
|
+
|
|
65
|
+
# avoid having a bunch of replays happening
|
|
66
|
+
for sched_ref in self.redis.zrange(q_key, 0, -1):
|
|
67
|
+
for planned_key in self.redis.zrange(sched_ref, 0, -1):
|
|
68
|
+
_dt, planned_id = planned_key.split("_")
|
|
69
|
+
planned_date = datetime.fromisoformat(sched_ref)
|
|
70
|
+
if planned_id == run_id and planned_date <= schedule:
|
|
71
|
+
event = await self.read_schedule(queue, run_id, planned_date)
|
|
72
|
+
if event is None: # event => None == scheduled replays..
|
|
73
|
+
await self.close_schedule(queue, run_id, planned_date)
|
|
74
|
+
|
|
75
|
+
sched_ref = schedule.isoformat()
|
|
76
|
+
sched_score = 1 / schedule.timestamp()
|
|
77
|
+
sched_key = "_".join([sched_ref, run_id])
|
|
78
|
+
|
|
79
|
+
self.redis.register_script("""
|
|
80
|
+
local q_key = KEYS[1]
|
|
81
|
+
local sched_ref = KEYS[2]
|
|
82
|
+
local sched_key = KEYS[3]
|
|
83
|
+
local sched_score = tonumber(ARGV[1])
|
|
84
|
+
local run_id = ARGV[2]
|
|
85
|
+
local content = ARGV[3]
|
|
86
|
+
|
|
87
|
+
redis.call("ZADD", q_key, 0, sched_ref)
|
|
88
|
+
redis.call("ZADD", sched_ref, sched_score, run_id)
|
|
89
|
+
redis.call("SET", sched_key, content)
|
|
90
|
+
""")(
|
|
91
|
+
keys=[q_key, sched_ref, sched_key],
|
|
92
|
+
args=[sched_score, run_id, "" if content is None else content.to_json()],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def read_schedule(
|
|
96
|
+
self, queue: str, run_id: str, schedule: datetime
|
|
97
|
+
) -> Union[Event, None]:
|
|
98
|
+
sched_key = "_".join([schedule.isoformat(), run_id])
|
|
99
|
+
ret = self.redis.get(sched_key)
|
|
100
|
+
if ret is None:
|
|
101
|
+
raise Exception(f"run not found: {sched_key}")
|
|
102
|
+
return None if ret == "" else Event().from_json(ret)
|
|
103
|
+
|
|
104
|
+
async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
|
|
105
|
+
q_key = "_".join(["schedules", queue])
|
|
106
|
+
sched_ref = schedule.isoformat()
|
|
107
|
+
sched_key = "_".join([sched_ref, run_id])
|
|
108
|
+
|
|
109
|
+
self.redis.register_script("""
|
|
110
|
+
local q_key = KEYS[1]
|
|
111
|
+
local sched_ref = KEYS[2]
|
|
112
|
+
local sched_key = KEYS[3]
|
|
113
|
+
local run_id = ARGV[1]
|
|
114
|
+
|
|
115
|
+
redis.call("ZREM", q_key, sched_ref)
|
|
116
|
+
redis.call("ZREM", sched_ref, run_id)
|
|
117
|
+
redis.call("DEL", sched_key)
|
|
118
|
+
""")(keys=[q_key, sched_ref, sched_key], args=[run_id])
|
|
119
|
+
|
|
120
|
+
print(f"closed {run_id}")
|
|
121
|
+
|
|
122
|
+
# TODO
|
|
123
|
+
async def active_leases(self, lease_seconds: int) -> List[str]:
|
|
124
|
+
f = Path("logs") / "leases"
|
|
125
|
+
ret = []
|
|
126
|
+
for lease in f.iterdir():
|
|
127
|
+
if not lease_held(lease, lease_seconds):
|
|
128
|
+
continue
|
|
129
|
+
ret.append(lease.name)
|
|
130
|
+
return ret
|
|
131
|
+
|
|
132
|
+
async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
133
|
+
f = Path("logs") / "leases" / run_id
|
|
134
|
+
return leasing_cas(f, "acquire", lease_seconds)
|
|
135
|
+
|
|
136
|
+
async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
|
|
137
|
+
f = Path("logs") / "leases" / run_id
|
|
138
|
+
|
|
139
|
+
if not f.exists():
|
|
140
|
+
raise Exception(f"lease not found: {f}")
|
|
141
|
+
|
|
142
|
+
return leasing_cas(f, "renew", lease_seconds)
|
|
143
|
+
|
|
144
|
+
async def remove_lease(self, run_id: str, lease_seconds: int):
|
|
145
|
+
f = Path("logs") / "leases" / run_id
|
|
146
|
+
|
|
147
|
+
if not f.exists():
|
|
148
|
+
raise Exception(f"lease not found: {f}")
|
|
149
|
+
|
|
150
|
+
still_holding = leasing_cas(f, "remove", lease_seconds)
|
|
151
|
+
if still_holding:
|
|
152
|
+
f.unlink()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def leasing_cas(f: Path, suffix: str, lease_seconds: int) -> bool:
|
|
156
|
+
if lease_held(f, lease_seconds):
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
return cas(f, suffix)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def lease_held(f: Path, lease_seconds: int) -> bool:
|
|
163
|
+
return (
|
|
164
|
+
f.exists()
|
|
165
|
+
and datetime.fromtimestamp(f.stat().st_mtime) + timedelta(seconds=lease_seconds)
|
|
166
|
+
> datetime.now()
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cas(f: Path, suffix: str) -> bool:
|
|
171
|
+
nonce = str(uuid4())
|
|
172
|
+
witness = f.parent / f"{f.name}.{suffix}"
|
|
173
|
+
witness.write_text(nonce)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
witness.rename(f)
|
|
177
|
+
return f.read_text() == nonce
|
|
178
|
+
except FileExistsError:
|
|
179
|
+
# another rename has already happened
|
|
180
|
+
witness.unlink()
|
|
181
|
+
return False
|
substantial/conductor.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
|
|
4
|
+
from substantial.agent import Agent
|
|
5
|
+
from substantial.backends.backend import Backend
|
|
6
|
+
from substantial.workflows import Store
|
|
7
|
+
from substantial.workflows.run import Run
|
|
8
|
+
from substantial.workflows.workflow import Workflow
|
|
9
|
+
import aioprocessing
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Conductor:
|
|
13
|
+
def __init__(self, backend: Backend):
|
|
14
|
+
self.backend = backend
|
|
15
|
+
|
|
16
|
+
def register(self, workflow: Workflow):
|
|
17
|
+
Store.register(workflow)
|
|
18
|
+
|
|
19
|
+
async def start(
|
|
20
|
+
self,
|
|
21
|
+
workflow: Workflow,
|
|
22
|
+
kwargs=None,
|
|
23
|
+
queue="default",
|
|
24
|
+
):
|
|
25
|
+
if kwargs is None:
|
|
26
|
+
kwargs = {}
|
|
27
|
+
|
|
28
|
+
run_id = f"{workflow.id}-{uuid4()}"
|
|
29
|
+
run = Run(run_id, queue, self.backend)
|
|
30
|
+
await run.start(kwargs)
|
|
31
|
+
return run
|
|
32
|
+
|
|
33
|
+
def run_for(self, run_id, queue="default"):
|
|
34
|
+
return Run(run_id, queue, self.backend)
|
|
35
|
+
|
|
36
|
+
def run(self, queue="default"):
|
|
37
|
+
return asyncio.create_task(self.run_as_process(queue))
|
|
38
|
+
|
|
39
|
+
async def run_as_process(self, queue):
|
|
40
|
+
agent = Agent(self.backend, queue)
|
|
41
|
+
p = aioprocessing.AioProcess(target=agent.run_sync)
|
|
42
|
+
try:
|
|
43
|
+
p.start()
|
|
44
|
+
await p.coro_join()
|
|
45
|
+
return p
|
|
46
|
+
except asyncio.CancelledError:
|
|
47
|
+
p.terminate()
|
|
48
|
+
print("Cancelled")
|
|
49
|
+
except Exception:
|
|
50
|
+
raise
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# sources: protocol/events.proto
|
|
3
|
+
# plugin: python-betterproto
|
|
4
|
+
# This file has been @generated
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
import betterproto
|
|
11
|
+
import betterproto.lib.google.protobuf as betterproto_lib_google_protobuf
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(eq=False, repr=False)
|
|
15
|
+
class Start(betterproto.Message):
|
|
16
|
+
kwargs: "betterproto_lib_google_protobuf.Struct" = betterproto.message_field(1)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(eq=False, repr=False)
|
|
20
|
+
class Save(betterproto.Message):
|
|
21
|
+
id: int = betterproto.uint32_field(1)
|
|
22
|
+
value: str = betterproto.string_field(2)
|
|
23
|
+
counter: int = betterproto.int32_field(3)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(eq=False, repr=False)
|
|
27
|
+
class Sleep(betterproto.Message):
|
|
28
|
+
id: int = betterproto.uint32_field(1)
|
|
29
|
+
start: datetime = betterproto.message_field(2)
|
|
30
|
+
end: datetime = betterproto.message_field(3)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(eq=False, repr=False)
|
|
34
|
+
class Send(betterproto.Message):
|
|
35
|
+
name: str = betterproto.string_field(1)
|
|
36
|
+
value: str = betterproto.string_field(2)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(eq=False, repr=False)
|
|
40
|
+
class Stop(betterproto.Message):
|
|
41
|
+
ok: str = betterproto.string_field(1, group="result")
|
|
42
|
+
err: str = betterproto.string_field(2, group="result")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(eq=False, repr=False)
|
|
46
|
+
class Event(betterproto.Message):
|
|
47
|
+
at: datetime = betterproto.message_field(1)
|
|
48
|
+
start: "Start" = betterproto.message_field(10, group="of")
|
|
49
|
+
save: "Save" = betterproto.message_field(11, group="of")
|
|
50
|
+
sleep: "Sleep" = betterproto.message_field(12, group="of")
|
|
51
|
+
send: "Send" = betterproto.message_field(13, group="of")
|
|
52
|
+
stop: "Stop" = betterproto.message_field(14, group="of")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(eq=False, repr=False)
|
|
56
|
+
class Records(betterproto.Message):
|
|
57
|
+
run_id: str = betterproto.string_field(1)
|
|
58
|
+
events: List["Event"] = betterproto.message_field(2)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# sources: protocol/metadata.proto
|
|
3
|
+
# plugin: python-betterproto
|
|
4
|
+
# This file has been @generated
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
import betterproto
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(eq=False, repr=False)
|
|
14
|
+
class Info(betterproto.Message):
|
|
15
|
+
message: str = betterproto.string_field(1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(eq=False, repr=False)
|
|
19
|
+
class Error(betterproto.Message):
|
|
20
|
+
message: str = betterproto.string_field(1)
|
|
21
|
+
stack: str = betterproto.string_field(2)
|
|
22
|
+
type: str = betterproto.string_field(3)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(eq=False, repr=False)
|
|
26
|
+
class Metadata(betterproto.Message):
|
|
27
|
+
at: datetime = betterproto.message_field(1)
|
|
28
|
+
info: "Info" = betterproto.message_field(10, group="of")
|
|
29
|
+
error: "Error" = betterproto.message_field(11, group="of")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(eq=False, repr=False)
|
|
33
|
+
class Records(betterproto.Message):
|
|
34
|
+
run_id: str = betterproto.string_field(1)
|
|
35
|
+
metadata: List["Metadata"] = betterproto.message_field(2)
|