dirigent-core 0.17.2__tar.gz → 0.18.0__tar.gz
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.
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/PKG-INFO +4 -4
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/pyproject.toml +4 -4
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/pyproject.toml.orig +4 -4
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +3 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/artifacts.py +41 -2
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/config.py +6 -4
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/database.py +25 -1
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/documents.py +5 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/claim.py +3 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/context.py +36 -2
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/executor.py +33 -5
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/references.py +15 -3
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/runs.py +1 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/logging.py +0 -3
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/messages.py +25 -3
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/models.py +4 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/LICENSE +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/README.md +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/__init__.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/env.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alembic/script.py.mako +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/alerting.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/auth.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/blockdocs.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/configdocs.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/directory.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/documentschema.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/__init__.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/definition.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/failure.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/recovery.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/services.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/engine/state.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/errors.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/examples.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/ids.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/migrations.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/pipelines.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/plugins.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/protocol.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/py.typed +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/ratelimit.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/registry.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/reporting.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/retention.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/scheduler.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/schemas.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/secrets.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/storage.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/telemetry.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/trigger_documents.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/__init__.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/backfill.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/materialize.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/schedules.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/triggers/webhooks.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/types.py +0 -0
- {dirigent_core-0.17.2 → dirigent_core-0.18.0}/src/dirigent_core/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: Dirigent engine core: schema, configuration, and plugin host.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
|
|
|
12
12
|
Requires-Dist: asyncpg>=0.31.0
|
|
13
13
|
Requires-Dist: cronsim>=2.7
|
|
14
14
|
Requires-Dist: cryptography>=46.0.5
|
|
15
|
-
Requires-Dist: dirigent-client==0.
|
|
16
|
-
Requires-Dist: dirigent-common==0.
|
|
17
|
-
Requires-Dist: dirigent-plugin==0.
|
|
15
|
+
Requires-Dist: dirigent-client==0.18.0
|
|
16
|
+
Requires-Dist: dirigent-common==0.18.0
|
|
17
|
+
Requires-Dist: dirigent-plugin==0.18.0
|
|
18
18
|
Requires-Dist: jsonschema>=4.26.0
|
|
19
19
|
Requires-Dist: opentelemetry-api>=1.44.0
|
|
20
20
|
Requires-Dist: pydantic-settings>=2.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.18.0"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,9 +17,9 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.
|
|
21
|
-
"dirigent-common==0.
|
|
22
|
-
"dirigent-plugin==0.
|
|
20
|
+
"dirigent-client==0.18.0",
|
|
21
|
+
"dirigent-common==0.18.0",
|
|
22
|
+
"dirigent-plugin==0.18.0",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.18.0"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,9 +17,9 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.
|
|
21
|
-
"dirigent-common==0.
|
|
22
|
-
"dirigent-plugin==0.
|
|
20
|
+
"dirigent-client==0.18.0",
|
|
21
|
+
"dirigent-common==0.18.0",
|
|
22
|
+
"dirigent-plugin==0.18.0",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
@@ -808,6 +808,9 @@ def upgrade() -> None:
|
|
|
808
808
|
sa.Column(
|
|
809
809
|
"remote_handle", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
|
|
810
810
|
),
|
|
811
|
+
sa.Column(
|
|
812
|
+
"fetched_output", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
|
|
813
|
+
),
|
|
811
814
|
sa.Column(
|
|
812
815
|
"poke_cursor", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
|
|
813
816
|
),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
5
6
|
from contextlib import suppress
|
|
6
7
|
from typing import Final
|
|
7
8
|
from uuid import UUID
|
|
@@ -9,6 +10,8 @@ from uuid import UUID
|
|
|
9
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
11
|
|
|
11
12
|
from dirigent_common import JsonMap
|
|
13
|
+
from dirigent_core.errors import DomainError
|
|
14
|
+
from dirigent_core.messages import OBJECT_MISSING
|
|
12
15
|
from dirigent_core.models import ArtifactRef, StepAttempt
|
|
13
16
|
from dirigent_core.storage import Storage, StorageError, join_uri, parse_uri
|
|
14
17
|
|
|
@@ -19,6 +22,24 @@ MARKDOWN_CONTENT_TYPE: Final = "text/markdown"
|
|
|
19
22
|
#: The key a text document inlines under, so an inline row is still a JSON document.
|
|
20
23
|
TEXT_KEY: Final = "text"
|
|
21
24
|
|
|
25
|
+
#: What an artifact row belonging to the run rather than to a step attempt names as its attempt.
|
|
26
|
+
NO_ATTEMPT: Final = "-"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ObjectMissing(DomainError):
|
|
30
|
+
"""The object this row names is not in storage."""
|
|
31
|
+
|
|
32
|
+
status = 404
|
|
33
|
+
message = OBJECT_MISSING
|
|
34
|
+
|
|
35
|
+
def __init__(self, reference: ArtifactRef) -> None:
|
|
36
|
+
"""Name the object that is gone, and the run and attempt whose row still names it."""
|
|
37
|
+
super().__init__(
|
|
38
|
+
uri=reference.uri or "",
|
|
39
|
+
run=str(reference.run_id),
|
|
40
|
+
attempt=str(reference.step_attempt_id) if reference.step_attempt_id else NO_ATTEMPT,
|
|
41
|
+
)
|
|
42
|
+
|
|
22
43
|
|
|
23
44
|
def canonical_json(value: object) -> bytes:
|
|
24
45
|
"""Serialize a value the one way the engine hashes and stores it."""
|
|
@@ -108,6 +129,24 @@ async def persist_document(
|
|
|
108
129
|
return reference
|
|
109
130
|
|
|
110
131
|
|
|
132
|
+
async def _present_uri(storage: Storage, reference: ArtifactRef) -> str:
|
|
133
|
+
"""Return the URI a row names once storage confirms the object is there, or refuse.
|
|
134
|
+
|
|
135
|
+
A database restored without the artifact root it was taken beside still holds every row,
|
|
136
|
+
so the object is stat-ed before it is opened: a read that refuses here names what is gone,
|
|
137
|
+
where one that opened the stream first would break mid-body with the status already sent.
|
|
138
|
+
"""
|
|
139
|
+
uri = reference.uri or ""
|
|
140
|
+
if await storage.stat(uri) is None:
|
|
141
|
+
raise ObjectMissing(reference)
|
|
142
|
+
return uri
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def open_artifact(storage: Storage, reference: ArtifactRef) -> AsyncGenerator[bytes]:
|
|
146
|
+
"""Open the stored object a row names for streaming, refusing before the first byte."""
|
|
147
|
+
return storage.open_read(await _present_uri(storage, reference))
|
|
148
|
+
|
|
149
|
+
|
|
111
150
|
async def load_document(storage: Storage, reference: ArtifactRef) -> str:
|
|
112
151
|
"""Read a text document back, from the row when it inlined and from storage when it did not."""
|
|
113
152
|
if reference.inline_value is not None:
|
|
@@ -115,7 +154,7 @@ async def load_document(storage: Storage, reference: ArtifactRef) -> str:
|
|
|
115
154
|
return inlined if isinstance(inlined, str) else ""
|
|
116
155
|
if reference.uri is None:
|
|
117
156
|
return ""
|
|
118
|
-
return (await storage.read_bytes(reference
|
|
157
|
+
return (await storage.read_bytes(await _present_uri(storage, reference))).decode()
|
|
119
158
|
|
|
120
159
|
|
|
121
160
|
async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UUID) -> JsonMap | None:
|
|
@@ -127,6 +166,6 @@ async def load_artifact(session: AsyncSession, storage: Storage, artifact_id: UU
|
|
|
127
166
|
return reference.inline_value
|
|
128
167
|
if reference.uri is None:
|
|
129
168
|
return None
|
|
130
|
-
payload = await storage.read_bytes(reference
|
|
169
|
+
payload = await storage.read_bytes(await _present_uri(storage, reference))
|
|
131
170
|
loaded: JsonMap = json.loads(payload)
|
|
132
171
|
return loaded
|
|
@@ -161,10 +161,12 @@ class Settings(BaseSettings):
|
|
|
161
161
|
"""How loud the process log is. This is the process log; a run's own telemetry is the
|
|
162
162
|
``log_entries`` table, and a CLI ``-v`` or ``--debug`` flag wins over this."""
|
|
163
163
|
|
|
164
|
-
log_format: Literal["console", "json"] =
|
|
165
|
-
"""How a command spells
|
|
164
|
+
log_format: Literal["console", "json"] | None = None
|
|
165
|
+
"""How a command spells its output and its logs: ``console`` renders, ``json`` writes records.
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
Unset, the terminal decides: a terminal gets the rendering, and a pipe, a container's log,
|
|
168
|
+
an agent's shell and CI get NDJSON, ``dg dev`` and ``dg server`` included. A command's
|
|
169
|
+
``--json`` or ``-o console`` wins over it.
|
|
168
170
|
"""
|
|
169
171
|
|
|
170
172
|
artifact_root: str = f"file://./{STATE_DIR}/artifacts"
|
|
@@ -248,7 +250,7 @@ class Settings(BaseSettings):
|
|
|
248
250
|
"""A running run with no attempt progress for this long is flagged as stuck."""
|
|
249
251
|
|
|
250
252
|
stale_worker: Duration = Field(default=timedelta(minutes=15), gt=timedelta(0))
|
|
251
|
-
"""A worker whose
|
|
253
|
+
"""A worker whose last heartbeat is older than this has its registry row reaped by the sweeper."""
|
|
252
254
|
|
|
253
255
|
docker_reap_interval: Duration = timedelta(minutes=5)
|
|
254
256
|
"""How often a docker-capable worker looks for compose stacks whose run has ended.
|
|
@@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
|
6
6
|
from contextlib import asynccontextmanager, suppress
|
|
7
7
|
from typing import Any, Final
|
|
8
8
|
|
|
9
|
+
import aiosqlite
|
|
9
10
|
import sqlalchemy as sa
|
|
10
11
|
import structlog
|
|
11
12
|
from sqlalchemy import event
|
|
@@ -78,11 +79,34 @@ def _configure_sqlite(engine: AsyncEngine) -> None:
|
|
|
78
79
|
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
async def open_sqlite(*args: Any, **kwargs: Any) -> aiosqlite.Connection:
|
|
83
|
+
"""Open one SQLite connection, waiting for its thread when the file will not open.
|
|
84
|
+
|
|
85
|
+
aiosqlite drives every connection from a worker thread that hands results back through
|
|
86
|
+
``call_soon_threadsafe``. A connection that fails to open queues that thread's stop and
|
|
87
|
+
raises without waiting for it, so the thread can call into an event loop that has closed
|
|
88
|
+
in the meantime and die with ``RuntimeError: Event loop is closed``.
|
|
89
|
+
"""
|
|
90
|
+
connection = aiosqlite.connect(*args, **kwargs)
|
|
91
|
+
worker = connection._thread # pyright: ignore[reportPrivateUsage] - the driver's own thread
|
|
92
|
+
worker.daemon = True
|
|
93
|
+
try:
|
|
94
|
+
return await connection
|
|
95
|
+
except BaseException:
|
|
96
|
+
await asyncio.to_thread(worker.join)
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
|
|
81
100
|
def create_engine(settings: Settings) -> AsyncEngine:
|
|
82
101
|
"""Build the async engine for the configured database."""
|
|
83
102
|
if settings.is_sqlite:
|
|
84
103
|
ensure_sqlite_directory(settings)
|
|
85
|
-
engine = create_async_engine(
|
|
104
|
+
engine = create_async_engine(
|
|
105
|
+
settings.database_url,
|
|
106
|
+
echo=settings.database_echo,
|
|
107
|
+
future=True,
|
|
108
|
+
connect_args={"async_creator_fn": open_sqlite},
|
|
109
|
+
)
|
|
86
110
|
_configure_sqlite(engine)
|
|
87
111
|
return engine
|
|
88
112
|
return create_async_engine(
|
|
@@ -44,6 +44,7 @@ from dirigent_core.messages import (
|
|
|
44
44
|
NOT_YAML,
|
|
45
45
|
PARAMS_SCHEMA_INVALID,
|
|
46
46
|
REFERENCE_GRID_IN_CONFIG,
|
|
47
|
+
REFERENCE_MALFORMED_ARTIFACTS,
|
|
47
48
|
REFERENCE_MALFORMED_ITEM,
|
|
48
49
|
REFERENCE_MALFORMED_RUN,
|
|
49
50
|
REFERENCE_MALFORMED_STEP,
|
|
@@ -892,6 +893,10 @@ def _reference_problem(
|
|
|
892
893
|
return None
|
|
893
894
|
case ["run", *_]:
|
|
894
895
|
return Issue.of(REFERENCE_MALFORMED_RUN, reference=reference)
|
|
896
|
+
case ["artifacts"]:
|
|
897
|
+
return None
|
|
898
|
+
case ["artifacts", *_]:
|
|
899
|
+
return Issue.of(REFERENCE_MALFORMED_ARTIFACTS, reference=reference)
|
|
895
900
|
case [namespace, *_]:
|
|
896
901
|
return Issue.of(REFERENCE_UNKNOWN_NAMESPACE, reference=reference, namespace=repr(namespace))
|
|
897
902
|
case _: # pragma: no cover - every shape above is total over a list of strings
|
|
@@ -60,6 +60,9 @@ class ClaimedUnit(BaseModel):
|
|
|
60
60
|
"""The run's trace context, so an attempt's span joins the trace the run started in."""
|
|
61
61
|
|
|
62
62
|
remote_handle: RemoteHandle | None = None
|
|
63
|
+
fetched_output: JsonMap | None = None
|
|
64
|
+
"""The result a previous call already fetched and committed, which settles this claim."""
|
|
65
|
+
|
|
63
66
|
poke_cursor: JsonMap | None = None
|
|
64
67
|
"""The cursor the last committed poke returned, handed to the next one."""
|
|
65
68
|
|
|
@@ -6,7 +6,8 @@ long step is visible working and its last lines still land with the outcome.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
|
-
from collections.abc import Mapping
|
|
9
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
10
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
10
11
|
from datetime import UTC, datetime, timedelta
|
|
11
12
|
from fnmatch import fnmatch
|
|
12
13
|
from pathlib import Path
|
|
@@ -26,7 +27,7 @@ from dirigent_core.messages import RUN_UNKNOWN_CONNECTION, RUN_UNKNOWN_SCHEMA, U
|
|
|
26
27
|
from dirigent_core.models import Connection, Schema
|
|
27
28
|
from dirigent_core.secrets import SecretBox
|
|
28
29
|
from dirigent_core.storage import AttemptStorage, Storage, connection_binder, work_dir
|
|
29
|
-
from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs
|
|
30
|
+
from dirigent_plugin import BlockFailure, ByteSink, Capture, ConnectionRef, ErrorClass, Logger, Runs
|
|
30
31
|
|
|
31
32
|
#: What an HTTP connection is assumed to call its fields, so core can build a client for
|
|
32
33
|
#: any connection kind without importing the package that contributed it.
|
|
@@ -230,6 +231,19 @@ class BufferedLogger(BaseModel):
|
|
|
230
231
|
self.full.set()
|
|
231
232
|
|
|
232
233
|
|
|
234
|
+
class CapturedStream:
|
|
235
|
+
"""A sink that carries the URI the engine opened it under, for a block to put in its output."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, uri: str, sink: ByteSink) -> None:
|
|
238
|
+
"""Bind the storage sink to the URI naming it."""
|
|
239
|
+
self.uri = uri
|
|
240
|
+
self._sink = sink
|
|
241
|
+
|
|
242
|
+
async def write(self, data: bytes) -> int:
|
|
243
|
+
"""Append bytes to the stream and return how many were accepted."""
|
|
244
|
+
return await self._sink.write(data)
|
|
245
|
+
|
|
246
|
+
|
|
233
247
|
class EngineStepContext:
|
|
234
248
|
"""The concrete StepContext the engine hands a block for exactly one call."""
|
|
235
249
|
|
|
@@ -297,6 +311,26 @@ class EngineStepContext:
|
|
|
297
311
|
"""Return the run-scoped URI prefix for intermediate artifacts."""
|
|
298
312
|
return self._scratch
|
|
299
313
|
|
|
314
|
+
def capture(self, name: str, *, content_type: str = "text/plain") -> AbstractAsyncContextManager[Capture]:
|
|
315
|
+
"""Open the storage object a block's named stream is written to, under the run's scratch."""
|
|
316
|
+
uri = f"{self._scratch.rstrip('/')}/{self._segment()}-{name}"
|
|
317
|
+
|
|
318
|
+
@asynccontextmanager
|
|
319
|
+
async def opened() -> AsyncGenerator[Capture]:
|
|
320
|
+
async with self.storage.open_write(uri, content_type=content_type) as sink:
|
|
321
|
+
yield CapturedStream(uri, sink)
|
|
322
|
+
|
|
323
|
+
return opened()
|
|
324
|
+
|
|
325
|
+
def _segment(self) -> str:
|
|
326
|
+
"""The path this attempt's captures sit under: the step, the fan-out item, and the attempt.
|
|
327
|
+
|
|
328
|
+
``{step}[/{item}]/attempt-{n}``. The step key is unique within the run and the item id
|
|
329
|
+
within a fan-out, so no two attempts of one run write over each other's streams.
|
|
330
|
+
"""
|
|
331
|
+
item = f"/{self.run_item_id}" if self.run_item_id is not None else ""
|
|
332
|
+
return f"{self.step}{item}/attempt-{self.attempt}"
|
|
333
|
+
|
|
300
334
|
@property
|
|
301
335
|
def work(self) -> Path:
|
|
302
336
|
"""Return the run's directory on this worker's own filesystem, made on first read.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""The engine proper: claim, call one block, record the outcome, ready the dependents.
|
|
2
2
|
|
|
3
3
|
Two invariants carry everything here. One transaction per state transition, so there is no
|
|
4
|
-
window in which a step is finished but its dependents have not been told. And a remote
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
window in which a step is finished but its dependents have not been told. And what a remote
|
|
5
|
+
call hands back is committed on its own the moment it arrives -- the handle ``execute``
|
|
6
|
+
returns, and the result ``fetch`` returns -- so a crash recovers into polling or settling
|
|
7
|
+
rather than into a duplicate job or a second fetch.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import asyncio
|
|
@@ -97,6 +98,8 @@ class Produced(BaseModel):
|
|
|
97
98
|
model_config = ConfigDict(frozen=True)
|
|
98
99
|
|
|
99
100
|
output: JsonMap
|
|
101
|
+
fetched: bool = False
|
|
102
|
+
"""True when a fetch returned this output and nothing has committed it yet."""
|
|
100
103
|
|
|
101
104
|
|
|
102
105
|
class Submitted(BaseModel):
|
|
@@ -252,6 +255,7 @@ class Engine:
|
|
|
252
255
|
item_outputs=item_outputs,
|
|
253
256
|
paired=frozenset(family),
|
|
254
257
|
scratch=scratch,
|
|
258
|
+
artifacts=self.services.storage.artifact_root,
|
|
255
259
|
run_id=run.id,
|
|
256
260
|
window_start=run.window_start,
|
|
257
261
|
window_end=run.window_end,
|
|
@@ -320,6 +324,7 @@ class Engine:
|
|
|
320
324
|
schemas=await load_schemas(session),
|
|
321
325
|
traceparent=run.traceparent,
|
|
322
326
|
remote_handle=RemoteHandle.model_validate(attempt.remote_handle) if attempt.remote_handle else None,
|
|
327
|
+
fetched_output=attempt.fetched_output,
|
|
323
328
|
poke_cursor=attempt.poke_cursor,
|
|
324
329
|
deadline_at=attempt.deadline_at,
|
|
325
330
|
gone_probes=attempt.gone_probes,
|
|
@@ -376,6 +381,8 @@ class Engine:
|
|
|
376
381
|
telemetry.record_failure(span, result.failure.message)
|
|
377
382
|
if isinstance(result, Submitted):
|
|
378
383
|
await self.record_handle(unit, result.handle)
|
|
384
|
+
if isinstance(result, Produced) and result.fetched:
|
|
385
|
+
await self._record_fetched(unit, result.output)
|
|
379
386
|
await self._record(
|
|
380
387
|
unit,
|
|
381
388
|
result,
|
|
@@ -440,6 +447,8 @@ class Engine:
|
|
|
440
447
|
unit: ClaimedUnit,
|
|
441
448
|
) -> CallResult:
|
|
442
449
|
"""Drive an operator's half of the contract: execute once, then probe and fetch."""
|
|
450
|
+
if unit.fetched_output is not None:
|
|
451
|
+
return Produced(output=unit.fetched_output)
|
|
443
452
|
if not unit.is_probe or unit.remote_handle is None:
|
|
444
453
|
with self._timed(unit.block_id, "execute"):
|
|
445
454
|
produced = await operator.execute(config, context)
|
|
@@ -460,8 +469,8 @@ class Engine:
|
|
|
460
469
|
)
|
|
461
470
|
case ProbeStatus.SUCCEEDED:
|
|
462
471
|
with self._timed(unit.block_id, "fetch"):
|
|
463
|
-
|
|
464
|
-
return Produced(output=
|
|
472
|
+
retrieved = await operator.fetch(handle, config, context)
|
|
473
|
+
return Produced(output=retrieved.model_dump(mode="json"), fetched=True)
|
|
465
474
|
case ProbeStatus.FAILED:
|
|
466
475
|
failed = (
|
|
467
476
|
Failure.unknown(REMOTE_JOB_SAID, detail=probe.message)
|
|
@@ -532,6 +541,24 @@ class Engine:
|
|
|
532
541
|
return
|
|
533
542
|
attempt.remote_handle = handle.model_dump(mode="json")
|
|
534
543
|
|
|
544
|
+
async def _record_fetched(self, unit: ClaimedUnit, output: JsonMap) -> None:
|
|
545
|
+
"""Commit a fetched result on its own, before the outcome that settles it."""
|
|
546
|
+
await with_deadlock_retry(lambda: self._record_fetched_once(unit, output))
|
|
547
|
+
|
|
548
|
+
async def _record_fetched_once(self, unit: ClaimedUnit, output: JsonMap) -> None:
|
|
549
|
+
"""Run that transaction once; the caller runs it again after a deadlock.
|
|
550
|
+
|
|
551
|
+
It locks the run and then writes the attempt, the order every outcome uses and the
|
|
552
|
+
opposite of the claim's. Nothing of it survives a rollback, so it is safe to repeat.
|
|
553
|
+
"""
|
|
554
|
+
async with session_scope(self.sessions) as session:
|
|
555
|
+
await lock_run(session, unit.run_id)
|
|
556
|
+
attempt = await session.get(StepAttempt, unit.attempt_id)
|
|
557
|
+
if attempt is None or not self._holds_lease(unit, attempt):
|
|
558
|
+
self._log_lost_lease(unit, attempt, "fetched result")
|
|
559
|
+
return
|
|
560
|
+
attempt.fetched_output = output
|
|
561
|
+
|
|
535
562
|
async def _cancel_what_was_just_submitted(
|
|
536
563
|
self,
|
|
537
564
|
session: AsyncSession,
|
|
@@ -746,6 +773,7 @@ class Engine:
|
|
|
746
773
|
)
|
|
747
774
|
attempt.status = AttemptStatus.SUCCEEDED
|
|
748
775
|
attempt.finished_at = now
|
|
776
|
+
attempt.fetched_output = None
|
|
749
777
|
attempt.error = None
|
|
750
778
|
attempt.error_code = None
|
|
751
779
|
attempt.error_params = None
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""``${...}`` reference resolution: the whole reference language, and nothing more.
|
|
2
2
|
|
|
3
|
-
There are no expressions, loops, or conditionals, and
|
|
4
|
-
``steps.*``, ``item``,
|
|
5
|
-
``run.window.end``.
|
|
3
|
+
There are no expressions, loops, or conditionals, and five namespaces: ``params.*``,
|
|
4
|
+
``steps.*``, ``item``, ``run.scratch`` / ``run.id`` / ``run.window.start`` /
|
|
5
|
+
``run.window.end``, and ``artifacts``.
|
|
6
|
+
|
|
7
|
+
``run.scratch`` is the run's own prefix, which retention sweeps with the run; ``artifacts`` is
|
|
8
|
+
the instance's storage root, which nothing sweeps, so ``${artifacts}/kept/x.json`` is how a
|
|
9
|
+
document names an object meant to outlive the run that wrote it.
|
|
6
10
|
|
|
7
11
|
``steps`` has three forms: ``steps.<name>.output.*`` is a step's stored output,
|
|
8
12
|
``steps.<name>.items`` is the list a fan-out maps over and only ``for_each`` reads it, and
|
|
@@ -29,6 +33,7 @@ from dirigent_common import JsonMap, Message
|
|
|
29
33
|
from dirigent_core.errors import DomainError
|
|
30
34
|
from dirigent_core.messages import (
|
|
31
35
|
ITEM_FAILED,
|
|
36
|
+
MALFORMED_ARTIFACTS,
|
|
32
37
|
MALFORMED_RUN,
|
|
33
38
|
MALFORMED_STEP,
|
|
34
39
|
NAMES_NOTHING,
|
|
@@ -93,6 +98,9 @@ class ReferenceScope(BaseModel):
|
|
|
93
98
|
"""The steps whose grid this one shares, and whose matching item it may therefore read."""
|
|
94
99
|
|
|
95
100
|
scratch: str = ""
|
|
101
|
+
artifacts: str = ""
|
|
102
|
+
"""The instance's storage root, which outlives every run written under it."""
|
|
103
|
+
|
|
96
104
|
run_id: UUID | None = None
|
|
97
105
|
|
|
98
106
|
window_start: datetime | None = None
|
|
@@ -287,6 +295,10 @@ def lookup(reference: str, scope: ReferenceScope) -> JsonValue:
|
|
|
287
295
|
return _step_output(reference, parts, scope)
|
|
288
296
|
case "run":
|
|
289
297
|
return _run_value(reference, parts, scope)
|
|
298
|
+
case "artifacts":
|
|
299
|
+
if parts[1:]:
|
|
300
|
+
raise UnknownReference(MALFORMED_ARTIFACTS, reference=reference)
|
|
301
|
+
return scope.artifacts
|
|
290
302
|
case unknown:
|
|
291
303
|
raise UnknownReference(UNKNOWN_NAMESPACE, reference=reference, namespace=repr(unknown))
|
|
292
304
|
|
|
@@ -247,6 +247,7 @@ async def create_run(
|
|
|
247
247
|
scope = ReferenceScope(
|
|
248
248
|
params=resolved_params,
|
|
249
249
|
scratch=scratch_prefix(services.settings.artifact_root, run_id),
|
|
250
|
+
artifacts=services.storage.artifact_root,
|
|
250
251
|
run_id=run_id,
|
|
251
252
|
window_start=window.start if window is not None else None,
|
|
252
253
|
window_end=window.end if window is not None else None,
|
|
@@ -21,9 +21,6 @@ if TYPE_CHECKING:
|
|
|
21
21
|
|
|
22
22
|
PACKAGE_LOGGER = "dirigent"
|
|
23
23
|
|
|
24
|
-
#: Where a container names the spelling once, for a process and for a command alike.
|
|
25
|
-
LOG_FORMAT_ENV = "DIRIGENT_LOG_FORMAT"
|
|
26
|
-
|
|
27
24
|
#: Third-party loggers that ship their own handlers and must be pointed at ours instead.
|
|
28
25
|
BRIDGED_LOGGERS = (
|
|
29
26
|
"uvicorn",
|
|
@@ -64,13 +64,24 @@ UNKNOWN_SCHEME = ARTIFACTS.define(
|
|
|
64
64
|
"no storage backend registered for scheme {scheme}; registered schemes: {registered}",
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
-
OUTSIDE_ROOT = ARTIFACTS.define(
|
|
67
|
+
OUTSIDE_ROOT = ARTIFACTS.define(
|
|
68
|
+
"outside_root",
|
|
69
|
+
"{uri} resolves outside the artifact root {root}; write ${{artifacts}}/<path> for an object "
|
|
70
|
+
"kept under this instance's root, or ${{run.scratch}}/<path> for one swept with the run",
|
|
71
|
+
)
|
|
68
72
|
|
|
69
73
|
UNKNOWN_STORAGE_CONNECTION = ARTIFACTS.define(
|
|
70
74
|
"unknown_connection",
|
|
71
75
|
"no connection coded {ref} to configure the {scheme} scheme from ({available})",
|
|
72
76
|
)
|
|
73
77
|
|
|
78
|
+
OBJECT_MISSING = ARTIFACTS.define(
|
|
79
|
+
"object_missing",
|
|
80
|
+
"there is nothing at {uri}: run {run} (attempt {attempt}) has an artifact row and storage "
|
|
81
|
+
"has no object; restore the artifact root from the backup that matches this database, "
|
|
82
|
+
"or prune the run",
|
|
83
|
+
)
|
|
84
|
+
|
|
74
85
|
|
|
75
86
|
HOST = Catalogue("host")
|
|
76
87
|
|
|
@@ -317,9 +328,15 @@ REFERENCE_MALFORMED_RUN = DOCUMENT.define(
|
|
|
317
328
|
"${{{reference}}} is malformed: run exposes only run.scratch, run.id, run.window.start and run.window.end",
|
|
318
329
|
)
|
|
319
330
|
|
|
331
|
+
REFERENCE_MALFORMED_ARTIFACTS = DOCUMENT.define(
|
|
332
|
+
"reference_malformed_artifacts",
|
|
333
|
+
"${{{reference}}} is malformed: artifacts is the storage root itself and has no fields; "
|
|
334
|
+
"write ${{artifacts}}/<path>",
|
|
335
|
+
)
|
|
336
|
+
|
|
320
337
|
REFERENCE_UNKNOWN_NAMESPACE = DOCUMENT.define(
|
|
321
338
|
"reference_unknown_namespace",
|
|
322
|
-
"${{{reference}}} names {namespace}, which is not one of params, steps, item, run",
|
|
339
|
+
"${{{reference}}} names {namespace}, which is not one of params, steps, item, run, artifacts",
|
|
323
340
|
)
|
|
324
341
|
|
|
325
342
|
FOR_EACH_READS_OUTPUT = DOCUMENT.define(
|
|
@@ -509,7 +526,7 @@ NO_ITEM = _unresolved("no_item", "this step does not fan out, so there is no ite
|
|
|
509
526
|
|
|
510
527
|
UNKNOWN_NAMESPACE = _unresolved(
|
|
511
528
|
"unknown_namespace",
|
|
512
|
-
"{namespace} is not a namespace; the reference language has params, steps, item, and
|
|
529
|
+
"{namespace} is not a namespace; the reference language has params, steps, item, run, and artifacts",
|
|
513
530
|
)
|
|
514
531
|
|
|
515
532
|
NO_OUTPUT = _unresolved("no_output", "step {step} has no stored output ({available})")
|
|
@@ -536,6 +553,11 @@ MALFORMED_RUN = _unresolved(
|
|
|
536
553
|
"run exposes only run.scratch, run.id, run.window.start and run.window.end",
|
|
537
554
|
)
|
|
538
555
|
|
|
556
|
+
MALFORMED_ARTIFACTS = _unresolved(
|
|
557
|
+
"malformed_artifacts",
|
|
558
|
+
"artifacts is the storage root itself and has no fields; write ${{artifacts}}/<path>",
|
|
559
|
+
)
|
|
560
|
+
|
|
539
561
|
NO_WINDOW = _unresolved(
|
|
540
562
|
"no_window",
|
|
541
563
|
"this run carries no window; a schedule-fired or backfilled run has one, "
|
|
@@ -288,6 +288,10 @@ class StepAttempt(Entity):
|
|
|
288
288
|
|
|
289
289
|
remote_handle: Mapped[JsonMap | None] = mapped_column(JsonDocument)
|
|
290
290
|
|
|
291
|
+
fetched_output: Mapped[JsonMap | None] = mapped_column(JsonDocument)
|
|
292
|
+
"""The result a fetch returned, committed before the outcome so a worker that dies
|
|
293
|
+
between the two settles from it rather than fetching again."""
|
|
294
|
+
|
|
291
295
|
poke_cursor: Mapped[JsonMap | None] = mapped_column(JsonDocument)
|
|
292
296
|
"""How far a sensor's poke has read, handed to the next poke as ``ctx.cursor``."""
|
|
293
297
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|