artest-runtime 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.
- artest/runtime/__init__.py +36 -0
- artest/runtime/artifacts.py +68 -0
- artest/runtime/events.py +108 -0
- artest/runtime/journal.py +545 -0
- artest/runtime/pipeline.py +88 -0
- artest/runtime/py.typed +0 -0
- artest/runtime/runner.py +383 -0
- artest/runtime/telemetry.py +59 -0
- artest/runtime/views.py +28 -0
- artest_runtime-0.2.0.dist-info/METADATA +21 -0
- artest_runtime-0.2.0.dist-info/RECORD +13 -0
- artest_runtime-0.2.0.dist-info/WHEEL +4 -0
- artest_runtime-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Typed DAG runtime with an event journal and a content-addressed artifact store.
|
|
2
|
+
|
|
3
|
+
Steps are typed async functions. Every state change is appended to a journal, which is what makes
|
|
4
|
+
runs resumable after a crash or a pause, and what feeds the live UI and the exported report.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from artest.runtime.artifacts import ArtifactMissingError, ArtifactStore, StoredBlob
|
|
8
|
+
from artest.runtime.events import ControlEvent, RunStatus, control_event
|
|
9
|
+
from artest.runtime.journal import Entry, Journal, RunRecord, RunTakenError, utc_now
|
|
10
|
+
from artest.runtime.pipeline import Pipeline, PipelineError
|
|
11
|
+
from artest.runtime.runner import Pause, Runner, RunOutcome, StepContext
|
|
12
|
+
from artest.runtime.telemetry import TelemetryUnavailableError, configure_telemetry
|
|
13
|
+
from artest.runtime.views import effective
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ArtifactMissingError",
|
|
17
|
+
"ArtifactStore",
|
|
18
|
+
"ControlEvent",
|
|
19
|
+
"Entry",
|
|
20
|
+
"Journal",
|
|
21
|
+
"Pause",
|
|
22
|
+
"Pipeline",
|
|
23
|
+
"PipelineError",
|
|
24
|
+
"RunOutcome",
|
|
25
|
+
"RunRecord",
|
|
26
|
+
"RunStatus",
|
|
27
|
+
"RunTakenError",
|
|
28
|
+
"Runner",
|
|
29
|
+
"StepContext",
|
|
30
|
+
"StoredBlob",
|
|
31
|
+
"TelemetryUnavailableError",
|
|
32
|
+
"configure_telemetry",
|
|
33
|
+
"control_event",
|
|
34
|
+
"effective",
|
|
35
|
+
"utc_now",
|
|
36
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The content-addressed artifact store: immutable bytes on disk, named by their SHA-256.
|
|
2
|
+
|
|
3
|
+
The same screenshot stored twice takes space once. Writes go to a temporary file that is then renamed,
|
|
4
|
+
so a reader never sees half an artifact. Callers write an artifact before appending any event that
|
|
5
|
+
references it, so a crash can leave an unreferenced file but never a reference to a missing one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from artest.runtime.journal import Journal
|
|
17
|
+
|
|
18
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class StoredBlob:
|
|
23
|
+
sha256: str
|
|
24
|
+
media_type: str
|
|
25
|
+
size: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArtifactMissingError(LookupError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArtifactStore:
|
|
33
|
+
def __init__(self, root: Path, journal: Journal) -> None:
|
|
34
|
+
self._root = root
|
|
35
|
+
self._journal = journal
|
|
36
|
+
|
|
37
|
+
def path(self, sha256: str) -> Path:
|
|
38
|
+
if not _SHA256.match(sha256):
|
|
39
|
+
msg = f"not a SHA-256: {sha256!r}"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
return self._root / sha256[:2] / sha256
|
|
42
|
+
|
|
43
|
+
async def put(self, data: bytes, media_type: str) -> StoredBlob:
|
|
44
|
+
sha256 = hashlib.sha256(data).hexdigest()
|
|
45
|
+
path = self.path(sha256)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
await asyncio.to_thread(_write_atomically, path, data)
|
|
48
|
+
await self._journal.record_artifact(sha256, media_type, len(data))
|
|
49
|
+
return StoredBlob(sha256=sha256, media_type=media_type, size=len(data))
|
|
50
|
+
|
|
51
|
+
async def get(self, sha256: str) -> bytes:
|
|
52
|
+
try:
|
|
53
|
+
return await asyncio.to_thread(self.path(sha256).read_bytes)
|
|
54
|
+
except FileNotFoundError as error:
|
|
55
|
+
raise ArtifactMissingError(sha256) from error
|
|
56
|
+
|
|
57
|
+
def exists(self, sha256: str) -> bool:
|
|
58
|
+
return self.path(sha256).exists()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_atomically(path: Path, data: bytes) -> None:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp")
|
|
64
|
+
with temporary.open("wb") as file:
|
|
65
|
+
file.write(data)
|
|
66
|
+
file.flush()
|
|
67
|
+
os.fsync(file.fileno())
|
|
68
|
+
temporary.replace(path)
|
artest/runtime/events.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Control events: what the runtime records about runs and steps.
|
|
2
|
+
|
|
3
|
+
They share the journal with the pipeline's own domain events. The runtime knows nothing about what steps
|
|
4
|
+
do, so it never looks inside a domain event beyond its ``type``. The step and attempt an event belongs
|
|
5
|
+
to live in the journal entry, not in the payload.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StringConstraints, TypeAdapter
|
|
12
|
+
|
|
13
|
+
Sha256 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RuntimeModel(BaseModel):
|
|
17
|
+
model_config = ConfigDict(frozen=True, extra="forbid", json_schema_serialization_defaults_required=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunStatus(StrEnum):
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
PAUSED = "paused"
|
|
23
|
+
COMPLETED = "completed"
|
|
24
|
+
FAILED = "failed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunStarted(RuntimeModel):
|
|
28
|
+
type: Literal["run_started"] = "run_started"
|
|
29
|
+
pipeline: str
|
|
30
|
+
inputs: dict[str, Sha256] = Field(
|
|
31
|
+
default_factory=dict[str, Sha256], description="Each run input, stored as JSON in the artifact store"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RunResumed(RuntimeModel):
|
|
36
|
+
type: Literal["run_resumed"] = "run_resumed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StepStarted(RuntimeModel):
|
|
40
|
+
"""A new attempt of a step. Consumers drop the step's domain events from earlier attempts."""
|
|
41
|
+
|
|
42
|
+
type: Literal["step_started"] = "step_started"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StepCompleted(RuntimeModel):
|
|
46
|
+
type: Literal["step_completed"] = "step_completed"
|
|
47
|
+
output: Sha256 = Field(description="The step's output, stored as JSON in the artifact store")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StepFailed(RuntimeModel):
|
|
51
|
+
type: Literal["step_failed"] = "step_failed"
|
|
52
|
+
error: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StepPaused(RuntimeModel):
|
|
56
|
+
type: Literal["step_paused"] = "step_paused"
|
|
57
|
+
reason: str
|
|
58
|
+
resume_after: AwareDatetime | None = None
|
|
59
|
+
detail: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RunPaused(RuntimeModel):
|
|
63
|
+
type: Literal["run_paused"] = "run_paused"
|
|
64
|
+
reason: str
|
|
65
|
+
resume_after: AwareDatetime | None = None
|
|
66
|
+
steps: tuple[str, ...]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RunCompleted(RuntimeModel):
|
|
70
|
+
type: Literal["run_completed"] = "run_completed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RunFailed(RuntimeModel):
|
|
74
|
+
type: Literal["run_failed"] = "run_failed"
|
|
75
|
+
steps: tuple[str, ...]
|
|
76
|
+
interrupted: bool = False
|
|
77
|
+
"""No step failed: the run was stopped, it crashed between steps, or the process running it died. Its
|
|
78
|
+
steps stay as they were, and a later run of the same ID goes on from them."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
ControlEvent = Annotated[
|
|
82
|
+
RunStarted
|
|
83
|
+
| RunResumed
|
|
84
|
+
| StepStarted
|
|
85
|
+
| StepCompleted
|
|
86
|
+
| StepFailed
|
|
87
|
+
| StepPaused
|
|
88
|
+
| RunPaused
|
|
89
|
+
| RunCompleted
|
|
90
|
+
| RunFailed,
|
|
91
|
+
Field(discriminator="type"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
control_event: TypeAdapter[ControlEvent] = TypeAdapter(ControlEvent)
|
|
95
|
+
|
|
96
|
+
CONTROL_EVENT_TYPES = frozenset(
|
|
97
|
+
{
|
|
98
|
+
"run_started",
|
|
99
|
+
"run_resumed",
|
|
100
|
+
"step_started",
|
|
101
|
+
"step_completed",
|
|
102
|
+
"step_failed",
|
|
103
|
+
"step_paused",
|
|
104
|
+
"run_paused",
|
|
105
|
+
"run_completed",
|
|
106
|
+
"run_failed",
|
|
107
|
+
}
|
|
108
|
+
)
|